Files
Dune/tests/truthtrance-discard-continuations.test.ts
T
Quintine 0f79f28ccb Complete forced Harkonnen returns with private saved inspection
Automatically return the full combined hand when it is the only legal
special-Karama return. Preserve real choices and restore the suspended
response or auction payment through the same settlement. Record the actual
blind draw as owner-only historical card faces, with a bound integrity
receipt; legacy forced saves describe held-card history truthfully.

Add readable private history and enlarged inspectors. Both manual and
forced returns publish only a faction/action completion tag. Wake idle
legacy exchanges through production recovery without writes for real
choices. Keep provisional auction funding rules and release gates.

Validation: types, lint, 5150 offline tests, build and 49 HTTP tests
passed. Independent review resolved history substitution and root review
completed idle recovery wiring. New tests cover 1-4 forced draws, real
choices, overlays, exact saved history, immutable rejection, all four legal
AI paths, production competing actions/recovery, private views and custody.
One genuine Advanced four-player sample completed 158 actions with
4 JSON restores and no rejected action; no difficulty tuning.

Browser QA QUTHVPTT used genuine Advanced setup and conserved Bidding
positions. The actual power returned four cards without a prompt; private
history/enlargement and exact refresh worked. Phone panel fit without
horizontal overflow with 44px inspector controls. Screenshot capture timed
out; complete visual acceptance and transient notice capture remain open.
Preserved every prior row across all ten tables, including 1586
rooms and 3354 seats. New QA/integration records bring totals to
1626 rooms and 3442 seats. No reset, restart or automation.
2026-09-21 16:45:20 +10:00

