Files
Dune/tests/rihani-face-dancers.test.ts

205 lines
9.1 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
beginRihani,
chooseRihaniDraw,
finishRihani,
rihaniSignature,
validateRihani,
type RihaniReceipt,
} from '../game/rihani-decipherer';
import {
validateNexusTraitorSnapshot,
type NexusTraitorSnapshot,
} from '../game/nexus-traitor-exchange';
const universe = Array.from({ length: 10 }, (_, index) => `leader-${index}`);
const random = () => 0.37;
function snapshot(): NexusTraitorSnapshot {
return {
reserve: universe.slice(4),
players: [
{
id: 't', faction: 'tleilaxu', traitors: [],
faceDancers: [
{ leader: universe[0], revealed: false },
{ leader: universe[1], revealed: true },
{ leader: universe[2], revealed: false },
],
},
{ id: 'a', faction: 'atreides', traitors: [universe[3]] },
],
};
}
function begin(normal = true, skilled = true, state = snapshot()) {
return beginRihani(state, universe, {
event: 'battle-rihani', turn: 2, owner: 't',
skill: { leader: 'tleilaxu-0', normal, skilled },
}, [], random);
}
function restore(receipt: RihaniReceipt): RihaniReceipt {
return JSON.parse(JSON.stringify(receipt)) as RihaniReceipt;
}
function resigned(receipt: RihaniReceipt, change: (r: RihaniReceipt) => void) {
const changed = restore(receipt);
change(changed);
changed.signature = rihaniSignature(changed);
return changed;
}
function conserved(receipt: RihaniReceipt) {
validateRihani(receipt, universe, receipt.state);
validateNexusTraitorSnapshot(receipt.state, universe);
assert.deepEqual(receipt.state.players[0].traitors, []);
assert.deepEqual(receipt.state.players[1], receipt.before.players[1]);
}
void test('native Tleilaxu Rihani inspects first, commits a separate draw and keeps one new unrevealed Face Dancer', () => {
const original = snapshot(), frozen = structuredClone(original);
let receipt = begin(true, true, original);
assert.deepEqual(original, frozen);
assert.equal(receipt.stage, 'offer');
assert.equal(receipt.peeked.length, 2);
assert.equal(new Set(receipt.peeked).size, 2);
assert.ok(receipt.peeked.every((id) => original.reserve.includes(id)));
assert.deepEqual(receipt.drawn, []);
assert.deepEqual(receipt.eligible, [universe[0], universe[2]]);
assert.deepEqual(receipt.state.players, original.players);
conserved(receipt);
receipt = restore(receipt);
const offered = structuredClone(receipt);
const pending = chooseRihaniDraw(receipt, universe, receipt.state, true);
assert.deepEqual(receipt, offered);
assert.equal(pending.stage, 'return');
assert.deepEqual(pending.drawn, offered.deckAfterPeek.slice(0, 2));
assert.deepEqual(pending.state.players[0].faceDancers, [
...original.players[0].faceDancers!,
...pending.drawn.map((leader) => ({ leader, revealed: false })),
]);
conserved(restore(pending));
for (const kept of pending.drawn) {
const restored = restore(pending), frozenPending = structuredClone(restored);
const done = finishRihani(restored, universe, restored.state, kept, universe[0], random);
assert.deepEqual(restored, frozenPending);
assert.equal(done.stage, 'complete');
assert.equal(done.kept, kept);
assert.equal(done.given, universe[0]);
assert.deepEqual(done.state.players[0].faceDancers, [
...original.players[0].faceDancers!.slice(1),
{ leader: kept, revealed: false },
]);
assert.ok(done.state.reserve.includes(universe[0]));
assert.ok(done.state.reserve.includes(pending.drawn.find((id) => id !== kept)!));
assert.ok(!done.state.reserve.includes(kept));
conserved(restore(done));
}
});
void test('Tleilaxu Rihani normal-only inspection, lower-only exchange and pre-draw decline preserve their distinct bands', () => {
const normal = begin(true, false);
assert.equal(normal.stage, 'complete');
assert.equal(normal.peeked.length, 2);
assert.deepEqual(normal.drawn, []);
assert.deepEqual(normal.state.players, normal.before.players);
conserved(normal);
const lower = begin(false, true);
assert.equal(lower.stage, 'offer');
assert.deepEqual(lower.peeked, []);
assert.deepEqual(lower.state.reserve, lower.before.reserve);
const drawn = chooseRihaniDraw(lower, universe, lower.state, true);
const complete = finishRihani(drawn, universe, drawn.state, drawn.drawn[0], universe[2], random);
conserved(complete);
const both = begin();
const declined = chooseRihaniDraw(restore(both), universe, both.state, false);
assert.equal(declined.stage, 'complete');
assert.deepEqual(declined.state, both.state);
assert.deepEqual(declined.peeked, both.peeked);
assert.deepEqual(declined.drawn, []);
conserved(restore(declined));
assert.throws(() => chooseRihaniDraw(declined, universe, declined.state, true));
});
void test('Rihani cannot replace a revealed Face Dancer or return both newly drawn cards', () => {
const offer = begin();
const drawn = chooseRihaniDraw(offer, universe, offer.state, true);
for (const given of [universe[1], ...drawn.drawn]) {
const before = structuredClone(drawn);
assert.throws(() => finishRihani(drawn, universe, drawn.state, drawn.drawn[0], given, random));
assert.deepEqual(drawn, before);
}
const allRevealed = snapshot();
allRevealed.players[0].faceDancers!.forEach((card) => { card.revealed = true; });
const noExchange = begin(true, true, allRevealed);
assert.deepEqual(noExchange.eligible, []);
assert.equal(noExchange.stage, 'complete');
assert.equal(noExchange.peeked.length, 2);
assert.deepEqual(noExchange.state.players, allRevealed.players);
conserved(noExchange);
});
void test('Rihani rejects status changes in old, drawn and retained Face Dancers even with a recomputed signature', () => {
const offer = begin();
const drawn = chooseRihaniDraw(offer, universe, offer.state, true);
const complete = finishRihani(drawn, universe, drawn.state, drawn.drawn[0], universe[0], random);
const invalid = [
resigned(offer, (r) => { r.state.players[0].faceDancers![1].revealed = false; }),
resigned(drawn, (r) => { r.state.players[0].faceDancers![0].revealed = true; }),
resigned(drawn, (r) => { r.state.players[0].faceDancers![3].revealed = true; }),
resigned(complete, (r) => { r.state.players[0].faceDancers![0].revealed = false; }),
resigned(complete, (r) => { r.state.players[0].faceDancers![2].revealed = true; }),
resigned(drawn, (r) => { r.eligible.push(universe[1]); }),
];
for (const receipt of invalid) assert.throws(() => validateRihani(receipt, universe));
const changedCurrent = structuredClone(drawn.state);
changedCurrent.players[0].faceDancers![1].revealed = false;
assert.throws(() => finishRihani(drawn, universe, changedCurrent, drawn.drawn[0], universe[0], random));
const duplicate = resigned(drawn, (r) => { r.state.reserve.push(r.drawn[0]); });
assert.throws(() => validateRihani(duplicate, universe));
});
void test('ordinary Rihani receipts retain the existing used-Traitor exclusion and JSON transition', () => {
const state = snapshot();
state.players[0] = { id: 't', faction: 'harkonnen', traitors: universe.slice(0, 3) };
const offer = beginRihani(state, universe, {
event: 'legacy-rihani', turn: 1, owner: 't',
skill: { leader: 'harkonnen-0', normal: true, skilled: true },
}, [universe[1]], random);
assert.deepEqual(offer.eligible, [universe[0], universe[2]]);
const drawn = chooseRihaniDraw(restore(offer), universe, offer.state, true);
assert.deepEqual(drawn.state.players[0].traitors, [...universe.slice(0, 3), ...drawn.drawn]);
const done = finishRihani(restore(drawn), universe, drawn.state, drawn.drawn[1], universe[0], random);
assert.deepEqual(done.state.players[0].traitors, [universe[1], universe[2], drawn.drawn[1]]);
assert.equal(Object.hasOwn(done.state.players[0], 'faceDancers'), false);
validateRihani(restore(done), universe, done.state);
});
void test('Rihani excludes permanently retired cards throughout peek, draw, return and recorded custody', () => {
const original = snapshot();
const retired = original.reserve.shift()!;
original.removed = [retired];
const offer = begin(true, true, original);
assert.equal(offer.peeked.includes(retired), false);
const pending = chooseRihaniDraw(offer, universe, offer.state, true);
assert.equal(pending.drawn.includes(retired), false);
const done = finishRihani(pending, universe, pending.state, pending.drawn[0], universe[0], random);
assert.deepEqual(done.state.removed, [retired]);
assert.equal(done.state.reserve.includes(retired), false);
assert.equal(done.state.players.some(p => p.traitors.includes(retired) ||
p.faceDancers?.some(card => card.leader === retired)), false);
assert.ok(done.state.reserve.includes(universe[0]));
assert.ok(done.state.players[0].faceDancers!.some(card => card.leader === pending.drawn[0] && !card.revealed));
conserved(done);
const rewritten = resigned(done, r => {
r.state.removed = [universe[0]];
r.state.reserve[r.state.reserve.indexOf(universe[0])] = retired;
});
validateNexusTraitorSnapshot(rewritten.state, universe);
const before = structuredClone(rewritten);
assert.throws(() => validateRihani(rewritten, universe));
assert.deepEqual(rewritten, before);
});