203 lines
7.2 KiB
TypeScript
203 lines
7.2 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
createTraitorDeclaration,
|
|
validateTraitorDeclaration,
|
|
validateTraitorDeclarations,
|
|
type TraitorDeclarationContext,
|
|
type TraitorDeclaration,
|
|
} from '../game/traitor-declarations';
|
|
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
|
|
|
|
const context = (harkonnen = false): TraitorDeclarationContext => ({
|
|
event: 'battle-4',
|
|
attacker: 'a',
|
|
defender: 'g',
|
|
plans: { a: { leader: 'a-leader' }, g: { leader: 'g-leader' } },
|
|
players: [
|
|
{
|
|
id: 'a',
|
|
faction: 'atreides',
|
|
ally: 'h',
|
|
traitors: harkonnen ? ['a-other'] : ['g-leader'],
|
|
},
|
|
{ id: 'g', faction: 'guild', traitors: ['a-leader'] },
|
|
{
|
|
id: 'h',
|
|
faction: 'harkonnen',
|
|
ally: 'a',
|
|
traitors: harkonnen ? ['g-leader'] : ['h-other'],
|
|
},
|
|
],
|
|
heroLeaderIds: [],
|
|
});
|
|
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
|
|
|
void test('declarations preserve a valid call after its original identity leaves the hand', () => {
|
|
for (const voter of ['a', 'g', 'h']) {
|
|
const c = context(voter === 'h');
|
|
const before = JSON.stringify(c);
|
|
const receipt = createTraitorDeclaration(c, voter);
|
|
assert.equal(JSON.stringify(c), before);
|
|
c.players.find((p) => p.id === voter)!.traitors = ['other-identity'];
|
|
const changed = JSON.stringify(c);
|
|
validateTraitorDeclaration(c, reload(receipt));
|
|
validateTraitorDeclarations(c, { [voter]: true }, { [voter]: receipt }, 1);
|
|
assert.equal(JSON.stringify(c), changed);
|
|
assert.throws(() => createTraitorDeclaration(c, voter), /must hold/);
|
|
assert.equal(receipt.identity, voter === 'g' ? 'a-leader' : 'g-leader');
|
|
}
|
|
});
|
|
|
|
void test('an allied Harkonnen declaration binds its beneficiary separately from its voter', () => {
|
|
const c = context(true);
|
|
const receipt = createTraitorDeclaration(c, 'h');
|
|
assert.equal(receipt.voter, 'h');
|
|
assert.equal(receipt.beneficiary, 'a');
|
|
assert.equal(receipt.target, 'g');
|
|
for (const ally of [null, 'g', 'missing']) {
|
|
const changed = reload(c);
|
|
changed.players[2].ally = ally;
|
|
assert.throws(() => validateTraitorDeclaration(changed, receipt));
|
|
}
|
|
const nonHarkonnen = reload(c);
|
|
nonHarkonnen.players[2].faction = 'fremen';
|
|
assert.throws(() => createTraitorDeclaration(nonHarkonnen, 'h'));
|
|
});
|
|
|
|
void test('Cheap Hero receipt binds the sealed card ID to the shared physical traitor identity', () => {
|
|
const c = context();
|
|
c.plans.g.leader = 'cheap-hero-card-2';
|
|
c.heroLeaderIds = ['cheap-hero-card-1', 'cheap-hero-card-2'];
|
|
c.players[0].traitors = [CHEAP_HERO_TRAITOR];
|
|
const receipt = createTraitorDeclaration(c, 'a');
|
|
assert.equal(receipt.leader, 'cheap-hero-card-2');
|
|
assert.equal(receipt.identity, CHEAP_HERO_TRAITOR);
|
|
c.players[0].traitors = [];
|
|
validateTraitorDeclaration(c, receipt);
|
|
c.plans.g.leader = 'cheap-hero-card-1';
|
|
assert.throws(() => validateTraitorDeclaration(c, receipt));
|
|
c.plans.g.leader = 'cheap-hero-card-2';
|
|
c.heroLeaderIds = [];
|
|
assert.throws(() => validateTraitorDeclaration(c, receipt));
|
|
});
|
|
|
|
void test('missing leaders, Kwisatz protection and missing custody reject creation without mutation', () => {
|
|
for (const mutate of [
|
|
(c: TraitorDeclarationContext) => {
|
|
c.plans.g.leader = null;
|
|
},
|
|
(c: TraitorDeclarationContext) => {
|
|
c.plans.g.kwisatz = true;
|
|
},
|
|
(c: TraitorDeclarationContext) => {
|
|
c.players[0].traitors = [];
|
|
},
|
|
]) {
|
|
const c = context();
|
|
mutate(c);
|
|
const before = JSON.stringify(c);
|
|
assert.throws(() => createTraitorDeclaration(c, 'a'));
|
|
assert.equal(JSON.stringify(c), before);
|
|
}
|
|
});
|
|
|
|
void test('receipt edits, deleted fields and extra fields cannot change the committed identity', () => {
|
|
const c = context();
|
|
const original = createTraitorDeclaration(c, 'a');
|
|
for (const field of Object.keys(original)) {
|
|
for (const remove of [false, true]) {
|
|
const receipt = reload(original);
|
|
const writable = receipt as unknown as Record<string, unknown>;
|
|
if (remove) delete writable[field];
|
|
else writable[field] = 'changed';
|
|
const before = JSON.stringify(receipt);
|
|
assert.throws(() => validateTraitorDeclaration(c, receipt));
|
|
assert.equal(JSON.stringify(receipt), before);
|
|
}
|
|
}
|
|
assert.throws(() =>
|
|
validateTraitorDeclaration(c, {
|
|
...original,
|
|
hidden: 'unexpected',
|
|
} as TraitorDeclaration),
|
|
);
|
|
});
|
|
|
|
void test('battle, target leader, protection and roster corruption reject while seat ordering does not', () => {
|
|
const c = context();
|
|
const receipt = createTraitorDeclaration(c, 'a');
|
|
for (const mutate of [
|
|
(copy: TraitorDeclarationContext) => {
|
|
copy.event = 'another-battle';
|
|
},
|
|
(copy: TraitorDeclarationContext) => {
|
|
copy.plans.g.leader = 'replacement';
|
|
},
|
|
(copy: TraitorDeclarationContext) => {
|
|
copy.plans.g.kwisatz = true;
|
|
},
|
|
(copy: TraitorDeclarationContext) => {
|
|
copy.players[2].faction = 'fremen';
|
|
},
|
|
(copy: TraitorDeclarationContext) => {
|
|
copy.players[2].id = 'other-seat';
|
|
},
|
|
(copy: TraitorDeclarationContext) => {
|
|
copy.players[2].id = 'a';
|
|
},
|
|
]) {
|
|
const changed = reload(c);
|
|
mutate(changed);
|
|
assert.throws(() => validateTraitorDeclaration(changed, receipt));
|
|
}
|
|
c.players = [...c.players].reverse();
|
|
validateTraitorDeclaration(c, receipt);
|
|
});
|
|
|
|
void test('a canceled false call retains evidence, but a false decision cannot be promoted by a new draw', () => {
|
|
const c = context(true);
|
|
const receipt = createTraitorDeclaration(c, 'h');
|
|
validateTraitorDeclarations(c, { h: false }, { h: receipt }, 1);
|
|
validateTraitorDeclarations(c, { a: false }, {}, 1);
|
|
c.players[0].traitors = ['g-leader', 'new-traitor'];
|
|
assert.throws(() => validateTraitorDeclarations(c, { a: true }, {}, 1));
|
|
assert.throws(() => validateTraitorDeclarations(c, {}, { h: receipt }, 1));
|
|
assert.throws(() =>
|
|
validateTraitorDeclarations(c, { h: undefined }, { h: receipt }, 1),
|
|
);
|
|
});
|
|
|
|
void test('versioned maps require every true call, exact voter keys and both metadata fields', () => {
|
|
const c = context();
|
|
const a = createTraitorDeclaration(c, 'a');
|
|
const g = createTraitorDeclaration(c, 'g');
|
|
validateTraitorDeclarations(c, { a: true, g: true }, { a, g }, 1);
|
|
assert.throws(() =>
|
|
validateTraitorDeclarations(c, { a: true, g: true }, { a }, 1),
|
|
);
|
|
assert.throws(() => validateTraitorDeclarations(c, { a: true }, { a: g }, 1));
|
|
assert.throws(() =>
|
|
validateTraitorDeclarations(c, { a: true }, undefined, 1),
|
|
);
|
|
assert.throws(() => validateTraitorDeclarations(c, { a: true }, { a }));
|
|
assert.throws(() =>
|
|
validateTraitorDeclarations(c, { a: true }, { a }, 2 as 1),
|
|
);
|
|
// Both absent is intentionally legacy; this does not certify historical calls.
|
|
validateTraitorDeclarations(c, { a: true }, undefined);
|
|
});
|
|
|
|
void test('later validation does not read any private traitor hand', () => {
|
|
const c = context();
|
|
const receipt = createTraitorDeclaration(c, 'a');
|
|
for (const player of c.players)
|
|
Object.defineProperty(player, 'traitors', {
|
|
get() {
|
|
throw new Error('Private hand was read');
|
|
},
|
|
});
|
|
validateTraitorDeclaration(c, receipt);
|
|
validateTraitorDeclarations(c, { a: true }, { a: receipt }, 1);
|
|
});
|