Resume suspended movement after Sapho recovery

This commit is contained in:
Quintine committed 2026-10-28 19:34:56 +11:00
1 parent 26be6fd8ee
commit ea910e9c82
11 files changed
+267 -17

No files matched your search

+10
View File
@@ -16,6 +16,16 @@ open mode gates.
## Current checkpoint and work ## 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. **Truthtrance about a public Basic Richese cache lot — 28 October.
Prototyped / Partial.** A named-face question covers the current public cache Prototyped / Partial.** A named-face question covers the current public cache
lot while preserving the separate Atreides inspection question. Single and lot while preserving the separate Atreides inspection question. Single and
+22 -5
View File
@@ -1,5 +1,22 @@
# Juice of Sapho: finite ordering implementation # 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 ## Recovered Sapho after the same holder's Last — 28 October 2026
**Prototyped / Partial.** An unstarted holder who previously used Sapho to go **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 that same holder's Last protection, preserves completed turns and respects an
already-started current actor and the Guild's existing timing. Semuta discard already-started current actor and the Guild's existing timing. Semuta discard
responses still finish through the original continuation. Another holder may responses still finish through the original continuation. Another holder may
not replace the protected Last under this bounded policy. Replaying after one's not replace the protected Last under this bounded policy. The later section
own started turn was suspended remains unsupported. 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 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, 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 Other ordered actions and competing first/Guild precedence beyond the retained
native choice remain unfinished. The own-started Movement and Revival policies native choice remain unfinished. The own-started Movement and Revival policies
above supersede the earlier blanket split-turn exclusion. above supersede the earlier blanket split-turn exclusion.
The same-holder unstarted Movement replay described above is supported; other The same-holder Movement replay described above is supported; other holders'
holders' protected Last and suspended-own-turn replay remain guarded. Silent protected Last remains guarded. Silent submission stays simultaneous with its
submission stays simultaneous with its original storm tie order. 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. 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.
+9 -1
View File
@@ -1,11 +1,19 @@
# Rule decision index # 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 ## 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 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 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. 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). [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 ## Truthtrance and a public Basic Richese cache lot — 28 October 2026
+2 -1
View File
@@ -8049,7 +8049,8 @@ function treacheryDiscardIntegrity(g: Game) {
typeof c.afterCurrent === 'string' && c.afterCurrent === c.previousActive && typeof c.afterCurrent === 'string' && c.afterCurrent === c.previousActive &&
c.afterCurrent !== c.player && g.movementRemaining[0] === c.afterCurrent && c.afterCurrent !== c.player && g.movementRemaining[0] === c.afterCurrent &&
saphoMovementStarted(g, getPlayer(g,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' (c.mode === 'first'
? g.movementRemaining[c.afterCurrent === undefined ? 0 : 1] === c.player && ? g.movementRemaining[c.afterCurrent === undefined ? 0 : 1] === c.player &&
c.previousActive !== c.player && !g.saphoMovementLast c.previousActive !== c.player && !g.saphoMovementLast
+1 -1
View File
@@ -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.', '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.', '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.', '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.', '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.', '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.', '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.',
+13 -6
View File
@@ -32,10 +32,12 @@ function validate(context: SaphoMovementOrderContext): OrderedOpportunity {
context.started.every(player => queue.eligible.includes(player)), context.started.every(player => queue.eligible.includes(player)),
'Started movement turns must name unique participants in this phase.'); 'Started movement turns must name unique participants in this phase.');
const started = queue.remaining.filter(player => context.started.includes(player)); const started = queue.remaining.filter(player => context.started.includes(player));
requireMovement(queue.currentStarted // A previous self-Last can suspend one started actor at the protected tail.
? started.length === 1 && started[0] === queue.current // Its already-spent movement and shipment remain in the engine's counters.
: started.length === 0, // No other noncurrent started participant is admitted.
'Only the current combined turn may have started among the remaining participants.'); 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' && requireMovement(context.guild === null || context.guild && typeof context.guild === 'object' &&
identifier(context.guild.player) && queue.eligible.includes(context.guild.player) && identifier(context.guild.player) && queue.eligible.includes(context.guild.player) &&
typeof context.guild.granted === 'boolean' && typeof context.guild.locked === 'boolean', 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', requireMovement(original.protectedLast === request.player && request.mode === 'first',
'Only the protected player may relinquish their earlier last position.'); '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; const queue = original.protectedLast ? { ...original, protectedLast: null } : original;
if (context.started.includes(request.player) && request.player === queue.current && if (context.started.includes(request.player) && request.player === queue.current &&
context.allowStartedLast === true) { context.allowStartedLast === true) {
@@ -96,15 +100,18 @@ export function reorderSaphoMovementOrder(context: SaphoMovementOrderContext, re
validateOrderedOpportunity(next); validateOrderedOpportunity(next);
return { event, afterCurrent: null, suspended: request.player, 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.'); 'Juice of Sapho cannot split a combined turn that has already started.');
const grantedGuild = context.guild && context.guild.player === queue.current && const grantedGuild = context.guild && context.guild.player === queue.current &&
context.guild.granted && !context.guild.locked; context.guild.granted && !context.guild.locked;
requireMovement(queue.currentStarted || request.mode !== 'first' || !grantedGuild, requireMovement(queue.currentStarted || request.mode !== 'first' || !grantedGuild,
'The Guild has already been granted the next combined turn.'); '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 }) ? protectOrderedOpportunityLast(queue, { event: queue.event, player: request.player })
: reorderOrderedOpportunity(queue, { event: queue.event, player: request.player, position: 'first' }); : 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]), requireMovement(next.remaining.some((player, index) => player !== queue.remaining[index]),
'This participant is already in that remaining movement position.'); 'This participant is already in that remaining movement position.');
return { event, afterCurrent: queue.currentStarted ? queue.current : null, next }; return { event, afterCurrent: queue.currentStarted ? queue.current : null, next };
+7 -1
View File
@@ -41,7 +41,7 @@ export function advanceSaphoStartedMovement(game: Game, until: (state: Game) =>
* shipment. Only unopened original deal/Spice ordering and original scalar * shipment. Only unopened original deal/Spice ordering and original scalar
* lotteries are selected; no played custody, resources, phase or queue is set. */ * lotteries are selected; no played custody, resources, phase or queue is set. */
export function createSaphoStartedMovementFixture(options: { advanced?: boolean; guildTake?: boolean; 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; const advanced = options.advanced ?? false, guildTake = options.guildTake ?? false;
assert.ok(!guildTake || advanced, 'Flexible Guild TAKE is an original Advanced rule'); assert.ok(!guildTake || advanced, 'Flexible Guild TAKE is an original Advanced rule');
const owner = 'emperor', guild = 'guild', canceler = 'atreides'; const owner = 'emperor', guild = 'guild', canceler = 'atreides';
@@ -50,6 +50,11 @@ export function createSaphoStartedMovementFixture(options: { advanced?: boolean;
const actions: SaphoStartedMovementStep[] = []; const actions: SaphoStartedMovementStep[] = [];
const act = (actor: string, action: Action) => { game = actSaphoStartedMovement(game, actor, action, actions); }; const act = (actor: string, action: Action) => { game = actSaphoStartedMovement(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSaphoStartedMovement(game, until, 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' }); for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); const lobby = structuredClone(game);
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); 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, lobby, setup, beforeStartingDeal, startingTruthSelection, afterSetup, acquired, acquisition, timing,
originalInventory, originalCards: originalInventory, actions, steps: actions, sourceActionCount: actions.length, originalInventory, originalCards: originalInventory, actions, steps: actions, sourceActionCount: actions.length,
staging: [ 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.', '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.`, `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.`,
] }; ] };
+3 -1
View File
@@ -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 }; const guild = order(); guild.guild = { player: 'active', granted: true, locked: false };
assert.throws(() => reorder(guild, 'owner', 'first'), SaphoMovementOrderError); assert.throws(() => reorder(guild, 'owner', 'first'), SaphoMovementOrderError);
const suspended = order(); suspended.started = ['done', 'owner']; 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 { function movementAction(game: Game, actor: string, mode: 'first' | 'last'): Action {
+4 -1
View File
@@ -57,7 +57,10 @@ void test('started self-last needs explicit Basic admission and cannot create a
assert.equal(request(advanced).next.protectedLast, 'active'); assert.equal(request(advanced).next.protectedLast, 'active');
const proposed = request(input); const proposed = request(input);
const already = { ...input, queue: proposed.next, started: ['done', 'active'] }; 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', () => { void test('a started actor is bound to its current phase, actor and whole remaining tail', () => {
@@ -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');
});
@@ -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);
});
}