diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 2589927f..956ee339 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,16 @@ open mode gates. ## Current checkpoint and work +**Recovered Sapho after a suspended Movement turn — 28 October. Prototyped / +Partial.** The holder may recover and replay the physical card to replace its +own Last with First, without restoring spent shipment or movement. A different +started actor finishes first. Seven related files pass 51 cases, including two +native Basic programmes with paid Box recovery and discard reactions; one also +checks spent ground movement. Box possession uses a conserved exchange. +Another holder's Last, a further order query while the holder waits behind a +started actor, broader combinations and deployed acceptance remain open. The +ordering is provisional. [Scope](JUICE_OF_SAPHO_RUNTIME.md#recovered-sapho-after-suspending-an-already-started-movement-turn--28-october-2026). + **Truthtrance about a public Basic Richese cache lot — 28 October. Prototyped / Partial.** A named-face question covers the current public cache lot while preserving the separate Atreides inspection question. Single and diff --git a/docs/JUICE_OF_SAPHO_RUNTIME.md b/docs/JUICE_OF_SAPHO_RUNTIME.md index 28e2cbc0..d7ba3b5d 100644 --- a/docs/JUICE_OF_SAPHO_RUNTIME.md +++ b/docs/JUICE_OF_SAPHO_RUNTIME.md @@ -1,5 +1,22 @@ # Juice of Sapho: finite ordering implementation +## Recovered Sapho after suspending an already-started Movement turn — 28 October 2026 + +**Prototyped / Partial.** A holder who used Last to suspend its own already +started combined turn can recover the physical Sapho and use First to resume +that same turn. Previous shipment and movement remain spent; the replay grants +no second allowance. If another player's turn has started, that player finishes +first. A current Guild grant remains protected. The same holder's earlier Last +protection is released, while another holder cannot replace it. The precedence +is a provisional online interpretation of the printed card. + +Two native Basic programmes exercise real shipment, Last, paid Box recovery, +First, discard reactions and completion, both before and after the next player +starts. Box possession is staged by a conserving exchange. Seven related files +pass 51 cases. A further Sapho order query while the resumed holder waits behind +another started actor remains guarded; wider combinations, browser and deployed +acceptance remain open. + ## Recovered Sapho after the same holder's Last — 28 October 2026 **Prototyped / Partial.** An unstarted holder who previously used Sapho to go @@ -8,8 +25,8 @@ go first among the remaining combined Movement turns. The fresh use removes that same holder's Last protection, preserves completed turns and respects an already-started current actor and the Guild's existing timing. Semuta discard responses still finish through the original continuation. Another holder may -not replace the protected Last under this bounded policy. Replaying after one's -own started turn was suspended remains unsupported. +not replace the protected Last under this bounded policy. The later section +above extends replay to the same holder's suspended started turn. The printed card has no stated once-per-turn limit; choosing how a later use interacts with an earlier protected position is a provisional interpretation, @@ -486,9 +503,9 @@ different holder behind the active turn under the selected provisional policy. Other ordered actions and competing first/Guild precedence beyond the retained native choice remain unfinished. The own-started Movement and Revival policies above supersede the earlier blanket split-turn exclusion. -The same-holder unstarted Movement replay described above is supported; other -holders' protected Last and suspended-own-turn replay remain guarded. Silent -submission stays simultaneous with its original storm tie order. +The same-holder Movement replay described above is supported; other holders' +protected Last remains guarded. Silent submission stays simultaneous with its +original storm tie order. The battle audit in [the battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) found a real Stone Burner counterexample: a plan admitted with original aggressor tie priority can become allocation-ambiguous after late Sapho. A complete implementation needs the actual allocation continuation (and a usable, possession-independent late intervention boundary), not rejection based on an opponent's hidden plan. No aggressor handler has been enabled around that defect. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index c0c6032a..f3505af2 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,11 +1,19 @@ # Rule decision index +## Recovered Sapho after a suspended Movement turn — 28 October 2026 + +The holder's fresh physical Sapho may provisionally replace its own protected +Last with First even after suspending a started turn. Spent actions remain +spent, and a different started actor finishes first. Competing holders remain +guarded. [Scope](JUICE_OF_SAPHO_RUNTIME.md#recovered-sapho-after-suspending-an-already-started-movement-turn--28-october-2026). + ## Recovered Sapho after the same holder's Last — 28 October 2026 The Sapho face does not state a once-per-turn limit. Provisionally, a fresh physical use by the same unstarted holder can replace its earlier Last with First, without taking another turn or changing a started opponent's turn. -Competing holders and an already-suspended own turn remain guarded. +Competing holders remain guarded; the later section above extends replay to +the holder's own suspended turn. [Scope](JUICE_OF_SAPHO_RUNTIME.md#recovered-sapho-after-the-same-holders-last--28-october-2026). ## Truthtrance and a public Basic Richese cache lot — 28 October 2026 diff --git a/game/engine.ts b/game/engine.ts index e1f61abc..71dcc68d 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -8049,7 +8049,8 @@ function treacheryDiscardIntegrity(g: Game) { typeof c.afterCurrent === 'string' && c.afterCurrent === c.previousActive && c.afterCurrent !== c.player && g.movementRemaining[0] === c.afterCurrent && saphoMovementStarted(g, getPlayer(g,c.afterCurrent)) && - g.movementRemaining.filter(id=>id!==c.afterCurrent).every(id=>!saphoMovementStarted(g,getPlayer(g,id)))) && + g.movementRemaining.filter(id=>id!==c.afterCurrent).every(id=> + !saphoMovementStarted(g,getPlayer(g,id)) || c.mode === 'first' && id === c.player)) && (c.mode === 'first' ? g.movementRemaining[c.afterCurrent === undefined ? 0 : 1] === c.player && c.previousActive !== c.player && !g.saphoMovementLast diff --git a/game/reference.ts b/game/reference.ts index 27b7d72b..7c809518 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -504,7 +504,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Discarding Sapho frees a hand slot. A full-hand holder can join a still-open lot through this order change. Once Around completed opportunities cannot repeat. A cyclic pass stays spent until a genuine raise reopens it; a finished lot cannot reopen.', 'The movement controls change complete shipment and movement turns at a boundary before the current player begins. First moves you ahead of the remaining unstarted turns, including after earlier players have fully finished. In Advanced, provisional first ordering retains an unspent Guild’s native next-boundary take/wait choice; Guild may still act ahead. An already granted current Guild turn cannot be displaced, and a Karama-locked privilege stays locked. Last stays after the Guild even if the Guild later chooses to wait.', 'At a clean boundary after another player has begun shipment and movement, an unstarted holder may provisionally reorder only later unstarted turns. First means first after that current actor finishes; last remains after Guild. In Basic or Advanced, the holder may also provisionally pause its own started turn and resume last, retaining already spent shipment and movement. Pending card, arrival, payment and shared movement preparations must finish first. Advanced Guild retains its next-boundary timing choice; Guild may voluntarily pause its own granted turn.', - 'If you recover the physical Sapho after choosing Last while your turn remains unstarted, you may use it again to choose First. This provisionally replaces your own Last protection; completed turns and the current actor remain unchanged. Another holder cannot replace your protected Last, and replay after suspending your own started turn remains unsupported.', + 'If you recover the physical Sapho after choosing Last, you may use it again to choose First, including after suspending your own started turn. This provisionally replaces your own Last protection without restoring spent actions. Completed turns stay complete and a different started current actor finishes first. Another holder cannot replace your protected Last.', 'Between completed battles, choose first or last among remaining battle choosers. The priority lasts for the rest of the Battle phase. You still need a real unresolved battle. Others may choose battles against you before your own turn; last is not immunity. This scope changes the chooser, not each battle’s aggressor or tie advantage.', 'In a fresh Richese battle, use unreserved Sapho at a clean boundary before both combatants finish final battle actions, including after sealed plans and revealed declarations. Habbanya still precedes ordinary aggressor tie priority. Participants, plans, promises, weapon choices and votes do not swap or replay. Older battles retain their pre-plan opportunity.', 'When two or more players declared Truthtrance in a supported clean parent, each queued holder may continue or use their held Sapho to move their entire pending question group first or last. Physical storm order and each holder’s card order stay unchanged. Supported Basic Semuta claim/pass restores the same question queue. Clean ordinary auction lots also preserve their standing bid and remaining clock. Richese cache Once Around and Silent lots preserve their original bidding and funding; Black Market, other special auctions, battle and pending-parent combinations remain unfinished.', diff --git a/game/sapho-movement-order.ts b/game/sapho-movement-order.ts index 795f27b8..9cba751f 100644 --- a/game/sapho-movement-order.ts +++ b/game/sapho-movement-order.ts @@ -32,10 +32,12 @@ function validate(context: SaphoMovementOrderContext): OrderedOpportunity { context.started.every(player => queue.eligible.includes(player)), 'Started movement turns must name unique participants in this phase.'); const started = queue.remaining.filter(player => context.started.includes(player)); - requireMovement(queue.currentStarted - ? started.length === 1 && started[0] === queue.current - : started.length === 0, - 'Only the current combined turn may have started among the remaining participants.'); + // A previous self-Last can suspend one started actor at the protected tail. + // Its already-spent movement and shipment remain in the engine's counters. + // No other noncurrent started participant is admitted. + requireMovement(started.includes(queue.current) === queue.currentStarted && + started.every(player => player === queue.current || player === queue.protectedLast), + 'Only the current or protected suspended combined turn may have started among the remaining participants.'); requireMovement(context.guild === null || context.guild && typeof context.guild === 'object' && identifier(context.guild.player) && queue.eligible.includes(context.guild.player) && typeof context.guild.granted === 'boolean' && typeof context.guild.locked === 'boolean', @@ -81,6 +83,8 @@ export function reorderSaphoMovementOrder(context: SaphoMovementOrderContext, re requireMovement(original.protectedLast === request.player && request.mode === 'first', 'Only the protected player may relinquish their earlier last position.'); } + const suspendedReplay = original.protectedLast === request.player && + original.current !== request.player && context.started.includes(request.player); const queue = original.protectedLast ? { ...original, protectedLast: null } : original; if (context.started.includes(request.player) && request.player === queue.current && context.allowStartedLast === true) { @@ -96,15 +100,18 @@ export function reorderSaphoMovementOrder(context: SaphoMovementOrderContext, re validateOrderedOpportunity(next); return { event, afterCurrent: null, suspended: request.player, next }; } - requireMovement(!context.started.includes(request.player), + requireMovement(!context.started.includes(request.player) || suspendedReplay, 'Juice of Sapho cannot split a combined turn that has already started.'); const grantedGuild = context.guild && context.guild.player === queue.current && context.guild.granted && !context.guild.locked; requireMovement(queue.currentStarted || request.mode !== 'first' || !grantedGuild, 'The Guild has already been granted the next combined turn.'); - const next = request.mode === 'last' + const reordered = request.mode === 'last' ? protectOrderedOpportunityLast(queue, { event: queue.event, player: request.player }) : reorderOrderedOpportunity(queue, { event: queue.event, player: request.player, position: 'first' }); + const next = suspendedReplay && reordered.current === request.player + ? { ...reordered, currentStarted: true } : reordered; + validateOrderedOpportunity(next); requireMovement(next.remaining.some((player, index) => player !== queue.remaining[index]), 'This participant is already in that remaining movement position.'); return { event, afterCurrent: queue.currentStarted ? queue.current : null, next }; diff --git a/tests/fixture-sapho-started-movement.ts b/tests/fixture-sapho-started-movement.ts index e66f21c4..9e6a4be2 100644 --- a/tests/fixture-sapho-started-movement.ts +++ b/tests/fixture-sapho-started-movement.ts @@ -41,7 +41,7 @@ export function advanceSaphoStartedMovement(game: Game, until: (state: Game) => * shipment. Only unopened original deal/Spice ordering and original scalar * lotteries are selected; no played custody, resources, phase or queue is set. */ export function createSaphoStartedMovementFixture(options: { advanced?: boolean; guildTake?: boolean; - truthFor?: readonly ('richese' | 'guild')[] } = {}) { + truthFor?: readonly ('richese' | 'guild')[]; ownerBeforeAtreides?: boolean } = {}) { const advanced = options.advanced ?? false, guildTake = options.guildTake ?? false; assert.ok(!guildTake || advanced, 'Flexible Guild TAKE is an original Advanced rule'); const owner = 'emperor', guild = 'guild', canceler = 'atreides'; @@ -50,6 +50,11 @@ export function createSaphoStartedMovementFixture(options: { advanced?: boolean; const actions: SaphoStartedMovementStep[] = []; const act = (actor: string, action: Action) => { game = actSaphoStartedMovement(game, actor, action, actions); }; const reach = (until: (state: Game) => boolean) => { game = advanceSaphoStartedMovement(game, until, actions); }; + if (options.ownerBeforeAtreides) { + act('atreides', { type: 'seatPosition', position: 5 }); + act(owner, { type: 'seatPosition', position: 2 }); + act('atreides', { type: 'seatPosition', position: 3 }); + } for (const player of game.players) act(player.id, { type: 'ready' }); const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); @@ -131,6 +136,7 @@ export function createSaphoStartedMovementFixture(options: { advanced?: boolean; lobby, setup, beforeStartingDeal, startingTruthSelection, afterSetup, acquired, acquisition, timing, originalInventory, originalCards: originalInventory, actions, steps: actions, sourceActionCount: actions.length, staging: [ + ...(options.ownerBeforeAtreides ? ['The original lobby uses real seat-position actions to seat Emperor before Atreides; default seating is unchanged.'] : []), 'Actual public ready/start (Advanced uses the original preview acknowledgement), original setup and paid cache1 purchase give Emperor the physical Sapho. Only an unopened original Atreides Karama deal position, one Basic/two Advanced original land Spice positions and scoped scalar lotteries are selected.', `The original ${advanced ? guildTake ? 'Guild TAKE' : 'Guild WAIT' : 'Basic in-order turn'} is followed by an actual one-force Polar Sink shipment costing ${current === guild ? 1 : 2}. The source stops after all original shipment responses settle, before Sapho or the current actor’s ground move/end. No dealt hand, balance, force, phase, queue, timing grant or pending receipt is installed.`, ] }; diff --git a/tests/sapho-movement-replay.test.ts b/tests/sapho-movement-replay.test.ts index 3f45b7ae..cec68140 100644 --- a/tests/sapho-movement-replay.test.ts +++ b/tests/sapho-movement-replay.test.ts @@ -37,7 +37,9 @@ void test('another seat cannot replace an existing Last, and replay cannot dupli const guild = order(); guild.guild = { player: 'active', granted: true, locked: false }; assert.throws(() => reorder(guild, 'owner', 'first'), SaphoMovementOrderError); const suspended = order(); suspended.started = ['done', 'owner']; - assert.throws(() => saphoMovementOrderEvent(suspended), SaphoMovementOrderError); + const resumed = reorder(suspended, 'owner', 'first'); + assert.equal(resumed.next.current, 'owner'); + assert.equal(resumed.next.currentStarted, true); }); function movementAction(game: Game, actor: string, mode: 'first' | 'last'): Action { diff --git a/tests/sapho-self-last-order.test.ts b/tests/sapho-self-last-order.test.ts index 59516fab..5b793d69 100644 --- a/tests/sapho-self-last-order.test.ts +++ b/tests/sapho-self-last-order.test.ts @@ -57,7 +57,10 @@ void test('started self-last needs explicit Basic admission and cannot create a assert.equal(request(advanced).next.protectedLast, 'active'); const proposed = request(input); const already = { ...input, queue: proposed.next, started: ['done', 'active'] }; - assert.throws(() => saphoMovementOrderEvent(already), SaphoMovementOrderError); + const resumed = request(already, 'active', 'first'); + assert.equal(resumed.next.current, 'active'); + assert.equal(resumed.next.currentStarted, true); + assert.equal(resumed.next.protectedLast, null); }); void test('a started actor is bound to its current phase, actor and whole remaining tail', () => { diff --git a/tests/sapho-suspended-replay-helper.test.ts b/tests/sapho-suspended-replay-helper.test.ts new file mode 100644 index 00000000..9f69aedd --- /dev/null +++ b/tests/sapho-suspended-replay-helper.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + reorderSaphoMovementOrder, saphoMovementOrderEvent, SaphoMovementOrderError, + type SaphoMovementOrderContext, +} from '../game/sapho-movement-order'; + +function context(currentStarted = false): SaphoMovementOrderContext { + return { + queue: { + event: 'movement:3', eligible: ['done', 'current', 'later', 'holder'], + remaining: ['current', 'later', 'holder'], completed: ['done'], + current: 'current', currentStarted, protectedLast: 'holder', + }, + started: currentStarted ? ['done', 'current', 'holder'] : ['done', 'holder'], + guild: null, + }; +} +function change(input: SaphoMovementOrderContext, player = 'holder', mode: 'first' | 'last' = 'first') { + return reorderSaphoMovementOrder(input, { event: saphoMovementOrderEvent(input), player, mode }); +} + +void test('suspended holder resumes its existing started turn when First becomes current', () => { + const input = context(), before = structuredClone(input), result = change(input); + assert.deepEqual(result.next.remaining, ['holder', 'current', 'later']); + assert.deepEqual(result.next.completed, ['done']); + assert.equal(result.next.current, 'holder'); + assert.equal(result.next.currentStarted, true); + assert.equal(result.next.protectedLast, null); + assert.equal(result.afterCurrent, null); + assert.equal(result.suspended, undefined); + assert.deepEqual(input, before); +}); + +void test('another started actor finishes before the suspended holder resumes', () => { + const input = context(true), before = structuredClone(input), result = change(input); + assert.deepEqual(result.next.remaining, ['current', 'holder', 'later']); + assert.deepEqual(result.next.completed, ['done']); + assert.equal(result.next.current, 'current'); + assert.equal(result.next.currentStarted, true); + assert.equal(result.next.protectedLast, null); + assert.equal(result.afterCurrent, 'current'); + assert.deepEqual(input, before); +}); + +void test('only the same holder can replace its protected suspended Last', () => { + const input = context(), before = structuredClone(input); + assert.throws(() => change(input, 'later', 'first'), SaphoMovementOrderError); + assert.throws(() => change(input, 'later', 'last'), SaphoMovementOrderError); + assert.throws(() => change(input, 'holder', 'last'), SaphoMovementOrderError); + assert.throws(() => reorderSaphoMovementOrder(input, + { event: 'movement:4', player: 'holder', mode: 'first' }), SaphoMovementOrderError); + assert.deepEqual(input, before); +}); + +void test('unrelated started tails and inconsistent current start are rejected', () => { + const unrelated = context(); unrelated.started = ['done', 'later', 'holder']; + assert.throws(() => saphoMovementOrderEvent(unrelated), SaphoMovementOrderError); + const noProtection = context(); noProtection.queue = { ...noProtection.queue, protectedLast: null }; + assert.throws(() => saphoMovementOrderEvent(noProtection), SaphoMovementOrderError); + const badCurrent = context(); badCurrent.started = ['current', 'holder']; + assert.throws(() => saphoMovementOrderEvent(badCurrent), SaphoMovementOrderError); +}); + +void test('a granted current Guild turn cannot be displaced by suspended First', () => { + const granted = context(); + granted.guild = { player: 'current', granted: true, locked: false }; + assert.throws(() => change(granted), SaphoMovementOrderError); + const currentStarted = context(true); + currentStarted.guild = { player: 'current', granted: true, locked: false }; + assert.equal(change(currentStarted).afterCurrent, 'current'); +}); diff --git a/tests/sapho-suspended-replay-runtime.test.ts b/tests/sapho-suspended-replay-runtime.test.ts new file mode 100644 index 00000000..bca8f261 --- /dev/null +++ b/tests/sapho-suspended-replay-runtime.test.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { TERRITORIES } from '../game/board'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { + advanceSaphoStartedMovement as advance, createSaphoStartedMovementFixture as fixture, + finishSaphoStartedMovement as finish, saphoStartedMovementClean as clean, + saphoStartedMovementInventory as inventory, saphoStartedMovementPlayer as player, + STARTED_MOVEMENT_SAPHO as SAPHO, +} from './fixture-sapho-started-movement'; + +function sapho(game: Game, owner: string, mode: 'first' | 'last'): Action { + const option = viewGame(game, owner).saphoOptions.find(row => row.scope === 'movement' && row.mode === mode); + assert.ok(option, `Sapho ${mode} must be available to the physical holder`); + return { type: 'card', card: SAPHO, scope: 'movement', event: option.event, mode }; +} + +for (const startedOpponent of [false, true]) { + void test(`Basic suspended Sapho holder recovers and replays First behind ${startedOpponent ? 'a started' : 'an unstarted'} current turn`, () => { + const f = fixture({ ownerBeforeAtreides: true }); + let game = structuredClone(f.game); + // Only Box ownership is staged: exchange its original unopened cache card + // for another physical card from the owner's hand, preserving all cards. + const holder = player(game, f.owner); + const boxIndex = game.richeseCache!.findIndex(card => card.effect === 'nullentropyBox'); + const exchangeIndex = holder.hand.findIndex(card => card.id !== SAPHO); + assert.ok(boxIndex >= 0 && exchangeIndex >= 0); + const [box] = game.richeseCache!.splice(boxIndex, 1); + game.richeseCache!.push(holder.hand.splice(exchangeIndex, 1)[0]); holder.hand.push(box); + assert.deepEqual(inventory(game), f.originalInventory); + + // Advance through the original turns, then make the owner's actual paid + // shipment before suspending that same combined turn to Last. + game = advance(game, state => clean(state) && state.active === f.owner); + const beforeShipment = structuredClone(game); + game = applyAction(game, f.owner, { type: 'ship', territory: 'polar_sink', sector: 0, + amount: 1, elite: 0, allyPayment: 0 }); + game = advance(game, clean); + assert.equal(player(game, f.owner).shipped, true); + assert.equal(player(game, f.owner).reserves, player(beforeShipment, f.owner).reserves - 1); + assert.equal(player(game, f.owner).spice, player(beforeShipment, f.owner).spice - 2); + let movedTo: string | undefined; + if (startedOpponent) { + for (const territory of TERRITORIES) { + for (const sector of territory.sectors) { + try { + game = applyAction(game, f.owner, { type: 'move', from: 'polar_sink:0', territory: territory.id, + sector, amount: 1, elite: 0 }); + movedTo = `${territory.id}:${sector}`; + break; + } catch { /* Try another destination through the native movement validator. */ } + } + if (movedTo) break; + } + assert.ok(movedTo, 'The real shipped force must have a legal destination.'); + game = advance(game, clean); + assert.equal(player(game, f.owner).moved, 1); + } + const ownerAfterShipment = structuredClone(player(game, f.owner)); + const remainingBeforeLast = [...game.movementRemaining!]; + game = applyAction(game, f.owner, sapho(game, f.owner, 'last')); + game = advance(game, clean); + assert.equal(game.saphoMovementLast?.player, f.owner); + assert.equal(game.movementRemaining!.at(-1), f.owner); + assert.notEqual(game.active, f.owner); + assert.ok(game.discard.some(card => card.id === SAPHO)); + + const current = game.active!; + if (startedOpponent) { + game = applyAction(game, current, { type: 'ship', territory: 'polar_sink', sector: 0, + amount: 1, elite: 0, allyPayment: 0 }); + game = advance(game, clean); + assert.equal(game.active, current); + assert.equal(player(game, current).shipped, true); + } + const currentBeforeReplay = structuredClone(player(game, current)); + const beforeBox = player(game, f.owner).spice; + game = applyAction(game, f.owner, { type: 'card', card: box.id }); + if (game.pendingNullentropy) game = applyAction(game, f.owner, { + type: 'decision', event: game.pendingNullentropy.event, card: SAPHO, + }); + game = advance(game, clean); + assert.equal(player(game, f.owner).spice, beforeBox - 2); + assert.ok(player(game, f.owner).hand.some(card => card.id === SAPHO)); + const remainingBeforeReplay = [...game.movementRemaining!]; + game = applyAction(game, f.owner, sapho(game, f.owner, 'first')); + game = advance(game, clean); + assert.equal(game.saphoMovementLast, null); + assert.deepEqual([...game.movementRemaining!].sort(), [...remainingBeforeReplay].sort()); + assert.deepEqual([...game.movementRemaining!].sort(), [...remainingBeforeLast].sort()); + assert.equal(player(game, f.owner).shipped, ownerAfterShipment.shipped); + assert.equal(player(game, f.owner).moved, ownerAfterShipment.moved); + assert.equal(player(game, f.owner).reserves, ownerAfterShipment.reserves); + if (startedOpponent) { + assert.equal(game.active, current); + assert.equal(game.movementRemaining![1], f.owner); + assert.deepEqual(player(game, current), currentBeforeReplay); + game = applyAction(game, current, { type: 'endMovement' }); + game = advance(game, clean); + } + assert.equal(game.active, f.owner); + const beforeDuplicate = structuredClone(game); + assert.throws(() => applyAction(game, f.owner, { type: 'ship', territory: 'polar_sink', sector: 0, + amount: 1, elite: 0, allyPayment: 0 })); + assert.deepEqual(game, beforeDuplicate); + assert.equal(player(game, f.owner).shipped, true); + assert.equal(player(game, f.owner).moved, ownerAfterShipment.moved); + if (movedTo) { + assert.equal(player(game, f.owner).forces[movedTo], 1); + assert.throws(() => applyAction(game, f.owner, { type: 'move', from: movedTo, + territory: 'polar_sink', sector: 0, amount: 1, elite: 0 })); + assert.deepEqual(game, beforeDuplicate); + } + assert.equal(game.discard.filter(card => card.id === SAPHO).length, 1); + assert.deepEqual(inventory(game), f.originalInventory); + + // The owner ends the same opportunity once; the remaining original + // actors also complete once, without synthesizing an extra turn. + const completed = finish(game, f, { shipFollowing: false }); + assert.equal(completed.turns.filter(turn => turn.actor === f.owner).length, 1); + assert.equal(completed.turns[0].shipment, null); + assert.deepEqual(inventory(completed.game), f.originalInventory); + }); +}