diff --git a/docs/ADVANCED_PREVIEW.md b/docs/ADVANCED_PREVIEW.md index f7e513bd..633cd9f6 100644 --- a/docs/ADVANCED_PREVIEW.md +++ b/docs/ADVANCED_PREVIEW.md @@ -60,6 +60,15 @@ this access change; see the [decision index](RULE_DECISIONS.md). - `advanced-preview-http.test.ts`: normal authenticated endpoints, explicit opt-in, origin/authority checks, readiness, duplicate start and refresh. +An Advanced six-seat sample exposed an Intrusion save boundary: a shipment +without an accompanying-advisor offer stored `followup: undefined`, which JSON +removed and changed the restored private views. Ordinary and Junction shipment +calls now omit that field when no follow-up exists. Focused regressions cover +both routes and every seat across JSON restoration. The same genuine six-seat +seed then completed at turn 10 after 1,531 actions and 41 restores. A separate +ten-game Basic/Advanced base-roster matrix (two through six seats) completed +with no rejected actions; these samples do not certify all Advanced interactions. + The private checkpoint directory records final check/build/HTTP results, ordinary preview-start complete-game samples, browser checks and saved-game preservation. These are development samples, not AI calibration or complete interaction coverage. diff --git a/game/engine.ts b/game/engine.ts index 4804f617..cb8d676a 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -19212,7 +19212,7 @@ function performJunctionTransport(g: Game, p: Player, action: Action) { const bg = byFaction(g, 'beneGesserit'); const followup = offPlanet && bg && bg.id !== p.id && spiritualAdvisorMaximum(g, bg.id) > 0 ? {shipment: p.id, destination} : undefined; - if (!intrusion(g, p, arrival.territory, {followup}) && followup) + if (!intrusion(g, p, arrival.territory, followup ? { followup } : undefined) && followup) g.decision = {kind: 'advisor', player: bg!.id, ...followup}; openTerritoryEntry(g, p, arrival.territory, arrival.sector, quote.amount, quote.elite, 'shipment'); } @@ -19696,7 +19696,7 @@ function commitShipment(g: Game, shipment: PendingShipment) { p.faction !== 'fremen' && bg && bg.id !== p.id && spiritualAdvisorMaximum(g, bg.id) > 0 ? { shipment: p.id, destination: location(to, s) } : undefined; - if (!intrusion(g, p, to, { followup }) && followup) + if (!intrusion(g, p, to, followup ? { followup } : undefined) && followup) g.decision = { kind: 'advisor', player: bg!.id, ...followup }; if (g.advanced && p.faction === 'fremen' && s === g.storm) g.response = { diff --git a/tests/advisors.test.ts b/tests/advisors.test.ts index e5ede228..fe43eb98 100644 --- a/tests/advisors.test.ts +++ b/tests/advisors.test.ts @@ -220,6 +220,19 @@ void test('advisors moving to an empty territory become fighters; optional occup assert.equal(isAdvisor(empty.players[1], 'imperial_basin'), false); assert.equal(empty.response, null); }); +void test('intrusion without advisor accompaniment restores the same decision and private views', () => { + const g = fixture(); + army(g, 'b', { 'arrakeen:10': 20 }); + const pending = applyAction(g, 'a', { + type: 'ship', territory: 'arrakeen', sector: 10, amount: 2, + }); + assert.equal(pending.decision?.kind, 'intrusion'); + assert.equal(Object.hasOwn(pending.decision!, 'followup'), false); + const restored = JSON.parse(JSON.stringify(pending)) as Game; + for (const player of pending.players) + assert.deepEqual(viewGame(restored, player.id), viewGame(pending, player.id)); +}); + void test('intrusion resolves before free accompaniment, with a separate cancelable token flip and same-turn advisor lock', () => { let g = fixture(); g.players[0].hand = [baseDeck().find((c) => c.effect === 'karama')!]; diff --git a/tests/junction-transport-review.test.ts b/tests/junction-transport-review.test.ts index 9bf5645a..54004692 100644 --- a/tests/junction-transport-review.test.ts +++ b/tests/junction-transport-review.test.ts @@ -279,3 +279,26 @@ void test('Junction distinguishes native Fremen Arrakis arrival from a foreign H viewGame(foreign, 'beneGesserit').decision, ); }); + +void test('Junction Intrusion without accompanying advisors survives JSON restoration', () => { + let g = fixture(true); + g.active = 'fremen'; + g.movementRemaining = ['fremen', 'atreides', 'beneGesserit', 'guild']; + const bg = player(g, 'beneGesserit'); + bg.forces = { 'wind_pass:14': 1 }; + bg.reserves = 19; + g = offered(g); + const option = viewGame(g, 'fremen').junctionTransport!; + g = applyAction(g, 'fremen', { + type: 'junctionShip', + event: option.event, + offer: option.offer!.event, + destination: 'wind_pass:14', + sources: { 'homeworld:fremen': { normal: 1, elite: 0 } }, + }); + assert.equal(g.decision?.kind, 'intrusion'); + assert.equal(Object.hasOwn(g.decision!, 'followup'), false); + const restored: Game = JSON.parse(JSON.stringify(g)); + for (const seat of g.players) + assert.deepEqual(viewGame(restored, seat.id), viewGame(g, seat.id)); +});