From 57321c2c8de8d5378fb68cb6bcb2696447cbfcf5 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:53:42 +1100 Subject: [PATCH] Resume worm rides after advanced Sneak arrivals --- docs/CURRENT_STATUS.md | 22 ++- docs/MORITANI_ENTRY_TIMING.md | 31 +++- docs/RULE_DECISIONS.md | 13 +- game/engine.ts | 93 +++++++++-- game/reference.ts | 4 +- tests/advanced-sneak-worm-runtime.test.ts | 153 +++++++++++++++++++ tests/ambassador-terror-overlap-worm.test.ts | 31 +++- tests/fixture-advanced-sneak-worm.ts | 52 +++++++ 8 files changed, 363 insertions(+), 36 deletions(-) create mode 100644 tests/advanced-sneak-worm-runtime.test.ts create mode 100644 tests/fixture-advanced-sneak-worm.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index f77cb057..eafe4a39 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,23 +17,31 @@ open mode gates. ## Current checkpoint and work +**Advanced Sneak during a worm ride — 29 October. Prototyped / Partial.** +A separate saved continuation retains the Fremen rider while a positive +Moritani Sneak group resolves its own arrival and reactions. It resumes the +original worm once, including the next ride or a summoned-worm Nexus. Ten +related files pass 75 cases, including existing bot choices; the worm fixtures +stage their starting positions. Great Maker, wider optional-module combinations, +full-game and deployed verification remain open. +[Scope](MORITANI_ENTRY_TIMING.md#advanced-sneak-on-a-worm-ride--29-october-2026). + **Direct Advanced Sneak Attack arrivals — 29 October. Prototyped / Partial.** Outside worm continuations, a positive Sneak group now gets its own Advanced arrival reactions, including an eligible Ecaz Ambassador after Bene Gesserit. The original action and existing dedicated Homeworld continuations remain. The focused native tests use genuine Advanced setup with controlled physical -token placement; ten related files pass 73 cases. A staged worm ride verifies -the temporary positive-entry guard and zero-force continuation. Full worm -support needs a separate saved rider continuation; optional combinations and -deployed verification remain open. +token placement; ten related files pass 73 cases. The later worm checkpoint +above replaces the prior positive-entry guard for ordinary rides; optional +combinations and deployed verification remain open. [Scope](MORITANI_ENTRY_TIMING.md#direct-advanced-sneak-arrivals--29-october-2026). **Advanced Ambassador child arrivals in the Ecaz–Moritani overlap — 29 October. Prototyped / Partial.** Bare native Advanced games can use the Fremen relocation and Guild shipment within the paired entry, retaining the child -Terror reaction and the original entry's remaining opportunity. Outside worm -continuations, Sneak Attack queues its own Bene Gesserit reactions without -overwriting that opportunity. +Terror reaction and the original entry's remaining opportunity. Sneak Attack +queues its own Bene Gesserit reactions without overwriting that opportunity; +the later worm checkpoint above extends the original ordinary-ride case. Eight related files pass 52 cases, including actual Advanced setup, both owner orders, controls and minimal bot actions; token selection and placements are controlled in the fixtures. Optional-module combinations, other nested cases, diff --git a/docs/MORITANI_ENTRY_TIMING.md b/docs/MORITANI_ENTRY_TIMING.md index 8a0a138d..5e87a278 100644 --- a/docs/MORITANI_ENTRY_TIMING.md +++ b/docs/MORITANI_ENTRY_TIMING.md @@ -2,6 +2,24 @@ Reviewed 2026-09-06. This supplements `MORITANI_TERROR_RULES.md`; it does not enable combined-faction play or certify an implementation order. +## Advanced Sneak on a worm ride — 29 October 2026 + +**Prototyped / Partial.** During an ordinary Advanced worm arrival, a positive +Sneak group retains a separate Fremen worm continuation while Moritani's group +finishes its own Bene Gesserit and Ambassador reactions. The original owners' +earned opportunities finish first; then the rider resumes once. A summoned worm +can still place its Nexus before the next ride. Zero forces create no child +arrival or held continuation. The child-first order is the same provisional +digital interpretation used for other entry reactions, not a publisher ruling. + +Ten related files pass 75 cases. Staged Advanced worm positions exercise direct +and paired parents, both owner orders, BG accompaniment, a Guild Ambassador +child, zero, next/final ride and a summoned-worm interruption; existing bot +choices remain legal. These are not full-game or deployed acceptance. Great +Maker arrivals retain a temporary positive-entry guard because their separate +saved arrival witness is not yet connected to this continuation. Other optional +module combinations remain unfinished. + ## Direct Advanced Sneak arrivals — 29 October 2026 **Prototyped / Partial.** A positive Advanced Sneak Attack outside a worm @@ -13,12 +31,9 @@ composition of the printed reserve entry and Ambassador interruption, not a publisher priority ruling. Caladan and revival source receipts retain their dedicated paths. -Worm continuations require a separately retained rider identity: a Moritani -child cannot simply inherit the Fremen rider's Ambassador continuation. The -positive choice is therefore temporarily blocked for Advanced worm parents; -Moritani may leave the token hidden or send zero. The staged worm regression -checks the visible reason, rejection and continued ride. Broader modules and -deployed verification remain open. +The later ordinary-worm checkpoint above retains the rider separately from +Moritani's child. Great Maker and broader modules remain open; deployed +verification remains open. Ten related files pass 73 cases. Direct runtime fixtures use genuine Advanced setup and physical token selection/placement under control, including BG and @@ -32,7 +47,7 @@ Fremen Ambassador relocation and Guild Ambassador shipment can now create a child arrival while the original paired Ambassador/Terror entry remains in progress. The existing child-first continuation preserves the original entry, remaining opportunity and physical movement or payment. A positive Sneak -Attack in this overlap outside a worm continuation queues its reactions until the original decisions +Attack in this overlap queues its reactions until the original decisions finish, including its Bene Gesserit opportunity. The owners' storm order and child ordering remain the provisional software interpretations below, not an official priority ruling; the printed Ambassador effects and interruption are @@ -40,7 +55,7 @@ described in GF9 Ecaz & Moritani rules pp. 8 and 15. Fremen destinations combining Advanced fighter Intrusion with Terror retain their existing restriction, and simultaneous Ambassador/Terror on Guild -accompaniment remains separate. Other optional modules, worm continuations, +accompaniment remains separate. Other optional modules, Great Maker, complete rules and deployed verification remain open. Eight related files pass 52 cases. Real Advanced setup covers both owner orders, diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 26e606cf..8dfa2f22 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,11 +1,18 @@ # Rule decision index +## Advanced Sneak on a worm ride — 29 October 2026 + +An ordinary worm rider's continuation waits for the separately committed Sneak +group and its reactions; the child does not inherit the Fremen rider's identity. +The ordering is provisional. Great Maker retains its separate guard. +[Scope](MORITANI_ENTRY_TIMING.md#advanced-sneak-on-a-worm-ride--29-october-2026). + ## Direct Advanced Sneak arrival — 29 October 2026 A positive Sneak group outside a worm continuation uses the existing separate -arrival queue after the original entry. Its ordering is provisional; Advanced -worm parents temporarily block a positive group until the rider continuation is -preserved separately. [Scope](MORITANI_ENTRY_TIMING.md#direct-advanced-sneak-arrivals--29-october-2026). +arrival queue after the original entry. Its ordering is provisional; the later +ordinary-worm policy above adds a separately retained rider. +[Scope](MORITANI_ENTRY_TIMING.md#direct-advanced-sneak-arrivals--29-october-2026). ## First-turn Duke proof and occupied Ecaz displacement — 29 October 2026 diff --git a/game/engine.ts b/game/engine.ts index 48bdbaec..057c5a56 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -2334,7 +2334,18 @@ export type Game = { caladanSneakAdvisor?: true; /** An ordinary Sneak group waits for an existing Ambassador/Terror parent. */ nativeSneakAdvisor?: true; + /** Binds this physical group to an independently retained worm continuation. */ + sneakWormEvent?: string; }[] | null; + /** The original worm ride is held while a committed Sneak arrival resolves. */ + pendingSneakWorm?: { + event: string; + turn: number; + phase: number; + rider: string; + parent: {kind: 'terror' | 'ambassador' | 'overlap'; event: string}; + stage: 'parent' | 'queued' | 'resolving'; + } | null; /** One committed entry with two independently optional expansion reactions. */ pendingArrivalOverlap?: { victoryEvent?: string; @@ -15103,7 +15114,29 @@ function finishSpicePass(g: Game) { nextPhase(g); } } +function holdSneakWormParent(g: Game, kind: 'terror' | 'ambassador' | 'overlap', event: string): boolean { + const frame = g.pendingSneakWorm; + if (!frame || frame.parent.kind !== kind || frame.parent.event !== event) return false; + requireRule(frame.stage === 'parent' && frame.turn === g.turn && frame.phase === g.phase, + 'The Sneak arrival lost its original worm parent.'); + frame.stage = 'queued'; + return true; +} +function sneakWormIntegrity(g: Game): void { + const frame = g.pendingSneakWorm; + if (!frame) return; + const queued = (g.pendingArrivalReaction ?? []).filter(row => row.sneakWormEvent === frame.event); + requireRule(g.status === 'playing' && g.advanced && frame.turn === g.turn && frame.phase === g.phase && g.phase === 1 && + typeof frame.event === 'string' && frame.event.length > 0 && + g.players.some(player => player.id === frame.rider && player.faction === 'fremen') && + !!frame.parent && ['terror','ambassador','overlap'].includes(frame.parent.kind) && + typeof frame.parent.event === 'string' && frame.parent.event.length > 0 && + (frame.stage === 'parent' || frame.stage === 'queued' ? queued.length === 1 : + frame.stage === 'resolving' && queued.length === 0), + 'The saved Sneak arrival lost its original worm continuation.'); +} function nextWormRide(g: Game) { + requireRule(!g.pendingSneakWorm, 'Finish the committed Sneak arrival before resuming another worm continuation.'); if (g.greatMaker?.stage === 'arrival') { completeGreatMaker(g); return; } if (g.summonedNexusBeforeRides) { g.summonedNexusBeforeRides = false; @@ -25277,7 +25310,7 @@ function finishArrivalOverlap(g: Game, overlap: NonNullable 0 && entry.revivalArrival ? {event:entry.revivalArrival.event,terror:{event:entry.entrySignature!,owner:p.id, territory:entry.territory,sector:entry.sector,amount,elite:0}} : undefined; - // Worm continuations can advance the phase synchronously; their child - // arrival needs a separate retained worm cursor before it can be queued. + const wormParent = g.pendingArrivalOverlap?.resume === 'wormRide' + ? {kind:'overlap' as const, event:g.pendingArrivalOverlap.event} + : entry.resume === 'ambassador' && g.pendingAmbassador?.resume === 'wormRide' + ? {kind:'ambassador' as const, event:g.pendingAmbassador.event} + : entry.resume === 'wormRide' + ? {kind:'terror' as const, event:entry.entrySignature ?? terrorEntrySignature(entry)} + : undefined; const advancedNativeArrival = g.advanced && entry.resume !== 'wormRide' && g.pendingArrivalOverlap?.resume !== 'wormRide' && g.pendingAmbassador?.resume !== 'wormRide'; + const wormSneak = amount > 0 && g.advanced && !!wormParent && !caladan && !revivalArrival; + if (wormSneak) { + const rider = byFaction(g, 'fremen'); + requireRule(rider && !g.pendingSneakWorm && g.phase === 1, + 'This Sneak arrival cannot retain its original worm rider.'); + const event = crypto.randomUUID(); + g.pendingSneakWorm = {event, turn:g.turn, phase:g.phase, rider:rider.id, parent:wormParent!, stage:'parent'}; + (g.pendingArrivalReaction ??= []).push({entrant:p.id,territory:entry.territory, + sector:entry.sector,amount,elite:0,cause:'shipment',resume:'none',turn:g.turn, + phase:g.phase,nativeSneakAdvisor:true,sneakWormEvent:event}); + } if (caladan) (g.homeworldVictoryReinforcement!.terrorArrivals ??= []).push(caladan.receipt); finishTerrorEntry(g); if (caladan) { @@ -28191,6 +28239,7 @@ function decideTerror(g: Game, p: Player, action: Action) { phase:g.phase,revivalArrival,revivalSneakAdvisor:true}); return; } + if (wormSneak) return; if (amount > 0 && (advancedNativeArrival || !g.advanced && (g.pendingArrivalOverlap || g.pendingAmbassador || g.pendingTerrorEntry || g.response || g.decision))) { // The child may have opened the original Terror decision. Let it finish @@ -43860,8 +43909,9 @@ function settleAutomaticContinuations(g: Game) { if (g.pendingHomeworldTupileCleanup || tupileSourceBlock(g)) return; if (g.truthtrance || g.phaseOpening || g.status === 'finished') return; if (!g.response && !g.decision && g.pendingAutomaticAdvisorFlips?.length) openAutomaticAdvisorFlip(g); - if (g.pendingArrivalReaction?.length && !g.response && !g.decision && - !g.pendingTerrorEntry && !g.pendingAmbassador && !g.pendingArrivalOverlap && !g.pendingSpiritualAdvisor) { + if (g.pendingArrivalReaction?.length && !g.response && !g.decision && !g.summonedWorm && !g.pendingKull && + !g.pendingTerrorEntry && !g.pendingAmbassador && !g.pendingArrivalOverlap && !g.pendingSpiritualAdvisor && + !g.pendingTreacheryDiscard) { const deferred = g.pendingArrivalReaction.shift()!; if (!g.pendingArrivalReaction.length) g.pendingArrivalReaction = null; if (deferred.victoryEvent !== undefined || deferred.cause === 'caladanReinforcement') @@ -43895,6 +43945,14 @@ function settleAutomaticContinuations(g: Game) { deferred.cause === 'shipment' && deferred.resume === 'none' && entrant!.faction === 'moritani' && deferred.amount > 0 && deferred.elite === 0, 'The queued Sneak reactions have lost their committed native entry.'); + if (deferred.sneakWormEvent) { + const frame = g.pendingSneakWorm; + requireRule(frame && frame.event === deferred.sneakWormEvent && frame.stage === 'queued' && + frame.turn === g.turn && frame.phase === g.phase && + g.players.some(player => player.id === frame.rider && player.faction === 'fremen'), + 'The queued Sneak group lost its original worm continuation.'); + frame.stage = 'resolving'; + } const bg = byFaction(g, 'beneGesserit'); const followup = bg && bg.id !== entrant!.id && spiritualAdvisorMaximum(g, bg.id) > 0 ? {shipment:entrant!.id,destination:location(deferred.territory,deferred.sector)} : undefined; @@ -43906,6 +43964,19 @@ function settleAutomaticContinuations(g: Game) { if (deferred.revivalArrival) resumeHomeworldRevivalReturn(g); continue; } + if (g.pendingSneakWorm?.stage === 'resolving' && !g.response && !g.decision && + !g.pendingTerrorEntry && !g.pendingAmbassador && !g.pendingArrivalOverlap && + !g.pendingArrivalReaction?.length && !g.pendingSpiritualAdvisor && + !g.pendingAutomaticAdvisorFlips?.length && !g.pendingTreacheryDiscard && !g.pendingKarama && + !g.summonedWorm && !g.pendingKull) { + const frame = g.pendingSneakWorm; + requireRule(frame.turn === g.turn && frame.phase === g.phase && g.phase === 1 && + g.players.some(player => player.id === frame.rider && player.faction === 'fremen'), + 'The completed Sneak arrival lost its original worm rider.'); + g.pendingSneakWorm = null; + nextWormRide(g); + continue; + } const response = g.response; const before = JSON.stringify(g); const bonus = g.pendingHomeworldOccupiedBonus; @@ -43980,6 +44051,7 @@ function settleAutomaticContinuations(g: Game) { } /** Internal authoritative continuation. Callers must persist with their usual CAS fence. */ export function normalizeAutomaticGame(state: Game): Game { + sneakWormIntegrity(state); emperorPhaseOpeningGiftIntegrity(state); emperorExtortionGiftIntegrity(state); emperorAuctionGiftIntegrity(state); @@ -44150,6 +44222,7 @@ function applyActionInner( execution: 'live' | 'shipmentPreparation' | 'movementPreparation' = 'live', initiator = id, ): Game { + sneakWormIntegrity(state); tupileSlotsIntegrity(state); const tupileBlock = tupileSourceBlock(state); requireRule(!tupileBlock, tupileBlock ?? 'The occupied Tupile source is unresolved.'); diff --git a/game/reference.ts b/game/reference.ts index e3df01c7..54c9a738 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4227,7 +4227,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Sabotage: randomly draw and discard a Treachery Card from the entrant if possible. Then you may give that player a card of your choice from your own hand. Giving a card is optional. If the gift puts the recipient above their hand limit, the recipient provisionally chooses one held card to discard, including the gift; the original entry waits for that fresh discard and any supported Semuta reaction.', 'Sneak Attack: send up to five of your reserve forces into the triggered territory at no cost, respecting storm and occupancy restrictions. This particular entry is allowed even when Atomics Aftermath is present.', 'Before revealing Sneak Attack, inspect its private available maximum and any reason positive entry is blocked. You may keep it hidden. Revealing spends the token even if you then choose Send no forces. For a supported entry, select zero through the displayed maximum; forces enter the arrival’s territory and sector without consuming your ordinary shipment or movement turn. Zero remains available when positive entry is blocked. Aftermath’s printed exception allows this reserve entry even after Atomics resolves. A positive entry during Shipment and Movement is treated as an off-planet shipment for the Heighliners token, which accrues its once-per-turn income for its owner, and the committed entry finishes before Bene Gesserit may answer it with an Intrusion choice or a free spiritual-advisor shipment.', - 'In Advanced play outside a worm continuation, the separate Sneak group can also trigger an eligible Ecaz Ambassador after its Bene Gesserit reactions. A positive Sneak group during a worm continuation is temporarily unavailable until the rider continuation can be preserved; leaving the token hidden or sending zero remains possible.', + 'In Advanced play the separate Sneak group can also trigger an eligible Ecaz Ambassador after its Bene Gesserit reactions. During an ordinary worm ride the original Fremen continuation waits for the child and resumes once. A positive group during a Great Maker arrival remains temporarily unavailable; leaving the token hidden or sending zero remains possible.', 'The random Assassination token is separate from the advanced Assassinate Leaders advantage. Neither ability supplies the other’s selection or timing rules.', ], checklist: [ @@ -4235,7 +4235,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Player controls', status: 'Partial', detail: 'Original actual390px Reveal Assassination resolves a captive Duke death6, displays Tanks inspection and resumes native Harkonnen movement. No selectable victim or projected pool; no overflow/browser errors.', evidence: ['components/game-table.tsx', 'components/duke-vidal.tsx'] }, { area: 'AI', status: 'Partial', detail: 'All four policies make accepted original decisions: Easy reveals, Medium/Hard/Brutal form an accepted Enemy of My Enemy alliance in the actual native position. Legal participation only; no calibrated strategy claim.', evidence: ['game/bots.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'Effective-control exceptional pool, public victim and spent-empty behavior are labelled provisional and separate from Advanced battle-loss Assassinate Leaders. Native printed values and no foreign-Ghola Duke remain binding.', evidence: ['docs/MORITANI_ASSASSINATION_SNEAK.md', 'docs/MORITANI_TERROR_RULES.md', 'docs/RULE_DECISIONS.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Advanced direct Sneak and related arrivals pass ten files and 73 cases, with genuine setup and controlled physical token placements; a staged worm case exercises its temporary guard. Earlier affected22-file195/195 and types/lint/build pass for Assassination. Pure custody/identity/value boundaries plus original capture-to-Terror runtime and real table death6 pass. Empty boundary resumes genuine Guild entry but stages prior native deaths. Full combinations/deployed acceptance open.', evidence: ['tests/advanced-sneak-direct-runtime.test.ts', 'tests/ambassador-terror-overlap-worm.test.ts', 'tests/moritani-terror-assassination.test.ts', 'tests/moritani-terror-assassination-runtime.test.ts', 'tests/moritani-force-leader.test.ts'] }, + { area: 'Verification', status: 'Partial', detail: 'Advanced worm Sneak and related arrivals pass ten files and 75 cases, using staged worm positions and legal bot choices. Earlier direct Sneak tests use genuine setup with controlled physical token placements. Earlier affected22-file195/195 and types/lint/build pass for Assassination. Pure custody/identity/value boundaries plus original capture-to-Terror runtime and real table death6 pass. Empty boundary resumes genuine Guild entry but stages prior native deaths. Full combinations/deployed acceptance open.', evidence: ['tests/advanced-sneak-worm-runtime.test.ts', 'tests/advanced-sneak-direct-runtime.test.ts', 'tests/ambassador-terror-overlap-worm.test.ts', 'tests/moritani-terror-assassination.test.ts', 'tests/moritani-terror-assassination-runtime.test.ts', 'tests/moritani-force-leader.test.ts'] }, ], related: [ 'moritani-terror', diff --git a/tests/advanced-sneak-worm-runtime.test.ts b/tests/advanced-sneak-worm-runtime.test.ts new file mode 100644 index 00000000..291e7916 --- /dev/null +++ b/tests/advanced-sneak-worm-runtime.test.ts @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame, type Game } from '../game/engine'; +import { guildAmbassadorShipments } from '../game/guild-ambassador'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { advancedSneakWormFixture, act, player, type WormOrder } from './fixture-advanced-sneak-worm'; + +function reveal(game: Game): Game { + assert.equal(game.pendingTerrorEntry?.stage, 'offer'); + assert.equal(viewGame(game, 'm').terrorEntry?.sneakAttack?.blocked, null); + game = act(game, 'm', {type:'decision', reveal:true}); + assert.equal(game.pendingTerrorEntry?.stage, 'sneakAttack'); + return game; +} + +for (const withBG of [false,true]) void test( + `Advanced worm Sneak resolves its own ${withBG ? 'BG and ' : ''}Ambassador before the next ride`, () => { + const f = advancedSneakWormFixture('direct', 'terror', withBG); + assert.equal(f.game.pendingArrivalOverlap ?? null, null, 'Fremen cannot trigger its own Ambassador'); + assert.equal(f.game.pendingTerrorEntry?.resume, 'wormRide'); + let game = reveal(f.game); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, 'm'); + view.players.find(player => player.id === 'm')!.bot = difficulty; + const choice = botActions(view)[0]; assert.ok(choice, difficulty); + assert.doesNotThrow(() => act(game, 'm', choice), difficulty); + } + const beforeReserves = player(game, 'm').reserves, beforeSpice = player(game, 'm').spice; + game = act(game, 'm', {type:'decision', amount:1}); + assert.equal(game.phase, 1); + assert.equal(player(game, 'm').reserves, beforeReserves - 1); + assert.equal(player(game, 'm').forces['arrakeen:10'], 1); + assert.equal(player(game, 'm').spice, beforeSpice); + assert.notDeepEqual(game.decision, f.nextRide, 'The next ride cannot overwrite this arrival'); + if (withBG) { + assert.equal(game.decision?.kind, 'advisor'); + assert.equal(game.decision?.player, 'bg'); + game = act(game, 'bg', {type:'decision', accept:false}); + } + const child = game.pendingAmbassador; assert.ok(child, 'Moritani earns the still-placed Ambassador'); + assert.equal(child.token, f.token); + assert.equal(child.entrant, 'm'); + game = act(game, 'ec', {type:'decision', event:child.event, decline:true}); + assert.deepEqual(game.decision, f.nextRide); + assert.deepEqual(game.wormRides, []); + assert.equal(game.pendingAmbassador ?? null, null); + assert.equal(game.pendingTerrorEntry ?? null, null); + assert.equal(player(game, 'm').reserves, beforeReserves - 1); + assert.equal(player(game, 'in').forces['arrakeen:10'], 2); + game = act(game, 'in', {type:'decision', accept:false}); + assert.notEqual(game.decision?.kind, 'wormRide'); + assert.deepEqual(game.wormRides, []); + }); + +void test('Advanced worm Sneak choosing zero resumes the ride without a child arrival', () => { + const f = advancedSneakWormFixture('direct', 'terror', true); + let game = reveal(f.game); + const beforeReserves = player(game, 'm').reserves; + game = act(game, 'm', {type:'decision', amount:0}); + assert.equal(player(game, 'm').reserves, beforeReserves); + assert.equal(game.pendingAmbassador ?? null, null); + assert.equal(game.pendingTerrorEntry ?? null, null); + assert.deepEqual(game.decision, f.nextRide); +}); + +void test('Advanced worm Sneak with no child reaction resumes the next ride once', () => { + const f = advancedSneakWormFixture('direct'); + const source = structuredClone(f.game); + const token = source.ecazAmbassadors!.tokens.find(row => row.id === f.token)!; + token.zone = 'supply'; token.location = null; + let game = reveal(source); + game = act(game, 'm', {type:'decision', amount:1}); + assert.deepEqual(game.decision, f.nextRide); + assert.equal(game.pendingSneakWorm ?? null, null); + assert.equal(game.pendingAmbassador ?? null, null); + assert.deepEqual(game.wormRides, []); +}); + +void test('Advanced Sneak on the last worm ride settles its child before ending the ride queue', () => { + const f = advancedSneakWormFixture('direct'); + const source = structuredClone(f.game); + // Remove the fixture's staged extra ride to exercise the final-ride path. + source.wormRides = []; + let game = reveal(source); + game = act(game, 'm', {type:'decision', amount:1}); + const child = game.pendingAmbassador; assert.ok(child); + assert.equal(child.entrant, 'm'); + game = act(game, 'ec', {type:'decision', event:child.event, decline:true}); + assert.equal(game.pendingAmbassador ?? null, null); + assert.equal(game.pendingTerrorEntry ?? null, null); + assert.notEqual(game.decision?.kind, 'wormRide'); + assert.deepEqual(game.wormRides, []); + assert.equal(player(game, 'm').forces['arrakeen:10'], 1); +}); + +for (const first of ['terror','ambassador'] as const satisfies readonly WormOrder[]) void test( + `Advanced worm ${first}-first overlap finishes its original owners before the Sneak child and next ride`, () => { + const f = advancedSneakWormFixture('overlap', first); + let game = f.game; + assert.equal(game.pendingArrivalOverlap?.first, first); + assert.equal(game.pendingArrivalOverlap?.resume, 'wormRide'); + if (first === 'ambassador') { + const original = game.pendingAmbassador; assert.ok(original); + game = act(game, 'ec', {type:'decision', event:original.event, decline:true}); + } + game = reveal(game); + const beforeReserves = player(game, 'm').reserves; + game = act(game, 'm', {type:'decision', amount:1}); + if (first === 'terror') { + const original = game.pendingAmbassador; assert.ok(original); + assert.equal(original.entrant, 'in'); + game = act(game, 'ec', {type:'decision', event:original.event, decline:true}); + } + const child = game.pendingAmbassador; assert.ok(child); + assert.equal(child.entrant, 'm'); + assert.equal(child.token, f.token); + assert.notDeepEqual(game.decision, f.nextRide); + game = act(game, 'ec', {type:'decision', event:child.event, decline:true}); + assert.deepEqual(game.decision, f.nextRide); + assert.equal(game.pendingArrivalOverlap ?? null, null); + assert.equal(player(game, 'm').reserves, beforeReserves - 1); + assert.equal(player(game, 'm').forces['arrakeen:10'], 1); + }); + +void test('Advanced worm Guild Ambassador child Sneak resolves before the parent rider resumes', () => { + const f = advancedSneakWormFixture('ambassador', 'ambassador'); + let game = f.game; + const entry = game.pendingAmbassador; assert.ok(entry); + assert.equal(game.ecazAmbassadors?.tokens.find(token => token.id === entry.token)?.effect, 'guild'); + assert.equal(entry.resume, 'wormRide'); + const spice = player(game, 'ec').spice, ecReserves = player(game, 'ec').reserves; + game = act(game, 'ec', {type:'decision', event:entry.event, trigger:true, beneficiary:'ec'}); + assert.equal(game.pendingAmbassador?.stage, 'ship'); + assert.ok(guildAmbassadorShipments(game, 'ec').destinations.some(row => + row.territory === f.childTerritory && row.sector === f.childSector)); + game = act(game, 'ec', {type:'decision', event:entry.event, amount:1, elite:0, + territory:f.childTerritory, sector:f.childSector}); + assert.equal(game.pendingTerrorEntry?.resume, 'ambassador'); + assert.equal(game.pendingTerrorEntry?.token, f.sneak); + game = reveal(game); + const reserves = player(game, 'm').reserves; + game = act(game, 'm', {type:'decision', amount:1}); + assert.equal(game.pendingAmbassador ?? null, null); + assert.equal(game.pendingTerrorEntry ?? null, null); + assert.deepEqual(game.decision, f.nextRide); + assert.equal(player(game, 'm').reserves, reserves - 1); + assert.equal(player(game, 'm').forces[`${f.childTerritory}:${f.childSector}`], 1); + assert.equal(player(game, 'ec').reserves, ecReserves - 1); + assert.equal(player(game, 'ec').spice, spice); + assert.equal(player(game, 'ec').forces[`${f.childTerritory}:${f.childSector}`], 1); + assert.equal(player(game, 'in').forces['arrakeen:10'], 2); +}); diff --git a/tests/ambassador-terror-overlap-worm.test.ts b/tests/ambassador-terror-overlap-worm.test.ts index 57dbb168..a1c55e89 100644 --- a/tests/ambassador-terror-overlap-worm.test.ts +++ b/tests/ambassador-terror-overlap-worm.test.ts @@ -69,20 +69,39 @@ function ready(state: Game) { } const queuedRide = { kind: 'wormRide', player: 'in', territory: 'hagga_basin' } as const; -void test('Advanced worm Sneak shows the temporary positive-entry guard and preserves the ride on zero', () => { +void test('Advanced worm Sneak preserves the original and child entries before the ride', () => { const { g } = table('sneakAttack'); let state = wormRide(g); - assert.match(viewGame(state, 'm').terrorEntry?.sneakAttack?.blocked ?? '', /worm ride/); + assert.equal(viewGame(state, 'm').terrorEntry?.sneakAttack?.blocked, null); state = action(state, 'm', {type:'decision', reveal:true}); - const before = structuredClone(state); - assert.throws(() => action(state, 'm', {type:'decision', amount:1}), /worm ride/); - assert.deepEqual(state, before); - state = action(state, 'm', {type:'decision', amount:0}); + state = action(state, 'm', {type:'decision', amount:1}); assert.equal(state.pendingArrivalOverlap?.stage, 'second'); state = action(state, 'ec', {type:'decision', event:state.pendingAmbassador!.event, decline:true}); + assert.equal(state.pendingAmbassador?.entrant, 'm'); + state = action(state, 'ec', {type:'decision', event:state.pendingAmbassador!.event, decline:true}); assert.deepEqual(state.decision, queuedRide); }); +void test('a summoned worm during a held Sneak arrival keeps its Nexus before the next ride', () => { + const { g } = table('sneakAttack'); + const karama = g.deck.find(card => card.effect === 'karama')!; + g.deck = g.deck.filter(card => card.id !== karama.id); + seat(g, 'in').hand = [karama]; + let state = wormRide(g); + state = action(state, 'm', {type:'decision', reveal:true}); + state = action(state, 'm', {type:'decision', amount:1}); + assert.equal(state.pendingAmbassador?.entrant, 'in'); + state = action(state, 'in', {type:'card', mode:'special', card:karama.id, territory:'the_great_flat'}); + assert.equal(state.summonedNexusBeforeRides, true); + state = action(state, 'ec', {type:'decision', event:state.pendingAmbassador!.event, decline:true}); + assert.equal(state.pendingAmbassador?.entrant, 'm'); + state = action(state, 'ec', {type:'decision', event:state.pendingAmbassador!.event, decline:true}); + assert.equal(state.pendingSneakWorm ?? null, null); + assert.equal(state.nexus, true); + assert.deepEqual(state.wormRides, ['hagga_basin']); + assert.equal(state.decision ?? null, null); +}); + void test('Terror first keeps Robbery overflow custody and the ordered arrival intact', () => { const { g, terror } = table('robbery'); seat(g, 'm').hand = g.deck.splice(0, 4); diff --git a/tests/fixture-advanced-sneak-worm.ts b/tests/fixture-advanced-sneak-worm.ts new file mode 100644 index 00000000..79893d7f --- /dev/null +++ b/tests/fixture-advanced-sneak-worm.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, newPlayer, type Action, type Game } from '../game/engine'; +import { baseDeck } from '../game/cards'; +import { createAmbassadors, placeAmbassador, type AmbassadorEffect } from '../game/ecaz-ambassadors'; +import { createTerrorState, placeTerror } from '../game/moritani-terror'; + +export type WormSneakKind = 'direct' | 'overlap' | 'ambassador'; +export type WormOrder = 'terror' | 'ambassador'; +export const player = (game: Game, id: string) => { + const result = game.players.find(p => p.id === id); assert.ok(result); return result; +}; +export const act = (game: Game, id: string, action: Action) => applyAction(structuredClone(game), id, action); + +/** Controlled Advanced worm position based on the existing overlap-worm + * fixture. Seats, forces, card deck, physical markers and the first and second + * rider decisions are staged; the accepted first ride and every subsequent + * force placement and decision are actual engine actions. */ +export function advancedSneakWormFixture(kind: WormSneakKind, first: WormOrder = 'terror', withBG = false) { + const game = createGame('ADVANCEDSNEAKWORM', newPlayer('ec', 'Ecaz', 'ecaz'), true, ['ecaz']); + game.players.push(newPlayer('m', 'Moritani', 'moritani'), newPlayer('in', 'Rider', 'fremen')); + if (withBG) game.players.push(newPlayer('bg', 'Bene Gesserit', 'beneGesserit')); + const order = first === 'terror' ? ['m','ec','in'] : ['ec','m','in']; + if (withBG) order.push('bg'); + Object.assign(game, {status:'playing', phase:1, turn:3, storm:18, active:null, + order, movementRemaining:[...order], deck:baseDeck()}); + for (const seat of game.players) + Object.assign(seat, {hand:[], spice:20, forces:{}, reserves:20}); + player(game,'in').forces = {'imperial_basin:10':3, 'hagga_basin:12':1}; + player(game,'in').reserves = 16; + const ambassadors = createAmbassadors(() => 0.2); + const effect: AmbassadorEffect = kind === 'direct' ? 'fremen' : kind === 'overlap' ? 'emperor' : 'guild'; + const token = ambassadors.tokens.find(row => row.effect === effect); assert.ok(token); + ambassadors.cohort = [token.id,...ambassadors.tokens.filter(row => row.effect !== 'ecaz' && row.id !== token.id) + .slice(0,4).map(row => row.id)]; + for (const row of ambassadors.tokens) { + row.zone = row.effect === 'ecaz' || ambassadors.cohort.includes(row.id) ? 'supply' : 'pool'; + row.location = null; + } + game.ecazAmbassadors = placeAmbassador(ambassadors, token.id, {turn:2, availableSpice:20, + destination:{id:'arrakeen',stronghold:true,inStorm:false,allowed:true}}).state; + const terror = createTerrorState(() => 0.2), sneak = terror.tokens.find(row => row.kind === 'sneakAttack'); + assert.ok(sneak); + const childTerritory = kind === 'ambassador' ? 'carthag' : 'arrakeen'; + const childSector = kind === 'ambassador' ? 11 : 10; + game.moritaniTerror = placeTerror(terror, sneak.id, childTerritory, 1); + game.decision = {kind:'wormRide', player:'in', territory:'imperial_basin'}; + game.wormRides = ['hagga_basin']; + const entry = act(game, 'in', {type:'decision', accept:true, territory:'arrakeen', sector:10, + forces:{'imperial_basin:10':2}}); + return {game:entry, token:token.id, sneak:sneak.id, kind, first, withBG, childTerritory, childSector, + nextRide:{kind:'wormRide',player:'in',territory:'hagga_basin'} as const}; +}