938 lines
31 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { runInNewContext } from 'node:vm';
import { webcrypto } from 'node:crypto';
import ts from 'typescript';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { botActions, runBots } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { baseDeck } from '../game/cards';
import { createAuditorLeader, CHOAM_AUDITOR_ID } from '../game/choam-auditor';
import type { FactionId } from '../game/catalog';
import { type TruthQuestion, type TruthFact } from '../game/truthtrance';
import { richeseCards } from '../game/richese-cards';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
/** Test-only observation of the unchanged production inner dispatcher, before
* its public wrapper drains automatic continuations. No runtime export or gate
* is changed, and all resumed actions use the real production exports. */
const observed: {
applyActionInner?: (g: Game, id: string, a: Action) => Game;
finishResponse?: (g: Game, canceled: boolean) => void;
} = {};
runInNewContext(
ts.transpileModule(
readFileSync(new URL('../game/engine.ts', import.meta.url), 'utf8') +
'\nexport { applyActionInner, finishResponse };\n',
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
{
exports: observed,
require: createRequire(new URL('../game/engine.ts', import.meta.url)),
crypto: webcrypto,
structuredClone,
TextEncoder,
JSON,
},
);
function inner(g: Game, id: string, a: Action) {
const before = structuredClone(g);
const result = reload(observed.applyActionInner!(g, id, a));
assert.deepEqual(g, before);
return result;
}
function fixture(faction: FactionId = 'atreides') {
const g = createGame('TRUTHFRAME', newPlayer('p', 'Asker', faction));
g.players.push(
newPlayer('t', 'Target', 'emperor'),
newPlayer('q', 'Other', 'harkonnen'),
);
Object.assign(g, {
status: 'playing',
phase: 4,
turn: 2,
storm: 18,
order: ['q', 'p', 't'],
deck: baseDeck(),
});
for (const p of g.players) {
p.hand = [];
p.traitorChoices = [];
p.traitors = [p.leaders[0].id];
p.spice = 10;
p.forces = {};
p.reserves = 20;
}
hold(g, 'p', 'Truthtrance');
hold(g, 't', 'Shield');
return g;
}
function hold(g: Game, id: string, name: string) {
const i = g.deck.findIndex((c) => c.name === name);
assert.ok(i >= 0, name);
const [card] = g.deck.splice(i, 1);
player(g, id).hand.push(card);
return card.id;
}
function declare(g: Game, id = 'p', cards?: string[]) {
const ids =
cards ??
player(g, id)
.hand.filter((c) => c.effect === 'truthtrance')
.map((c) => c.id);
return applyAction(g, id, {
type: 'card',
card: ids[0],
cards: ids.slice(1),
});
}
function priority(state: Game) {
let g = state;
while (g.truthtrance?.stage === 'priority')
g = applyAction(
g,
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
{ type: 'truthPass' },
);
return g;
}
const fact: TruthQuestion = {
kind: 'fact',
target: 't',
fact: { kind: 'hand', name: 'Shield' },
};
const ask = (g: Game, question: TruthQuestion = fact) =>
applyAction(g, g.truthtrance!.queue[0].player, {
type: 'truthAsk',
question,
});
const asked = (g = fixture(), question: TruthQuestion = fact) =>
ask(priority(declare(g)), question);
const answer = (value = 'yes'): Action => ({
type: 'truthAnswer',
answer: value,
});
function receipt(g: Game) {
const c = g.pendingTreacheryDiscard!.continuation;
if (c.kind !== 'truthtranceDiscard')
throw Error('Missing consumed Truthtrance frame');
return c;
}
function recover(g: Game) {
const before = structuredClone(g),
done = normalizeAutomaticGame(reload(g));
assert.deepEqual(g, before);
assert.equal(done.pendingTreacheryDiscard, null);
assert.deepEqual(reload(normalizeAutomaticGame(reload(done))), reload(done));
return done;
}
function inventory(g: Game) {
const ids = [
...g.deck,
...g.discard,
...g.players.flatMap((p) => p.hand),
...(g.richeseCache ?? []),
...(g.ornithopter ? [g.ornithopter.card] : []),
]
.map((c) => c.id)
.sort();
assert.equal(new Set(ids).size, ids.length);
return ids;
}
void test('definite facts commit one public answer and the asker-owned card before restoring the table', () => {
const facts: TruthFact[] = [
{ kind: 'hand', name: 'Shield' },
{ kind: 'handCount', name: 'Shield', compare: 'eq', value: 1 },
{ kind: 'spice', compare: 'eq', value: 10 },
{ kind: 'traitor', leader: player(fixture(), 't').leaders[0].id },
{
kind: 'and',
terms: [
{ kind: 'handCount', name: 'Shield', compare: 'gte', value: 1 },
{ kind: 'spice', compare: 'lte', value: 10 },
],
},
];
for (const leaf of facts) {
const before = asked(fixture(), { kind: 'fact', target: 't', fact: leaf }),
card = before.truthtrance!.queue[0].card;
assert.equal(viewGame(before, 't').truthAnswer, 'yes');
assert.equal(viewGame(before, 'p').truthAnswer, null);
const pending = inner(before, 't', answer()),
c = receipt(pending),
batch = pending.pendingTreacheryDiscard!.batch;
assert.equal(pending.truthtrance, null);
assert.equal(pending.truthHistory!.length, 1);
assert.equal(c.historyIndex, 0);
assert.equal(c.record.answer, 'yes');
assert.equal(c.record.asker, 'p');
assert.deepEqual(c.consumed, { player: 'p', card });
assert.deepEqual(
batch.entries.map((e) => [e.card.id, e.discardedBy, e.publicFace]),
[[card, 'p', true]],
);
assert.equal(c.remaining, null);
assert.equal(player(pending, 'p').hand.length, 0);
const done = recover(pending);
assert.deepEqual(done.players, pending.players);
assert.deepEqual(done.log, pending.log);
assert.deepEqual(done.truthHistory, pending.truthHistory);
assert.deepEqual(reload(done), reload(applyAction(before, 't', answer())));
assert.deepEqual(inventory(done), inventory(before));
}
});
void test('separate holders preserve storm priority and retire one queue head per distinct discard event', () => {
const initial = fixture();
hold(initial, 'q', 'Truthtrance');
let state = priority(declare(declare(initial), 'q'));
assert.deepEqual(
state.truthtrance!.queue.map((q) => q.player),
['q', 'p'],
);
const queue = structuredClone(state.truthtrance!.queue),
passed = [...state.truthtrance!.passed];
state = ask(state);
const pending = inner(state, 't', answer());
assert.equal(receipt(pending).consumed.player, 'q');
assert.equal(pending.truthtrance, null);
assert.deepEqual(receipt(pending).remaining?.queue, queue.slice(1));
const next = recover(pending);
assert.equal(next.truthtrance?.stage, 'ask');
assert.equal(next.truthtrance!.question, null);
assert.deepEqual(next.truthtrance!.passed, passed);
assert.deepEqual(next.truthtrance!.queue, queue.slice(1));
const second = inner(ask(next), 't', answer('yes')),
done = recover(second);
assert.equal(second.pendingTreacheryDiscard!.sequence, 2);
assert.equal(done.truthtrance, null);
assert.deepEqual(
done.truthHistory!.map((h) => h.asker),
['q', 'p'],
);
assert.equal(done.discard.length, 2);
assert.deepEqual(inventory(done), inventory(initial));
});
void test('same-holder declaration order survives a consumed head and an unknown/save for its remaining card', () => {
const initial = fixture();
hold(initial, 'p', 'Truthtrance');
const ids = player(initial, 'p')
.hand.filter((c) => c.effect === 'truthtrance')
.map((c) => c.id)
.reverse();
const pending = inner(
ask(priority(declare(initial, 'p', ids))),
't',
answer(),
);
assert.equal(receipt(pending).consumed.card, ids[0]);
let done = recover(pending);
assert.equal(done.truthtrance!.queue[0].card, ids[1]);
done = ask(done, {
kind: 'freeform',
target: 't',
scope: 'fact',
text: 'Can you determine an unknown fact?',
});
done = applyAction(done, 't', answer('unknown'));
assert.equal(done.truthtrance!.stage, 'unknown');
assert.equal(done.pendingTreacheryDiscard, null);
done = applyAction(done, 'p', { type: 'truthSave' });
assert.equal(done.truthtrance, null);
assert.equal(done.resolvedTreacheryDiscardSequence, 1);
assert.ok(player(done, 'p').hand.some((c) => c.id === ids[1]));
assert.equal(done.truthHistory!.length, 2);
});
void test('unknown then re-ask consumes one physical card and binds the later exact history index', () => {
let state = asked(fixture(), {
kind: 'freeform',
target: 't',
scope: 'fact',
text: 'What cannot be known?',
});
state = applyAction(state, 't', answer('unknown'));
assert.equal(state.pendingTreacheryDiscard ?? null, null);
assert.equal(state.treacheryDiscardSequence ?? 0, 0);
assert.equal(player(state, 'p').hand.length, 1);
const pending = inner(ask(state), 't', answer());
assert.equal(receipt(pending).historyIndex, 1);
const done = recover(pending);
assert.deepEqual(
done.truthHistory!.map((r) => r.answer),
['unknown', 'yes'],
);
assert.equal(done.discard.length, 1);
assert.equal(done.resolvedTreacheryDiscardSequence, 1);
});
void test('a definite legacy setup fact can retire its card without advancing incomplete traitor selection', () => {
const initial = fixture();
initial.status = 'setup';
initial.phase = 0;
player(initial, 't').traitors = [];
player(initial, 't').traitorChoices = [player(initial, 't').leaders[1].id];
const before = asked(initial),
pending = inner(before, 't', answer());
assert.equal(pending.status, 'setup');
assert.equal(receipt(pending).record.phase, 0);
const done = recover(pending);
assert.equal(done.status, 'setup');
assert.equal(done.phase, 0);
assert.deepEqual(
player(done, 't').traitorChoices,
player(initial, 't').traitorChoices,
);
assert.equal(done.truthtrance, null);
assert.deepEqual(inventory(done), inventory(initial));
});
for (const value of ['yes', 'no'] as const)
void test(`a ${value} shipment answer binds once before the consumed card frame and remains enforceable afterward`, () => {
const initial = fixture();
initial.phase = 5;
initial.active = 't';
initial.order = ['t', 'p', 'q'];
initial.movementRemaining = [...initial.order];
const before = asked(initial, {
kind: 'shipment',
target: 't',
territory: 'carthag',
minimum: 2,
});
const pending = inner(before, 't', answer(value));
assert.equal(pending.shipmentPromises!.length, 1);
assert.equal(pending.shipmentPromises![0].answer, value === 'yes');
const done = recover(pending);
assert.deepEqual(done.shipmentPromises, pending.shipmentPromises);
assert.deepEqual(
reload(done),
reload(applyAction(before, 't', answer(value))),
);
const wrong: Action =
value === 'yes'
? { type: 'endMovement' }
: { type: 'ship', territory: 'carthag', sector: 11, amount: 2 };
assert.throws(() => applyAction(done, 't', wrong));
const legal: Action =
value === 'yes'
? { type: 'ship', territory: 'carthag', sector: 11, amount: 2 }
: { type: 'endMovement' };
const fulfilled = applyAction(done, 't', legal);
assert.equal(fulfilled.shipmentPromises!.length, 1);
assert.equal(fulfilled.shipmentPromises![0].fulfilled, true);
inventory(fulfilled);
});
for (const stage of ['unsubmitted', 'submitted', 'revealed'] as const)
void test(`battle ${stage} question preserves its exact promise behavior across consumption`, () => {
let initial = fixture();
initial.phase = 6;
initial.active = 'p';
for (const id of ['p', 't']) {
player(initial, id).forces = { 'arrakeen:10': 4 };
player(initial, id).reserves = 16;
}
initial = applyAction(initial, 'p', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 't',
});
while (initial.battle?.preparation)
initial = applyAction(initial, initial.battle.preparation.owner, {
type: 'declineBattlePower',
});
const plan = (id: string): Action => ({
type: 'battlePlan',
dial: id === 't' ? 2 : 0,
support: 0,
leader: player(initial, id).leaders[0].id,
});
if (stage !== 'unsubmitted') initial = applyAction(initial, 't', plan('t'));
if (stage === 'revealed') initial = applyAction(initial, 'p', plan('p'));
const before = asked(initial, {
kind: 'battlePlan',
target: 't',
territory: 'arrakeen',
claim: { kind: 'dial', compare: 'gte', value: 2 },
});
const pending = inner(before, 't', answer()),
promises = pending.battle!.truthPromises ?? [];
assert.equal(promises.length, stage === 'unsubmitted' ? 1 : 0);
assert.equal(
receipt(pending).promise?.kind ?? null,
stage === 'unsubmitted' ? 'battle' : null,
);
const done = recover(pending);
assert.deepEqual(done.battle, pending.battle);
assert.deepEqual(reload(done), reload(applyAction(before, 't', answer())));
if (stage === 'unsubmitted') {
assert.throws(() => applyAction(done, 't', { ...plan('t'), dial: 0 }));
const planned = applyAction(done, 't', plan('t'));
assert.equal(planned.battle!.plans.t.dial, 2);
}
assert.deepEqual(inventory(done), inventory(initial));
});
void test('public answer history remains public while hidden fact alternatives and next-answer assistance stay private', () => {
const initial = fixture();
const question: TruthQuestion = {
kind: 'fact',
target: 't',
fact: {
kind: 'or',
terms: [
{ kind: 'hand', name: 'Shield' },
{ kind: 'traitor', leader: player(initial, 't').leaders[0].id },
],
},
};
const pending = inner(asked(initial, question), 't', answer()),
alternate = reload(pending);
const substitute = alternate.deck.findIndex((c) => c.name === 'Snooper');
const shieldIndex = player(alternate, 't').hand.findIndex(
(c) => c.name === 'Shield',
);
[player(alternate, 't').hand[shieldIndex], alternate.deck[substitute]] = [
alternate.deck[substitute],
player(alternate, 't').hand[shieldIndex],
];
for (const p of pending.players) {
const view = viewGame(pending, p.id);
assert.equal(view.automaticContinuationPending, true);
assert.equal(view.truthtrance, null);
assert.equal(view.truthAnswer, null);
assert.equal(view.truthShipmentAnswers, null);
assert.equal(view.truthBattleAnswers, null);
assert.equal(view.truthHistory[0].answer, 'yes');
assert.deepEqual(view.truthHistory[0].question, question);
assert.ok(!('pendingTreacheryDiscard' in view));
if (p.id !== 't') assert.deepEqual(viewGame(alternate, p.id), view);
for (const level of DIFFICULTIES) {
view.players.find((x) => x.id === p.id)!.bot = level;
assert.deepEqual(botActions(view), []);
}
}
const run = runBots(reload(pending), 0);
assert.deepEqual(
reload({ ...run, botsPending: undefined }),
reload({ ...recover(pending), botsPending: undefined }),
);
});
void test('an underlying phase opening stays paused until every queued Truthtrance has finished', () => {
const initial = fixture();
hold(initial, 'q', 'Truthtrance');
initial.phaseOpening = { passed: ['t'], initialize: false };
const queued = priority(declare(declare(initial), 'q'));
const pending = inner(ask(queued), 't', answer());
assert.equal(pending.phaseOpening, null);
const next = recover(pending);
assert.equal(next.truthtrance!.queue[0].player, 'p');
assert.deepEqual(next.phaseOpening, initial.phaseOpening);
const done = recover(inner(ask(next), 't', answer()));
assert.equal(done.truthtrance, null);
assert.deepEqual(done.phaseOpening, initial.phaseOpening);
});
void test('Truthtrance consumes a held card while Ornithopter remains escrowed, before separate final-flight retirement', () => {
let initial = fixture();
initial.phase = 5;
initial.active = 'p';
initial.movementRemaining = ['p', 'q', 't'];
initial.richeseCache = richeseCards();
const i = initial.richeseCache.findIndex((c) => c.effect === 'ornithopter');
const orni = initial.richeseCache.splice(i, 1)[0];
player(initial, 'p').hand.push(orni);
player(initial, 'p').forces = { 'imperial_basin:10': 3 };
player(initial, 'p').reserves = 17;
initial = applyAction(initial, 'p', {
type: 'move',
movementCard: orni.id,
ornithopter: 'twoGroups',
forces: { 'imperial_basin:10': 1 },
territory: 'arrakeen',
sector: 10,
});
const pending = inner(asked(initial), 't', answer());
assert.equal(pending.ornithopter!.completed, 1);
assert.equal(receipt(pending).consumed.player, 'p');
const next = recover(pending);
assert.deepEqual(next.ornithopter, initial.ornithopter);
const done = applyAction(next, 'p', {
type: 'move',
ornithopterEvent: next.ornithopter!.event,
forces: { 'imperial_basin:10': 1 },
territory: 'carthag',
sector: 11,
});
assert.equal(done.ornithopter, null);
assert.equal(player(done, 'p').moved, 2);
assert.equal(done.resolvedTreacheryDiscardSequence, 2);
assert.deepEqual(inventory(done), inventory(initial));
});
void test('a queued follow-up prevents early Robbery overflow completion until the final consumed head retires', () => {
let initial = fixture('moritani');
initial.phase = 5;
initial.active = 't';
initial.movementRemaining = ['t', 'p', 'q'];
hold(initial, 'p', 'Truthtrance');
player(initial, 'p').hand.push(...initial.deck.splice(0, 2));
initial.moritaniTerror = createTerrorState(() => 0);
const token = initial.moritaniTerror.tokens.find(
(t) => t.kind === 'robbery',
)!;
initial.moritaniTerror = placeTerror(
initial.moritaniTerror,
token.id,
'arrakeen',
1,
);
initial = applyAction(initial, 't', {
type: 'ship',
amount: 1,
territory: 'arrakeen',
sector: 10,
});
initial = applyAction(initial, 'p', { type: 'decision', reveal: true });
initial = applyAction(initial, 'p', { type: 'decision', choice: 'card' });
assert.equal(player(initial, 'p').hand.length, 5);
assert.equal(initial.pendingTerrorEntry?.stage, 'discard');
const pending = inner(asked(initial), 't', answer());
assert.equal(player(pending, 'p').hand.length, 4);
const next = recover(pending);
assert.ok(next.truthtrance);
assert.equal(next.pendingTerrorEntry?.stage, 'discard');
const done = recover(inner(ask(next), 't', answer()));
assert.equal(done.truthtrance, null);
assert.equal(done.pendingTerrorEntry, null);
assert.equal(done.decision, null);
assert.equal(player(done, 'p').hand.length, 3);
assert.deepEqual(done.deck, initial.deck);
assert.equal(done.resolvedTreacheryDiscardSequence, 2);
assert.deepEqual(inventory(done), inventory(initial));
});
for (const stage of ['response', 'payment'] as const)
void test(`consumed Truthtrance restores the ${stage} of an Auditor from an actual completed battle`, () => {
let initial = fixture('richese');
initial.phase = 6;
initial.active = 'p';
initial.advanced = true;
initial.order = ['p', 'q', 't'];
initial.players[2] = newPlayer('q', 'CHOAM', 'choam');
player(initial, 'q').spice = 10;
player(initial, 'q').leaders.push(createAuditorLeader());
hold(initial, 'p', 'Snooper');
for (const id of ['p', 'q']) {
player(initial, id).forces = { 'arrakeen:10': 4 };
player(initial, id).reserves = 16;
}
if (stage === 'response') hold(initial, 't', 'Karama');
initial = applyAction(initial, 'p', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'q',
});
for (let i = 0; i < 30; i++) {
if (initial.response) {
initial = applyAction(
initial,
initial.players.find((p) => !initial.response!.passed.includes(p.id))!
.id,
{ type: 'passResponse' },
);
continue;
}
const b = initial.battle!;
if (b.preLeader && !b.preLeader.closed)
initial = applyAction(
initial,
[b.attacker, b.defender].find(
(id) => !b.preLeader!.ready.includes(id),
)!,
{ type: 'battlePreparationReady', event: b.event },
);
else if (b.preparation)
initial = applyAction(initial, b.preparation.owner, {
type: 'declineBattlePower',
});
else break;
}
initial = applyAction(initial, 'p', {
type: 'battlePlan',
leader: player(initial, 'p').leaders[0].id,
dial: 0,
support: 0,
});
initial = applyAction(initial, 'q', {
type: 'battlePlan',
leader: CHOAM_AUDITOR_ID,
dial: 4,
support: 4,
});
initial = applyAction(initial, 'p', { type: 'traitorCall', call: false });
initial = applyAction(initial, 'q', { type: 'traitorCall', call: false });
assert.equal(initial.decision?.kind, 'choamAudit');
initial = applyAction(initial, 'q', {
type: 'decision',
event: initial.pendingAuditor!.event,
audit: true,
});
assert.equal(initial.pendingAuditor?.stage, stage);
const pending = inner(asked(initial), 't', answer()),
done = recover(pending);
assert.deepEqual(done.pendingAuditor, initial.pendingAuditor);
assert.deepEqual(done.response, initial.response);
assert.deepEqual(done.decision, initial.decision);
assert.deepEqual(done.players, pending.players);
for (const p of initial.players) viewGame(pending, p.id);
let result = done;
while (result.response)
result = applyAction(
result,
result.players.find((p) => !result.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
result = applyAction(result, 'p', {
type: 'decision',
event: result.pendingAuditor!.event,
pay: false,
});
assert.equal(result.pendingAuditor, null);
assert.deepEqual(
result.auditorInsight!.cards.map((c) => c.name),
['Snooper'],
);
assert.deepEqual(inventory(result), inventory(initial));
assert.equal(result.truthHistory!.length, 1);
});
void test('a real BG Worthless conversion retains its nested response while only Truthtrance becomes fresh', () => {
let initial = fixture('beneGesserit');
initial.advanced = true;
initial.phase = 5;
initial.active = 't';
const i = initial.deck.findIndex((c) => c.kind === 'worthless'),
worthless = initial.deck.splice(i, 1)[0];
player(initial, 'p').hand.push(worthless);
hold(initial, 'q', 'Karama');
initial = applyAction(initial, 'p', {
type: 'card',
card: worthless.id,
mode: 'shipment',
target: 't',
});
assert.equal(initial.response?.kind, 'worthlessKarama');
const pending = inner(asked(initial), 't', answer()),
done = recover(pending);
assert.deepEqual(done.pendingKarama, initial.pendingKarama);
assert.deepEqual(done.response, initial.response);
assert.equal(pending.pendingTreacheryDiscard!.batch.entries.length, 1);
assert.equal(
pending.pendingTreacheryDiscard!.batch.entries[0].card.effect,
'truthtrance',
);
assert.ok(pending.discard.some((c) => c.id === worthless.id));
let result = done;
while (result.response)
result = applyAction(
result,
result.players.find((p) => !result.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
assert.deepEqual(result.karamaShipping, {
owner: 'p',
player: 't',
card: worthless.id,
});
assert.equal(result.pendingKarama, null);
assert.deepEqual(inventory(result), inventory(initial));
});
void test('a real Harkonnen hand-exchange return decision survives Truthtrance without a second sample', () => {
let initial = fixture('harkonnen');
initial.advanced = true;
initial.phase = 3;
const karama = hold(initial, 'p', 'Karama');
// Retain a real return choice even after the intervening Truthtrance is spent.
hold(initial, 'p', 'Shield');
player(initial, 'q').hand.push(...initial.deck.splice(0, 2));
initial = applyAction(initial, 'p', {
type: 'card',
mode: 'special',
card: karama,
target: 'q',
amount: 1,
});
assert.equal(initial.decision?.kind, 'handExchange');
const pending = inner(asked(initial), 't', answer()),
done = recover(pending);
assert.deepEqual(done.pendingExchange, reload(initial).pendingExchange);
assert.deepEqual(done.decision, initial.decision);
assert.deepEqual(done.players, pending.players);
assert.deepEqual(done.deck, pending.deck);
const returned = player(done, 'p').hand[0].id;
const result = applyAction(done, 'p', {
type: 'decision',
returnCards: [returned],
});
assert.equal(result.pendingExchange, null);
assert.ok(player(result, 'q').hand.some((c) => c.id === returned));
assert.equal(result.resolvedTreacheryDiscardSequence, 1);
assert.deepEqual(inventory(result), inventory(initial));
});
function rejectsCorrupt(pending: Game, mutations: ((g: Game) => void)[]) {
for (const [index, mutate] of mutations.entries()) {
const corrupt = reload(pending);
mutate(corrupt);
const before = structuredClone(corrupt);
assert.throws(
() => normalizeAutomaticGame(corrupt),
`normalization mutation ${index}`,
);
assert.throws(
() => applyAction(corrupt, 'p', { type: 'advanceBots' }),
`action mutation ${index}`,
);
assert.throws(() => viewGame(corrupt, 'p'), `projection mutation ${index}`);
assert.deepEqual(corrupt, before);
}
}
void test('corrupt physical receipts, answered history, queue custody and suspended contexts reject atomically', () => {
const initial = fixture();
hold(initial, 'q', 'Truthtrance');
const pending = inner(
ask(priority(declare(declare(initial), 'q'))),
't',
answer(),
);
const mutations: ((g: Game) => void)[] = [
(g) => {
g.turn++;
},
(g) => {
g.phase++;
},
(g) => {
g.status = 'finished';
},
(g) => {
g.treacheryDiscardSequence!++;
},
(g) => {
g.resolvedTreacheryDiscardSequence = g.treacheryDiscardSequence;
},
(g) => {
g.pendingTreacheryDiscard!.sequence++;
},
(g) => {
g.pendingTreacheryDiscard!.batch.event += 'stale';
},
(g) => {
g.pendingTreacheryDiscard!.batch.cause = 'terror:sabotage';
},
(g) => {
g.pendingTreacheryDiscard!.batch.entries[0].publicFace = false;
},
(g) => {
g.pendingTreacheryDiscard!.batch.entries[0].discardedBy = 't';
},
(g) => {
receipt(g).consumed.player = 't';
},
(g) => {
receipt(g).consumed.card = 'missing';
},
(g) => {
g.discard = [];
},
(g) => {
g.deck.push(structuredClone(g.discard.at(-1)!));
},
(g) => {
player(g, 't').hand.push(structuredClone(g.discard.at(-1)!));
},
(g) => {
g.discard.at(-1)!.effect = 'karama';
},
(g) => {
receipt(g).historyIndex++;
},
(g) => {
receipt(g).historyIndex = -1;
},
(g) => {
receipt(g).record.answer = 'unknown';
},
(g) => {
g.truthHistory![0].answer = 'no';
},
(g) => {
g.truthHistory!.push(structuredClone(g.truthHistory![0]));
},
(g) => {
receipt(g).record.question.target = 'q';
},
(g) => {
receipt(g).remaining = null;
},
(g) => {
receipt(g).remaining!.stage = 'priority';
},
(g) => {
receipt(g).remaining!.question = fact;
},
(g) => {
receipt(g).remaining!.queue.push(
structuredClone(receipt(g).remaining!.queue[0]),
);
},
(g) => {
receipt(g).remaining!.queue[0].card = receipt(g).consumed.card;
},
(g) => {
receipt(g).remaining!.queue[0].player = 't';
},
(g) => {
player(g, 'p').hand = [];
},
(g) => {
g.deck.push(structuredClone(player(g, 'p').hand[0]));
},
(g) => {
receipt(g).remaining!.passed.push('missing');
},
(g) => {
receipt(g).remaining!.passed.push(receipt(g).remaining!.passed[0]);
},
(g) => {
receipt(g).parentSignature += 'stale';
},
(g) => {
receipt(g).resume.phaseOpening = { passed: [], initialize: true };
},
(g) => {
Object.assign(receipt(g).resume, {
decision: { kind: 'handExchange', player: 't' },
});
},
(g) => {
g.active = 't';
},
(g) => {
g.order.reverse();
},
];
for (const key of [
'truthtrance',
'response',
'decision',
'phaseOpening',
'pendingKarama',
'pendingNullentropy',
])
mutations.push((g) => {
Object.assign(g, { [key]: {} });
});
rejectsCorrupt(pending, mutations);
const done = recover(pending),
replay = reload(done);
replay.pendingTreacheryDiscard = structuredClone(
pending.pendingTreacheryDiscard,
);
rejectsCorrupt(replay, [() => {}]);
});
void test('the already-bound shipment promise cannot be deleted, replaced, released or fulfilled before recovery', () => {
const initial = fixture();
initial.phase = 5;
initial.active = 't';
initial.order = ['t', 'p', 'q'];
initial.movementRemaining = [...initial.order];
const pending = inner(
asked(initial, {
kind: 'shipment',
target: 't',
territory: 'carthag',
minimum: 2,
}),
't',
answer(),
);
rejectsCorrupt(pending, [
(g) => {
receipt(g).promise = null;
},
(g) => {
receipt(g).promise!.index++;
},
(g) => {
g.shipmentPromises = [];
},
(g) => {
g.shipmentPromises![0].answer = false;
},
(g) => {
g.shipmentPromises![0].released = true;
},
(g) => {
g.shipmentPromises![0].fulfilled = true;
},
(g) => {
const promise = g.shipmentPromises![0];
assert.equal(promise.claim, undefined);
assert.ok(typeof promise.minimum === 'number');
promise.minimum++;
},
(g) => {
g.shipmentPromises![0].player = 'p';
},
(g) => {
g.shipmentPromises!.push(structuredClone(g.shipmentPromises![0]));
},
]);
});
void test('wrong actors, false answers and invalid timing cannot consume the held queue head', () => {
const initial = fixture(),
declared = declare(initial),
before = asked(initial);
for (const [state, id, action] of [
[declared, 't', answer()],
[before, 'p', answer()],
[before, 'q', answer()],
[before, 't', answer('no')],
[before, 't', answer('unknown')],
[before, 't', answer('maybe')],
] as [Game, string, Action][]) {
const snapshot = structuredClone(state);
assert.throws(() => applyAction(state, id, action));
assert.deepEqual(state, snapshot);
assert.equal(state.pendingTreacheryDiscard ?? null, null);
assert.ok(player(state, 'p').hand.some((c) => c.effect === 'truthtrance'));
assert.equal(state.truthHistory?.length ?? 0, 0);
}
});