Support Sapho ordering for simultaneous Truthtrances

This commit is contained in:
Quintine committed 2026-10-27 18:31:15 +11:00
1 parent 4690200f59
commit 298c8245a6
16 files changed
+1020 -3

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { actSaphoStartedMovement as act, createSaphoStartedMovementFixture,
saphoStartedMovementInventory as inventory, saphoStartedMovementPlayer as player } from './fixture-sapho-started-movement';
export { act, inventory, player };
export type OrderFrame = {event:string;holders:string[];passed:string[];revision:number};
export function truthOrderFrame(game: Game): OrderFrame {
assert.equal(game.truthtrance?.stage, 'order');
const frame = (game.truthtrance as typeof game.truthtrance & {order?:OrderFrame}).order;
assert.ok(frame); return frame;
}
/** Original public R/A/E/G Basic or Advanced setup, selected unopened original
* Truth deals and a genuinely purchased Sapho. Controlled supplemental card
* custody: exchange one actually dealt Truth with Emperor's other physical
* starting card so the genuine Sapho owner also holds a Truthtrance. This is
* explicitly not a claimed native Truth acquisition. Every physical card,
* hand count, purse, force, turn, phase and original started parent is retained.
* The remaining Truth stays with its original recipient. */
export function createSaphoTruthOrderFixture(options: {advanced?:boolean; mode?:'first'|'last'; withoutSapho?:boolean} = {}) {
const advanced = options.advanced ?? false, mode = options.mode ?? 'first';
const source = createSaphoStartedMovementFixture({advanced,truthFor:['richese','guild']});
let game = structuredClone(source.game);
const owner = source.owner;
// The original physical order is Richese, Atreides, Emperor, Guild. Taking
// Guild's Truth makes Emperor last; taking Richese's makes Emperor first.
const donor = mode === 'first' ? 'guild' : 'richese';
const other = mode === 'first' ? 'richese' : 'guild';
assert.ok(game.order.indexOf(other) !== game.order.indexOf(owner));
const from = player(game,donor), receiver = player(game,owner);
const truthAt = from.hand.findIndex(card => card.effect === 'truthtrance'); assert.ok(truthAt >= 0);
const otherAt = receiver.hand.findIndex(card => card.id !== source.card && card.effect !== 'truthtrance'); assert.ok(otherAt >= 0);
const [truth] = from.hand.splice(truthAt,1), [exchange] = receiver.hand.splice(otherAt,1);
from.hand.push(exchange); receiver.hand.push(truth);
const remainingTruth = player(game,other).hand.find(card => card.effect === 'truthtrance'); assert.ok(remainingTruth);
assert.ok(receiver.hand.some(card => card.id === source.card));
if (options.withoutSapho) {
// Controlled card custody before either declaration: exchange the genuine
// Sapho with another actual cache card. This tests creation of the public
// window without inspecting whether anybody owns the card.
const at = receiver.hand.findIndex(card => card.id === source.card), cache = game.richeseCache;
assert.ok(at >= 0 && cache?.length);
const [sapho] = receiver.hand.splice(at,1), replacement = cache.shift()!;
receiver.hand.push(replacement); cache.push(sapho);
}
assert.deepEqual(inventory(game),source.originalInventory);
const before = structuredClone(game), order = [...game.order], parent = {
phase:game.phase, turn:game.turn, active:game.active,
movementRemaining:structuredClone(game.movementRemaining),
players:game.players.map(row => ({id:row.id,spice:row.spice,reserves:row.reserves,tanks:row.tanks,
shipped:row.shipped,moved:row.moved,forces:structuredClone(row.forces)})),
};
// Submit in reverse of physical storm order to demonstrate that network
// arrival is not the question order. Both declarations are real actions.
for (const actor of [owner,other]) {
const card = player(game,actor).hand.find(row => row.effect === 'truthtrance'); assert.ok(card);
game = act(game,actor,{type:'card',card:card.id});
}
while (game.truthtrance?.stage === 'priority') {
const seat = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(seat);
game = act(game,seat.id,{type:'truthPass'});
}
const frame = truthOrderFrame(game);
assert.deepEqual([...frame.holders].sort(),[owner,other].sort());
assert.deepEqual(game.order,order);
assert.deepEqual(inventory(game),source.originalInventory);
const queue = game.truthtrance!.queue.map(row => row.player);
assert.equal(mode === 'first' ? queue.at(-1) : queue[0],owner,
`The actual pending group must offer a non-no-op ${mode} choice`);
return {game,source,before,advanced,mode,owner,other,donor,truth:truth.id,
otherTruth:remainingTruth.id,card:source.card,originalCards:source.originalInventory,order,parent,
disclosure:'Actual public setup, paid original Sapho acquisition and started movement; one labelled conserved card swap places an original dealt Truth in the Sapho owner hand.'};
}
export type SaphoTruthOrderFixture = ReturnType<typeof createSaphoTruthOrderFixture>;
export function passTruthOrder(game: Game, actor: string): Game {
const frame = truthOrderFrame(game);
return act(game,actor,{type:'truthOrderPass',event:frame.event,revision:frame.revision});
}
export function playSaphoTruthOrder(game: Game, actor: string, card: string, mode:'first'|'last'): Game {
const frame = truthOrderFrame(game);
return act(game,actor,{type:'saphoTruthOrder',event:frame.event,revision:frame.revision,card,mode});
}
export function finishTruthOrder(state: Game): Game {
let game = state;
while (game.truthtrance?.stage === 'order') {
const frame = truthOrderFrame(game), next = frame.holders.find(holder => !frame.passed.includes(holder));
assert.ok(next); game = passTruthOrder(game,next);
}
assert.equal(game.truthtrance?.stage,'ask'); return game;
}
/** Public fresh-discard offers are intentionally possession-independent: even
* a table with no held Semuta must pass its neutral reaction window. */
export function passFreshDiscard(state: Game): Game {
let game = state;
for (let count = 0; game.pendingTreacheryDiscard?.reaction?.stage === 'offer' && count < game.players.length; count++) {
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction!;
assert.equal(reaction.stage,'offer');
if (reaction.stage !== 'offer') throw new Error('The Semuta response must remain an offer.');
const actor = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(actor);
game = act(game,actor.id,{type:'semutaPass',event:pending.batch.event});
}
assert.ok(!game.pendingTreacheryDiscard?.reaction || game.pendingTreacheryDiscard.reaction.stage !== 'offer');
return game;
}
export function finishOriginalTruthQuestions(state: Game): Game {
let game = state;
while (game.truthtrance) {
assert.equal(game.truthtrance.stage,'ask');
const asker = game.truthtrance.queue[0].player;
const target = game.players.find(row => row.id !== asker)!.id;
game = act(game,asker,{type:'truthAsk',question:{kind:'fact',target,
fact:{kind:'spice',compare:'gte',value:0}}});
assert.equal(game.truthtrance?.stage,'answer');
game = act(game,target,{type:'truthAnswer',answer:'yes'});
// If another player holds Semuta, the real completed Truth discard can
// open its own fresh reaction; pass it rather than bypassing custody.
game = passFreshDiscard(game);
}
return game;
}
export function rejectSaphoTruthOrder(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game,actor,action));
assert.deepEqual(game,before,'Rejected ordering action must not mutate its input');
}
export function ownOrderChoices(game: Game, actor: string) {
return viewGame(game,actor).saphoTruthOrderChoices;
}
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { Action } from '../game/engine';
import { SaphoTruthOrder, SaphoTruthOrderChoiceButton, SaphoTruthOrderPass,
type SaphoTruthOrderProps } from '../components/sapho-truth-order';
function props(choices: SaphoTruthOrderProps['choices'] = []): SaphoTruthOrderProps & { sent: Action[] } {
const sent: Action[] = [];
return { frame: { event: 'public-order-event', revision: 2, holders: ['one', 'two'], passed: [] },
me: 'one', choices, busy: false, sent, act(action) { sent.push(action); } };
}
const render = (input: SaphoTruthOrderProps) => renderToStaticMarkup(createElement(SaphoTruthOrder, input));
void test('queued holders receive a public continue opportunity without owning Sapho', () => {
const input = props(), html = render(input);
assert.match(html, /Truthtrance question order/);
assert.match(html, /0 of 2 players have finished this choice/);
assert.match(html, /Continue without changing question order/);
assert.doesNotMatch(html, /Use Juice of Sapho to ask/);
const button = SaphoTruthOrderPass(input); button.props.onClick();
assert.deepEqual(input.sent, [{ type: 'truthOrderPass', event: 'public-order-event', revision: 2 }]);
});
void test('only private quoted choices render and dispatch exact event-bound actions', () => {
const choices = [{ card: 'private-card-id', mode: 'first' as const }, { card: 'private-card-id', mode: 'last' as const }];
const input = props(choices), html = render(input);
assert.match(html, /Use Juice of Sapho to ask first/);
assert.match(html, /Use Juice of Sapho to ask last/);
assert.doesNotMatch(html, /private-card-id/);
for (const choice of choices) SaphoTruthOrderChoiceButton({ ...input, choice }).props.onClick();
assert.deepEqual(input.sent, choices.map(choice => ({ type: 'saphoTruthOrder', event: 'public-order-event', revision: 2, ...choice })));
});
void test('busy, passed and non-holder viewers cannot issue either action', () => {
for (const variant of ['busy', 'passed', 'other'] as const) {
const input = props([{ card: 'sapho-card', mode: 'first' }]);
if (variant === 'busy') input.busy = true;
if (variant === 'passed') input.frame.passed.push(input.me);
if (variant === 'other') input.me = 'other';
SaphoTruthOrderPass(input).props.onClick();
SaphoTruthOrderChoiceButton({ ...input, choice: input.choices[0] }).props.onClick();
assert.deepEqual(input.sent, []);
if (variant !== 'busy') assert.doesNotMatch(render(input), /data-testid="sapho-truth-order-pass"/);
else assert.match(render(input), /disabled=""/);
}
});
void test('callbacks captured before an event, revision, pass or quoted choice changes do not send', () => {
for (const change of ['event', 'revision', 'pass', 'choice'] as const) {
const input = props([{ card: 'sapho-card', mode: 'first' }]);
const pass = SaphoTruthOrderPass(input), choice = SaphoTruthOrderChoiceButton({ ...input, choice: input.choices[0] });
if (change === 'event') input.frame.event = 'new-event';
if (change === 'revision') input.frame.revision++;
if (change === 'pass') input.frame.passed.push(input.me);
if (change === 'choice') input.choices.splice(0);
if (change !== 'choice') pass.props.onClick();
choice.props.onClick();
assert.deepEqual(input.sent, []);
}
});
@@ -0,0 +1,82 @@
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 { viewGame, type Action, type GameView } from '../game/engine';
import { SaphoTruthOrderPass, SaphoTruthOrderChoiceButton } from '../components/sapho-truth-order';
import { act, inventory, createSaphoTruthOrderFixture, passFreshDiscard,
finishOriginalTruthQuestions } from './fixture-sapho-truth-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 { GameTable } = await import('../components/game-table');
const { Truthtrance } = await import('../components/truthtrance');
aliases.deregister();
const table = (game: GameView) => renderToStaticMarkup(createElement(GameTable, {
game, busy: false, send: async () => {}, onExit() {},
}));
const truth = (game: GameView) => renderToStaticMarkup(createElement(Truthtrance, {
game, busy: false, act() {},
}));
for (const advanced of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} queued holders see and use actual Truth-order controls`, () => {
// Native public setup, a genuinely purchased Sapho and an original started
// movement parent. One labeled, physical-card-conserving swap places an
// already-dealt Truth card into the Sapho owner's hand; see the fixture.
const f = createSaphoTruthOrderFixture({advanced,mode:'first'});
let game = f.game;
const otherView = viewGame(game,f.other), otherFrame = otherView.truthtrance?.order;
assert.ok(otherFrame); assert.deepEqual(otherView.saphoTruthOrderChoices,[]);
assert.match(truth(otherView),/Continue without changing question order/);
assert.match(table(otherView),/Continue without changing question order/);
assert.doesNotMatch(truth(otherView),/Use Juice of Sapho to ask/);
assert.doesNotMatch(truth(otherView),new RegExp(f.card));
// Invoke the actual hook-free leaf callbacks rendered by Truthtrance; SSR
// does not operate a browser, hydration or interactive selectors.
const sent: Action[] = [];
const pass = SaphoTruthOrderPass({frame:otherFrame,me:f.other,choices:otherView.saphoTruthOrderChoices,
busy:false,act(action) { sent.push(action); }});
assert.equal(pass.props.disabled,false); pass.props.onClick();
assert.deepEqual([...sent],[{type:'truthOrderPass',event:otherFrame.event,revision:otherFrame.revision}]);
game = act(game,f.other,sent.pop()!);
const ownerView = viewGame(game,f.owner), ownerFrame = ownerView.truthtrance?.order;
assert.ok(ownerFrame); assert.ok(ownerFrame.passed.includes(f.other));
const choice = ownerView.saphoTruthOrderChoices.find(row => row.card === f.card && row.mode === 'first');
assert.ok(choice);
assert.match(truth(ownerView),/Use Juice of Sapho to ask first/);
assert.match(table(ownerView),/Use Juice of Sapho to ask first/);
const button = SaphoTruthOrderChoiceButton({frame:ownerFrame,me:f.owner,choices:ownerView.saphoTruthOrderChoices,
choice,busy:false,act(action) { sent.push(action); }});
assert.equal(button.props.disabled,false); button.props.onClick();
assert.deepEqual([...sent],[{type:'saphoTruthOrder',event:ownerFrame.event,revision:ownerFrame.revision,
card:f.card,mode:'first'}]);
game = act(game,f.owner,sent.pop()!);
// Basic's supported Semuta profile can open a neutral reaction; actually
// pass it before observing the restored first original Truth question.
game = passFreshDiscard(game);
assert.equal(game.truthtrance?.stage,'ask');
assert.equal(game.truthtrance.queue[0].player,f.owner);
assert.deepEqual(game.order,f.order);
assert.deepEqual(inventory(game),f.originalCards);
game = finishOriginalTruthQuestions(game);
assert.equal(game.truthtrance,null);
assert.equal(game.active,f.parent.active);
assert.equal(game.phase,f.parent.phase);
assert.deepEqual(inventory(game),f.originalCards);
},
);
+196
View File
@@ -0,0 +1,196 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handLimit, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { act, inventory, player, createSaphoTruthOrderFixture as fixture,
truthOrderFrame as frame, passTruthOrder as pass, playSaphoTruthOrder as play,
finishTruthOrder as finish, finishOriginalTruthQuestions as answer, passFreshDiscard,
rejectSaphoTruthOrder as reject, ownOrderChoices as choices } from './fixture-sapho-truth-order';
for (const advanced of [false,true]) for (const mode of ['first','last'] as const) {
const label = `${advanced ? 'Advanced' : 'Basic'} ${mode}`;
void test(`${label} Sapho reorders the original pending Truth groups and resumes their parent`, () => {
const f = fixture({advanced,mode}), state = f.game, before = structuredClone(state);
const current = frame(state), ownerChoices = choices(state,f.owner);
assert.ok(ownerChoices.some(option => option.card === f.card && option.mode === mode));
assert.deepEqual(choices(state,f.other), [], 'A different holder is not told which card Emperor holds');
const unchanged = mode === 'first' ? 'last' : 'first';
reject(state,f.owner,{type:'saphoTruthOrder',event:current.event,revision:current.revision,card:f.card,mode:unchanged});
reject(state,f.owner,{type:'saphoTruthOrder',event:'stale',revision:current.revision,card:f.card,mode});
reject(state,f.owner,{type:'saphoTruthOrder',event:current.event,revision:current.revision + 1,card:f.card,mode});
reject(state,'atreides',{type:'truthOrderPass',event:current.event,revision:current.revision});
assert.deepEqual(state,before);
let game = play(state,f.owner,f.card,mode);
// The neutral Semuta offer may suspend Truth even when nobody holds the
// card; each player explicitly passes before the original frame resumes.
game = passFreshDiscard(game);
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(game.order,f.order,'The physical storm order never changes');
assert.ok(frame(game).passed.includes(f.owner));
reject(game,f.owner,{type:'saphoTruthOrder',event:current.event,revision:current.revision,card:f.card,mode});
game = finish(game);
const queue = game.truthtrance!.queue.map(row => row.player);
assert.equal(mode === 'first' ? queue[0] : queue.at(-1),f.owner);
assert.deepEqual([...queue].sort(),[f.owner,f.other].sort());
game = answer(game);
assert.equal(game.truthtrance,null);
assert.equal(game.discard.filter(card => [f.card,f.truth,f.otherTruth].includes(card.id)).length,3);
assert.equal(game.turn,f.parent.turn); assert.equal(game.phase,f.parent.phase);
assert.equal(game.active,f.parent.active); assert.deepEqual(game.movementRemaining,f.parent.movementRemaining);
assert.deepEqual(game.players.map(row => ({id:row.id,spice:row.spice,reserves:row.reserves,tanks:row.tanks,
shipped:row.shipped,moved:row.moved,forces:row.forces})),f.parent.players);
assert.deepEqual(inventory(game),f.originalCards);
});
}
for (const advanced of [false,true]) {
const label = advanced ? 'Advanced' : 'Basic';
void test(`${label} all Truth holders can pass ordering without any Sapho being held`, () => {
// The optional cache exchange occurs before either actual declaration,
// so window creation cannot depend on whether anybody holds Sapho.
const f = fixture({advanced,mode:'first',withoutSapho:true}), state = structuredClone(f.game);
assert.ok(state.players.every(row => !row.hand.some(card => card.id === f.card)));
assert.deepEqual(inventory(state),f.originalCards);
const initialQueue = state.truthtrance!.queue.map(row => ({...row}));
const initialFrame = frame(state);
for (const holder of initialFrame.holders) assert.deepEqual(choices(state,holder),[]);
let game = pass(state,initialFrame.holders[0]);
const updated = frame(game);
reject(game,initialFrame.holders[0],{type:'truthOrderPass',event:updated.event,revision:updated.revision});
game = finish(game);
assert.deepEqual(game.truthtrance!.queue,initialQueue);
game = answer(game);
assert.deepEqual(inventory(game),f.originalCards);
});
void test(`${label} each minimal bot profile can advance the public ordering window legally`, () => {
const f = fixture({advanced,mode:'first'});
for (const difficulty of DIFFICULTIES) for (const actor of [f.owner,f.other]) {
const view = viewGame(f.game,actor);
view.players.find(row => row.id === actor)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action,`${difficulty} ${actor}`);
assert.ok(action.type === 'truthOrderPass' || action.type === 'saphoTruthOrder',
`The ${difficulty} bot must use a legal ordering action`);
const progressed = act(structuredClone(f.game),actor,action);
assert.ok(!progressed.truthtrance || progressed.truthtrance.stage === 'order' || progressed.truthtrance.stage === 'ask');
assert.deepEqual(inventory(progressed),f.originalCards);
}
});
}
for (const claim of [false,true]) void test(
`Basic labeled conserved Semuta ${claim ? 'claim' : 'pass'} restores the same Truth ordering window`, () => {
const f = fixture({advanced:false,mode:'first'}), state = structuredClone(f.game);
// Controlled physical supplement: the original cached Semuta is swapped
// with Atreides' original held card. The underlying public setup and
// actually purchased Sapho are untouched; no card/resource is created.
const claimant = player(state,'atreides'), cache = state.richeseCache;
assert.ok(cache); const semutaAt = cache.findIndex(card => card.effect === 'semutaDrug'); assert.ok(semutaAt >= 0);
assert.ok(claimant.hand.length > 0);
const [semuta] = cache.splice(semutaAt,1), [exchange] = claimant.hand.splice(0,1);
claimant.hand.push(semuta); cache.push(exchange);
assert.deepEqual(inventory(state),f.originalCards);
const prior = structuredClone(frame(state));
let game = play(state,f.owner,f.card,'first');
assert.ok(game.pendingTreacheryDiscard?.reaction,'The actual fresh Sapho discard offers Semuta');
assert.equal(game.pendingTreacheryDiscard.batch.entries[0].card.id,f.card);
const event = game.pendingTreacheryDiscard.batch.event;
assert.equal(viewGame(game,'atreides').semutaReaction?.canCommit,true);
if (claim) game = act(game,'atreides',{type:'semutaCommit',event});
else while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
const pending: NonNullable<Game['pendingTreacheryDiscard']> = game.pendingTreacheryDiscard;
const reaction: NonNullable<NonNullable<Game['pendingTreacheryDiscard']>['reaction']> = pending.reaction!;
assert.equal(reaction.stage,'offer');
if (reaction.stage !== 'offer') throw new Error('The Semuta response must remain an offer.');
const actor = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(actor);
game = act(game,actor.id,{type:'semutaPass',event:pending.batch.event});
}
assert.equal(game.pendingTreacheryDiscard,null);
const resumed = frame(game);
assert.equal(resumed.event,prior.event);
assert.deepEqual([...resumed.holders].sort(),[...prior.holders].sort());
assert.ok(resumed.passed.includes(f.owner));
assert.equal(player(game,'atreides').hand.some(card => card.id === f.card),claim);
assert.equal(game.discard.some(card => card.id === semuta.id),claim);
assert.deepEqual(inventory(game),f.originalCards);
game = answer(finish(game));
assert.equal(game.truthtrance,null);
assert.equal(game.active,f.parent.active); assert.equal(game.phase,f.parent.phase);
assert.deepEqual(inventory(game),f.originalCards);
},
);
for (const claim of [false,true]) void test(
`Basic Semuta ${claim ? 'claim' : 'pass'} after the final Sapho response resumes directly to Truth questions`, () => {
const f = fixture({advanced:false,mode:'first'}), state = structuredClone(f.game);
// Controlled conserved custody, as above: swap the original cached Semuta
// into Atreides' hand before either participant responds to the order frame.
const claimant = player(state,'atreides'), cache = state.richeseCache;
assert.ok(cache); const semutaAt = cache.findIndex(card => card.effect === 'semutaDrug'); assert.ok(semutaAt >= 0);
assert.ok(claimant.hand.length > 0);
const [semuta] = cache.splice(semutaAt,1), [exchange] = claimant.hand.splice(0,1);
claimant.hand.push(semuta); cache.push(exchange);
assert.deepEqual(inventory(state),f.originalCards);
let game = pass(state,f.other);
assert.deepEqual(frame(game).passed,[f.other]);
// The only remaining holder plays Sapho, so the ordering is complete but
// its fresh-discard reaction must settle before the first question opens.
game = play(game,f.owner,f.card,'first');
assert.ok(game.pendingTreacheryDiscard?.reaction,'The actual fresh Sapho discard offers Semuta');
assert.equal(game.pendingTreacheryDiscard.batch.entries[0].card.id,f.card);
const event = game.pendingTreacheryDiscard.batch.event;
if (claim) game = act(game,'atreides',{type:'semutaCommit',event});
else game = passFreshDiscard(game);
assert.equal(game.pendingTreacheryDiscard,null);
assert.equal(game.truthtrance?.stage,'ask');
assert.equal(game.truthtrance?.queue[0].player,f.owner);
assert.equal(player(game,'atreides').hand.some(card => card.id === f.card),claim);
assert.equal(game.discard.some(card => card.id === semuta.id),claim);
assert.deepEqual(inventory(game),f.originalCards);
game = answer(game);
assert.equal(game.truthtrance,null);
assert.equal(game.phase,f.parent.phase); assert.equal(game.active,f.parent.active);
assert.deepEqual(inventory(game),f.originalCards);
},
);
for (const mode of ['first','last'] as const) void test(
`Basic a second Truth holder cannot displace an established Sapho ${mode} after reclaiming it with Semuta`, () => {
const f = fixture({advanced:false,mode}), state = structuredClone(f.game);
// Controlled conserved custody: move the original cached Semuta into the
// other already-queued Truth holder's real hand, without modifying funds,
// the queue or any physical card identity. The hand has a genuine vacancy.
const other = player(state,f.other), cache = state.richeseCache;
assert.ok(cache); const semutaAt = cache.findIndex(card => card.effect === 'semutaDrug'); assert.ok(semutaAt >= 0);
assert.ok(other.hand.length < handLimit(other));
const [semuta] = cache.splice(semutaAt,1); other.hand.push(semuta);
assert.ok(other.hand.some(card => card.id === f.otherTruth));
assert.deepEqual(inventory(state),f.originalCards);
const before = frame(state);
let game = play(state,f.owner,f.card,mode);
assert.ok(game.pendingTreacheryDiscard?.reaction,'The physical Sapho discard offers the held Semuta');
const event = game.pendingTreacheryDiscard.batch.event;
assert.equal(viewGame(game,f.other).semutaReaction?.canCommit,true);
game = act(game,f.other,{type:'semutaCommit',event});
assert.equal(game.pendingTreacheryDiscard,null);
assert.ok(player(game,f.other).hand.some(card => card.id === f.card));
assert.ok(game.discard.some(card => card.id === semuta.id));
const after = frame(game);
assert.equal(after.event,before.event);
assert.ok(after.passed.includes(f.owner)); assert.ok(!after.passed.includes(f.other));
assert.equal(mode === 'first' ? game.truthtrance!.queue[0].player : game.truthtrance!.queue.at(-1)!.player,f.owner);
assert.ok(!choices(game,f.other).some(choice => choice.card === f.card && choice.mode === mode),
'The later holder cannot override the earlier protected position');
reject(game,f.other,{type:'saphoTruthOrder',event:after.event,revision:after.revision,card:f.card,mode});
assert.equal(mode === 'first' ? game.truthtrance!.queue[0].player : game.truthtrance!.queue.at(-1)!.player,f.owner);
game = pass(game,f.other);
assert.equal(game.truthtrance?.stage,'ask');
assert.equal(mode === 'first' ? game.truthtrance!.queue[0].player : game.truthtrance!.queue.at(-1)!.player,f.owner);
game = answer(game);
assert.equal(game.truthtrance,null);
assert.equal(game.phase,f.parent.phase); assert.equal(game.active,f.parent.active);
assert.deepEqual(inventory(game),f.originalCards);
},
);
+117
View File
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
SaphoTruthOrderError, createSaphoTruthOrder, passSaphoTruthOrder,
quoteSaphoTruthOrder, reorderSaphoTruthOrder, validateSaphoTruthOrder,
} from '../game/sapho-truth-order';
const original = [
{ player: 'a', card: 'a1', source: 'printed' },
{ player: 'a', card: 'a2', source: 'shrine' },
{ player: 'b', card: 'b1', source: 'printed' },
{ player: 'c', card: 'c1', source: 'printed' },
];
const frame = () => createSaphoTruthOrder(original, 'opaque-event')!;
void test('the public window is created from distinct committed holders, never their hidden hands', () => {
assert.equal(createSaphoTruthOrder([], 'empty'), null);
assert.equal(createSaphoTruthOrder(original.slice(0, 2), 'one-holder'), null);
assert.deepEqual(frame(), { event: 'opaque-event', revision: 0, holders: ['a', 'b', 'c'], passed: [] });
assert.deepEqual(quoteSaphoTruthOrder(frame(), original, 'a'), { event: 'opaque-event', revision: 0, modes: ['last'] });
assert.deepEqual(quoteSaphoTruthOrder(frame(), original, 'b'), { event: 'opaque-event', revision: 0, modes: ['first', 'last'] });
assert.deepEqual(quoteSaphoTruthOrder(frame(), original, 'c'), { event: 'opaque-event', revision: 0, modes: ['first'] });
});
void test('first and last move the entire group, preserving every original row and internal card order', () => {
const before = structuredClone(original), start = frame();
const first = reorderSaphoTruthOrder(start, original, { event: start.event, revision: 0, player: 'b', mode: 'first' });
assert.deepEqual(first.queue.map(row => row.card), ['b1', 'a1', 'a2', 'c1']);
assert.deepEqual(first.frame, { event: 'opaque-event', revision: 1, holders: ['a', 'b', 'c'], passed: ['b'], protectedFirst: 'b' });
assert.equal(first.done, false);
assert.deepEqual(original, before);
const last = reorderSaphoTruthOrder(first.frame, first.queue, { event: start.event, revision: 1, player: 'a', mode: 'last' });
assert.deepEqual(last.queue.map(row => row.card), ['b1', 'c1', 'a1', 'a2']);
assert.deepEqual(last.queue.filter(row => row.player === 'a'), before.slice(0, 2));
assert.deepEqual(last.frame.passed, ['b', 'a']);
assert.deepEqual(last.frame.holders, ['a', 'b', 'c']);
});
void test('every public holder may pass independently, and only the last response completes the window', () => {
const first = passSaphoTruthOrder(frame(), original, { event: 'opaque-event', revision: 0, player: 'c' });
assert.equal(first.done, false);
const second = passSaphoTruthOrder(first.frame, first.queue, { event: 'opaque-event', revision: 1, player: 'a' });
assert.equal(second.done, false);
const third = passSaphoTruthOrder(second.frame, second.queue, { event: 'opaque-event', revision: 2, player: 'b' });
assert.equal(third.done, true);
assert.deepEqual(third.queue, original);
assert.deepEqual(third.frame.passed, ['c', 'a', 'b']);
});
void test('expired events, stale revisions and duplicate or outsider responses leave the input unchanged', () => {
const initial = frame(), first = passSaphoTruthOrder(initial, original, { event: 'opaque-event', revision: 0, player: 'a' });
const before = structuredClone(first);
for (const request of [
{ event: 'other', revision: 1, player: 'b' },
{ event: 'opaque-event', revision: 0, player: 'b' },
{ event: 'opaque-event', revision: 1, player: 'a' },
{ event: 'opaque-event', revision: 1, player: 'outsider' },
]) assert.throws(() => passSaphoTruthOrder(first.frame, first.queue, request), SaphoTruthOrderError);
assert.deepEqual(first, before);
});
void test('no-op choices and malformed orders reject without consuming an opportunity', () => {
const initial = frame(), before = structuredClone(initial);
for (const request of [
{ event: initial.event, revision: 0, player: 'a', mode: 'first' as const },
{ event: initial.event, revision: 0, player: 'c', mode: 'last' as const },
]) assert.throws(() => reorderSaphoTruthOrder(initial, original, request), SaphoTruthOrderError);
assert.deepEqual(initial, before);
assert.throws(() => createSaphoTruthOrder([original[0], original[2], original[1]], 'event'), /together/);
assert.throws(() => validateSaphoTruthOrder({ ...initial, passed: ['a'], revision: 0 }, original), /revision/);
assert.throws(() => validateSaphoTruthOrder({ ...initial, holders: ['a', 'b', 'd'] }, original), /original Truth holder/);
assert.throws(() => validateSaphoTruthOrder({ ...initial, passed: ['outsider'], revision: 1 }, original), /original holders/);
});
void test('JSON round-trip preserves the event, revision and grouped ordering without private data in the frame', () => {
const initial = frame(), moved = reorderSaphoTruthOrder(initial, original, { event: initial.event, revision: 0, player: 'c', mode: 'first' });
const restored = JSON.parse(JSON.stringify(moved)) as typeof moved;
assert.doesNotMatch(JSON.stringify(restored.frame), /a1|a2|b1|c1|shrine/);
validateSaphoTruthOrder(restored.frame, restored.queue);
assert.deepEqual(quoteSaphoTruthOrder(restored.frame, restored.queue, 'a'), { event: 'opaque-event', revision: 1, modes: ['last'] });
});
void test('an earlier first choice is protected when another holder later obtains and plays Sapho', () => {
const initial = frame();
const first = reorderSaphoTruthOrder(initial, original, { event: initial.event, revision: 0, player: 'b', mode: 'first' });
// A second physical use is possible after another player claims the same
// discarded Sapho through Semuta. It may not displace the first holder.
assert.deepEqual(quoteSaphoTruthOrder(first.frame, first.queue, 'c').modes, []);
assert.throws(() => reorderSaphoTruthOrder(first.frame, first.queue,
{ event: initial.event, revision: 1, player: 'c', mode: 'first' }), /protects/);
assert.deepEqual(quoteSaphoTruthOrder(first.frame, first.queue, 'a').modes, ['last']);
const opposite = reorderSaphoTruthOrder(first.frame, first.queue,
{ event: initial.event, revision: 1, player: 'a', mode: 'last' });
assert.deepEqual(opposite.queue.map(row => row.player), ['b','c','a','a']);
assert.equal(opposite.frame.protectedFirst, 'b');
assert.equal(opposite.frame.protectedLast, 'a');
assert.deepEqual(quoteSaphoTruthOrder(opposite.frame, opposite.queue, 'c').modes, []);
validateSaphoTruthOrder(opposite.frame, opposite.queue);
});
void test('an earlier last choice remains protected, and legacy frames need no protection fields', () => {
const initial = frame();
const last = reorderSaphoTruthOrder(initial, original, { event: initial.event, revision: 0, player: 'b', mode: 'last' });
assert.equal(last.frame.protectedLast, 'b');
assert.deepEqual(last.queue.map(row => row.player), ['a','a','c','b']);
assert.deepEqual(quoteSaphoTruthOrder(last.frame,last.queue,'c').modes, ['first']);
assert.throws(() => reorderSaphoTruthOrder(last.frame,last.queue,
{event:initial.event,revision:1,player:'c',mode:'last'}), /protects/);
const first = reorderSaphoTruthOrder(last.frame,last.queue,
{event:initial.event,revision:1,player:'c',mode:'first'});
assert.deepEqual(first.queue.map(row => row.player), ['c','a','a','b']);
assert.equal(first.frame.protectedFirst,'c'); assert.equal(first.frame.protectedLast,'b');
assert.throws(() => validateSaphoTruthOrder(first.frame,[...first.queue].reverse()), /together|protected/);
const legacy = JSON.parse(JSON.stringify(initial)) as typeof initial;
assert.equal(legacy.protectedFirst,undefined); assert.equal(legacy.protectedLast,undefined);
validateSaphoTruthOrder(legacy,original);
});