Allow Sapho order behind a started turn
This commit is contained in:
1 parent
3bf7d26e80
commit
8e6602633d
17 files changed
+1121
-62
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user