Files
Dune/tests/nexus-choam-betrayal-quote.test.ts

145 lines
7.2 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import type { Card } from '../game/cards';
import type { Game } from '../game/engine';
import {
nexusChoamBetrayalSignature, quoteNexusChoamBetrayal,
validateNexusChoamBetrayal, type NexusChoamBetrayal,
} from '../game/nexus-choam-betrayal';
import { nexusTraitorFixture } from './fixture-nexus-traitors';
import { nexusReload } from './fixture-nexus-cards';
function position(phase = 5) {
const g = nexusTraitorFixture({ ownerFaction: 'atreides', opponentFaction: 'choam', phase });
const owner = g.players[0].id, target = g.players[1].id;
const cards = g.nexusCards!.cards!;
const index = cards.deck.indexOf('choam');
assert.ok(index >= 0);
cards.deck[index] = cards.hands[owner]!;
cards.hands[owner] = 'choam';
const selected = g.deck.splice(0, 2);
assert.equal(selected.length, 2);
g.players[1].hand.push(...selected);
return { g, owner, target, selected };
}
void test('outside Bidding, a CHOAM Betrayal offer reads neither hidden hand count nor identities', () => {
const { g, owner, target, selected } = position();
const offer = quoteNexusChoamBetrayal(g, owner)!;
assert.deepEqual(offer.target, { id: target, name: g.players[1].name, handSize: null });
assert.equal(offer.blocked, null);
assert.deepEqual(JSON.parse(offer.event), ['nexusChoamBetrayal', g.turn, g.phase, owner, target]);
for (const card of selected) assert.ok(!JSON.stringify(offer).includes(card.id));
for (const count of [0, 1, 3]) {
const alternate = nexusReload(g);
alternate.deck.push(...alternate.players[1].hand.splice(0));
alternate.players[1].hand.push(...alternate.deck.splice(0, count));
assert.deepEqual(quoteNexusChoamBetrayal(alternate, owner), offer);
}
g.players[1].hand = new Proxy(g.players[1].hand, {
get() { throw new Error('Private CHOAM hand was read'); },
});
assert.deepEqual(quoteNexusChoamBetrayal(g, owner), offer);
const bidding = position(3);
assert.equal(quoteNexusChoamBetrayal(bidding.g, bidding.owner)!.target.handSize, 2);
bidding.g.deck.push(...bidding.g.players[1].hand.splice(0));
assert.match(quoteNexusChoamBetrayal(bidding.g, bidding.owner)!.blocked!, /no Treachery/);
});
void test('the same clean boundary is available across printed phases, but not through live interactions', () => {
for (const phase of [0, 2, 5, 7, 8]) {
const { g, owner } = position(phase);
assert.equal(quoteNexusChoamBetrayal(g, owner)!.blocked, null);
assert.match(quoteNexusChoamBetrayal(g, owner, true)!.blocked!, /current interaction/);
}
const { g, owner, target } = position();
for (const change of [
(copy: Game) => { copy.response = { kind: 'card' } as never; },
(copy: Game) => { copy.decision = { kind: 'guildTiming' } as never; },
(copy: Game) => { copy.pendingTreacheryDiscard = { sequence: 1 } as never; },
(copy: Game) => { copy.nexusCards!.phase = { stage: 'drawing' } as never; },
(copy: Game) => { copy.battle = { event: 'ongoing' } as never; },
(copy: Game) => { copy.pendingExchange = { response: null, decision: null }; },
]) {
const copy = nexusReload(g); change(copy);
assert.match(quoteNexusChoamBetrayal(copy, owner)!.blocked!, /current interaction/);
}
const empty = nexusReload(g);
empty.deck.push(...empty.players[1].hand.splice(0));
assert.equal(quoteNexusChoamBetrayal(empty, owner)!.blocked, null);
const allied = nexusReload(g);
allied.players[0].ally = allied.players[2].id;
allied.players[2].ally = owner;
assert.match(quoteNexusChoamBetrayal(allied, owner)!.blocked!, /unallied/);
assert.equal(quoteNexusChoamBetrayal(g, target), null);
assert.equal(quoteNexusChoamBetrayal(g, 'foreign'), null);
const noCard = nexusReload(g);
noCard.nexusCards!.cards!.hands[owner] = null;
assert.equal(quoteNexusChoamBetrayal(noCard, owner), null);
const absent = nexusReload(g);
absent.players[1].faction = 'guild';
assert.equal(quoteNexusChoamBetrayal(absent, owner), null);
});
void test('signed sampled-card receipt validates after discard and JSON reload without future hand custody', () => {
const { g, owner, target, selected } = position();
const offer = quoteNexusChoamBetrayal(g, owner)!;
const record: NexusChoamBetrayal = {
event: offer.event, turn: g.turn, phase: g.phase, owner, target,
card: selected[0].id, handBefore: 2, stage: 'discard', signature: '',
};
record.signature = nexusChoamBetrayalSignature(record);
const physical = [...g.deck, ...g.players.flatMap(player => player.hand), ...g.discard];
assert.doesNotThrow(() => validateNexusChoamBetrayal(g, record, physical));
const spent = nexusReload(g);
spent.nexusCards!.cards!.hands[owner] = null;
spent.nexusCards!.cards!.discard.push('choam');
const discarded = spent.players[1].hand.shift()!;
spent.discard.push(discarded);
spent.players[1].hand.push(spent.deck.shift()!);
const restored = nexusReload(spent), receipt = JSON.parse(JSON.stringify(record)) as NexusChoamBetrayal;
assert.doesNotThrow(() => validateNexusChoamBetrayal(restored, receipt,
[...restored.deck, ...restored.discard, ...restored.players.flatMap(player => player.hand)]));
receipt.stage = 'complete'; receipt.signature = nexusChoamBetrayalSignature(receipt);
restored.turn++;
assert.doesNotThrow(() => validateNexusChoamBetrayal(restored, receipt,
[...restored.deck, ...restored.discard, ...restored.players.flatMap(player => player.hand)]));
assert.equal(restored.players[1].hand.length, record.handBefore);
});
void test('malformed, stale or forged source/selection receipts cannot become historical evidence', () => {
const { g, owner, target, selected } = position();
const event = quoteNexusChoamBetrayal(g, owner)!.event;
const receipt: NexusChoamBetrayal = {
event, turn: g.turn, phase: g.phase, owner, target, card: selected[0].id,
handBefore: 2, stage: 'discard', signature: '',
};
receipt.signature = nexusChoamBetrayalSignature(receipt);
const physical = [...g.deck, ...g.players.flatMap(player => player.hand), ...g.discard];
const invalid = (patch: Partial<NexusChoamBetrayal>, cards: readonly Card[] = physical) =>
assert.throws(() => validateNexusChoamBetrayal(g, { ...receipt, ...patch }, cards));
invalid({ event: 'forged' });
invalid({ card: selected[1].id });
invalid({ owner: target });
invalid({ target: g.players[2].id });
invalid({ phase: receipt.phase + 1 });
invalid({ turn: receipt.turn + 1 });
invalid({ handBefore: 1 });
invalid({ handBefore: 0 });
invalid({ handBefore: Number.MAX_SAFE_INTEGER + 1 });
invalid({ stage: 'pending' as never });
invalid({ card: 'invented', signature: nexusChoamBetrayalSignature({ ...receipt, card: 'invented' }) });
invalid({}, physical.filter(card => card.id !== receipt.card));
invalid({}, [...physical, selected[0]]);
invalid({ extra: 'hidden' } as never);
const changedRoster = nexusReload(g);
changedRoster.players[1].faction = 'guild';
assert.throws(() => validateNexusChoamBetrayal(changedRoster, receipt, physical));
const foreignOwner = nexusReload(g);
foreignOwner.players[0].faction = 'choam';
assert.throws(() => validateNexusChoamBetrayal(foreignOwner, receipt, physical));
const future = nexusReload(g);
future.phase = 2;
assert.throws(() => validateNexusChoamBetrayal(future, receipt, physical));
});