Allow Sapho order behind a started turn

This commit is contained in:
Quintine committed 2026-10-25 23:33:04 +11:00
1 parent 3bf7d26e80
commit 8e6602633d
17 files changed
+1121 -62

No files matched your search

+200
View File
@@ -0,0 +1,200 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { nativeShipmentSources } from '../game/homeworld-options';
import {
GUILD_FIRST_SAPHO, actSaphoGuildFirst, nextSaphoGuildFirstStep,
type SaphoGuildFirstStep,
} from './fixture-sapho-guild-first';
import { createSemutaHomeworldSaphoFixture } from './fixture-semuta-homeworld-sapho';
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep } from './fixture-semuta-homeworld-choam';
export const STARTED_MOVEMENT_SAPHO = GUILD_FIRST_SAPHO;
export type SaphoStartedMovementStep = SaphoGuildFirstStep;
export const actSaphoStartedMovement = actSaphoGuildFirst;
export const nextSaphoStartedMovementStep = nextSaphoGuildFirstStep;
export const saphoStartedMovementPlayer = (game: Game, actor = 'emperor') => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const saphoStartedMovementClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingShipment &&
!game.pendingKarama && !game.currentAuctionSale;
/** Count physical zones once, excluding historical ordinary-auction aliases. */
export function saphoStartedMovementInventory(game: Game) {
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length); return ids;
}
export function advanceSaphoStartedMovement(game: Game, until: (state: Game) => boolean,
actions: SaphoStartedMovementStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextSaphoStartedMovementStep(game); assert.ok(step, 'Complete the original owned control first');
game = actSaphoStartedMovement(game, step.actor, step.action, actions);
}
throw Error('The native Sapho programme did not reach its finite boundary');
}
/** Original public Basic or Advanced setup, actual cache1 purchase and positive
* 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 } = {}) {
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';
let game = createGame('SAPHOSTARTEDMOVEMENT', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
for (const faction of ['atreides', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
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); };
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' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced);
assert.ok(game.players.every(player => player.hand.length === 0));
const originalInventory = saphoStartedMovementInventory(game); assert.equal(originalInventory.length, 45);
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const counter = game.deck[index].id;
[game.deck[1], game.deck[index]] = [game.deck[index], game.deck[1]];
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), front: Game['spiceDeck'] = [];
assert.ok(saphoStartedMovementPlayer(game, canceler).hand.some(card => card.id === counter));
const spiceInventory = game.spiceDeck.map(card => JSON.stringify(card)).sort();
for (let count = 0; count < (advanced ? 2 : 1); count++) {
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
front.push(game.spiceDeck.splice(at, 1)[0]);
}
game.spiceDeck.unshift(...front);
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), spiceInventory);
let beforeBid: Game | undefined, bidStep: SaphoStartedMovementStep | undefined, bidAt = -1;
for (let count = 0; count < 900 && !saphoStartedMovementPlayer(game, owner).hand.some(card => card.id === STARTED_MOVEMENT_SAPHO); count++) {
const step = nextSaphoStartedMovementStep(game); assert.ok(step);
if (step.actor === owner && step.action.type === 'richeseBid' && step.action.amount === 1) {
beforeBid = structuredClone(game); bidStep = structuredClone(step); bidAt = actions.length;
}
act(step.actor, step.action);
}
assert.ok(beforeBid && bidStep && bidAt >= 0);
assert.ok(saphoStartedMovementPlayer(game, owner).hand.some(card => card.id === STARTED_MOVEMENT_SAPHO));
const acquired = structuredClone(game), acquisition = {
before: beforeBid, bid: bidStep, steps: structuredClone(actions.slice(bidAt)), after: acquired,
};
assert.equal(saphoStartedMovementPlayer(beforeBid, owner).spice - saphoStartedMovementPlayer(game, owner).spice, 1);
assert.equal(saphoStartedMovementPlayer(game, 'richese').spice - saphoStartedMovementPlayer(beforeBid, 'richese').spice, 1);
const timing: { before: Game; step: SaphoStartedMovementStep; declared: Game; after: Game }[] = [];
if (advanced) {
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
const before = structuredClone(game), step: SaphoStartedMovementStep = { actor: guild,
action: { type: 'decision', take: guildTake } };
act(step.actor, step.action); const declared = structuredClone(game); reach(saphoStartedMovementClean);
timing.push({ before, step, declared, after: structuredClone(game) });
} else reach(state => state.phase === 5 && saphoStartedMovementClean(state));
const beforeShipment = structuredClone(game), current = game.active; assert.ok(current && current !== owner);
assert.equal(current, guildTake ? guild : 'richese');
const step: SaphoStartedMovementStep = { actor: current,
action: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 } };
const shipmentAt = actions.length;
act(step.actor, step.action); const declared = structuredClone(game); reach(saphoStartedMovementClean);
const shipment = { before: beforeShipment, step, declared,
steps: structuredClone(actions.slice(shipmentAt)), after: structuredClone(game) };
assert.equal(game.active, current); assert.equal(saphoStartedMovementPlayer(game, current).shipped, true);
assert.equal(saphoStartedMovementPlayer(game, current).moved, 0);
assert.equal(saphoStartedMovementPlayer(beforeShipment, current).spice - saphoStartedMovementPlayer(game, current).spice,
current === guild ? 1 : 2);
assert.equal(saphoStartedMovementPlayer(beforeShipment, current).reserves - saphoStartedMovementPlayer(game, current).reserves, 1);
assert.equal(saphoStartedMovementPlayer(game, current).forces['polar_sink:0'], 1);
assert.equal(saphoStartedMovementPlayer(game, owner).shipped, false);
assert.equal(saphoStartedMovementPlayer(game, owner).moved, 0);
assert.ok(saphoStartedMovementPlayer(game, canceler).hand.some(card => card.id === counter));
assert.deepEqual(saphoStartedMovementInventory(game), originalInventory);
const source = structuredClone(game);
return { game, source, advanced, guildTake, owner, guild, canceler, counter, karama: counter,
card: STARTED_MOVEMENT_SAPHO, current, actor: current, beforeShipment, shipment,
lobby, setup, beforeStartingDeal, afterSetup, acquired, acquisition, timing,
originalInventory, originalCards: originalInventory, actions, steps: actions, sourceActionCount: actions.length,
staging: [
'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.`,
] };
}
export type SaphoStartedMovementFixture = ReturnType<typeof createSaphoStartedMovementFixture>;
/** afterCurrent is projected explanation, never an action field. */
export function saphoStartedMovementAction(game: Game, mode: 'first' | 'last', owner = 'emperor'): Action {
const option = viewGame(game, owner).saphoOptions.find(option => option.scope === 'movement' && option.mode === mode);
assert.ok(option, `The actual waiting tail must offer Sapho ${mode}`);
return { type: 'card', card: STARTED_MOVEMENT_SAPHO, scope: option.scope, event: option.event, mode: option.mode };
}
/** LABELLED supplemental source: the borrowed canonical fixture physically
* conserves Sapho/Semuta custody rather than earning their acquisition. Its
* setup, current shipment, subsequent card disposal and continuation are real.
* No further card, resource, profile, phase or queue assignment is made here. */
export function createSaphoStartedSemutaFixture(advanced = false) {
const inherited = createSemutaHomeworldSaphoFixture('movement', 'last', advanced);
let game = structuredClone(inherited.game);
const actions: SaphoStartedMovementStep[] = [];
const beforeShipment = structuredClone(game), current = game.active; assert.ok(current && current !== inherited.owner);
const sources = nativeShipmentSources(viewGame(game, current), 1, 0); assert.ok(sources);
const step: SaphoStartedMovementStep = { actor: current, action: { type: 'ship', territory: 'polar_sink',
sector: 0, amount: 1, elite: 0, homeworldSources: sources, allyPayment: 0 } };
game = applySemutaHomeworldChoamStep(game, step.actor, step.action, actions);
game = advanceSemutaHomeworldChoam(game, saphoStartedMovementClean, actions);
assert.equal(game.active, current); assert.equal(saphoStartedMovementPlayer(game, current).shipped, true);
assert.equal(saphoStartedMovementPlayer(beforeShipment, current).spice - saphoStartedMovementPlayer(game, current).spice, 2);
const shipment = { before: beforeShipment, step, after: structuredClone(game), steps: structuredClone(actions) };
const option = viewGame(game, inherited.owner).saphoOptions.find(option => option.scope === 'movement' && option.mode === 'last');
assert.ok(option, 'The original owner changes last position in this waiting tail');
const action: Action = { type: 'card', card: inherited.card, scope: option.scope, event: option.event, mode: option.mode };
const originalInventory = saphoStartedMovementInventory(inherited.game);
assert.deepEqual(saphoStartedMovementInventory(game), originalInventory);
return { ...inherited, game, source: structuredClone(game), current, actor: current, action, event: option.event,
advanced, beforeShipment, shipment, originalInventory,
sourceActionCount: inherited.actions.length + actions.length, actions: [...inherited.actions, ...actions],
nativeAcquisition: false,
staging: [...inherited.staging,
'Inherited CONTROLLED conserved Sapho/Semuta custody is supplemental, not a native purchase claim. This new branch actually pays two spice to ship one original Homeworld force to Polar Sink before queued LAST, with no added played-state assignments.'] };
}
/** Finish each actual combined turn once. Following Emperor/Guild turns make
* real paid shipments; an already shipped current actor only completes its
* original turn. Native Guild choices/response controls remain real actions. */
export function finishSaphoStartedMovement(state: Game, fixture: Pick<SaphoStartedMovementFixture, 'owner' | 'guild'>,
options: { guildTake?: boolean; shipFollowing?: boolean } = {}, actions: SaphoStartedMovementStep[] = []) {
let game = state;
const turns: { actor: string; before: Game; shipment: SaphoStartedMovementStep | null;
afterShipment: Game; end: SaphoStartedMovementStep; afterEnd: Game }[] = [];
const timing: { before: Game; step: SaphoStartedMovementStep; declared: Game; after: Game }[] = [];
const act = (actor: string, action: Action) => { game = actSaphoStartedMovement(game, actor, action, actions); };
for (let count = 0; game.phase === 5 && count < 160; count++) {
if (!saphoStartedMovementClean(game)) {
if (!game.response && !game.phaseOpening && game.decision?.kind === 'guildTiming') {
const before = structuredClone(game), step: SaphoStartedMovementStep = { actor: fixture.guild,
action: { type: 'decision', take: options.guildTake ?? false } };
act(step.actor, step.action); const declared = structuredClone(game);
game = advanceSaphoStartedMovement(game, saphoStartedMovementClean, actions);
timing.push({ before, step, declared, after: structuredClone(game) });
} else {
const step = nextSaphoStartedMovementStep(game); assert.ok(step); act(step.actor, step.action);
}
continue;
}
const actor = game.active; assert.ok(actor && !turns.some(turn => turn.actor === actor));
const before = structuredClone(game);
const shipment: SaphoStartedMovementStep | null = options.shipFollowing !== false &&
(actor === fixture.owner || actor === fixture.guild) && !saphoStartedMovementPlayer(game, actor).shipped
? { actor, action: { type: 'ship', territory: actor === fixture.owner ? 'imperial_basin' : 'carthag',
sector: actor === fixture.owner ? 9 : 11, amount: 1, elite: 0, allyPayment: 0 } } : null;
if (shipment) {
act(shipment.actor, shipment.action); game = advanceSaphoStartedMovement(game, saphoStartedMovementClean, actions);
}
const afterShipment = structuredClone(game), end: SaphoStartedMovementStep = { actor, action: { type: 'endMovement' } };
act(end.actor, end.action); turns.push({ actor, before, shipment, afterShipment, end, afterEnd: structuredClone(game) });
}
assert.notEqual(game.phase, 5, 'Every original combined turn must finish finitely');
return { game, turns, timing, actions };
}
+42 -4
View File
@@ -394,7 +394,7 @@ void test('All four AI profiles use the private Sapho first option after a compl
}
});
void test('Shipment and movement commitments both prevent splitting the active combined turn', () => {
void test('after shipment or movement another holder may queue last without splitting the active combined turn', () => {
let g = movement();
g = applyAction(g, 'a', {
type: 'ship',
@@ -403,8 +403,18 @@ void test('Shipment and movement commitments both prevent splitting the active c
amount: 1,
});
assert.equal(g.players[0].shipped, true);
assert.deepEqual(viewGame(g, 'e').saphoOptions, []);
reject(g, 'e', action(g, 'last'));
const shipped = reload(g).players[0];
const shipmentOption = viewGame(g, 'e').saphoOptions.find(option => option.scope === 'movement' && option.mode === 'last');
assert.ok(shipmentOption && shipmentOption.scope === 'movement');
assert.equal(shipmentOption.afterCurrent, 'a');
g = applyAction(reload(g), 'e', { type: 'card', card,
scope: shipmentOption.scope, mode: shipmentOption.mode, event: shipmentOption.event });
assert.equal(g.active, 'a');
assert.deepEqual(g.players[0], shipped);
assert.deepEqual(g.movementRemaining, ['a', 'g', 'e']);
assert.equal(g.players[1].hand.some(c => c.id === card), false);
assert.equal(g.discard.filter(c => c.id === card).length, 1);
let moved = movement();
moved.players[0].forces = { 'arrakeen:10': 1 };
moved.players[0].reserves = 19;
@@ -418,6 +428,17 @@ void test('Shipment and movement commitments both prevent splitting the active c
assert.equal(moved.players[0].moved, 1);
reject(moved, 'e', action(moved, 'last'));
reject(moved, 'e', action(moved, 'first'));
const active = reload(moved).players[0];
const movementOption = viewGame(moved, 'e').saphoOptions.find(option => option.scope === 'movement' && option.mode === 'last');
assert.ok(movementOption && movementOption.scope === 'movement');
assert.equal(movementOption.afterCurrent, 'a');
moved = applyAction(reload(moved), 'e', { type: 'card', card,
scope: movementOption.scope, mode: movementOption.mode, event: movementOption.event });
assert.equal(moved.active, 'a');
assert.deepEqual(moved.players[0], active);
assert.deepEqual(moved.movementRemaining, ['a', 'g', 'e']);
assert.equal(moved.players[1].hand.some(c => c.id === card), false);
assert.equal(moved.discard.filter(c => c.id === card).length, 1);
});
void test('Completed holder and already-first/already-last movement no-ops cannot consume Sapho', () => {
@@ -524,7 +545,7 @@ void test('early Guild timing makes the previous penultimate faction actually la
assert.deepEqual(acted, ['g', 'a', 'e']);
});
void test('a granted early Guild turn becomes ineligible for Sapho interruption after shipment starts', () => {
void test('after Guild shipment another holder may queue last while Guild cannot split its own started turn', () => {
for (const holder of ['a', 'g']) {
let g = movement(holder, true);
g = applyAction(g, 'g', { type: 'decision', take: true });
@@ -538,11 +559,28 @@ void test('a granted early Guild turn becomes ineligible for Sapho interruption
g = applyAction(g, 'g', { type: 'decision', allow: true });
assert.equal(g.players[2].shipped, true);
assert.equal(g.active, 'g');
assert.deepEqual(viewGame(g, holder).saphoOptions, []);
reject(g, holder, action(g, 'last'));
assert.ok(
g.players.find((p) => p.id === holder)!.hand.some((c) => c.id === card),
);
const options = viewGame(g, holder).saphoOptions;
if (holder === 'g') {
assert.deepEqual(options, []);
continue;
}
const option = options.find(option => option.scope === 'movement' && option.mode === 'last');
assert.ok(option && option.scope === 'movement');
assert.equal(option.afterCurrent, 'g');
const before = reload(g);
g = applyAction(reload(g), holder, { type: 'card', card,
scope: option.scope, mode: option.mode, event: option.event });
assert.equal(g.active, 'g');
assert.deepEqual(g.players[2], before.players[2]);
assert.equal(g.guildTimingGranted, before.guildTimingGranted);
assert.equal(g.guildTimingLocked, before.guildTimingLocked);
assert.deepEqual(g.movementRemaining, ['g', 'e', 'a']);
assert.equal(g.players[0].hand.some(c => c.id === card), false);
assert.equal(g.discard.filter(c => c.id === card).length, 1);
}
});
+4 -1
View File
@@ -3,7 +3,7 @@ import { test } from 'node:test';
import { viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import {
actSaphoCyclic, completeSaphoCyclicAuction, createSaphoCyclicAuctionFixture, saphoCyclicAction,
actSaphoCyclic, advanceSaphoCyclic, completeSaphoCyclicAuction, createSaphoCyclicAuctionFixture, saphoCyclicAction,
saphoCyclicBid, saphoCyclicLot, saphoCyclicPlayer as player, type SaphoCyclicAuctionFixture,
} from './fixture-sapho-cyclic-auction';
@@ -144,6 +144,9 @@ void test('genuine pending passes survive Sapho and cannot receive a repeat turn
assert.equal(saphoCyclicLot(game).active,f.owner);
const before = saphoCyclicLot(game);
game = actSaphoCyclic(game,f.owner,saphoCyclicAction(game,'last'));
// Fresh Basic Richese also offers its original public used-card response.
// Finish that real boundary before another bidder acts; do not skip it.
game = advanceSaphoCyclic(game,state=>!state.pendingTreacheryDiscard);
assert.deepEqual(saphoCyclicLot(game).passed,before.passed);
const nextPass = saphoCyclicBid(game,null);
game = actSaphoCyclic(game,nextPass.actor,nextPass.action);
+164
View File
@@ -0,0 +1,164 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { protectOrderedOpportunityLast, reorderOrderedOpportunity } from '../game/ordered-opportunity';
import {
reorderSaphoMovementOrder, saphoMovementOrderEvent, SaphoMovementOrderError,
type SaphoMovementOrderContext,
} from '../game/sapho-movement-order';
function context(started = false): SaphoMovementOrderContext {
return {
queue: {
event: 'movement:3', eligible: ['done', 'active', 'later', 'holder', 'guild'],
remaining: ['active', 'later', 'holder', 'guild'], completed: ['done'],
current: 'active', currentStarted: started, protectedLast: null,
},
started: started ? ['done', 'active'] : ['done'],
guild: { player: 'guild', granted: false, locked: false },
};
}
function reorder(input: SaphoMovementOrderContext, player: string, mode: 'first' | 'last') {
return reorderSaphoMovementOrder(input, { event: saphoMovementOrderEvent(input), player, mode });
}
function reject(input: SaphoMovementOrderContext, player: string, mode: 'first' | 'last', event?: string) {
const before = structuredClone(input);
assert.throws(() => reorderSaphoMovementOrder(input, {
event: event ?? saphoMovementOrderEvent(input), player, mode,
}), SaphoMovementOrderError);
assert.deepEqual(input, before);
}
void test('clean first and last retain the existing immutable ordered-opportunity semantics', () => {
for (const mode of ['first', 'last'] as const) {
const input = context(), before = structuredClone(input);
const quote = reorder(input, 'holder', mode);
const expected = mode === 'first'
? reorderOrderedOpportunity(input.queue, { event: input.queue.event, player: 'holder', position: 'first' })
: protectOrderedOpportunityLast(input.queue, { event: input.queue.event, player: 'holder' });
assert.equal(quote.event, input.queue.event);
assert.equal(quote.afterCurrent, null);
assert.deepEqual(quote.next, expected);
assert.deepEqual(input, before);
assert.notEqual(quote.next.remaining, input.queue.remaining);
assert.notEqual(quote.next.eligible, input.queue.eligible);
assert.notEqual(quote.next.completed, input.queue.completed);
}
const ownCleanTurn = reorder(context(), 'active', 'last');
assert.deepEqual(ownCleanTurn.next.remaining, ['later', 'holder', 'guild', 'active']);
assert.equal(ownCleanTurn.next.protectedLast, 'active');
});
void test('started-tail first preserves the active combined turn and all other relative positions', () => {
const input = context(true), before = structuredClone(input);
const quote = reorder(input, 'holder', 'first');
assert.equal(quote.afterCurrent, 'active');
assert.deepEqual(quote.next.remaining, ['active', 'holder', 'later', 'guild']);
assert.equal(quote.next.current, 'active');
assert.equal(quote.next.currentStarted, true);
assert.equal(quote.next.event, 'movement:3');
assert.notEqual(quote.event, quote.next.event);
assert.ok(quote.event.startsWith('sapho-after:'));
assert.deepEqual(input, before);
});
void test('started-tail last includes Guild while retaining the active turn and base protection event', () => {
const input = context(true), before = structuredClone(input);
const quote = reorder(input, 'holder', 'last');
assert.deepEqual(quote.next.remaining, ['active', 'later', 'guild', 'holder']);
assert.equal(quote.next.current, 'active');
assert.equal(quote.afterCurrent, 'active');
assert.equal(quote.next.protectedLast, 'holder');
assert.equal(quote.next.event, input.queue.event);
assert.deepEqual(input, before);
assert.throws(() => saphoMovementOrderEvent({ ...input, queue: quote.next }), SaphoMovementOrderError);
});
void test('an already started actor cannot use either mode to split its combined turn', () => {
reject(context(true), 'active', 'first');
reject(context(true), 'active', 'last');
const anotherStarted = context(true);
anotherStarted.started = ['active', 'holder'];
reject(anotherStarted, 'later', 'last', 'movement:3');
const inconsistentCurrent = context();
inconsistentCurrent.started = ['active'];
reject(inconsistentCurrent, 'holder', 'first', 'movement:3');
const missingCurrent = context(true);
missingCurrent.started = ['done'];
reject(missingCurrent, 'holder', 'first', 'movement:3');
});
void test('granted Guild priority blocks clean displacement but a started Guild turn stays first during tail reorder', () => {
const input = context();
input.queue = { ...input.queue, remaining: ['guild', 'later', 'holder', 'active'], current: 'guild' };
input.guild = { player: 'guild', granted: true, locked: false };
reject(input, 'holder', 'first');
assert.deepEqual(reorder(input, 'holder', 'last').next.remaining, ['guild', 'later', 'active', 'holder']);
input.queue = { ...input.queue, currentStarted: true };
input.started = ['done', 'guild'];
const before = structuredClone(input), quote = reorder(input, 'holder', 'first');
assert.deepEqual(quote.next.remaining, ['guild', 'holder', 'later', 'active']);
assert.equal(quote.next.current, 'guild');
assert.equal(quote.afterCurrent, 'guild');
assert.deepEqual(input, before);
});
void test('ordinary Guild timing flags and Basic null timing retain their own later native opportunities', () => {
const input = context(), before = structuredClone(input);
assert.deepEqual(reorder(input, 'holder', 'first').next.remaining, ['holder', 'active', 'later', 'guild']);
assert.deepEqual(input.guild, { player: 'guild', granted: false, locked: false });
assert.deepEqual(input, before);
const basic = { ...context(), guild: null };
assert.deepEqual(reorder(basic, 'holder', 'last').next.remaining, ['active', 'later', 'guild', 'holder']);
const locked = context();
locked.queue = { ...locked.queue, remaining: ['guild', 'later', 'holder', 'active'], current: 'guild' };
locked.guild = { player: 'guild', granted: true, locked: true };
assert.equal(reorder(locked, 'holder', 'first').next.current, 'holder');
});
void test('started event binds the base phase, active actor and complete public tail with encoded identities', () => {
const input = context(true), event = saphoMovementOrderEvent(input);
assert.equal(saphoMovementOrderEvent(structuredClone(input)), event);
assert.equal(saphoMovementOrderEvent({ ...input, started: ['active'] }), event);
reject(input, 'holder', 'first', input.queue.event);
const reordered = context(true);
reordered.queue = { ...reordered.queue, remaining: ['active', 'later', 'guild', 'holder'] };
reject(reordered, 'holder', 'first', event);
const nextPhase = context(true);
nextPhase.queue = { ...nextPhase.queue, event: 'movement:4' };
reject(nextPhase, 'holder', 'first', event);
const differentCurrent = context(true);
differentCurrent.queue = { ...differentCurrent.queue, remaining: ['later', 'active', 'holder', 'guild'], current: 'later' };
differentCurrent.started = ['later'];
reject(differentCurrent, 'holder', 'last', event);
const encoded = context(true);
const rename = (id: string) => id === 'active' ? 'actor:/"[]' : id;
encoded.queue = { ...encoded.queue, eligible: encoded.queue.eligible.map(rename),
remaining: encoded.queue.remaining.map(rename), current: rename('active') };
encoded.started = [rename('active')];
assert.notEqual(saphoMovementOrderEvent(encoded), event);
assert.ok(saphoMovementOrderEvent(encoded).includes('%22'));
});
void test('no-op, completed, foreign, protected and malformed requests cannot consume Sapho', () => {
reject(context(), 'active', 'first');
reject(context(), 'guild', 'last');
reject(context(true), 'later', 'first');
reject(context(true), 'guild', 'last');
reject(context(), 'done', 'last');
reject(context(), 'foreign', 'first');
const protectedLast = context();
protectedLast.queue = { ...protectedLast.queue, protectedLast: 'guild' };
reject(protectedLast, 'holder', 'first', 'movement:3');
const unknownStarted = context();
unknownStarted.started = ['foreign'];
reject(unknownStarted, 'holder', 'first', 'movement:3');
const duplicateStarted = context(true);
duplicateStarted.started = ['active', 'active'];
reject(duplicateStarted, 'holder', 'last', 'movement:3');
const malformed = context();
malformed.queue = { ...malformed.queue, current: 'holder' };
reject(malformed, 'holder', 'first', 'movement:3');
const finished = context();
finished.queue = { ...finished.queue, remaining: [], completed: finished.queue.eligible, current: null };
reject(finished, 'holder', 'first', 'movement:3');
});
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { richeseCards } from '../game/richese-cards';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { JuiceOfSapho, SaphoOrderButton } = await import('../components/juice-of-sapho');
const foundSapho = richeseCards().find(card => card.effect === 'juiceOfSapho');
assert.ok(foundSapho);
const sapho = foundSapho;
type Option = NonNullable<GameView['saphoOptions']>[number];
/** Controlled owner-view override for component contracts only. The native
* engine suite owns the started-turn producer, queue and physical card path. */
function projected(mode: 'first' | 'last' = 'first', queued = true, advanced = true): GameView {
const lobby = createGame('SAPHOCONTROLS', newPlayer('holder', 'Sapho holder', 'atreides'), advanced);
joinGame(lobby, newPlayer('active', 'Active Emperor', 'emperor'));
joinGame(lobby, newPlayer('guild', 'Guild', 'guild'));
const game = viewGame(lobby, 'holder');
game.status = 'playing';
game.phase = 5;
game.active = 'active';
game.players.find(player => player.id === game.me)!.hand = [{ ...sapho }];
game.saphoOptions = [{ scope: 'movement', event: 'original-movement-event', mode,
...(queued ? { afterCurrent: 'active' } : {}) }];
return game;
}
const panel = (game: GameView, busy = false) =>
renderToStaticMarkup(createElement(JuiceOfSapho, { game, busy, act() {} }));
/** Invoke the real hook-free button used by JuiceOfSapho, without replacing
* React hooks or the component's callback. */
function press(option: Option, busy = false): Action[] {
const actions: Action[] = [];
const button = SaphoOrderButton({ card: sapho!.id, option, busy,
act(action) { actions.push(action); } });
button.props.onClick();
return actions;
}
void test('queued first and last keep their button labels and persistent named-player timing guidance', () => {
for (const mode of ['first', 'last'] as const) {
const game = projected(mode);
for (let render = 0; render < 2; render++) {
const html = panel(game);
assert.match(html, new RegExp(`Take your movement turn ${mode} and discard Juice of Sapho`));
assert.match(html, /Provisional queued movement order: Active Emperor finishes the current combined shipment and movement turn unchanged/);
assert.match(html, new RegExp(`Your ${mode} position applies among later unstarted combined turns`));
assert.match(html, /Completed turns and actual shipment and movement counters stay unchanged/);
}
}
});
void test('actual queued button callbacks submit exactly the existing action fields without display metadata', () => {
for (const mode of ['first', 'last'] as const) {
const option = projected(mode).saphoOptions[0];
const expected: Action = { type: 'card', card: sapho.id, scope: 'movement', event: option.event, mode };
assert.deepEqual(press(option), [expected]);
assert.deepEqual(Object.keys(press(option)[0]).sort(), ['card', 'event', 'mode', 'scope', 'type']);
}
});
void test('busy queued controls retain their explanation, render disabled and cannot dispatch', () => {
const game = projected('last');
const html = panel(game, true);
assert.match(html, /disabled=""[^>]*>Take your movement turn last and discard Juice of Sapho/);
assert.match(html, /Active Emperor finishes the current combined shipment and movement turn unchanged/);
assert.deepEqual(press(game.saphoOptions[0], true), []);
});
void test('Advanced Guild timing remains at the next boundary and last remains after the Guild', () => {
for (const mode of ['first', 'last'] as const) {
assert.match(panel(projected(mode)), /The Advanced Guild keeps its native timing choice at the next boundary/);
assert.match(panel(projected(mode)), /A last position still follows the Guild/);
}
assert.doesNotMatch(panel(projected('first', true, false)), /The Advanced Guild keeps its native timing choice/);
});
void test('clean movement options keep their existing description and exact action payload', () => {
for (const mode of ['first', 'last'] as const) {
const game = projected(mode, false);
const html = panel(game);
assert.match(html, new RegExp(`Take the ${mode} remaining combined shipment and movement turn`));
assert.doesNotMatch(html, /Provisional queued movement order|Active Emperor finishes/);
assert.deepEqual(press(game.saphoOptions[0]), [{ type: 'card', card: sapho.id,
scope: 'movement', event: 'original-movement-event', mode }]);
}
});
void test('unavailable movement guidance covers clean and queued windows without splitting turns or preparations', () => {
const game = projected();
game.saphoOptions = [];
const html = panel(game);
assert.match(html, /clean combined-turn boundaries and queuing behind a started active turn/);
assert.match(html, /Finish pending interactions and preparations first/);
assert.match(html, /Sapho never splits an active combined shipment and movement turn/);
assert.match(html, /last remains after the Guild/);
assert.doesNotMatch(html, /Take your movement turn (first|last) and discard/);
game.players.find(player => player.id === game.me)!.hand = [];
assert.equal(panel(game), '');
});
aliases.deregister();
+211
View File
@@ -0,0 +1,211 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import {
actSaphoStartedMovement as act, advanceSaphoStartedMovement as advance,
createSaphoStartedMovementFixture as fixture, createSaphoStartedSemutaFixture,
finishSaphoStartedMovement as finish, saphoStartedMovementAction as action,
saphoStartedMovementClean as clean, saphoStartedMovementPlayer as player,
saphoStartedMovementInventory as inventory, type SaphoStartedMovementFixture,
} from './fixture-sapho-started-movement';
import { advanceSemutaHomeworldChoam, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
const resources = (game: Game) => ({ players: game.players.map(p => ({ id: p.id, spice: p.spice,
reserves: p.reserves, tanks: p.tanks, forces: p.forces, elites: p.elites,
shipped: p.shipped, moved: p.moved })), homeworlds: game.homeworlds,
hajr: game.hajr, karamaShipping: game.karamaShipping, ornithopter: game.ornithopter });
const effectiveQueue = (game: Game) => [game.active!, ...game.movementRemaining!.filter(id => id !== game.active)];
const queuedOption = (game: Game, owner: string, mode: 'first' | 'last') => {
const option = viewGame(game, owner).saphoOptions.find(option => option.scope === 'movement' && option.mode === mode);
assert.ok(option); return option as typeof option & { afterCurrent?: string };
};
function reject(game: Game, actor: string, proposed: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, proposed)); assert.deepEqual(game, before);
}
function expectedQueue(game: Game, owner: string, mode: 'first' | 'last') {
const current = game.active!, tail = effectiveQueue(game).slice(1).filter(id => id !== owner);
return [current, ...(mode === 'first' ? [owner, ...tail] : [...tail, owner])];
}
function source(f: SaphoStartedMovementFixture) {
assert.equal(f.game.advanced, f.advanced); assert.equal(f.setup.advanced, f.advanced);
assert.ok(f.actions.some(step => step.action.type === 'start' && !!step.action.advancedPreview === f.advanced));
assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.equal(f.beforeStartingDeal.deck[1].id, f.counter);
assert.ok(player(f.afterSetup, f.canceler).hand.some(card => card.id === f.counter));
assert.equal(f.acquisition.bid.actor, f.owner); assert.equal(f.acquisition.bid.action.amount, 1);
assert.equal(player(f.acquisition.before, f.owner).spice - player(f.acquired, f.owner).spice, 1);
assert.equal(player(f.acquired, 'richese').spice - player(f.acquisition.before, 'richese').spice, 1);
assert.equal(f.shipment.step.actor, f.current); assert.equal(f.shipment.step.action.amount, 1);
assert.equal(player(f.beforeShipment, f.current).shipped, false);
assert.equal(player(f.game, f.current).shipped, true); assert.equal(player(f.game, f.current).moved, 0);
assert.equal(player(f.game, f.current).forces['polar_sink:0'], 1);
assert.equal(player(f.beforeShipment, f.current).reserves - player(f.game, f.current).reserves, 1);
assert.equal(player(f.beforeShipment, f.current).spice - player(f.game, f.current).spice, f.guildTake ? 1 : 2);
assert.equal(f.game.active, f.current); assert.equal(player(f.game, f.owner).shipped, false);
assert.equal(player(f.game, f.owner).moved, 0);
assert.ok(player(f.game, f.owner).hand.some(card => card.id === f.card));
assert.ok(player(f.game, f.canceler).hand.some(card => card.id === f.counter));
assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(inventory(f.game), f.originalInventory);
}
function ordered(f: SaphoStartedMovementFixture, mode: 'first' | 'last') {
const option = queuedOption(f.game, f.owner, mode), proposed = action(f.game, mode), before = resources(f.game);
assert.equal(option.afterCurrent, f.current); assert.equal('afterCurrent' in proposed, false);
assert.deepEqual(Object.keys(proposed).sort(), ['card', 'event', 'mode', 'scope', 'type']);
assert.notEqual(option.event, `movement:${f.game.turn}`, 'The started-tail quote binds its current and remaining actors');
const game = act(structuredClone(f.game), f.owner, proposed);
assert.deepEqual(game.movementRemaining, expectedQueue(f.game, f.owner, mode));
assert.equal(game.active, f.current); assert.deepEqual(player(game, f.current), player(f.game, f.current));
assert.deepEqual(resources(game), before); assert.deepEqual(game.order, f.game.order);
assert.equal(game.guildTimingGranted, f.game.guildTimingGranted); assert.equal(game.guildTimingLocked, f.game.guildTimingLocked);
assert.equal(game.decision, null); assert.equal(game.response, null);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(game, f.owner).hand.some(card => card.id === f.card), false);
assert.deepEqual(inventory(game), f.originalInventory);
if (mode === 'last') assert.deepEqual(game.saphoMovementLast, { event: `movement:${game.turn}`, turn: game.turn, player: f.owner });
else assert.ok(!game.saphoMovementLast);
return game;
}
function complete(f: SaphoStartedMovementFixture, game: Game, mode: 'first' | 'last', guildTake = false) {
const result = finish(game, f, { guildTake });
assert.notEqual(result.game.phase, 5); assert.deepEqual(result.game.movementRemaining, []);
assert.deepEqual(result.game.order, f.game.order); assert.deepEqual(inventory(result.game), f.originalInventory);
assert.equal(result.turns[0].actor, f.current); assert.equal(result.turns[0].shipment, null);
assert.deepEqual(player(result.turns[0].afterShipment, f.current), player(game, f.current));
assert.equal(result.actions.filter(step => step.action.type === 'endMovement').length, result.turns.length);
assert.equal(new Set(result.turns.map(turn => turn.actor)).size, result.turns.length);
assert.deepEqual(result.turns.map(turn => turn.actor).sort(), [...effectiveQueue(f.game)].sort());
assert.equal(player(result.game, f.current).forces['polar_sink:0'], 1);
assert.equal(player(result.game, f.current).shipped, true); assert.equal(player(result.game, f.current).moved, 0);
assert.equal(result.actions.some(step => step.actor === f.current && step.action.type === 'ship'), false);
assert.equal(result.game.discard.filter(card => card.id === f.card).length, 1);
const own = result.turns.find(turn => turn.actor === f.owner)!; assert.ok(own.shipment);
assert.equal(player(own.before, f.owner).spice - player(own.afterShipment, f.owner).spice, 2);
assert.equal(player(result.game, f.owner).forces['imperial_basin:9'], 1);
if (!guildTake || f.guildTake || mode === 'last')
assert.deepEqual(result.turns.map(turn => turn.actor), expectedQueue(f.game, f.owner, mode));
if (mode === 'last') assert.equal(result.turns.at(-1)!.actor, f.owner);
return result;
}
for (const advanced of [false, true]) for (const mode of ['first', 'last'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} Sapho ${mode} waits behind a genuinely paid current turn and finishes the native queue`, () => {
const f = fixture({ advanced }); source(f);
const game = ordered(f, mode), result = complete(f, game, mode);
if (advanced) {
assert.equal(f.timing.length, 1); assert.equal(f.timing[0].step.action.take, false);
assert.ok(result.timing.every(timing => timing.step.action.take === false));
} else assert.equal(result.timing.length, 0);
});
}
void test('queued Sapho first leaves an unspent Advanced Guild its actual next-boundary TAKE before the waiting holder', () => {
const f = fixture({ advanced: true }); source(f);
const game = ordered(f, 'first'), result = complete(f, game, 'first', true);
assert.equal(result.timing[0].step.action.take, true);
assert.equal(result.timing[0].declared.response?.kind, 'guildTiming');
assert.equal(result.timing[0].after.active, f.guild);
assert.deepEqual(result.turns.map(turn => turn.actor), [f.current, f.guild, f.owner, 'atreides']);
assert.ok(result.actions.some(step => step.action.type === 'passResponse'));
const guild = result.turns.find(turn => turn.actor === f.guild)!; assert.ok(guild.shipment);
assert.equal(player(guild.before, f.guild).spice - player(guild.afterShipment, f.guild).spice, 1);
});
void test('a genuinely taken and paid Advanced Guild turn stays whole while Sapho first changes only its waiting tail', () => {
const f = fixture({ advanced: true, guildTake: true }); source(f);
assert.equal(f.current, f.guild); assert.equal(f.game.guildTimingGranted, true);
assert.equal(f.timing[0].declared.response?.kind, 'guildTiming');
const result = complete(f, ordered(f, 'first'), 'first');
assert.deepEqual(result.turns.map(turn => turn.actor), [f.guild, f.owner, 'richese', 'atreides']);
assert.equal(result.turns[0].shipment, null);
});
for (const advanced of [false, true]) {
const label = advanced ? 'Advanced' : 'Basic';
void test(`${label} queued Sapho event expires when the actual current turn ends with the physical card retained`, () => {
const f = fixture({ advanced }), proposed = action(f.game, 'first');
let game = act(structuredClone(f.game), f.current, { type: 'endMovement' });
game = advance(game, clean);
assert.notEqual(game.active, f.current);
assert.notEqual(queuedOption(game, f.owner, 'first').event, proposed.event);
reject(game, f.owner, proposed);
assert.ok(player(game, f.owner).hand.some(card => card.id === f.card));
assert.deepEqual(inventory(game), f.originalInventory);
});
void test(`${label} an actual active Sapho holder cannot split its own already paid combined turn`, () => {
const f = fixture({ advanced });
let game = advance(structuredClone(f.game), state => clean(state) && state.active === f.owner);
const proposed = action(game, 'last');
game = act(game, f.owner, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 });
game = advance(game, clean);
assert.equal(game.active, f.owner); assert.equal(player(game, f.owner).shipped, true);
assert.equal(viewGame(game, f.owner).saphoOptions.some(option => option.scope === 'movement'), false);
reject(game, f.owner, proposed);
assert.ok(player(game, f.owner).hand.some(card => card.id === f.card));
});
}
void test('already first and last waiting positions cannot spend Sapho through no-op or projected-only action fields', () => {
const f = fixture();
let first = act(structuredClone(f.game), f.current, { type: 'endMovement' });
first = advance(first, clean); assert.equal(first.active, 'atreides');
first = act(first, 'atreides', { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 });
first = advance(first, clean);
const lastOption = queuedOption(first, f.owner, 'last');
assert.equal(viewGame(first, f.owner).saphoOptions.some(option => option.scope === 'movement' && option.mode === 'first'), false);
reject(first, f.owner, { type: 'card', card: f.card, scope: 'movement', mode: 'first', event: lastOption.event });
const g = fixture({ advanced: true, guildTake: true }), firstOption = queuedOption(g.game, g.owner, 'first');
assert.equal(viewGame(g.game, g.owner).saphoOptions.some(option => option.scope === 'movement' && option.mode === 'last'), false);
reject(g.game, g.owner, { type: 'card', card: g.card, scope: 'movement', mode: 'last', event: firstOption.event });
reject(f.game, f.owner, { ...action(f.game, 'first'), afterCurrent: f.current });
assert.ok(player(f.game, f.owner).hand.some(card => card.id === f.card));
});
void test('all four minimal profiles use actual projected queued Sapho choices without sending afterCurrent', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced });
for (const difficulty of DIFFICULTIES) {
const own = viewGame(f.game, f.owner); own.players.find(p => p.id === f.owner)!.bot = difficulty;
const proposed = botActions(own).find(proposed => proposed.type === 'card' && proposed.card === f.card && proposed.scope === 'movement');
assert.ok(proposed, `${difficulty} answers the real queued opportunity`);
assert.equal('afterCurrent' in proposed, false);
const option = own.saphoOptions.find(option => option.event === proposed.event && option.mode === proposed.mode);
assert.ok(option); assert.equal((option as typeof option & { afterCurrent?: string }).afterCurrent, f.current);
const game = act(structuredClone(f.game), f.owner, proposed);
assert.equal(game.active, f.current); assert.deepEqual(resources(game), resources(f.game));
assert.deepEqual(inventory(game), f.originalInventory);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
}
}
});
for (const claim of [false, true]) void test(
`labelled conserved-custody Homeworld supplement: Semuta ${claim ? 'claim' : 'pass'} resumes queued LAST behind an actual paid current shipment`, () => {
const f = createSaphoStartedSemutaFixture(true), before = resources(f.game), queue = expectedQueue(f.game, f.owner, 'last');
assert.equal(f.nativeAcquisition, false); assert.ok(f.staging.some(line => line.includes('CONTROLLED')));
assert.equal(queuedOption(f.game, f.owner, 'last').afterCurrent, f.current);
const offered = act(structuredClone(f.game), f.owner, f.action);
assert.ok(offered.pendingTreacheryDiscard); assert.equal(offered.active, f.current);
assert.deepEqual(offered.movementRemaining, queue); assert.deepEqual(resources(offered), before);
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
reject(offered, f.current, { type: 'endMovement' });
const event = offered.pendingTreacheryDiscard.batch.event;
const resumed = reactSemutaHomeworldChoam(offered, f.holder, claim);
assert.ok(!resumed.pendingTreacheryDiscard); assert.equal(resumed.active, f.current);
assert.deepEqual(resumed.movementRemaining, queue); assert.deepEqual(resources(resumed), before);
assert.deepEqual(inventory(resumed), f.originalInventory);
assert.equal(player(resumed, f.holder).hand.filter(card => card.id === f.card).length, Number(claim));
assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
assert.deepEqual(resumed.saphoMovementLast, { event: `movement:${resumed.turn}`, turn: resumed.turn, player: f.owner });
reject(resumed, f.holder, { type: 'semutaCommit', event });
const actions: { actor: string; action: Action }[] = [];
const done = advanceSemutaHomeworldChoam(resumed, state => state.phase > 5 && clean(state), actions);
assert.equal(actions.some(step => step.actor === f.current && step.action.type === 'ship'), false);
assert.equal(actions.filter(step => step.actor === f.current && step.action.type === 'endMovement').length, 1);
assert.equal(player(done, f.current).forces['polar_sink:0'], 1);
assert.deepEqual(inventory(done), f.originalInventory);
},
);
+10 -2
View File
@@ -187,7 +187,7 @@ for (const advanced of [false, true]) for (const scope of scopes) for (const mod
}
for (const advanced of [false, true]) for (const mode of modes) void test(
`${advanced ? 'Advanced' : 'Basic'} Homeworld Sapho ${mode}: actual paid current shipment closes the unstarted Movement window`, () => {
`${advanced ? 'Advanced' : 'Basic'} Homeworld Sapho ${mode}: paid shipment expires the old unstarted quote and new tail options preserve the current turn`, () => {
const f = createSemutaHomeworldSaphoFixture('movement', mode, advanced);
const origin = 'red_chasm:7';
const actor = f.game.active!;
@@ -199,8 +199,16 @@ for (const advanced of [false, true]) for (const mode of modes) void test(
game => !game.response && !game.decision && !game.pendingTreacheryDiscard && !game.phaseOpening);
assert.equal(player(started, actor).shipped, true);
const paid = resources(started), original = stock(started);
assert.ok(!viewGame(started, f.owner).saphoOptions.some(option => option.scope === 'movement'));
const options = viewGame(started, f.owner).saphoOptions.filter(option => option.scope === 'movement');
assert.ok(options.length > 0, 'The unstarted holder can reorder later unstarted combined turns');
assert.ok(options.every(option => option.scope === 'movement' && option.afterCurrent === actor));
assert.ok(options.every(option => option.event !== f.action.event),
'Queued tail options have a new source; the original unstarted quote expires');
const queue = [...started.movementRemaining!];
assert.equal(started.active, actor);
assert.throws(() => applyAction(started, f.owner, f.action));
assert.equal(started.active, actor);
assert.deepEqual(started.movementRemaining, queue);
assert.deepEqual(resources(started), paid);
assert.deepEqual(stock(started), original);
assert.equal(player(started, f.owner).hand.some(card => card.id === f.card), true);