Files
Dune/tests/moritani-terror.test.ts
T
2026-09-08 07:37:52 +10:00

439 lines
14 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
createTerrorState,
placeTerror,
projectTerror,
revealTerror,
returnTerror,
TERROR_COMMON_GAMEPLAY,
TERROR_DEFINITIONS,
TERROR_KINDS,
TERROR_STRONGHOLDS,
type TerrorState,
} from '../game/moritani-terror';
void test('six unique secret faces use opaque physical IDs and the supplied shuffle', () => {
let calls = 0;
const state = createTerrorState(() => {
calls++;
return 0;
});
assert.equal(calls, 5);
assert.deepEqual(
state.tokens.map((token) => token.id),
['terror-1', 'terror-2', 'terror-3', 'terror-4', 'terror-5', 'terror-6'],
);
assert.deepEqual(
state.tokens.map((token) => token.kind).sort(),
[...TERROR_KINDS].sort(),
);
assert.ok(
state.tokens.every(
(token) => token.status === 'available' && token.location === null,
),
);
assert.deepEqual(
state,
createTerrorState(() => 0),
);
assert.notDeepEqual(
state.tokens.map((token) => token.kind),
createTerrorState(() => 0.999).tokens.map((token) => token.kind),
);
state.tokens[0].status = 'removed';
assert.equal(createTerrorState(() => 0).tokens[0].status, 'available');
for (const draw of [-1, 1, Infinity, NaN])
assert.throws(() => createTerrorState(() => draw), /random draws/);
});
void test('the public projection conceals supply IDs and every placed face while revealing spent faces', () => {
const initial = createTerrorState(() => 0);
assert.deepEqual(projectTerror(initial, false), { tokens: [] });
const state: TerrorState = {
placementTurn: 3,
tokens: [
{ ...initial.tokens[0], status: 'placed', location: 'arrakeen' },
{ ...initial.tokens[1], status: 'removed', location: 'carthag' },
{ ...initial.tokens[2], kind: 'extortion', status: 'extortion' },
...initial.tokens.slice(3),
],
};
assert.deepEqual(projectTerror(state, false), {
placementTurn: 3,
tokens: [
{ id: 'terror-1', status: 'placed', location: 'arrakeen' },
{
id: 'terror-2',
kind: state.tokens[1].kind,
status: 'removed',
location: null,
},
{
id: 'terror-3',
kind: 'extortion',
status: 'extortion',
location: null,
},
],
});
const altered = structuredClone(state);
altered.tokens[0].kind = 'assassination';
altered.tokens[3].id = 'a-different-hidden-id';
altered.tokens[3].kind = 'robbery';
assert.deepEqual(projectTerror(altered, false), projectTerror(state, false));
});
void test('both projections clone data and exclude extra runtime fields', () => {
const state = placeTerror(
createTerrorState(() => 0),
'terror-1',
'arrakeen',
1,
);
Object.assign(state, { secret: 'state secret' });
Object.assign(state.tokens[0], { secret: 'token secret' });
const owner = projectTerror(state, true);
const publicView = projectTerror(state, false);
assert.equal('secret' in owner, false);
assert.equal('secret' in owner.tokens[0], false);
assert.equal('secret' in publicView.tokens[0], false);
assert.equal('kind' in publicView.tokens[0], false);
owner.tokens[0].kind = 'assassination';
owner.tokens[1].location = 'carthag';
publicView.tokens[0].location = 'carthag';
assert.equal(state.tokens[0].location, 'arrakeen');
assert.equal(state.tokens[0].kind, 'atomics');
assert.equal(state.tokens[1].location, null);
});
void test('placement accepts exactly the five ordinary strongholds without storm conditions', () => {
assert.deepEqual(TERROR_STRONGHOLDS, [
'arrakeen',
'carthag',
'sietch_tabr',
'habbanya_ridge_sietch',
'tueks_sietch',
]);
const initial = createTerrorState(() => 0);
for (const destination of TERROR_STRONGHOLDS) {
const placed = placeTerror(initial, 'terror-1', destination, 1);
assert.equal(placed.tokens[0].location, destination);
assert.equal(placed.tokens[0].status, 'placed');
assert.equal(placed.placementTurn, 1);
}
for (const destination of [
'hms',
'hidden_mobile_stronghold',
'giedi_prime',
'imperial_basin',
'arrakeen:10',
'',
]) {
assert.throws(
() => placeTerror(initial, 'terror-1', destination, 1),
/five ordinary strongholds/,
);
}
assert.ok(
initial.tokens.every(
(token) => token.status === 'available' && token.location === null,
),
);
});
void test('relocation retains physical identity and face, spends one opportunity, and never mutates inputs', () => {
const initial = createTerrorState(() => 0);
const placed = placeTerror(initial, 'terror-1', 'arrakeen', 2);
const before = structuredClone(placed);
for (const turn of [1, 2])
assert.throws(
() => placeTerror(placed, 'terror-2', 'carthag', turn),
/already been used/,
);
assert.throws(
() => placeTerror(placed, 'terror-1', 'arrakeen', 3),
/different stronghold/,
);
assert.throws(
() => placeTerror(placed, 'terror-2', 'arrakeen', 3),
/already contains/,
);
assert.deepEqual(placed, before);
const moved = placeTerror(placed, 'terror-1', 'carthag', 3);
assert.deepEqual(moved.tokens[0], {
...placed.tokens[0],
location: 'carthag',
});
assert.equal(moved.placementTurn, 3);
assert.deepEqual(placed, before);
moved.tokens[1].status = 'removed';
assert.equal(placed.tokens[1].status, 'available');
assert.equal(initial.tokens[0].status, 'available');
});
void test('invalid turns and unavailable or ambiguous token selections leave custody untouched', () => {
const state = createTerrorState(() => 0);
const before = structuredClone(state);
for (const turn of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
assert.throws(
() => placeTerror(state, 'terror-1', 'arrakeen', turn),
/valid turn/,
);
}
assert.throws(
() => placeTerror(state, 'unknown', 'arrakeen', 1),
/Choose a Terror token/,
);
const duplicate = { tokens: [...state.tokens, { ...state.tokens[0] }] };
assert.throws(
() => placeTerror(duplicate, 'terror-1', 'arrakeen', 1),
/Choose a Terror token/,
);
for (const status of ['removed', 'extortion'] as const) {
const unavailable = structuredClone(state);
unavailable.tokens[0].status = status;
assert.throws(
() => placeTerror(unavailable, 'terror-1', 'arrakeen', 1),
/not available/,
);
}
assert.deepEqual(state, before);
});
void test('six immutable component guides distinguish source verification from unfinished runtime effects', () => {
assert.deepEqual(
Object.keys(TERROR_DEFINITIONS).sort(),
[...TERROR_KINDS].sort(),
);
assert.deepEqual(
TERROR_KINDS.map((kind) => TERROR_DEFINITIONS[kind].name),
[
'Assassination',
'Atomics',
'Extortion',
'Robbery',
'Sabotage',
'Sneak Attack',
],
);
assert.ok(Object.isFrozen(TERROR_DEFINITIONS));
assert.ok(Object.isFrozen(TERROR_KINDS));
assert.ok(Object.isFrozen(TERROR_STRONGHOLDS));
assert.ok(Object.isFrozen(TERROR_COMMON_GAMEPLAY));
for (const kind of TERROR_KINDS) {
const definition = TERROR_DEFINITIONS[kind];
assert.equal(definition.kind, kind);
assert.equal(definition.quantity, 1);
assert.ok(definition.summary.length > 30);
assert.ok(definition.gameplay.length > TERROR_COMMON_GAMEPLAY.length);
assert.ok(
definition.gameplay.every(
(paragraph) => paragraph.length > 30 && !/https?:\/\//.test(paragraph),
),
);
assert.equal(definition.verification.inventory, 'verified');
assert.equal(definition.verification.sourceRules, 'verified');
assert.equal(
definition.verification.runtimeEffects,
['robbery', 'sabotage', 'assassination', 'sneakAttack'].includes(kind)
? 'partial'
: 'not-implemented',
);
assert.equal(definition.verification.componentArtwork, 'not-verified');
assert.ok(Object.isFrozen(definition));
assert.ok(Object.isFrozen(definition.gameplay));
assert.ok(Object.isFrozen(definition.verification));
assert.ok(Object.isFrozen(definition.verification.unresolved));
}
});
void test('revelation removes ordinary tokens and sets Extortion aside without changing other custody', () => {
for (const kind of TERROR_KINDS) {
const state = createTerrorState(() => 0);
const token = state.tokens.find((candidate) => candidate.kind === kind)!;
const placed = placeTerror(
{ ...state, supplyEpoch: 4 },
token.id,
'arrakeen',
2,
);
const before = structuredClone(placed);
const revealed = revealTerror(placed, token.id);
assert.deepEqual(
revealed.tokens.find((candidate) => candidate.id === token.id),
{
...token,
status: kind === 'extortion' ? 'extortion' : 'removed',
location: null,
},
);
assert.equal(revealed.placementTurn, 2);
assert.equal(revealed.supplyEpoch, 4);
assert.deepEqual(placed, before);
assert.ok(
revealed.tokens.every(
(candidate, index) => candidate !== placed.tokens[index],
),
);
assert.throws(
() => revealTerror(revealed, token.id),
/placed Terror token/,
);
}
const initial = createTerrorState(() => 0);
assert.throws(
() => revealTerror(initial, initial.tokens[0].id),
/placed Terror token/,
);
assert.throws(() => revealTerror(initial, 'unknown'), /placed Terror token/);
const placed = placeTerror(initial, initial.tokens[0].id, 'arrakeen', 1);
assert.throws(
() =>
revealTerror(
{ tokens: [placed.tokens[0], placed.tokens[0]] },
placed.tokens[0].id,
),
/placed Terror token/,
);
});
void test('return rotates the complete available supply and keeps unrelated public identities stable', () => {
let state = createTerrorState(() => 0);
const extortion = state.tokens.find((token) => token.kind === 'extortion')!;
const robbery = state.tokens.find((token) => token.kind === 'robbery')!;
const atomics = state.tokens.find((token) => token.kind === 'atomics')!;
state = placeTerror(state, extortion.id, 'arrakeen', 1);
state = revealTerror(state, extortion.id);
state = placeTerror(state, robbery.id, 'carthag', 2);
state = placeTerror(state, atomics.id, 'sietch_tabr', 3);
state = revealTerror(state, atomics.id);
const before = structuredClone(state);
const result = returnTerror(state, extortion.id, () => 0);
assert.deepEqual(state, before);
assert.equal(result.supplyEpoch, 1);
assert.equal(result.placementTurn, 3);
const supply = result.tokens.filter((token) => token.status === 'available');
assert.equal(supply.length, 4);
assert.ok(
supply.every(
(token) =>
token.location === null &&
!state.tokens.some((old) => old.id === token.id),
),
);
assert.deepEqual(
supply.map((token) => token.id),
[
'terror-supply-1-1',
'terror-supply-1-2',
'terror-supply-1-3',
'terror-supply-1-4',
],
);
for (const id of [robbery.id, atomics.id])
assert.deepEqual(
result.tokens.find((token) => token.id === id),
state.tokens.find((token) => token.id === id),
);
assert.equal('supplyEpoch' in projectTerror(result, true), false);
assert.equal('supplyEpoch' in projectTerror(result, false), false);
assert.deepEqual(
projectTerror(result, false).tokens,
projectTerror(state, false).tokens.filter(
(token) => token.id !== extortion.id,
),
);
result.tokens[0].location = 'arrakeen';
assert.deepEqual(state, before);
});
void test('fresh random supply slots break returned-face and previous array-position associations', () => {
let state = createTerrorState(() => 0);
const token = state.tokens.find(
(candidate) => candidate.kind === 'extortion',
)!;
state = revealTerror(placeTerror(state, token.id, 'arrakeen', 1), token.id);
const low = returnTerror(state, token.id, () => 0);
const high = returnTerror(state, token.id, () => 0.999);
assert.deepEqual(projectTerror(low, false), projectTerror(high, false));
assert.deepEqual(
low.tokens.map((candidate) => candidate.id),
high.tokens.map((candidate) => candidate.id),
);
assert.notEqual(
low.tokens.find((candidate) => candidate.kind === 'extortion')!.id,
high.tokens.find((candidate) => candidate.kind === 'extortion')!.id,
);
assert.notDeepEqual(
low.tokens.map((candidate) => candidate.kind),
high.tokens.map((candidate) => candidate.kind),
);
const lowPlaced = placeTerror(low, low.tokens[0].id, 'carthag', 2);
const highPlaced = placeTerror(high, high.tokens[0].id, 'carthag', 2);
assert.notEqual(lowPlaced.tokens[0].kind, highPlaced.tokens[0].kind);
assert.deepEqual(
projectTerror(lowPlaced, false),
projectTerror(highPlaced, false),
);
assert.equal(lowPlaced.supplyEpoch, 1);
const returnedAgain = returnTerror(
lowPlaced,
lowPlaced.tokens[0].id,
() => 0,
);
assert.equal(returnedAgain.supplyEpoch, 2);
assert.ok(
returnedAgain.tokens.every((candidate) =>
candidate.id.startsWith('terror-supply-2-'),
),
);
assert.equal(returnedAgain.placementTurn, 2);
});
void test('invalid return custody, randomness, and epochs fail without mutating the state', () => {
const initial = createTerrorState(() => 0);
assert.throws(
() => returnTerror(initial, initial.tokens[0].id, () => 0),
/placed Terror token or revealed Extortion/,
);
assert.throws(
() => returnTerror(initial, 'unknown', () => 0),
/placed Terror token or revealed Extortion/,
);
const placed = placeTerror(initial, initial.tokens[0].id, 'arrakeen', 1);
const removed = revealTerror(placed, placed.tokens[0].id);
assert.throws(
() => returnTerror(removed, removed.tokens[0].id, () => 0),
/placed Terror token or revealed Extortion/,
);
assert.throws(
() =>
returnTerror(
{ tokens: [placed.tokens[0], placed.tokens[0]] },
placed.tokens[0].id,
() => 0,
),
/placed Terror token or revealed Extortion/,
);
const before = structuredClone(placed);
for (const value of [-1, 1, Infinity, NaN])
assert.throws(
() => returnTerror(placed, placed.tokens[0].id, () => value),
/random draws/,
);
for (const supplyEpoch of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER])
assert.throws(
() =>
returnTerror({ ...placed, supplyEpoch }, placed.tokens[0].id, () => 0),
/epoch is invalid/,
);
assert.deepEqual(placed, before);
const reused = structuredClone(placed);
reused.tokens[1].id = 'terror-supply-1-1';
assert.throws(
() => returnTerror(reused, reused.tokens[0].id, () => 0),
/reuse an existing identity/,
);
});