Support Sapho ordering for simultaneous Truthtrances
This commit is contained in:
1 parent
4690200f59
commit
298c8245a6
16 files changed
+1020
-3
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user