Files
Dune/tests/nexus-guild-cunning.test.ts

252 lines
7.2 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
createNexusGuildCunning,
validateNexusGuildCunning,
nexusGuildCunningMoves,
type NexusGuildCunningContext,
type NexusGuildCunningReceipt,
} from '../game/nexus-guild-cunning';
const context = {
turn: 3,
phase: 5,
active: 'g',
players: [
{ id: 'a', faction: 'atreides' as const },
{ id: 'g', faction: 'guild' as const },
{ id: 'f', faction: 'fremen' as const },
],
};
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
const make = (moved = 0, hajrUsed = false, shipped = false) =>
createNexusGuildCunning(context, 'g', { shipped, moved, hajrUsed });
void test('Guild Cunning records the ended original combined turn whether its shipment and movement were used or declined', () => {
for (const shipped of [false, true])
for (const hajrUsed of [false, true])
for (let moved = 0; moved <= (hajrUsed ? 2 : 1); moved++) {
const before = { shipped, moved, hajrUsed },
original = clone(before);
const receipt = createNexusGuildCunning(context, 'g', before);
assert.equal(
receipt.event,
JSON.stringify(['nexusGuildCunning', 3, 'g']),
);
assert.deepEqual(receipt.before, original);
assert.deepEqual(before, original);
assert.notEqual(receipt.before, before);
assert.equal(receipt.mode, 'cunning');
validateNexusGuildCunning(context, clone(receipt));
}
});
void test('second shipment never supplies an ordinary move, including a declined first movement', () => {
for (const moved of [0, 1]) {
const receipt = make(moved, false, true);
assert.equal(nexusGuildCunningMoves(receipt, false), 0);
assert.throws(() => nexusGuildCunningMoves(receipt, false, 1));
assert.equal(
nexusGuildCunningMoves(receipt, true),
1,
'later Hajr grants exactly its extra slot',
);
assert.equal(nexusGuildCunningMoves(receipt, true, 1), 0);
}
});
void test('previously played Hajr keeps an unused extra slot after decline, but an already consumed Hajr grants no third move', () => {
for (const moved of [0, 1]) {
const receipt = make(moved, true);
assert.equal(nexusGuildCunningMoves(receipt, true), 1);
assert.equal(nexusGuildCunningMoves(receipt, true, 1), 0);
assert.throws(() => nexusGuildCunningMoves(receipt, false));
}
const consumed = make(2, true);
assert.equal(nexusGuildCunningMoves(consumed, true), 0);
assert.throws(() => nexusGuildCunningMoves(consumed, true, 1));
for (const count of [-1, 0.5, 2, NaN, Infinity])
assert.throws(() => nexusGuildCunningMoves(make(1, true), true, count));
});
void test('only native unallied Guild can create the receipt at its own phase-five boundary', () => {
const before = { shipped: false, moved: 0, hajrUsed: false };
for (const phase of [0, 4, 6, 8])
assert.throws(() =>
createNexusGuildCunning({ ...context, phase }, 'g', before),
);
assert.throws(() =>
createNexusGuildCunning({ ...context, active: 'a' }, 'g', before),
);
assert.throws(() =>
createNexusGuildCunning({ ...context, active: 'a' }, 'a', before),
);
assert.throws(() =>
createNexusGuildCunning(
{
...context,
players: context.players.map((p) => ({
...p,
ally: p.id === 'g' ? 'a' : null,
})),
},
'g',
before,
),
);
for (const bad of [
{ ...before, moved: 2 },
{ ...before, moved: 3, hajrUsed: true },
{ ...before, moved: -1 },
{ ...before, moved: 0.5 },
{ ...before, moved: NaN },
{ ...before, shipped: 0 },
{ ...before, hajrUsed: 1 },
{ ...before, extra: 1 },
])
assert.throws(() =>
createNexusGuildCunning(context, 'g', bad as typeof before),
);
});
void test('completed original receipts survive later alliances, turns and resource changes without reading private fields', () => {
const receipt = make(1, true, true);
const trap = () => {
throw new Error('private field was read');
};
const later: NexusGuildCunningContext = {
turn: 7,
players: context.players.map((p) => ({
...p,
get ally() {
return trap();
},
get hand() {
return trap();
},
get spice() {
return trap();
},
get shipped() {
return trap();
},
get moved() {
return trap();
},
get reserves() {
return trap();
},
})),
};
validateNexusGuildCunning(later, receipt);
assert.equal(nexusGuildCunningMoves(receipt, true), 1);
assert.throws(() =>
validateNexusGuildCunning({ ...later, turn: 2 }, receipt),
);
assert.throws(() =>
validateNexusGuildCunning(
{ ...later, players: later.players.slice(0, 2) },
receipt,
),
);
});
void test('creation reads only public roster, owner alliance, timing and the supplied original allowance', () => {
const trap = () => {
throw new Error('private field was read');
};
const publicContext = {
...context,
players: context.players.map((p) =>
Object.freeze({
...p,
ally: null,
get hand() {
return trap();
},
get spice() {
return trap();
},
get forces() {
return trap();
},
}),
),
};
Object.freeze(publicContext.players);
Object.freeze(publicContext);
const before = Object.freeze({ shipped: true, moved: 1, hajrUsed: true });
const receipt = createNexusGuildCunning(publicContext, 'g', before);
assert.deepEqual(Object.keys(receipt).sort(), [
'before',
'event',
'mode',
'owner',
'phase',
'roster',
'signature',
'turn',
'version',
]);
assert.equal(JSON.stringify(receipt).includes('hand'), false);
assert.equal(nexusGuildCunningMoves(Object.freeze(receipt), true, 1), 0);
});
void test('changed original facts, foreign fields, deleted proofs and malformed roster identities reject immutably', () => {
const receipt = make(1, true, true);
const mutations: ((r: NexusGuildCunningReceipt) => void)[] = [
(r) => {
r.before.moved = 0;
},
(r) => {
r.before.shipped = false;
},
(r) => {
r.before.hajrUsed = false;
},
(r) => {
r.event = 'stale';
},
(r) => {
r.owner = 'a';
},
(r) => {
r.turn = 2;
},
(r) => {
r.signature = '';
},
(r) => {
r.roster.reverse();
},
(r) => {
r.roster[0].id = 'changed';
},
(r) => {
delete (r as Partial<NexusGuildCunningReceipt>).before;
},
(r) => {
Object.assign(r, { stage: 'secondShipment' });
},
];
for (const mutate of mutations) {
const bad = clone(receipt);
mutate(bad);
const original = clone(bad);
assert.throws(() => validateNexusGuildCunning(context, bad));
assert.deepEqual(bad, original);
}
for (const players of [
[...context.players, context.players[0]],
context.players.map((p) => ({ ...p, id: 'same' })),
context.players.map((p) => ({ ...p, faction: 'guild' as const })),
context.players.map((p) => ({
...p,
id: p.id === 'a' ? '__proto__' : p.id,
})),
])
assert.throws(() =>
validateNexusGuildCunning({ ...context, players }, receipt),
);
});