Files
Dune/tests/richese-cancellation.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

552 lines
17 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { webcrypto } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { runInNewContext } from 'node:vm';
import ts from 'typescript';
import { baseDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import {
applyAction,
createGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import {
quoteRicheseCancellation,
RicheseCancellationError,
} from '../game/richese-cancellation';
function fixture(advanced = true, phase = 5, giftId = 'richese-ornithopter') {
const g = createGame(
'RICHRESTORE',
newPlayer('r', 'Richese', 'richese'),
advanced,
['choam'],
);
g.players.push(
newPlayer('b', 'BG', 'beneGesserit'),
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('h', 'Harkonnen', 'harkonnen'),
newPlayer('g', 'Guild', 'guild'),
);
Object.assign(g, {
status: 'playing',
turn: 2,
phase,
active: 'h',
order: ['r', 'b', 'e', 'h', 'g'],
storm: 18,
deck: baseDeck(),
discard: [],
richeseCache: richeseCards(),
response: null,
decision: null,
phaseOpening: null,
});
for (const p of g.players) {
p.hand = [];
p.spice = 20;
p.forces = {};
p.reserves = 20;
p.traitors = [];
}
const player = (id: string) => g.players.find((p) => p.id === id)!;
player('r').ally = 'h';
player('h').ally = 'r';
player('e').ally = 'g';
player('g').ally = 'e';
const take = (
id: string,
from: 'deck' | 'richeseCache',
match: (c: Game['deck'][number]) => boolean,
) => {
const pile = g[from]!,
index = pile.findIndex(match);
assert.ok(index >= 0);
const card = pile.splice(index, 1)[0];
player(id).hand.push(card);
return card;
};
const gift = take('r', 'richeseCache', (c) => c.id === giftId),
special = take('r', 'deck', (c) => c.effect === 'karama'),
printed = take('g', 'deck', (c) => c.effect === 'karama'),
bg = take('b', 'deck', (c) => c.kind === 'worthless'),
bg2 = take('b', 'deck', (c) => c.kind === 'worthless');
if (giftId !== 'richese-karama')
take('h', 'richeseCache', (c) => c.id === 'richese-karama');
return { g, gift, special, printed, bg, bg2 };
}
const player = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
function passOne(state: Game) {
let g = state;
const original = g.response!.kind;
const originalUse = JSON.stringify(g.pendingKarama?.use);
for (
let i = 0;
g.response?.kind === original &&
(original !== 'worthlessKarama' ||
JSON.stringify(g.pendingKarama?.use) === originalUse) &&
i < 20;
i++
) {
const p = g.players.find((p) => !g.response!.passed.includes(p.id));
assert.ok(p);
g = applyAction(g, p.id, { type: 'passResponse' });
}
return g;
}
function parent(f = fixture()) {
let g = applyAction(f.g, 'e', { type: 'emperorGift', amount: 2 });
g = applyAction(g, 'b', { type: 'card', card: f.bg.id, mode: 'cancel' });
assert.equal(g.response?.kind, 'worthlessKarama');
assert.ok(g.pendingKarama?.opportunity);
return { ...f, g };
}
function gift(f = parent()) {
return {
...f,
g: applyAction(f.g, 'r', { type: 'richeseGift', card: f.gift.id }),
};
}
function income(f = parent()) {
const acquire = f.g.richeseCache!.find((c) => c.id !== 'richese-karama')!.id;
const g = applyAction(f.g, 'r', {
type: 'card',
card: f.special.id,
mode: 'special',
acquire,
});
assert.equal(g.response?.kind, 'richesePurchaseIncome');
return { ...f, g, acquire };
}
function quote(g: Game) {
const q = quoteRicheseCancellation(g, g.response!);
assert.ok(q);
return q;
}
const observed = {} as {
currentRicheseCancellationQuote: (
g: Game,
r: NonNullable<Game['response']>,
) => unknown;
};
const engineURL = new URL('../game/engine.ts', import.meta.url);
runInNewContext(
ts.transpileModule(
readFileSync(engineURL, 'utf8') +
'\nexport { currentRicheseCancellationQuote };',
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
{
exports: observed,
require: createRequire(engineURL),
crypto: webcrypto,
structuredClone,
TextEncoder,
JSON,
},
);
void test('all ten canonical gifts have exact canceled receipts in Basic and Advanced across all nine phases', () => {
let count = 0;
for (const advanced of [false, true])
for (let phase = 0; phase < 9; phase++)
for (const card of richeseCards()) {
const f = fixture(advanced, phase, card.id),
g = applyAction(f.g, 'r', { type: 'richeseGift', card: card.id }),
before = structuredClone(g),
q = quote(g);
assert.equal(q.kind, 'richeseGift');
if (q.kind === 'richeseGift')
assert.deepEqual(q.blocked, { turn: 2, phase, cards: [card.id] });
assert.deepEqual(q.resume, g.pendingRicheseGift!.resume);
assert.deepEqual(g, before);
count++;
}
assert.equal(count, 180);
});
for (const form of ['printed', 'bg'] as const)
void test(`a genuine ${form} canceled gift restores its older BG conversion without transferring or revealing the gift`, () => {
const f = gift(),
before = structuredClone(f.g),
q = quote(f.g),
card = form === 'printed' ? f.printed : f.bg2,
actor = form === 'printed' ? 'g' : 'b';
let g = applyAction(f.g, actor, {
type: 'card',
card: card.id,
mode: 'cancel',
});
assert.deepEqual(f.g, before);
if (form === 'bg') {
assert.equal(g.pendingKarama?.use.kind, 'cancel');
g = passOne(JSON.parse(JSON.stringify(g)) as Game);
}
assert.equal(g.pendingRicheseGift, null);
assert.deepEqual(
g.richeseGiftBlocked,
q.kind === 'richeseGift' ? q.blocked : null,
);
assert.equal(g.response?.kind, 'worthlessKarama');
assert.deepEqual(g.pendingKarama, q.resume.pendingKarama);
assert.ok(player(g, 'r').hand.some((c) => c.id === f.gift.id));
assert.ok(!player(g, 'h').hand.some((c) => c.id === f.gift.id));
assert.equal(g.discard.filter((c) => c.id === card.id).length, 1);
assert.equal(JSON.stringify(viewGame(g, 'e')).includes(f.gift.id), false);
g = passOne(g);
assert.equal(g.response, null);
assert.equal(player(g, 'e').spice, 20);
assert.equal(player(g, 'g').spice, 20);
});
for (const form of ['printed', 'bg'] as const)
void test(`a genuine ${form} purchase-income cancellation keeps the paid purchase and restores the earlier conversion once`, () => {
const f = income(),
before = structuredClone(f.g),
q = quote(f.g),
card = form === 'printed' ? f.printed : f.bg2,
actor = form === 'printed' ? 'g' : 'b';
assert.equal(q.kind, 'richesePurchaseIncome');
let g = applyAction(f.g, actor, {
type: 'card',
card: card.id,
mode: 'cancel',
});
if (form === 'bg') g = passOne(JSON.parse(JSON.stringify(g)) as Game);
assert.deepEqual(f.g, before);
assert.equal(g.pendingRichesePurchaseIncome, null);
assert.equal(player(g, 'r').spice, 17);
assert.equal(player(g, 'e').spice, 20);
assert.equal(
player(g, 'r').hand.filter((c) => c.id === f.acquire).length,
1,
);
assert.equal(g.discard.filter((c) => c.id === f.special.id).length, 1);
assert.equal(g.discard.filter((c) => c.id === card.id).length, 1);
assert.equal(g.response?.kind, 'worthlessKarama');
assert.deepEqual(g.pendingKarama, q.resume.pendingKarama);
assert.equal(JSON.stringify(viewGame(g, 'g')).includes(f.acquire), false);
g = passOne(g);
assert.equal(g.response, null);
assert.equal(player(g, 'r').spice, 17);
assert.equal(player(g, 'e').spice, 20);
});
void test('a gift restores a real Harkonnen hand exchange with its inherited BG declaration and saved response', () => {
const f = parent(fixture(true, 3));
// Keep a real choice after spending the Karama; a sole held card returns automatically.
player(f.g, 'h').hand.push(...f.g.deck.splice(0, 1));
const hark = player(f.g, 'h').hand.find((c) => c.effect === 'karama')!;
let g = applyAction(f.g, 'h', {
type: 'card',
card: hark.id,
mode: 'special',
target: 'b',
amount: 1,
});
assert.equal(g.decision?.kind, 'handExchange');
assert.equal(g.response, null);
const decision = structuredClone(g.decision),
priorResponse = structuredClone(g.pendingExchange!.response);
const gifted = applyAction(g, 'r', { type: 'richeseGift', card: f.gift.id });
assert.doesNotThrow(() => quote(gifted));
g = applyAction(gifted, 'g', {
type: 'card',
card: f.printed.id,
mode: 'cancel',
});
assert.deepEqual(g.decision, decision);
assert.deepEqual(g.pendingExchange!.response, priorResponse);
assert.equal(g.response, null);
g = applyAction(g, 'h', { type: 'decision', returnCards: [f.bg2.id] });
assert.equal(g.response?.kind, 'worthlessKarama');
assert.ok(player(g, 'b').hand.some((c) => c.id === f.bg2.id));
});
void test('denied gift cancellation does not demand present custody, spare recipient capacity, alliance or transfer funds', () => {
const f = gift(),
g = structuredClone(f.g);
const at = player(g, 'r').hand.findIndex((c) => c.id === f.gift.id);
g.discard.push(...player(g, 'r').hand.splice(at, 1));
player(g, 'r').ally = null;
player(g, 'h').ally = null;
player(g, 'h').hand.push(...g.deck.splice(0, 8));
player(g, 'r').spice = 0;
assert.equal(quote(g).kind, 'richeseGift');
const next = applyAction(g, 'g', {
type: 'card',
card: f.printed.id,
mode: 'cancel',
});
assert.equal(next.pendingRicheseGift, null);
assert.equal(next.discard.filter((c) => c.id === f.gift.id).length, 1);
assert.equal(player(next, 'h').hand.length, 9);
});
void test('current blocked gifts append once while a prior-phase blocked list is replaced', () => {
const f = gift();
f.g.richeseGiftBlocked = {
turn: 2,
phase: 5,
cards: ['richese-semuta-drug'],
};
assert.deepEqual(
quote(f.g).kind === 'richeseGift' &&
(quote(f.g) as { blocked: unknown }).blocked,
{ turn: 2, phase: 5, cards: ['richese-semuta-drug', f.gift.id] },
);
f.g.richeseGiftBlocked.phase = 4;
const q = quote(f.g);
assert.ok(q.kind === 'richeseGift');
assert.deepEqual(q.blocked.cards, [f.gift.id]);
});
void test('canceled receipts and suspended control corruptions reject before card cost, mutation or RNG', (t) => {
const source = gift();
const mutations: ((g: Game) => void)[] = [
(g) => {
g.pendingRicheseGift!.turn++;
},
(g) => {
g.pendingRicheseGift!.phase = 3;
},
(g) => {
g.pendingRicheseGift!.event = '';
},
(g) => {
g.pendingRicheseGift!.intent.owner = 'e';
},
(g) => {
g.response!.recipient = 'b';
},
(g) => {
g.pendingRicheseGift!.intent.cardId = 'fake';
},
(g) => {
g.pendingRicheseGift!.resume.response!.kind = 'unknown' as never;
},
(g) => {
g.pendingRicheseGift!.resume.response!.passed = ['g', 'g'];
},
(g) => {
g.pendingRicheseGift!.resume.decision = {
kind: 'unknown',
player: 'r',
} as never;
},
(g) => {
g.pendingRicheseGift!.resume.decision = {
kind: 'advisor',
player: 'missing',
shipment: 'x',
};
},
(g) => {
g.pendingRicheseGift!.resume.pendingKarama!.owner = 'missing';
},
(g) => {
g.pendingRicheseGift!.resume.pendingKarama!.opportunity!.signature =
'stale';
},
(g) => {
g.pendingRicheseGift!.resume.response = {
kind: 'richeseGift',
owner: 'r',
recipient: 'h',
passed: [],
};
},
(g) => {
g.pendingRicheseGift!.resume.phaseOpening = {
initialize: true,
passed: ['missing'],
};
},
(g) => {
g.richeseGiftBlocked = { turn: 2, phase: 5, cards: [source.gift.id] };
},
];
const random = t.mock.method(crypto, 'getRandomValues', () => {
throw Error('No random draw');
}),
uuid = t.mock.method(crypto, 'randomUUID', () => {
throw Error('No event');
});
for (const mutate of mutations) {
const bad = structuredClone(source.g);
mutate(bad);
const before = structuredClone(bad);
assert.throws(() =>
observed.currentRicheseCancellationQuote(bad, bad.response!),
);
assert.throws(() =>
applyAction(bad, 'g', {
type: 'card',
card: source.printed.id,
mode: 'cancel',
}),
);
assert.deepEqual(bad, before);
assert.ok(player(bad, 'g').hand.some((c) => c.id === source.printed.id));
}
assert.equal(random.mock.callCount(), 0);
assert.equal(uuid.mock.callCount(), 0);
});
void test('purchase income rejects stale owner, amount and malformed resumed controls without inventing a purchased-card receipt', () => {
const f = income();
for (const mutate of [
(g: Game) => {
g.pendingRichesePurchaseIncome!.owner = 'r';
},
(g: Game) => {
g.response!.amount = 2;
},
(g: Game) => {
g.pendingRichesePurchaseIncome!.turn++;
},
(g: Game) => {
g.pendingRichesePurchaseIncome!.resume.response = {
kind: 'richesePurchaseIncome',
owner: 'e',
amount: 3,
passed: [],
};
},
(g: Game) => {
g.pendingRichesePurchaseIncome!.resume.decision = {
kind: 'handExchange',
player: 'h',
target: 'b',
count: 1,
};
},
]) {
const g = structuredClone(f.g);
mutate(g);
const before = structuredClone(g);
assert.throws(() =>
applyAction(g, 'g', { type: 'card', card: f.printed.id, mode: 'cancel' }),
);
assert.deepEqual(g, before);
}
const noMoney = structuredClone(f.g);
player(noMoney, 'r').spice = 0;
assert.equal(quote(noMoney).kind, 'richesePurchaseIncome');
});
void test('cross-receipt and object-reference cycles reject, while detached resumes preserve legacy optional fields', () => {
const f = gift();
const self = structuredClone(f.g);
self.pendingRicheseGift!.resume.pendingKarama!.use = {
kind: 'cancel',
response: { kind: 'richeseGift', owner: 'r', recipient: 'h', passed: [] },
};
assert.throws(() => quote(self), RicheseCancellationError);
const cycle = structuredClone(f.g);
cycle.pendingRicheseGift!.resume.response = {
kind: 'richesePurchaseIncome',
owner: 'e',
amount: 3,
passed: [],
};
cycle.pendingRichesePurchaseIncome = {
owner: 'e',
turn: 2,
phase: 5,
resume: {
response: { kind: 'richeseGift', owner: 'r', recipient: 'h', passed: [] },
decision: null,
pendingKarama: null,
},
};
assert.throws(() => quote(cycle), /recursive/);
const legacy = structuredClone(f.g);
delete legacy.pendingRicheseGift!.resume.pendingKarama!.opportunity;
const q = quote(legacy),
before = structuredClone(legacy);
q.resume.pendingKarama!.owner = 'detached';
assert.deepEqual(legacy, before);
});
void test('an actually suspended Emperor gift rejects malformed restored source semantics before the first cancellation cost', () => {
const initial = fixture();
const declared = applyAction(initial.g, 'e', {
type: 'emperorGift',
amount: 2,
});
const gifted = applyAction(declared, 'r', {
type: 'richeseGift',
card: initial.gift.id,
});
for (const mutate of [
(g: Game) => {
g.pendingRicheseGift!.resume.response!.owner = 'r';
},
(g: Game) => {
g.pendingRicheseGift!.resume.response!.recipient = undefined;
},
(g: Game) => {
g.pendingRicheseGift!.resume.response!.amount = -1;
},
]) {
const bad = structuredClone(gifted);
mutate(bad);
const before = structuredClone(bad);
assert.throws(() =>
observed.currentRicheseCancellationQuote(bad, bad.response!),
);
assert.throws(() =>
applyAction(bad, 'g', {
type: 'card',
card: initial.printed.id,
mode: 'cancel',
}),
);
assert.deepEqual(bad, before);
}
});
void test('a gift can restore actual purchase income but rejects a corrupted receipt in that live parent chain', () => {
const f = income(),
gifted = applyAction(f.g, 'r', { type: 'richeseGift', card: f.gift.id });
assert.equal(
gifted.pendingRicheseGift!.resume.response?.kind,
'richesePurchaseIncome',
);
assert.equal(quote(gifted).kind, 'richeseGift');
for (const mutate of [
(g: Game) => {
g.pendingRichesePurchaseIncome!.owner = 'r';
},
(g: Game) => {
g.pendingRichesePurchaseIncome!.turn++;
},
(g: Game) => {
g.pendingRicheseGift!.resume.response!.amount = 4;
},
]) {
const bad = structuredClone(gifted);
mutate(bad);
const before = structuredClone(bad);
assert.throws(() => quote(bad), RicheseCancellationError);
assert.throws(() =>
applyAction(bad, 'g', {
type: 'card',
card: f.printed.id,
mode: 'cancel',
}),
);
assert.deepEqual(bad, before);
}
const continued = applyAction(gifted, 'g', {
type: 'card',
card: f.printed.id,
mode: 'cancel',
});
assert.equal(continued.pendingRicheseGift, null);
assert.equal(continued.response?.kind, 'richesePurchaseIncome');
assert.ok(continued.pendingRichesePurchaseIncome);
assert.equal(player(continued, 'r').spice, 17);
assert.equal(player(continued, 'e').spice, 20);
});