239 lines
5.9 KiB
TypeScript
239 lines
5.9 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
createNexusChoam,
|
|
validateNexusChoam,
|
|
type NexusChoamContext,
|
|
type NexusChoamEffect,
|
|
type NexusChoamReceipt,
|
|
} from '../game/nexus-choam';
|
|
|
|
const context: NexusChoamContext = {
|
|
turn: 3,
|
|
players: [
|
|
{ id: 'c', faction: 'choam' },
|
|
{ id: 'a', faction: 'atreides' },
|
|
{ id: 'h', faction: 'harkonnen' },
|
|
],
|
|
};
|
|
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
|
|
|
void test('CHOAM Nexus receipts enforce effect timing while leaving Kull response authority to the caller', () => {
|
|
for (const [effect, phase] of [
|
|
['kulon', 5],
|
|
['laLaLa', 4],
|
|
['gamont', 8],
|
|
['baliset', 5],
|
|
['jubba', 0],
|
|
] as const) {
|
|
const receipt = createNexusChoam(
|
|
context,
|
|
'c',
|
|
phase,
|
|
'physical-poison-1',
|
|
effect,
|
|
);
|
|
validateNexusChoam(context, clone(receipt));
|
|
for (let wrong = 0; wrong <= 8; wrong++)
|
|
if (wrong !== phase)
|
|
assert.throws(
|
|
() =>
|
|
createNexusChoam(context, 'c', wrong, 'physical-poison-1', effect),
|
|
/timing/,
|
|
);
|
|
}
|
|
for (let phase = 0; phase <= 8; phase++)
|
|
validateNexusChoam(
|
|
context,
|
|
createNexusChoam(context, 'c', phase, 'physical-shield-1', 'kull'),
|
|
);
|
|
});
|
|
|
|
void test('CHOAM historical receipt survives later turns and roster ordering without inspecting private state', () => {
|
|
const receipt = createNexusChoam(
|
|
context,
|
|
'c',
|
|
5,
|
|
'physical-poison-1',
|
|
'kulon',
|
|
);
|
|
const before = clone(receipt);
|
|
const later = {
|
|
turn: 4,
|
|
players: [...context.players].reverse().map((p) =>
|
|
Object.defineProperties(
|
|
{ ...p },
|
|
{
|
|
hand: {
|
|
get() {
|
|
throw new Error('private hand read');
|
|
},
|
|
},
|
|
ally: {
|
|
get() {
|
|
throw new Error('historical alliance read');
|
|
},
|
|
},
|
|
spice: {
|
|
get() {
|
|
throw new Error('private balance read');
|
|
},
|
|
},
|
|
},
|
|
),
|
|
),
|
|
};
|
|
validateNexusChoam(later, clone(receipt));
|
|
assert.deepEqual(receipt, before);
|
|
const second = createNexusChoam(
|
|
context,
|
|
'c',
|
|
5,
|
|
'physical-shield-1',
|
|
'kulon',
|
|
);
|
|
assert.notEqual(receipt.event, second.event);
|
|
assert.notEqual(
|
|
receipt.event,
|
|
createNexusChoam(context, 'c', 5, receipt.card, 'baliset').event,
|
|
);
|
|
});
|
|
|
|
void test('CHOAM receipt rejects changed target, effect, turn, roster or identity without changing evidence', () => {
|
|
const receipt = createNexusChoam(
|
|
context,
|
|
'c',
|
|
5,
|
|
'physical-poison-1',
|
|
'kulon',
|
|
);
|
|
const edits: ((r: NexusChoamReceipt) => void)[] = [
|
|
(r) => {
|
|
r.card = 'other';
|
|
},
|
|
(r) => {
|
|
r.effect = 'baliset';
|
|
},
|
|
(r) => {
|
|
r.turn++;
|
|
},
|
|
(r) => {
|
|
r.owner = 'a';
|
|
},
|
|
(r) => {
|
|
r.event = 'other';
|
|
},
|
|
(r) => {
|
|
r.phase = 8;
|
|
},
|
|
(r) => {
|
|
r.roster.pop();
|
|
},
|
|
(r) => {
|
|
r.roster[0].faction = 'guild';
|
|
},
|
|
(r) => {
|
|
r.signature = 'changed';
|
|
},
|
|
(r) => {
|
|
delete (r as Partial<NexusChoamReceipt>).card;
|
|
},
|
|
(r) => {
|
|
Object.assign(r, { stage: 'complete' });
|
|
},
|
|
];
|
|
for (const edit of edits) {
|
|
const bad = clone(receipt);
|
|
edit(bad);
|
|
const before = clone(bad);
|
|
assert.throws(() => validateNexusChoam(context, bad), /CHOAM Nexus/);
|
|
assert.deepEqual(bad, before);
|
|
}
|
|
assert.throws(
|
|
() => validateNexusChoam({ ...context, turn: 2 }, receipt),
|
|
/CHOAM Nexus/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
validateNexusChoam(
|
|
{
|
|
...context,
|
|
players: context.players.map((p) =>
|
|
p.id === 'c' ? { ...p, faction: 'guild' } : p,
|
|
),
|
|
},
|
|
receipt,
|
|
),
|
|
/CHOAM Nexus/,
|
|
);
|
|
});
|
|
|
|
void test('CHOAM receipt creation rejects malformed or foreign inputs before producing a grant', () => {
|
|
const before = clone(context);
|
|
for (const owner of ['a', 'missing', '__proto__'])
|
|
assert.throws(
|
|
() => createNexusChoam(context, owner, 5, 'card', 'kulon'),
|
|
/CHOAM Nexus/,
|
|
);
|
|
for (const phase of [-1, 9, 1.5, NaN])
|
|
assert.throws(
|
|
() => createNexusChoam(context, 'c', phase, 'card', 'kull'),
|
|
/CHOAM Nexus/,
|
|
);
|
|
for (const card of ['', ' ', '__proto__'])
|
|
assert.throws(
|
|
() => createNexusChoam(context, 'c', 5, card, 'kulon'),
|
|
/CHOAM Nexus/,
|
|
);
|
|
for (const effect of ['unknown', 'constructor', '__proto__'])
|
|
assert.throws(
|
|
() =>
|
|
createNexusChoam(context, 'c', 5, 'card', effect as NexusChoamEffect),
|
|
/CHOAM Nexus/,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
createNexusChoam(
|
|
{ ...context, players: [...context.players, context.players[0]] },
|
|
'c',
|
|
5,
|
|
'card',
|
|
'kulon',
|
|
),
|
|
/roster/,
|
|
);
|
|
assert.deepEqual(context, before);
|
|
});
|
|
|
|
void test('self-consistent saved Kull receipts still reject invalid intrinsic cost, owner and stamps', () => {
|
|
const receipt = createNexusChoam(context, 'c', 5, 'physical-weapon-1', 'kull');
|
|
const patches: Partial<NexusChoamReceipt>[] = [
|
|
{ card: '' },
|
|
{ card: ' ' },
|
|
{ card: '__proto__' },
|
|
{ owner: 'a' },
|
|
{ owner: 'missing' },
|
|
{ turn: 0 },
|
|
{ turn: context.turn + 1 },
|
|
{ turn: 1.5 },
|
|
{ phase: -1 },
|
|
{ phase: 9 },
|
|
{ phase: 5.5 },
|
|
{ effect: 'unknown' as NexusChoamEffect },
|
|
];
|
|
for (const patch of patches) {
|
|
const invalid = { ...clone(receipt), ...patch };
|
|
invalid.event = JSON.stringify([
|
|
'nexusChoam', invalid.turn, invalid.phase, invalid.owner, invalid.card, invalid.effect,
|
|
]);
|
|
invalid.signature = JSON.stringify([
|
|
invalid.version, invalid.event, invalid.turn, invalid.phase,
|
|
invalid.owner, invalid.card, invalid.effect,
|
|
invalid.roster.map(player => [player.id, player.faction]),
|
|
]);
|
|
const before = clone(invalid);
|
|
assert.throws(() => validateNexusChoam(context, invalid));
|
|
assert.deepEqual(invalid, before);
|
|
}
|
|
});
|