Support Sapho replay and public cache questions

This commit is contained in:
Quintine committed 2026-10-28 19:28:19 +11:00
1 parent 7d71647a90
commit 26be6fd8ee
17 files changed
+393 -22

No files matched your search

+3 -1
View File
@@ -70,7 +70,9 @@ void test('started-tail last includes Guild while retaining the active turn and
assert.equal(quote.next.protectedLast, 'holder');
assert.equal(quote.next.event, input.queue.event);
assert.deepEqual(input, before);
assert.throws(() => saphoMovementOrderEvent({ ...input, queue: quote.next }), SaphoMovementOrderError);
const protectedContext = { ...input, queue: quote.next };
const event = saphoMovementOrderEvent(protectedContext);
assert.throws(() => reorderSaphoMovementOrder(protectedContext, { event, player: 'later', mode: 'first' }), SaphoMovementOrderError);
});
void test('an already started actor cannot use either mode to split its combined turn', () => {
+84
View File
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { reorderSaphoMovementOrder, saphoMovementOrderEvent, SaphoMovementOrderError,
type SaphoMovementOrderContext } from '../game/sapho-movement-order';
import { advanceSaphoStartedMovement, createSaphoStartedMovementFixture, saphoStartedMovementClean,
saphoStartedMovementInventory, saphoStartedMovementPlayer, STARTED_MOVEMENT_SAPHO } from './fixture-sapho-started-movement';
function order(started = false): SaphoMovementOrderContext {
return { queue: { event: 'movement:3', eligible: ['done', 'active', 'later', 'owner'],
completed: ['done'], remaining: ['active', 'later', 'owner'], current: 'active',
currentStarted: started, protectedLast: 'owner' },
started: started ? ['done', 'active'] : ['done'], guild: null };
}
function reorder(context: SaphoMovementOrderContext, player: string, mode: 'first' | 'last') {
return reorderSaphoMovementOrder(context, { event: saphoMovementOrderEvent(context), player, mode });
}
void test('a fresh Sapho First relinquishes the same holder’s prior Last protection', () => {
for (const started of [false, true]) {
const context = order(started), before = structuredClone(context);
const quote = reorder(context, 'owner', 'first');
assert.deepEqual(quote.next.remaining, started ? ['active', 'owner', 'later'] : ['owner', 'active', 'later']);
assert.equal(quote.next.protectedLast, null);
assert.deepEqual(quote.next.completed, ['done']);
assert.equal(quote.afterCurrent, started ? 'active' : null);
assert.deepEqual(context, before);
}
});
void test('another seat cannot replace an existing Last, and replay cannot duplicate it', () => {
const context = order(), before = structuredClone(context), event = saphoMovementOrderEvent(context);
for (const [player, mode] of [['later', 'first'], ['later', 'last'], ['owner', 'last']] as const)
assert.throws(() => reorderSaphoMovementOrder(context, { event, player, mode }), SaphoMovementOrderError);
assert.throws(() => reorderSaphoMovementOrder(context, { event: 'movement:4', player: 'owner', mode: 'first' }), SaphoMovementOrderError);
assert.deepEqual(context, before);
const guild = order(); guild.guild = { player: 'active', granted: true, locked: false };
assert.throws(() => reorder(guild, 'owner', 'first'), SaphoMovementOrderError);
const suspended = order(); suspended.started = ['done', 'owner'];
assert.throws(() => saphoMovementOrderEvent(suspended), SaphoMovementOrderError);
});
function movementAction(game: Game, actor: string, mode: 'first' | 'last'): Action {
const option = viewGame(game, actor).saphoOptions.find(item => item.scope === 'movement' && item.mode === mode);
assert.ok(option, `Sapho ${mode} must be available to its holder`);
return { type: 'card', card: STARTED_MOVEMENT_SAPHO, scope: 'movement', event: option.event, mode };
}
void test('paid Box recovers the physical Sapho for First after its own Last in a Basic movement phase', () => {
const f = createSaphoStartedMovementFixture();
let game = structuredClone(f.beforeShipment);
const owner = saphoStartedMovementPlayer(game, f.owner);
// Supplemental staging exchanges one unopened cache card for an existing held
// card. Setup, the Sapho purchase and the original movement entry are real.
const index = game.richeseCache!.findIndex(card => card.effect === 'nullentropyBox');
assert.ok(index >= 0 && owner.hand.length > 1);
const [box] = game.richeseCache!.splice(index, 1);
const exchange = owner.hand.findIndex(card => card.id !== STARTED_MOVEMENT_SAPHO);
assert.ok(exchange >= 0); game.richeseCache!.push(owner.hand.splice(exchange, 1)[0]); owner.hand.push(box);
assert.ok(owner.spice >= 2); assert.deepEqual(saphoStartedMovementInventory(game), f.originalInventory);
const beforeLast = structuredClone(game), last = movementAction(game, f.owner, 'last');
game = applyAction(game, f.owner, last);
game = advanceSaphoStartedMovement(game, saphoStartedMovementClean);
assert.equal(game.saphoMovementLast?.player, f.owner);
assert.equal(game.movementRemaining!.at(-1), f.owner);
assert.ok(game.discard.some(card => card.id === STARTED_MOVEMENT_SAPHO));
assert.equal(saphoStartedMovementPlayer(game, f.owner).spice, owner.spice);
const savedQueue = [...game.movementRemaining!], cash = saphoStartedMovementPlayer(game, f.owner).spice;
game = applyAction(game, f.owner, { type: 'card', card: box.id });
if (game.pendingNullentropy) game = applyAction(game, f.owner, { type: 'decision',
event: game.pendingNullentropy.event, card: STARTED_MOVEMENT_SAPHO });
game = advanceSaphoStartedMovement(game, saphoStartedMovementClean);
assert.equal(saphoStartedMovementPlayer(game, f.owner).spice, cash - 2);
assert.ok(saphoStartedMovementPlayer(game, f.owner).hand.some(card => card.id === STARTED_MOVEMENT_SAPHO));
assert.deepEqual(game.movementRemaining, savedQueue);
const first = movementAction(game, f.owner, 'first');
game = applyAction(game, f.owner, first);
game = advanceSaphoStartedMovement(game, saphoStartedMovementClean);
assert.equal(game.movementRemaining![0], f.owner);
assert.equal(game.saphoMovementLast, null);
assert.equal(game.discard.filter(card => card.id === STARTED_MOVEMENT_SAPHO).length, 1);
assert.deepEqual(game.order, beforeLast.order);
assert.deepEqual(saphoStartedMovementInventory(game), f.originalInventory);
});
+1 -1
View File
@@ -54,7 +54,7 @@ void test('started self-last needs explicit Basic admission and cannot create a
reject(alone);
const advanced = context();
advanced.guild = { player: 'last', granted: false, locked: false };
reject(advanced);
assert.equal(request(advanced).next.protectedLast, 'active');
const proposed = request(input);
const already = { ...input, queue: proposed.next, started: ['done', 'active'] };
assert.throws(() => saphoMovementOrderEvent(already), SaphoMovementOrderError);
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import * as jsxRuntime from 'react/jsx-runtime';
import { renderToStaticMarkup } from 'react-dom/server';
import ts from 'typescript';
import * as board from '../game/board';
import * as catalog from '../game/catalog';
import { createGame, joinGame, newPlayer, viewGame } from '../game/engine';
import * as knowledge from '../game/truthtrance-knowledge';
import * as cardNames from '../game/truthtrance-card-names';
import { KnowledgeFactFields } from '../components/truthtrance-knowledge';
import { TruthFactFields, invalidTruthFact } from '../components/mixed-shipment-fields';
import { TruthFactExpressionFields } from '../components/truth-fact-expression-fields';
import type { TruthFact } from '../game/truthtrance';
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
return [node, ...elements(node.props.children)];
}
void test('single and compound current facts expose the public cache auction selector', () => {
const game = createGame('CACHELOTUI', newPlayer('ask', 'Asker', 'emperor'), false, ['choam']);
joinGame(game, newPlayer('target', 'Target', 'richese'));
const view = viewGame(game, 'ask');
const fact: TruthFact = { kind: 'cacheLot', name: 'Shield' };
const single = renderToStaticMarkup(createElement(TruthFactFields, {
value: fact, game: view, target: 'target', onChange() {},
}));
assert.match(single, /value="cacheLot" selected/);
assert.match(single, /Public Basic Richese cache auction card/);
assert.match(single, /value="Shield" selected/);
assert.match(single, /If that auction is unavailable/);
const compound: TruthFact = { kind: 'or', terms: [fact, { kind: 'spice', compare: 'gte', value: 3 }] };
const grouped = renderToStaticMarkup(createElement(TruthFactExpressionFields, {
value: compound, game: view, target: 'target', disabled: false, onChange() {},
}));
assert.match(grouped, /value="cacheLot" selected/);
assert.match(grouped, /Public Basic Richese cache auction card/);
assert.match(grouped, /value="Shield" selected/);
assert.equal(invalidTruthFact(fact, view, 'target'), false);
assert.equal(invalidTruthFact({ kind: 'cacheLot', name: 'not a printed card' }, view, 'target'), true);
});
void test('cache card control returns a selected printed name without altering the fact kind', () => {
const exports: { KnowledgeFactFields?: typeof KnowledgeFactFields } = {};
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/truthtrance-knowledge.tsx', import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
}).outputText, {
exports,
require(name: string) {
if (name === 'react/jsx-runtime') return jsxRuntime;
if (name === 'react') return { useId: () => 'cache-lot-test' };
if (name === '@/game/catalog') return catalog;
if (name === '@/game/board') return board;
if (name === '@/game/truthtrance-knowledge') return knowledge;
if (name === '@/game/truthtrance-card-names') return cardNames;
if (name === '@/components/ui/input') return { Input: 'input' };
throw new Error(`Unexpected import ${name}`);
},
});
assert.ok(exports.KnowledgeFactFields);
let fact: knowledge.KnowledgeFact = { kind: 'cacheLot', name: 'Shield' };
const render = () => elements(exports.KnowledgeFactFields!({ value: fact, onChange(value) { fact = value; } }));
const select = render().find(element => element.type === 'select');
assert.ok(select);
const next = cardNames.TRUTH_CARD_NAMES.find(name => name !== 'Shield'); assert.ok(next);
(select.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: next } });
assert.deepEqual(JSON.parse(JSON.stringify(fact)), { kind: 'cacheLot', name: next });
assert.equal(knowledge.knowledgeFactInputError(fact), null);
});
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names';
import { knowledgeFactAnswer, knowledgeFactText, parseKnowledgeFact, truthKnowledgeOf } from '../game/truthtrance-knowledge';
import {
advanceTruthtranceRichesePromises as advance,
createTruthtranceRichesePromisesFixture as fixture,
truthtranceRichesePromisesClean as clean,
} from './fixture-truthtrance-richese-promises';
/** The original fixture orders only unopened cards and advances the actual auction. */
function liveCache() {
const f = fixture();
const game = advance(f.earlySource, state => clean(state) && state.phase === 3 &&
state.richeseAuction?.source === 'cache' && !state.richeseAuction.outcome);
const card = game.richeseCache!.find(item => item.id === game.richeseAuction!.cardId);
assert.ok(card);
return { game, card };
}
void test('public cache question validates printed faces and answers from the active lot', () => {
const { game, card } = liveCache();
const yes = { kind: 'cacheLot', name: card.name } as const;
const absent = TRUTH_CARD_NAMES.find(name => name !== card.name); assert.ok(absent);
assert.deepEqual(parseKnowledgeFact({ ...yes, extra: true }), yes);
assert.throws(() => parseKnowledgeFact({ kind: 'cacheLot', name: 'Not a real card' }));
assert.match(knowledgeFactText(yes), /public Richese cache auction card/);
for (const player of game.players) {
const knowledge = truthKnowledgeOf(game, player);
assert.equal(knowledge.cacheLot, card.name);
assert.equal(knowledgeFactAnswer(yes, knowledge), 'yes');
assert.equal(knowledgeFactAnswer({ kind: 'cacheLot', name: absent }, knowledge), 'no');
}
});
void test('cache lot question becomes unknown outside the supported active Basic public lot', () => {
const { game, card } = liveCache();
const fact = { kind: 'cacheLot', name: card.name } as const;
const outside = [
(copy: typeof game) => { copy.phase = 4; },
(copy: typeof game) => { copy.advanced = true; },
(copy: typeof game) => { copy.richeseAuction = null; },
(copy: typeof game) => { copy.richeseAuction!.source = 'blackMarket'; },
(copy: typeof game) => { copy.richeseAuction!.outcome = { kind: 'unbid' }; },
(copy: typeof game) => { copy.richeseCache = copy.richeseCache!.filter(item => item.id !== card.id); },
];
for (const change of outside) {
const staged = structuredClone(game); change(staged);
for (const player of staged.players)
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(staged, player)), 'unknown');
}
assert.equal(knowledgeFactAnswer(fact), 'unknown');
});
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame } from '../game/engine';
import { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names';
import {
actTruthtranceRichesePromises as act,
advanceTruthtranceRichesePromises as advance,
askTruthtranceRichesePromises as ask,
createTruthtranceRichesePromisesFixture as fixture,
passTruthRicheseFreshDiscard,
truthtranceRichesePromisesClean as clean,
truthtranceRichesePromisesInventory as inventory,
} from './fixture-truthtrance-richese-promises';
/** Actual setup, auction declaration and public cache lot; only unopened deck
* order is selected by the shared fixture. */
function liveCache() {
const f = fixture();
const game = advance(f.earlySource, state => clean(state) && state.phase === 3 &&
state.richeseAuction?.source === 'cache' && !state.richeseAuction.outcome);
const card = game.richeseCache!.find(item => item.id === game.richeseAuction!.cardId);
assert.ok(card);
return { f, game, card };
}
for (const expected of ['yes', 'no'] as const) {
void test(`native Basic public cache question answers ${expected} and all bot profiles continue`, () => {
const { f, game, card } = liveCache();
for (const seat of game.players) assert.equal(viewGame(game, seat.id).richeseAuction?.card?.id, card.id);
const name = expected === 'yes' ? card.name : TRUTH_CARD_NAMES.find(face => face !== card.name);
assert.ok(name);
const originalLot = structuredClone(game.richeseAuction);
const asked = ask(structuredClone(game), f, { kind: 'fact', target: f.richese,
fact: { kind: 'cacheLot', name } });
assert.equal(viewGame(asked, f.richese).truthAnswer, expected);
const before = structuredClone(asked);
assert.throws(() => act(asked, f.richese, { type: 'truthAnswer', answer: expected === 'yes' ? 'no' : 'yes' }));
assert.deepEqual(asked, before);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(asked, f.richese);
view.players.find(player => player.id === f.richese)!.bot = difficulty;
assert.deepEqual(botActions(view), [{ type: 'truthAnswer', answer: expected }]);
const done = passTruthRicheseFreshDiscard(act(structuredClone(asked), f.richese,
{ type: 'truthAnswer', answer: expected }));
assert.equal(done.truthtrance, null);
assert.equal(done.truthHistory!.at(-1)!.answer, expected);
assert.deepEqual(done.richeseAuction, originalLot);
assert.deepEqual(inventory(done), f.originalCards);
}
});
}