205 lines
9.1 KiB
TypeScript
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);
|
|
});
|