Support Sapho ordering during Advanced Revival
This commit is contained in:
1 parent
011b6aa096
commit
82370d906f
15 files changed
+1092
-28
No files matched your search
@@ -153,6 +153,10 @@ export function completeSaphoExtortion(fixture: SaphoExtortionFixture, mode: 'fi
|
||||
const actions: SaphoExtortionStep[] = [], answers: { before: Game; step: SaphoExtortionStep; after: Game }[] = [];
|
||||
const previous = saphoExtortionAnswer(game, false), action = saphoExtortionAction(game, mode);
|
||||
game = actSaphoExtortion(game, fixture.owner, action, actions);
|
||||
// Public Basic Richese now keeps its actual Semuta discard opportunity.
|
||||
// Complete those native all-seat passes before answering the same payer.
|
||||
if (game.pendingTreacheryDiscard)
|
||||
game = advanceSaphoExtortion(game,saphoExtortionPending,actions);
|
||||
const ordered = structuredClone(game);
|
||||
for (let count = 0; game.moritaniExtortion && count < fixture.game.players.length; count++) {
|
||||
assert.ok(saphoExtortionPending(game));
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { TERRITORIES, location } from '../game/board';
|
||||
import { PLAYER_CIRCLE_SECTORS } from '../game/player-positions';
|
||||
import { actAdvisorPreparation } from './fixture-advisor-preparation';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { saphoStartedMovementInventory } from './fixture-sapho-started-movement';
|
||||
import { advancedRevivalOrderStocks } from './fixture-advanced-revival-order';
|
||||
import { createSemutaHomeworldSaphoFixture } from './fixture-semuta-homeworld-sapho';
|
||||
import { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
|
||||
|
||||
export const REVIVAL_SAPHO = 'richese-juice-of-sapho';
|
||||
export type SaphoRevivalOrderStep = { actor: string; action: Action };
|
||||
export type SaphoRevivalOrderTransition = { before: Game; steps: SaphoRevivalOrderStep[]; after: Game };
|
||||
export const actSaphoRevivalOrder = actAdvisorPreparation;
|
||||
export const saphoRevivalOrderInventory = saphoStartedMovementInventory;
|
||||
export const saphoRevivalOrderStocks = advancedRevivalOrderStocks;
|
||||
export const saphoRevivalOrderPlayer = (game: Game, actor: string) => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const saphoRevivalOrderClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.pendingRevival && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
|
||||
/** Only native pending controls are consumed. No voluntary revival or Sapho
|
||||
* use is selected, and completed participants are never reintroduced. */
|
||||
export function nextSaphoRevivalOrder(game: Game): SaphoRevivalOrderStep | null {
|
||||
if (saphoRevivalOrderClean(game) && game.status === 'playing' && game.turn === 1 &&
|
||||
game.phase === 0 && game.stormPending === null) {
|
||||
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
|
||||
if (actor) {
|
||||
const position = game.playerPositions?.richese; assert.ok(position);
|
||||
const circle = PLAYER_CIRCLE_SECTORS[position - 1];
|
||||
// Stop two sectors before the actual Richese circle. The next original
|
||||
// card1 then stops one sector before it, preserving the same First Player.
|
||||
const destination = ((circle - 3 + 18) % 18) + 1;
|
||||
const distance = (destination - game.storm + 18) % 18;
|
||||
return { actor, action: { type: 'stormDial', amount: game.stormDialers.indexOf(actor) === 0 ? distance : 0 } };
|
||||
}
|
||||
}
|
||||
if (saphoRevivalOrderClean(game) && game.phase === 4 && game.revivalOrder?.remaining[0])
|
||||
return { actor: game.revivalOrder.remaining[0], action: { type: 'ready' } };
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard && game.decision) {
|
||||
const d = game.decision;
|
||||
if (d.kind === 'guildTiming') return { actor: d.player, action: { type: 'decision', take: false } };
|
||||
if (d.kind === 'revivalStop') return { actor: d.player, action: { type: 'decision', decline: true } };
|
||||
if (d.kind === 'richeseCache') {
|
||||
const card = game.richeseCache?.find(card => card.id === REVIVAL_SAPHO) ?? game.richeseCache?.[0]; assert.ok(card);
|
||||
return { actor: d.player, action: { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: card.id, method: 'onceAround', direction: 'clockwise' } };
|
||||
}
|
||||
if (d.kind === 'richeseUnbid') return { actor: d.player,
|
||||
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
||||
}
|
||||
const lot = game.richeseAuction;
|
||||
if (saphoRevivalOrderClean(game) && lot?.cardId === REVIVAL_SAPHO && !lot.outcome &&
|
||||
lot.active === 'emperor' && lot.bid === 0)
|
||||
return { actor: 'emperor', action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } };
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceSaphoRevivalOrder(game: Game, until: (state: Game) => boolean,
|
||||
actions: SaphoRevivalOrderStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Sapho/Revival source ended before its next control');
|
||||
const step = nextSaphoRevivalOrder(game);
|
||||
assert.ok(step, `Choose the original control at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actSaphoRevivalOrder(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original Sapho/Revival source did not reach its finite boundary');
|
||||
}
|
||||
export function finishSaphoRevivalOrder(game: Game, actions: SaphoRevivalOrderStep[] = []): Game {
|
||||
return advanceSaphoRevivalOrder(game, state => state.phase === 5 && saphoRevivalOrderClean(state), actions);
|
||||
}
|
||||
export function saphoRevivalOrderAction(game: Game, mode: 'first' | 'last', owner = 'emperor'): Action {
|
||||
const option = viewGame(game, owner).saphoOptions.find(option => option.scope === 'revival' && option.mode === mode);
|
||||
assert.ok(option, `The original remaining Revival queue offers ${mode}`);
|
||||
return { type: 'card', card: REVIVAL_SAPHO, scope: option.scope, event: option.event, mode: option.mode };
|
||||
}
|
||||
|
||||
/** Original public R/A/E/G +choam setup and actual cache1 purchase give Emperor
|
||||
* its one physical Sapho. started:true additionally earns a Richese normal1
|
||||
* desert shipment, next native card Storm death and actual free1 return.
|
||||
* Legal original first-Storm dials stop two sectors before Richese's circle,
|
||||
* retaining its first opportunity through the later original card1.
|
||||
* Only unopened starting-deal/Spice positions and scalar lotteries are chosen;
|
||||
* no played card, force, wallet, phase, queue or started flag is installed. */
|
||||
export function createSaphoRevivalOrderFixture(options: { started?: boolean } = {}) {
|
||||
const owner = 'emperor', current = 'richese', started = options.started ?? false;
|
||||
let game = createGame('SAPHOREVIVALORDER', newPlayer(current, 'Richese', 'richese'), true, ['choam']);
|
||||
for (const faction of ['atreides', 'emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: SaphoRevivalOrderStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSaphoRevivalOrder(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSaphoRevivalOrder(game, until, actions); };
|
||||
const transition = (run: () => void): SaphoRevivalOrderTransition => {
|
||||
const before = structuredClone(game), from = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
|
||||
const setup = structuredClone(game); assert.equal(game.status, 'setup');
|
||||
assert.ok(game.players.every(player => !player.hand.length));
|
||||
const originalInventory = saphoRevivalOrderInventory(game); assert.equal(originalInventory.length, 45);
|
||||
const counterIndex = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(counterIndex >= 0);
|
||||
[game.deck[1], game.deck[counterIndex]] = [game.deck[counterIndex], game.deck[1]];
|
||||
const counter = game.deck[1].id, beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing'); const afterSetup = structuredClone(game);
|
||||
assert.ok(saphoRevivalOrderPlayer(game, 'atreides').hand.some(card => card.id === counter));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1, 2, 3].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
let beforeBid: Game | undefined, bid: SaphoRevivalOrderStep | undefined, bidAt = -1;
|
||||
for (let count = 0; !saphoRevivalOrderPlayer(game, owner).hand.some(card => card.id === REVIVAL_SAPHO) && count < 900; count++) {
|
||||
const step = nextSaphoRevivalOrder(game); assert.ok(step);
|
||||
if (step.actor === owner && step.action.type === 'richeseBid' && step.action.amount === 1) {
|
||||
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
|
||||
}
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.ok(beforeBid && bid && bidAt >= 0);
|
||||
const acquisition = { before: beforeBid, bid, steps: structuredClone(actions.slice(bidAt)), after: structuredClone(game) };
|
||||
assert.equal(saphoRevivalOrderPlayer(beforeBid, owner).spice - saphoRevivalOrderPlayer(game, owner).spice, 1);
|
||||
assert.equal(saphoRevivalOrderPlayer(game, current).spice - saphoRevivalOrderPlayer(beforeBid, current).spice, 1);
|
||||
reach(state => state.turn === 1 && state.phase === 4 && saphoRevivalOrderClean(state));
|
||||
const firstRevival = structuredClone(game);
|
||||
const richesePosition = game.playerPositions?.richese; assert.ok(richesePosition);
|
||||
const richeseCircle = PLAYER_CIRCLE_SECTORS[richesePosition - 1];
|
||||
assert.equal(game.storm, ((richeseCircle - 3 + 18) % 18) + 1);
|
||||
let shipment: SaphoRevivalOrderTransition | null = null, storm: SaphoRevivalOrderTransition | null = null;
|
||||
let revival: SaphoRevivalOrderTransition | null = null, casualtyLocation: string | null = null;
|
||||
if (started) {
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === current && saphoRevivalOrderClean(state));
|
||||
// The scoped scalar lottery keeps the original later Storm card at1.
|
||||
const sector = game.storm % 18 + 1;
|
||||
const target = TERRITORIES.find(t => t.type === 'sand' && t.id !== 'imperial_basin' && t.sectors.includes(sector) &&
|
||||
game.players.every(player => !Object.entries(player.forces).some(([key, count]) => count > 0 && key.startsWith(`${t.id}:`))));
|
||||
assert.ok(target, 'An original empty sand territory lies in the next Storm sector');
|
||||
casualtyLocation = location(target.id, sector);
|
||||
shipment = transition(() => {
|
||||
act(current, { type: 'ship', territory: target.id, sector, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(saphoRevivalOrderClean);
|
||||
});
|
||||
assert.equal(saphoRevivalOrderPlayer(game, current).forces[casualtyLocation], 1);
|
||||
assert.equal(saphoRevivalOrderPlayer(shipment.before, current).spice - saphoRevivalOrderPlayer(game, current).spice, 2);
|
||||
reach(state => state.turn === 2 && state.phase === 0 && saphoRevivalOrderClean(state));
|
||||
assert.equal(game.stormMovementSource?.kind, 'card'); assert.equal(game.stormPending, 1);
|
||||
storm = transition(() => { reach(state => state.turn === 2 && state.phase === 1); });
|
||||
assert.equal(saphoRevivalOrderPlayer(game, current).forces[casualtyLocation], undefined);
|
||||
assert.equal(saphoRevivalOrderPlayer(game, current).tanks, 1);
|
||||
reach(state => state.turn === 2 && state.phase === 4 && saphoRevivalOrderClean(state));
|
||||
assert.equal(game.revivalOrder?.remaining[0], current);
|
||||
revival = transition(() => { act(current, { type: 'revive', amount: 1, elite: 0 }); reach(saphoRevivalOrderClean); });
|
||||
assert.equal(saphoRevivalOrderPlayer(game, current).tanks, 0);
|
||||
assert.equal(saphoRevivalOrderPlayer(game, current).revived, 1);
|
||||
assert.equal(game.revivalOrder?.started, true);
|
||||
}
|
||||
assert.equal(game.revivalOrder?.remaining[0], current);
|
||||
assert.deepEqual(saphoRevivalOrderInventory(game), originalInventory);
|
||||
assert.ok(viewGame(game, owner).saphoOptions.some(option => option.scope === 'revival' && option.mode === 'first'));
|
||||
assert.ok(viewGame(game, owner).saphoOptions.some(option => option.scope === 'revival' && option.mode === 'last'));
|
||||
return { game, sourceGame: structuredClone(game), owner, current, actor: current, card: REVIVAL_SAPHO,
|
||||
started, initial, lobby, setup, beforeStartingDeal, afterSetup, firstRevival, richeseCircle, acquisition,
|
||||
shipment, storm, revival, casualtyLocation, counter, originalInventory, originalSpice, spiceOrder,
|
||||
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length, staging: [] as string[] };
|
||||
}
|
||||
|
||||
/** CONTROLLED physical-card supplement only. The inherited opted-in Semuta
|
||||
* fixture conserves Sapho/Semuta between original zones; it does not earn their
|
||||
* acquisitions. Its later phase progression and new discard/claim are actual.
|
||||
* No added force, wallet, phase, queue or receipt is installed here. */
|
||||
export function createSaphoRevivalSemutaFixture() {
|
||||
const inherited = createSemutaHomeworldSaphoFixture('movement', 'last', true);
|
||||
const actions = [...inherited.actions];
|
||||
const game = advanceSaphoRevivalOrder(inherited.game,
|
||||
state => state.turn === 2 && state.phase === 4 && saphoRevivalOrderClean(state), actions);
|
||||
const option = viewGame(game, inherited.owner).saphoOptions.find(option => option.scope === 'revival'); assert.ok(option);
|
||||
assert.ok(option.mode === 'first' || option.mode === 'last');
|
||||
const action = saphoRevivalOrderAction(game, option.mode, inherited.owner);
|
||||
return { game, sourceGame: structuredClone(game), owner: inherited.owner, holder: inherited.holder,
|
||||
current: game.revivalOrder!.remaining[0], card: REVIVAL_SAPHO, mode: option.mode, action,
|
||||
originalInventory: saphoRevivalOrderInventory(game), actions, sourceActionCount: actions.length,
|
||||
nativeAcquisition: false, staging: [...inherited.staging,
|
||||
'CONTROLLED conserved Sapho/Semuta acquisition remains supplemental. Actual original continuation reaches fresh Advanced turn-two Revival before the new card discard.'] };
|
||||
}
|
||||
export const reactSaphoRevivalSemuta = reactSemutaHomeworldChoam;
|
||||
@@ -0,0 +1,211 @@
|
||||
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 { RevivalTurn, FinishRevivalTurn } from '../components/revival-turn';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { createSaphoRevivalOrderFixture, actSaphoRevivalOrder, saphoRevivalOrderAction } from './fixture-sapho-revival-order';
|
||||
|
||||
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');
|
||||
aliases.deregister();
|
||||
const sapho = richeseCards().find(card => card.effect === 'juiceOfSapho'); assert.ok(sapho);
|
||||
type Option = NonNullable<GameView['saphoOptions']>[number];
|
||||
|
||||
// Controlled public order/entitled-card projections supplement the native
|
||||
// producer below. They install no authoritative queue, card custody or outcome.
|
||||
// React renders the stateful panel; only actual hook-free buttons are invoked.
|
||||
function projected(mode: 'first' | 'last' = 'first', queued = false): GameView {
|
||||
const source = createGame('SAPHOREVIVALCONTROLS', newPlayer('holder', 'Sapho holder', 'atreides'), true, ['choam']);
|
||||
joinGame(source, newPlayer('active', 'Active Emperor', 'emperor'));
|
||||
joinGame(source, newPlayer('early', 'Earlier Richese', 'richese'));
|
||||
joinGame(source, newPlayer('late', 'Later Guild', 'guild'));
|
||||
const game = viewGame(source, 'holder');
|
||||
game.status = 'playing'; game.phase = 4; game.turn = 2; game.active = 'active';
|
||||
game.players.find(player => player.id === game.me)!.hand = [{ ...sapho! }];
|
||||
const order = ['active', 'early', 'holder', 'late'];
|
||||
game.revivalOrder = { version: 1, turn: 2, original: order, remaining: [...order], completed: [], started: queued };
|
||||
game.revival.orderBlocked = 'Wait for Active Emperor to finish their Revival turn.';
|
||||
game.saphoOptions = [{ scope: 'revival', event: 'controlled-revival-order-event', mode,
|
||||
...(queued ? { afterCurrent: 'active' } : {}) }];
|
||||
return game;
|
||||
}
|
||||
function panel(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(JuiceOfSapho, { game, busy, act() {} }));
|
||||
}
|
||||
function revival(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(RevivalTurn, { game, busy, act() {} }));
|
||||
}
|
||||
function press(option: Option, busy = false, card = sapho!.id): Action[] {
|
||||
const actions: Action[] = [];
|
||||
SaphoOrderButton({ card, option, busy, act(action) { actions.push(action); } }).props.onClick();
|
||||
return actions;
|
||||
}
|
||||
|
||||
void test('clean first and last Revival choices use their own labels and preserve completed-turn guidance', () => {
|
||||
for (const mode of ['first', 'last'] as const) {
|
||||
const game = projected(mode), before = structuredClone(game);
|
||||
for (let render = 0; render < 2; render++) {
|
||||
const html = panel(game);
|
||||
assert.match(html, new RegExp(`Take your Revival turn ${mode} and discard Juice of Sapho`));
|
||||
assert.match(html, new RegExp(`Take the ${mode} remaining Revival turn`));
|
||||
assert.match(html, /Completed Revival turns stay finished/);
|
||||
assert.match(html, /without granting another revival, resetting allowances or returning completed players to the queue/);
|
||||
assert.doesNotMatch(html, /Provisional queued Revival order|Take your movement turn (first|last) and discard/);
|
||||
}
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('queued first and last persistently name the started actor and label later-tail timing provisional', () => {
|
||||
for (const mode of ['first', 'last'] as const) {
|
||||
const game = projected(mode, true);
|
||||
for (let render = 0; render < 2; render++) {
|
||||
const html = panel(game);
|
||||
assert.match(html, /aria-label="Provisional queued Revival order"/);
|
||||
assert.match(html, /Active Emperor finishes the current Revival turn unchanged/);
|
||||
assert.match(html, new RegExp(`Your ${mode} position applies among later unstarted Revival turns`));
|
||||
assert.match(html, /Completed turns, returned pieces, payments and revival allowances stay unchanged/);
|
||||
assert.doesNotMatch(html, /Provisional queued movement order|combined shipment and movement turn unchanged/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual clean and queued callbacks send only the original five action fields and busy cannot dispatch', () => {
|
||||
for (const queued of [false, true]) for (const mode of ['first', 'last'] as const) {
|
||||
const option = projected(mode, queued).saphoOptions[0];
|
||||
const expected: Action = { type: 'card', card: sapho!.id, scope: 'revival', event: option.event, mode };
|
||||
assert.deepEqual(press(option), [expected]);
|
||||
assert.deepEqual(Object.keys(press(option)[0]).sort(), ['card', 'event', 'mode', 'scope', 'type']);
|
||||
assert.deepEqual(press(option, true), []);
|
||||
assert.match(panel(projected(mode, queued), true), new RegExp(`disabled=""[^>]*>Take your Revival turn ${mode} and discard Juice of Sapho`));
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic unstamped and completed controlled views offer no Revival order button even with stale projected options', () => {
|
||||
const basic = projected(); basic.advanced = false; basic.revivalOrder = null;
|
||||
const legacy = projected(); legacy.revivalOrder = null;
|
||||
const complete = projected(); complete.revivalOrder!.completed = ['holder'];
|
||||
complete.revivalOrder!.remaining = ['active', 'early', 'late'];
|
||||
for (const game of [basic, legacy, complete]) {
|
||||
const html = panel(game);
|
||||
assert.doesNotMatch(html, /Take your Revival turn (first|last) and discard/);
|
||||
assert.match(html, /existing timing|Completed turns stay finished/);
|
||||
}
|
||||
assert.equal(revival(basic), ''); assert.equal(revival(legacy), '');
|
||||
const absent = projected(); absent.players.find(player => player.id === absent.me)!.hand = [];
|
||||
assert.equal(panel(absent), '');
|
||||
});
|
||||
|
||||
void test('unavailable ordered Revival guidance keeps completed turns and the current started actor intact', () => {
|
||||
const game = projected(); game.saphoOptions = [];
|
||||
const html = panel(game);
|
||||
assert.match(html, /only among remaining unstarted Revival turns/);
|
||||
assert.match(html, /A player who has started finishes their Revival choices unchanged/);
|
||||
assert.match(html, /already-held positions and committed cards are unavailable/);
|
||||
assert.match(revival(game), /Juice of Sapho may change the remaining order; completed Revival turns stay finished/);
|
||||
});
|
||||
|
||||
void test('existing movement and auction buttons keep their original scope labels and payloads', () => {
|
||||
const game = projected();
|
||||
for (const option of [
|
||||
{ scope: 'movement', event: 'original-movement', mode: 'first' },
|
||||
{ scope: 'onceAround', event: 'original-auction', mode: 'last' },
|
||||
] as const) {
|
||||
game.saphoOptions = [option];
|
||||
assert.deepEqual(press(option), [{ type: 'card', card: sapho!.id, ...option }]);
|
||||
assert.match(panel(game), option.scope === 'movement'
|
||||
? /Take your movement turn first and discard Juice of Sapho/
|
||||
: /Bid last and discard Juice of Sapho/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Revival controls do not inspect raw order history rival hands or private return stocks', () => {
|
||||
const game = projected('last', true);
|
||||
Object.defineProperty(game.revivalOrder!, 'uses', { get() { throw Error('Controls inspected raw Sapho history'); } });
|
||||
for (const player of game.players) {
|
||||
const fields = ['traitors', 'faceDancers', 'spice', 'reserves', 'leaders', 'forces', 'tanks', ...(player.id === game.me ? [] : ['hand'])];
|
||||
for (const field of fields)
|
||||
Object.defineProperty(player, field, { get() { throw Error(`Controls inspected private ${player.id}.${field}`); } });
|
||||
}
|
||||
assert.match(panel(game), /Provisional queued Revival order/);
|
||||
assert.match(revival(game), /Current Revival actor: Active Emperor/);
|
||||
assert.deepEqual(press(game.saphoOptions[0]), [{ type: 'card', card: sapho!.id,
|
||||
scope: 'revival', event: 'controlled-revival-order-event', mode: 'last' }]);
|
||||
});
|
||||
|
||||
const nativeSources = new Map<boolean, ReturnType<typeof createSaphoRevivalOrderFixture>>();
|
||||
function native(started: boolean) {
|
||||
let source = nativeSources.get(started);
|
||||
if (!source) { source = createSaphoRevivalOrderFixture({ started }); nativeSources.set(started, source); }
|
||||
return { ...source, game: structuredClone(source.game) };
|
||||
}
|
||||
|
||||
void test('earned native clean and queued first/last options render and their actual button actions apply the same selected scope', () => {
|
||||
for (const started of [false, true]) for (const mode of ['first', 'last'] as const) {
|
||||
const source = native(started), game = viewGame(source.game, source.owner);
|
||||
const card = game.players.find(player => player.id === source.owner)!.hand!.find(candidate => candidate.id === source.card);
|
||||
assert.ok(card);
|
||||
const option = game.saphoOptions.find(candidate => candidate.scope === 'revival' && candidate.mode === mode);
|
||||
assert.ok(option?.scope === 'revival');
|
||||
const html = panel(game);
|
||||
assert.match(html, new RegExp(`Take your Revival turn ${mode} and discard Juice of Sapho`));
|
||||
if (started) {
|
||||
assert.equal(option.afterCurrent, source.current);
|
||||
const currentName = game.players.find(player => player.id === source.current)!.name;
|
||||
assert.ok(html.includes(`${currentName} finishes the current Revival turn unchanged`));
|
||||
assert.match(html, /Provisional queued Revival order/);
|
||||
} else {
|
||||
assert.equal(option.afterCurrent, undefined);
|
||||
assert.doesNotMatch(html, /Provisional queued Revival order/);
|
||||
}
|
||||
const sent = press(option, false, card.id);
|
||||
assert.deepEqual(sent, [saphoRevivalOrderAction(source.game, mode, source.owner)]);
|
||||
assert.deepEqual(press(option, true, card.id), []);
|
||||
const priorCompleted = [...source.game.revivalOrder!.completed];
|
||||
const after = actSaphoRevivalOrder(source.game, source.owner, sent[0]);
|
||||
assert.equal(after.revivalOrder!.version, 2);
|
||||
assert.deepEqual(after.revivalOrder!.completed, priorCompleted);
|
||||
if (started) assert.equal(after.active, source.current);
|
||||
else if (mode === 'first') assert.equal(after.active, source.owner);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual v2 Revival views preserve remaining/completed display and permit only the current Finish callback', () => {
|
||||
for (const started of [false, true]) for (const mode of ['first', 'last'] as const) {
|
||||
const source = native(started);
|
||||
const after = actSaphoRevivalOrder(source.game, source.owner, saphoRevivalOrderAction(source.game, mode, source.owner));
|
||||
const actor = after.revivalOrder!.remaining[0], game = viewGame(after, actor), sent: Action[] = [];
|
||||
assert.equal(game.revivalOrder!.version, 2);
|
||||
Object.defineProperty(game.revivalOrder!, 'uses', { get() { throw Error('Revival display inspected validated order history'); } });
|
||||
const html = revival(game);
|
||||
assert.match(html, /Remaining Revival turns:/);
|
||||
assert.match(html, /Completed Revival turns:/);
|
||||
assert.match(html, /Juice of Sapho may change the remaining order/);
|
||||
const leaf = FinishRevivalTurn({ game, busy: false, act(action) { sent.push(action); } });
|
||||
assert.equal(leaf.props.disabled, false);
|
||||
FinishRevivalTurn({ game, busy: true, act(action) { sent.push(action); } }).props.onClick();
|
||||
assert.deepEqual(sent, []);
|
||||
leaf.props.onClick(); assert.deepEqual(sent, [{ type: 'ready' }]);
|
||||
const next = actSaphoRevivalOrder(after, actor, sent[0]);
|
||||
assert.ok(next.revivalOrder!.completed.includes(actor));
|
||||
const completed = viewGame(next, actor), rejected: Action[] = [];
|
||||
const finished = FinishRevivalTurn({ game: completed, busy: false, act(action) { rejected.push(action); } });
|
||||
assert.equal(finished.props.disabled, true); finished.props.onClick();
|
||||
assert.deepEqual(rejected, []);
|
||||
assert.match(revival(completed), /Your Revival turn is finished/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
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 { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import {
|
||||
actSaphoRevivalOrder as act, advanceSaphoRevivalOrder as advance,
|
||||
createSaphoRevivalOrderFixture as fixture, createSaphoRevivalSemutaFixture,
|
||||
finishSaphoRevivalOrder as finish, reactSaphoRevivalSemuta,
|
||||
saphoRevivalOrderAction as action, saphoRevivalOrderClean as clean,
|
||||
saphoRevivalOrderInventory as inventory, saphoRevivalOrderPlayer as player,
|
||||
saphoRevivalOrderStocks as stocks,
|
||||
} from './fixture-sapho-revival-order';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
function reject(game: Game, actor: string, proposed: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, proposed));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
function expected(game: Game, owner: string, mode: 'first' | 'last') {
|
||||
const frame = game.revivalOrder; assert.ok(frame);
|
||||
const locked = frame.started ? [frame.remaining[0]] : [];
|
||||
const other = frame.remaining.filter(id => id !== owner && !locked.includes(id));
|
||||
return [...locked, ...(mode === 'first' ? [owner, ...other] : [...other, owner])];
|
||||
}
|
||||
function assertNativeSource(f: ReturnType<typeof fixture>) {
|
||||
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
|
||||
assert.ok(f.beforeStartingDeal.players.every(seat => !seat.hand.length));
|
||||
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.acquisition.after, f.owner).spice, 1);
|
||||
assert.equal(player(f.acquisition.after, 'richese').spice - player(f.acquisition.before, 'richese').spice, 1);
|
||||
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.ok(f.sourceActionCount > 20);
|
||||
const dials = f.sourceActions.filter(step => step.action.type === 'stormDial');
|
||||
assert.equal(dials.length, 2);
|
||||
assert.ok(dials.every(step => typeof step.action.amount === 'number' && step.action.amount >= 0 && step.action.amount <= 20));
|
||||
assert.equal(f.firstRevival.storm, ((f.richeseCircle - 3 + 18) % 18) + 1);
|
||||
assert.deepEqual(f.staging, []); assert.deepEqual(inventory(f.game), f.originalInventory);
|
||||
assert.equal(f.game.active, f.current); assert.equal(f.game.revivalOrder?.started, f.started);
|
||||
if (f.started) {
|
||||
assert.ok(f.shipment && f.storm && f.revival && f.casualtyLocation);
|
||||
assert.equal(player(f.shipment.after, f.current).forces[f.casualtyLocation], 1);
|
||||
assert.equal(f.storm.before.stormMovementSource?.kind, 'card');
|
||||
assert.equal(player(f.storm.after, f.current).tanks, 1);
|
||||
assert.equal(player(f.game, f.current).tanks, 0); assert.equal(player(f.game, f.current).revived, 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const started of [false, true]) for (const mode of ['first', 'last'] as const) {
|
||||
void test(`native Sapho Revival ${mode} ${started ? 'queues behind an actual returned-force owner' : 'reorders clean unstarted turns'} and completes once`, () => {
|
||||
const f = fixture({ started }); assertNativeSource(f);
|
||||
const proposed = action(f.game, mode), option = viewGame(f.game, f.owner).saphoOptions.find(o =>
|
||||
o.scope === 'revival' && o.mode === mode); assert.ok(option);
|
||||
assert.deepEqual(Object.keys(proposed).sort(), ['card', 'event', 'mode', 'scope', 'type']);
|
||||
assert.equal('afterCurrent' in proposed, false);
|
||||
if (started) assert.equal((option as typeof option & { afterCurrent?: string }).afterCurrent, f.current);
|
||||
const before = stocks(f.game), physicalOrder = [...f.game.order];
|
||||
const wanted = expected(f.game, f.owner, mode);
|
||||
let game = act(f.game, f.owner, proposed);
|
||||
assert.equal(game.revivalOrder?.version, 2);
|
||||
assert.deepEqual(game.revivalOrder?.original, physicalOrder);
|
||||
assert.deepEqual(game.revivalOrder?.remaining, wanted);
|
||||
assert.deepEqual(game.revivalOrder?.completed, []);
|
||||
assert.equal(game.revivalOrder?.started, started);
|
||||
assert.equal(game.active, wanted[0]); assert.deepEqual(game.order, physicalOrder);
|
||||
assert.deepEqual(stocks(game), before);
|
||||
assert.equal(player(game, f.owner).hand.some(card => card.id === f.card), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
reject(game, f.owner, proposed);
|
||||
const completions: string[] = [];
|
||||
game = reload(game);
|
||||
while (game.phase === 4 && game.revivalOrder?.remaining.length) {
|
||||
assert.ok(clean(game)); const actor = game.revivalOrder.remaining[0]; completions.push(actor);
|
||||
game = act(game, actor, { type: 'ready' });
|
||||
}
|
||||
game = finish(game);
|
||||
assert.deepEqual(completions, wanted); assert.equal(new Set(completions).size, wanted.length);
|
||||
assert.equal(game.phase, 5); assert.deepEqual(game.order, physicalOrder);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(stocks(game), before);
|
||||
});
|
||||
}
|
||||
|
||||
void test('native completed holder keeps its physical Sapho without reopening its finished Revival', () => {
|
||||
const f = fixture(), old = action(f.game, 'last');
|
||||
let game = advance(f.game, state => clean(state) && state.revivalOrder?.remaining[0] === f.owner);
|
||||
game = act(game, f.owner, { type: 'ready' });
|
||||
assert.ok(game.revivalOrder?.completed.includes(f.owner));
|
||||
assert.ok(player(game, f.owner).hand.some(card => card.id === f.card));
|
||||
assert.deepEqual(viewGame(game, f.owner).saphoOptions.filter(option => option.scope === 'revival'), []);
|
||||
reject(game, f.owner, old);
|
||||
game = finish(game); assert.equal(game.phase, 5);
|
||||
});
|
||||
|
||||
void test('native Sapho Revival rejects forged display metadata and a changed event before disposal', () => {
|
||||
const f = fixture({ started: true }), proposed = action(f.game, 'first');
|
||||
reject(f.game, f.owner, { ...proposed, afterCurrent: f.current });
|
||||
reject(f.game, f.owner, { ...proposed, event: 'stale-revival-event' });
|
||||
reject(f.game, 'atreides', proposed);
|
||||
assert.equal(f.game.discard.filter(card => card.id === f.card).length, 0);
|
||||
assert.equal(player(f.game, f.current).revived, 1);
|
||||
});
|
||||
|
||||
void test('all four minimal profiles propose an accepted native queued Revival order change', () => {
|
||||
const f = fixture({ started: true });
|
||||
for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(f.game, f.owner); view.players.find(seat => seat.id === f.owner)!.bot = level;
|
||||
const proposed = botActions(view).find(candidate => candidate.type === 'card' && candidate.scope === 'revival');
|
||||
assert.ok(proposed, `${level} offers the physical owned card's legal Revival path`);
|
||||
const game = act(f.game, f.owner, proposed);
|
||||
assert.equal(game.active, f.current); assert.equal(game.revivalOrder?.started, true);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled conserved-card supplement refuses to split the genuine started owner opportunity', () => {
|
||||
const f = fixture({ started: true }), game = structuredClone(f.game);
|
||||
// This supplement conserves the actual paid card but does not claim a native
|
||||
// transfer to Richese. Its force return and started opportunity remain native.
|
||||
const donor = player(game, f.owner), index = donor.hand.findIndex(card => card.id === f.card); assert.ok(index >= 0);
|
||||
player(game, f.current).hand.push(donor.hand.splice(index, 1)[0]);
|
||||
assert.equal(game.revivalOrder?.started, true);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
assert.deepEqual(viewGame(game, f.current).saphoOptions.filter(option => option.scope === 'revival'), []);
|
||||
const borrowed = action(f.game, 'last');
|
||||
reject(game, f.current, borrowed);
|
||||
});
|
||||
|
||||
for (const claim of [false, true]) {
|
||||
void test(`controlled Semuta acquisition supplement ${claim ? 'claims the actual Sapho discard' : 'passes the actual Sapho discard'} and restores the same Revival scope`, () => {
|
||||
const f = createSaphoRevivalSemutaFixture();
|
||||
assert.equal(f.nativeAcquisition, false); assert.ok(f.staging.length > 0);
|
||||
const before = stocks(f.game), original = [...f.game.order];
|
||||
const paused = act(f.game, f.owner, f.action);
|
||||
assert.equal(paused.pendingTreacheryDiscard?.continuation?.kind, 'saphoRevivalDiscard');
|
||||
assert.ok(paused.pendingTreacheryDiscard?.reaction);
|
||||
const queue = structuredClone(paused.revivalOrder), requests = structuredClone(paused.revivalRequests);
|
||||
for (const seat of paused.players)
|
||||
assert.deepEqual(viewGame(paused, seat.id).saphoOptions.filter(option => option.scope === 'revival'), [],
|
||||
'The live discard reaction supplies no competing Revival reorder');
|
||||
let game = reactSaphoRevivalSemuta(reload(paused), f.holder, claim);
|
||||
game = advance(game, clean);
|
||||
assert.deepEqual(game.revivalOrder, queue); assert.deepEqual(game.revivalRequests, requests);
|
||||
assert.equal(game.active, queue?.remaining[0]); assert.deepEqual(game.order, original);
|
||||
assert.deepEqual(stocks(game), before); assert.deepEqual(inventory(game), f.originalInventory);
|
||||
assert.equal(player(game, f.owner).hand.some(card => card.id === f.card), false);
|
||||
if (claim) {
|
||||
assert.ok(player(game, f.holder).hand.some(card => card.id === f.card));
|
||||
assert.equal(player(game, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false);
|
||||
assert.equal(game.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, 1);
|
||||
} else assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
|
||||
game = finish(game);
|
||||
assert.equal(game.phase, 5); assert.deepEqual(inventory(game), f.originalInventory);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createRevivalOrder as create, finishRevivalOrder as finish, startRevivalOrder as start,
|
||||
validateRevivalOrder as validate, revivalOrderBlock as block,
|
||||
saphoRevivalOrderEvent as event, reorderSaphoRevivalOrder as reorder,
|
||||
RevivalOrderError, type RevivalOrder, type RevivalOrderContext,
|
||||
} from '../game/revival-order';
|
||||
|
||||
const context = (ready: string[] = []): RevivalOrderContext => ({ turn: 5,
|
||||
order: ['a', 'b', 'c', 'd'], ready });
|
||||
function use(ctx: RevivalOrderContext, frame: RevivalOrder, player: string, mode: 'first' | 'last') {
|
||||
return reorder(ctx, frame, { event: event(ctx, frame), player, mode });
|
||||
}
|
||||
function rejects(ctx: RevivalOrderContext, frame: RevivalOrder, run: () => unknown) {
|
||||
const before = structuredClone({ ctx, frame });
|
||||
assert.throws(run, RevivalOrderError); assert.deepEqual({ ctx, frame }, before);
|
||||
}
|
||||
|
||||
void test('ordinary Revival creation, partial start and finish keep exact version-one bytes without a Sapho history', () => {
|
||||
const ctx = context(), frame = create(ctx);
|
||||
assert.equal(JSON.stringify(frame), '{"version":1,"turn":5,"original":["a","b","c","d"],"remaining":["a","b","c","d"],"completed":[],"started":false}');
|
||||
const started = start(ctx, frame, 'a'); assert.equal(started.version, 1);
|
||||
assert.equal(Object.hasOwn(started, 'uses'), false); assert.equal(started.started, true);
|
||||
const done = finish(ctx, started, 'a'); validate(context(['a']), done);
|
||||
assert.equal(JSON.stringify(done), '{"version":1,"turn":5,"original":["a","b","c","d"],"remaining":["b","c","d"],"completed":["a"],"started":false}');
|
||||
assert.deepEqual(create(context(['a'])), done); assert.equal(frame.started, false);
|
||||
});
|
||||
|
||||
void test('Sapho first upgrades only an actual changed opportunity and preserves every physical seat', () => {
|
||||
const ctx = context(), frame = create(ctx), beforeEvent = event(ctx, frame);
|
||||
const result = use(ctx, frame, 'd', 'first');
|
||||
assert.equal(result.order.version, 2); assert.equal(result.afterCurrent, undefined);
|
||||
assert.deepEqual(result.order.remaining, ['d', 'a', 'b', 'c']); assert.deepEqual(result.order.original, ctx.order);
|
||||
assert.deepEqual(result.order.completed, []); validate(ctx, result.order);
|
||||
assert.ok(result.order.version === 2);
|
||||
assert.deepEqual(result.order.uses, [{ event: beforeEvent, player: 'd', mode: 'first', completed: [], started: false }]);
|
||||
assert.notEqual(event(ctx, result.order), beforeEvent);
|
||||
rejects(ctx, result.order, () => reorder(ctx, result.order, { event: beforeEvent, player: 'b', mode: 'last' }));
|
||||
assert.equal(frame.version, 1); assert.deepEqual(frame.remaining, ctx.order);
|
||||
});
|
||||
|
||||
void test('Sapho last moves the whole unstarted opportunity and rejects already-held, completed and foreign positions', () => {
|
||||
const ctx = context(), frame = create(ctx), result = use(ctx, frame, 'a', 'last');
|
||||
assert.deepEqual(result.order.remaining, ['b', 'c', 'd', 'a']); validate(ctx, result.order);
|
||||
rejects(ctx, frame, () => use(ctx, frame, 'a', 'first'));
|
||||
rejects(ctx, frame, () => use(ctx, frame, 'd', 'last'));
|
||||
rejects(ctx, frame, () => use(ctx, frame, 'foreign', 'first'));
|
||||
const later = context(['a']), laterFrame = create(later);
|
||||
rejects(later, laterFrame, () => use(later, laterFrame, 'a', 'first'));
|
||||
const done = create(context(['a', 'b', 'c', 'd']));
|
||||
rejects(context(['a', 'b', 'c', 'd']), done, () => use(context(['a', 'b', 'c', 'd']), done, 'd', 'first'));
|
||||
});
|
||||
|
||||
void test('a started current actor locks its entire opportunity while another faction can become first after it', () => {
|
||||
const ctx = context(), fresh = create(ctx), old = event(ctx, fresh), frame = start(ctx, fresh, 'a');
|
||||
assert.notEqual(event(ctx, frame), old);
|
||||
rejects(ctx, frame, () => reorder(ctx, frame, { event: old, player: 'd', mode: 'first' }));
|
||||
rejects(ctx, frame, () => use(ctx, frame, 'a', 'first'));
|
||||
rejects(ctx, frame, () => use(ctx, frame, 'a', 'last'));
|
||||
const first = use(ctx, frame, 'd', 'first');
|
||||
assert.equal(first.afterCurrent, 'a'); assert.equal(first.order.started, true);
|
||||
assert.deepEqual(first.order.remaining, ['a', 'd', 'b', 'c']);
|
||||
assert.equal(block(ctx, first.order, 'a'), null); assert.match(block(ctx, first.order, 'd')!, /Wait/);
|
||||
rejects(ctx, first.order, () => use(ctx, first.order, 'd', 'first'));
|
||||
const last = use(ctx, first.order, 'b', 'last');
|
||||
assert.equal(last.afterCurrent, 'a'); assert.deepEqual(last.order.remaining, ['a', 'd', 'c', 'b']);
|
||||
validate(ctx, last.order); assert.equal(start(ctx, last.order, 'a').started, true);
|
||||
});
|
||||
|
||||
void test('later Sapho uses advance only the actual changed completion prefix and never reopen a finished faction', () => {
|
||||
const ctx = context(), first = use(ctx, create(ctx), 'd', 'first').order;
|
||||
const afterD = finish(ctx, start(ctx, first, 'd'), 'd'), later = context(['d']);
|
||||
validate(later, afterD); assert.deepEqual(afterD.remaining, ['a', 'b', 'c']);
|
||||
const afterUse = use(later, afterD, 'a', 'last').order;
|
||||
validate(later, afterUse); assert.deepEqual(afterUse.remaining, ['b', 'c', 'a']);
|
||||
assert.ok(afterUse.version === 2); assert.deepEqual(afterUse.uses[1].completed, ['d']);
|
||||
rejects(later, afterUse, () => use(later, afterUse, 'd', 'first'));
|
||||
rejects(ctx, first, () => validate(context(['a']), { ...afterD, completed: ['a'] }));
|
||||
const afterB = finish(later, afterUse, 'b'), finalContext = context(['d', 'b']);
|
||||
validate(finalContext, afterB); assert.deepEqual(afterB.remaining, ['c', 'a']);
|
||||
rejects(finalContext, afterB, () => validate(context(['b', 'd']), { ...afterB, completed: ['b', 'd'] }));
|
||||
});
|
||||
|
||||
void test('genuine repeated recovered-card uses retain independent history revisions and survive JSON without an arbitrary cap', () => {
|
||||
const ctx = context(); let frame = create(ctx);
|
||||
// Physical card recovery/spend is authorized by the engine, outside this pure
|
||||
// scheduling helper. Each supplied declaration names a fresh current event.
|
||||
for (let index = 0; index < 6; index++) frame = use(ctx, frame, 'b', index % 2 ? 'last' : 'first').order;
|
||||
assert.ok(frame.version === 2); assert.equal(frame.uses.length, 6);
|
||||
assert.equal(new Set(frame.uses.map(use => use.event)).size, 6);
|
||||
const restored = JSON.parse(JSON.stringify(frame)) as RevivalOrder;
|
||||
validate(ctx, restored); assert.equal(event(ctx, restored), event(ctx, frame));
|
||||
const started = start(ctx, restored, 'a');
|
||||
assert.ok(started.version === 2); assert.deepEqual(started.uses, frame.uses);
|
||||
const done = finish(ctx, started, 'a'); validate(context(['a']), done);
|
||||
assert.ok(done.version === 2); assert.deepEqual(done.uses, frame.uses);
|
||||
});
|
||||
|
||||
void test('recorded started actors cannot be unlocked by later history entries or a changed final frame before completion', () => {
|
||||
const ctx = context(), first = use(ctx, start(ctx, create(ctx), 'a'), 'd', 'first').order;
|
||||
const second = use(ctx, first, 'b', 'last').order; assert.ok(second.version === 2);
|
||||
rejects(ctx, second, () => validate(ctx, { ...second, started: false }));
|
||||
const forged = structuredClone(second); forged.uses[1].started = false;
|
||||
// Even a matching replacement event cannot erase the earlier started lock.
|
||||
forged.uses[1].event = `saphoRevival:${JSON.stringify([5, ctx.order, first.remaining, [], false, 1])}`;
|
||||
forged.started = false;
|
||||
rejects(ctx, second, () => validate(ctx, forged));
|
||||
const completed = finish(ctx, second, 'a'), later = context(['a']);
|
||||
validate(later, completed); assert.equal(completed.started, false);
|
||||
const changed = use(later, completed, 'c', 'first').order;
|
||||
validate(later, changed); assert.deepEqual(changed.remaining, ['c', 'd', 'b']);
|
||||
});
|
||||
|
||||
void test('malformed history, stale boundaries and altered queues reject without repairing or mutating either input', () => {
|
||||
const ctx = context(), frame = use(ctx, create(ctx), 'd', 'first').order; assert.ok(frame.version === 2);
|
||||
const originalUse = frame.uses[0];
|
||||
for (const value of [
|
||||
{ ...frame, uses: [] }, { ...frame, uses: null }, { ...frame, extra: true },
|
||||
{ ...frame, uses: [{ ...originalUse, extra: true }] },
|
||||
{ ...frame, uses: [{ ...originalUse, completed: ['b'] }] },
|
||||
{ ...frame, uses: [{ ...originalUse, completed: ['a', 'a'] }] },
|
||||
{ ...frame, uses: [{ ...originalUse, player: 'foreign' }] },
|
||||
{ ...frame, uses: [{ ...originalUse, mode: 'last' }] },
|
||||
{ ...frame, uses: [{ ...originalUse, event: `${originalUse.event}:stale` }] },
|
||||
{ ...frame, remaining: ['d', 'b', 'a', 'c'] },
|
||||
{ ...frame, remaining: ['d', 'a', 'b', 'b'] },
|
||||
{ ...frame, completed: ['d'], remaining: ['a', 'b', 'c'] },
|
||||
]) rejects(ctx, frame, () => validate(ctx, value as unknown as RevivalOrder));
|
||||
rejects(ctx, frame, () => validate({ ...ctx, turn: 6 }, frame));
|
||||
rejects(ctx, frame, () => reorder(ctx, frame, { event: event(ctx, frame), player: 'c', mode: 'middle' } as never));
|
||||
rejects(ctx, frame, () => reorder(ctx, frame, { event: event(ctx, frame), player: 'c', mode: 'first', extra: true } as never));
|
||||
});
|
||||
|
||||
void test('reordered snapshots and start/finish copies preserve independent original, queue and per-use arrays', () => {
|
||||
const ctx = context(), frame = use(ctx, create(ctx), 'd', 'first').order;
|
||||
const changed = use(ctx, frame, 'a', 'last').order;
|
||||
assert.ok(frame.version === 2 && changed.version === 2);
|
||||
assert.notEqual(changed.original, frame.original); assert.notEqual(changed.remaining, frame.remaining);
|
||||
assert.notEqual(changed.completed, frame.completed); assert.notEqual(changed.uses, frame.uses);
|
||||
assert.notEqual(changed.uses[0], frame.uses[0]); assert.notEqual(changed.uses[0].completed, frame.uses[0].completed);
|
||||
changed.original.pop(); changed.remaining.pop(); changed.completed.push('output-only'); changed.uses[0].completed.push('output-only');
|
||||
validate(ctx, frame); assert.deepEqual(frame.remaining, ['d', 'a', 'b', 'c']);
|
||||
assert.deepEqual(frame.uses[0].completed, []); assert.deepEqual(ctx.order, ['a', 'b', 'c', 'd']);
|
||||
});
|
||||
Reference in new issue
Block a user