Resume suspended movement after Sapho recovery
This commit is contained in:
1 parent
26be6fd8ee
commit
ea910e9c82
11 files changed
+267
-17
No files matched your search
@@ -41,7 +41,7 @@ export function advanceSaphoStartedMovement(game: Game, until: (state: Game) =>
|
||||
* shipment. Only unopened original deal/Spice ordering and original scalar
|
||||
* lotteries are selected; no played custody, resources, phase or queue is set. */
|
||||
export function createSaphoStartedMovementFixture(options: { advanced?: boolean; guildTake?: boolean;
|
||||
truthFor?: readonly ('richese' | 'guild')[] } = {}) {
|
||||
truthFor?: readonly ('richese' | 'guild')[]; ownerBeforeAtreides?: boolean } = {}) {
|
||||
const advanced = options.advanced ?? false, guildTake = options.guildTake ?? false;
|
||||
assert.ok(!guildTake || advanced, 'Flexible Guild TAKE is an original Advanced rule');
|
||||
const owner = 'emperor', guild = 'guild', canceler = 'atreides';
|
||||
@@ -50,6 +50,11 @@ export function createSaphoStartedMovementFixture(options: { advanced?: boolean;
|
||||
const actions: SaphoStartedMovementStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSaphoStartedMovement(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSaphoStartedMovement(game, until, actions); };
|
||||
if (options.ownerBeforeAtreides) {
|
||||
act('atreides', { type: 'seatPosition', position: 5 });
|
||||
act(owner, { type: 'seatPosition', position: 2 });
|
||||
act('atreides', { type: 'seatPosition', position: 3 });
|
||||
}
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
@@ -131,6 +136,7 @@ export function createSaphoStartedMovementFixture(options: { advanced?: boolean;
|
||||
lobby, setup, beforeStartingDeal, startingTruthSelection, afterSetup, acquired, acquisition, timing,
|
||||
originalInventory, originalCards: originalInventory, actions, steps: actions, sourceActionCount: actions.length,
|
||||
staging: [
|
||||
...(options.ownerBeforeAtreides ? ['The original lobby uses real seat-position actions to seat Emperor before Atreides; default seating is unchanged.'] : []),
|
||||
'Actual public ready/start (Advanced uses the original preview acknowledgement), original setup and paid cache1 purchase give Emperor the physical Sapho. Only an unopened original Atreides Karama deal position, one Basic/two Advanced original land Spice positions and scoped scalar lotteries are selected.',
|
||||
`The original ${advanced ? guildTake ? 'Guild TAKE' : 'Guild WAIT' : 'Basic in-order turn'} is followed by an actual one-force Polar Sink shipment costing ${current === guild ? 1 : 2}. The source stops after all original shipment responses settle, before Sapho or the current actor’s ground move/end. No dealt hand, balance, force, phase, queue, timing grant or pending receipt is installed.`,
|
||||
] };
|
||||
|
||||
@@ -37,7 +37,9 @@ void test('another seat cannot replace an existing Last, and replay cannot dupli
|
||||
const guild = order(); guild.guild = { player: 'active', granted: true, locked: false };
|
||||
assert.throws(() => reorder(guild, 'owner', 'first'), SaphoMovementOrderError);
|
||||
const suspended = order(); suspended.started = ['done', 'owner'];
|
||||
assert.throws(() => saphoMovementOrderEvent(suspended), SaphoMovementOrderError);
|
||||
const resumed = reorder(suspended, 'owner', 'first');
|
||||
assert.equal(resumed.next.current, 'owner');
|
||||
assert.equal(resumed.next.currentStarted, true);
|
||||
});
|
||||
|
||||
function movementAction(game: Game, actor: string, mode: 'first' | 'last'): Action {
|
||||
|
||||
@@ -57,7 +57,10 @@ void test('started self-last needs explicit Basic admission and cannot create a
|
||||
assert.equal(request(advanced).next.protectedLast, 'active');
|
||||
const proposed = request(input);
|
||||
const already = { ...input, queue: proposed.next, started: ['done', 'active'] };
|
||||
assert.throws(() => saphoMovementOrderEvent(already), SaphoMovementOrderError);
|
||||
const resumed = request(already, 'active', 'first');
|
||||
assert.equal(resumed.next.current, 'active');
|
||||
assert.equal(resumed.next.currentStarted, true);
|
||||
assert.equal(resumed.next.protectedLast, null);
|
||||
});
|
||||
|
||||
void test('a started actor is bound to its current phase, actor and whole remaining tail', () => {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
reorderSaphoMovementOrder, saphoMovementOrderEvent, SaphoMovementOrderError,
|
||||
type SaphoMovementOrderContext,
|
||||
} from '../game/sapho-movement-order';
|
||||
|
||||
function context(currentStarted = false): SaphoMovementOrderContext {
|
||||
return {
|
||||
queue: {
|
||||
event: 'movement:3', eligible: ['done', 'current', 'later', 'holder'],
|
||||
remaining: ['current', 'later', 'holder'], completed: ['done'],
|
||||
current: 'current', currentStarted, protectedLast: 'holder',
|
||||
},
|
||||
started: currentStarted ? ['done', 'current', 'holder'] : ['done', 'holder'],
|
||||
guild: null,
|
||||
};
|
||||
}
|
||||
function change(input: SaphoMovementOrderContext, player = 'holder', mode: 'first' | 'last' = 'first') {
|
||||
return reorderSaphoMovementOrder(input, { event: saphoMovementOrderEvent(input), player, mode });
|
||||
}
|
||||
|
||||
void test('suspended holder resumes its existing started turn when First becomes current', () => {
|
||||
const input = context(), before = structuredClone(input), result = change(input);
|
||||
assert.deepEqual(result.next.remaining, ['holder', 'current', 'later']);
|
||||
assert.deepEqual(result.next.completed, ['done']);
|
||||
assert.equal(result.next.current, 'holder');
|
||||
assert.equal(result.next.currentStarted, true);
|
||||
assert.equal(result.next.protectedLast, null);
|
||||
assert.equal(result.afterCurrent, null);
|
||||
assert.equal(result.suspended, undefined);
|
||||
assert.deepEqual(input, before);
|
||||
});
|
||||
|
||||
void test('another started actor finishes before the suspended holder resumes', () => {
|
||||
const input = context(true), before = structuredClone(input), result = change(input);
|
||||
assert.deepEqual(result.next.remaining, ['current', 'holder', 'later']);
|
||||
assert.deepEqual(result.next.completed, ['done']);
|
||||
assert.equal(result.next.current, 'current');
|
||||
assert.equal(result.next.currentStarted, true);
|
||||
assert.equal(result.next.protectedLast, null);
|
||||
assert.equal(result.afterCurrent, 'current');
|
||||
assert.deepEqual(input, before);
|
||||
});
|
||||
|
||||
void test('only the same holder can replace its protected suspended Last', () => {
|
||||
const input = context(), before = structuredClone(input);
|
||||
assert.throws(() => change(input, 'later', 'first'), SaphoMovementOrderError);
|
||||
assert.throws(() => change(input, 'later', 'last'), SaphoMovementOrderError);
|
||||
assert.throws(() => change(input, 'holder', 'last'), SaphoMovementOrderError);
|
||||
assert.throws(() => reorderSaphoMovementOrder(input,
|
||||
{ event: 'movement:4', player: 'holder', mode: 'first' }), SaphoMovementOrderError);
|
||||
assert.deepEqual(input, before);
|
||||
});
|
||||
|
||||
void test('unrelated started tails and inconsistent current start are rejected', () => {
|
||||
const unrelated = context(); unrelated.started = ['done', 'later', 'holder'];
|
||||
assert.throws(() => saphoMovementOrderEvent(unrelated), SaphoMovementOrderError);
|
||||
const noProtection = context(); noProtection.queue = { ...noProtection.queue, protectedLast: null };
|
||||
assert.throws(() => saphoMovementOrderEvent(noProtection), SaphoMovementOrderError);
|
||||
const badCurrent = context(); badCurrent.started = ['current', 'holder'];
|
||||
assert.throws(() => saphoMovementOrderEvent(badCurrent), SaphoMovementOrderError);
|
||||
});
|
||||
|
||||
void test('a granted current Guild turn cannot be displaced by suspended First', () => {
|
||||
const granted = context();
|
||||
granted.guild = { player: 'current', granted: true, locked: false };
|
||||
assert.throws(() => change(granted), SaphoMovementOrderError);
|
||||
const currentStarted = context(true);
|
||||
currentStarted.guild = { player: 'current', granted: true, locked: false };
|
||||
assert.equal(change(currentStarted).afterCurrent, 'current');
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
advanceSaphoStartedMovement as advance, createSaphoStartedMovementFixture as fixture,
|
||||
finishSaphoStartedMovement as finish, saphoStartedMovementClean as clean,
|
||||
saphoStartedMovementInventory as inventory, saphoStartedMovementPlayer as player,
|
||||
STARTED_MOVEMENT_SAPHO as SAPHO,
|
||||
} from './fixture-sapho-started-movement';
|
||||
|
||||
function sapho(game: Game, owner: string, mode: 'first' | 'last'): Action {
|
||||
const option = viewGame(game, owner).saphoOptions.find(row => row.scope === 'movement' && row.mode === mode);
|
||||
assert.ok(option, `Sapho ${mode} must be available to the physical holder`);
|
||||
return { type: 'card', card: SAPHO, scope: 'movement', event: option.event, mode };
|
||||
}
|
||||
|
||||
for (const startedOpponent of [false, true]) {
|
||||
void test(`Basic suspended Sapho holder recovers and replays First behind ${startedOpponent ? 'a started' : 'an unstarted'} current turn`, () => {
|
||||
const f = fixture({ ownerBeforeAtreides: true });
|
||||
let game = structuredClone(f.game);
|
||||
// Only Box ownership is staged: exchange its original unopened cache card
|
||||
// for another physical card from the owner's hand, preserving all cards.
|
||||
const holder = player(game, f.owner);
|
||||
const boxIndex = game.richeseCache!.findIndex(card => card.effect === 'nullentropyBox');
|
||||
const exchangeIndex = holder.hand.findIndex(card => card.id !== SAPHO);
|
||||
assert.ok(boxIndex >= 0 && exchangeIndex >= 0);
|
||||
const [box] = game.richeseCache!.splice(boxIndex, 1);
|
||||
game.richeseCache!.push(holder.hand.splice(exchangeIndex, 1)[0]); holder.hand.push(box);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
|
||||
// Advance through the original turns, then make the owner's actual paid
|
||||
// shipment before suspending that same combined turn to Last.
|
||||
game = advance(game, state => clean(state) && state.active === f.owner);
|
||||
const beforeShipment = structuredClone(game);
|
||||
game = applyAction(game, f.owner, { type: 'ship', territory: 'polar_sink', sector: 0,
|
||||
amount: 1, elite: 0, allyPayment: 0 });
|
||||
game = advance(game, clean);
|
||||
assert.equal(player(game, f.owner).shipped, true);
|
||||
assert.equal(player(game, f.owner).reserves, player(beforeShipment, f.owner).reserves - 1);
|
||||
assert.equal(player(game, f.owner).spice, player(beforeShipment, f.owner).spice - 2);
|
||||
let movedTo: string | undefined;
|
||||
if (startedOpponent) {
|
||||
for (const territory of TERRITORIES) {
|
||||
for (const sector of territory.sectors) {
|
||||
try {
|
||||
game = applyAction(game, f.owner, { type: 'move', from: 'polar_sink:0', territory: territory.id,
|
||||
sector, amount: 1, elite: 0 });
|
||||
movedTo = `${territory.id}:${sector}`;
|
||||
break;
|
||||
} catch { /* Try another destination through the native movement validator. */ }
|
||||
}
|
||||
if (movedTo) break;
|
||||
}
|
||||
assert.ok(movedTo, 'The real shipped force must have a legal destination.');
|
||||
game = advance(game, clean);
|
||||
assert.equal(player(game, f.owner).moved, 1);
|
||||
}
|
||||
const ownerAfterShipment = structuredClone(player(game, f.owner));
|
||||
const remainingBeforeLast = [...game.movementRemaining!];
|
||||
game = applyAction(game, f.owner, sapho(game, f.owner, 'last'));
|
||||
game = advance(game, clean);
|
||||
assert.equal(game.saphoMovementLast?.player, f.owner);
|
||||
assert.equal(game.movementRemaining!.at(-1), f.owner);
|
||||
assert.notEqual(game.active, f.owner);
|
||||
assert.ok(game.discard.some(card => card.id === SAPHO));
|
||||
|
||||
const current = game.active!;
|
||||
if (startedOpponent) {
|
||||
game = applyAction(game, current, { type: 'ship', territory: 'polar_sink', sector: 0,
|
||||
amount: 1, elite: 0, allyPayment: 0 });
|
||||
game = advance(game, clean);
|
||||
assert.equal(game.active, current);
|
||||
assert.equal(player(game, current).shipped, true);
|
||||
}
|
||||
const currentBeforeReplay = structuredClone(player(game, current));
|
||||
const beforeBox = player(game, f.owner).spice;
|
||||
game = applyAction(game, f.owner, { type: 'card', card: box.id });
|
||||
if (game.pendingNullentropy) game = applyAction(game, f.owner, {
|
||||
type: 'decision', event: game.pendingNullentropy.event, card: SAPHO,
|
||||
});
|
||||
game = advance(game, clean);
|
||||
assert.equal(player(game, f.owner).spice, beforeBox - 2);
|
||||
assert.ok(player(game, f.owner).hand.some(card => card.id === SAPHO));
|
||||
const remainingBeforeReplay = [...game.movementRemaining!];
|
||||
game = applyAction(game, f.owner, sapho(game, f.owner, 'first'));
|
||||
game = advance(game, clean);
|
||||
assert.equal(game.saphoMovementLast, null);
|
||||
assert.deepEqual([...game.movementRemaining!].sort(), [...remainingBeforeReplay].sort());
|
||||
assert.deepEqual([...game.movementRemaining!].sort(), [...remainingBeforeLast].sort());
|
||||
assert.equal(player(game, f.owner).shipped, ownerAfterShipment.shipped);
|
||||
assert.equal(player(game, f.owner).moved, ownerAfterShipment.moved);
|
||||
assert.equal(player(game, f.owner).reserves, ownerAfterShipment.reserves);
|
||||
if (startedOpponent) {
|
||||
assert.equal(game.active, current);
|
||||
assert.equal(game.movementRemaining![1], f.owner);
|
||||
assert.deepEqual(player(game, current), currentBeforeReplay);
|
||||
game = applyAction(game, current, { type: 'endMovement' });
|
||||
game = advance(game, clean);
|
||||
}
|
||||
assert.equal(game.active, f.owner);
|
||||
const beforeDuplicate = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, f.owner, { type: 'ship', territory: 'polar_sink', sector: 0,
|
||||
amount: 1, elite: 0, allyPayment: 0 }));
|
||||
assert.deepEqual(game, beforeDuplicate);
|
||||
assert.equal(player(game, f.owner).shipped, true);
|
||||
assert.equal(player(game, f.owner).moved, ownerAfterShipment.moved);
|
||||
if (movedTo) {
|
||||
assert.equal(player(game, f.owner).forces[movedTo], 1);
|
||||
assert.throws(() => applyAction(game, f.owner, { type: 'move', from: movedTo,
|
||||
territory: 'polar_sink', sector: 0, amount: 1, elite: 0 }));
|
||||
assert.deepEqual(game, beforeDuplicate);
|
||||
}
|
||||
assert.equal(game.discard.filter(card => card.id === SAPHO).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
|
||||
// The owner ends the same opportunity once; the remaining original
|
||||
// actors also complete once, without synthesizing an extra turn.
|
||||
const completed = finish(game, f, { shipFollowing: false });
|
||||
assert.equal(completed.turns.filter(turn => turn.actor === f.owner).length, 1);
|
||||
assert.equal(completed.turns[0].shipment, null);
|
||||
assert.deepEqual(inventory(completed.game), f.originalInventory);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user