439 lines
14 KiB
TypeScript
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/,
|
|
);
|
|
});
|