304 lines
14 KiB
TypeScript
304 lines
14 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
answerNexusFremenProtection,
|
|
createNexusFremenProtection,
|
|
currentNexusFremenProtectionOwner,
|
|
validateNexusFremenProtection,
|
|
type NexusFremenProtectionFrame,
|
|
type NexusFremenProtectionInput,
|
|
type NexusFremenProtectionPlayer,
|
|
} from '../game/nexus-fremen-protection';
|
|
|
|
const sparseOrder: string[] = [];
|
|
sparseOrder.length = 3;
|
|
|
|
/** Structural public data for the pure module, not a staged engine game. */
|
|
function occurrence(): NexusFremenProtectionInput {
|
|
return {
|
|
event: 'd6d3fb17-14e4-4a31-a717-c041a9b02d7c',
|
|
turn: 3,
|
|
territory: 'hagga_basin',
|
|
origin: 'natural',
|
|
pile: 0,
|
|
index: 4,
|
|
order: ['h', 'r', 'a'],
|
|
players: [
|
|
{ id: 'a', faction: 'atreides', hasNexus: true, forces: { 'hagga_basin:12': 2 } },
|
|
{ id: 'h', faction: 'harkonnen', hasNexus: true, forces: { 'hagga_basin:13': 1 } },
|
|
{
|
|
id: 'r', faction: 'richese', hasNexus: true, forces: {},
|
|
noField: { deployed: { location: { territory: 'hagga_basin', sector: 12 } } },
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function created(input = occurrence()): NexusFremenProtectionFrame {
|
|
const frame = createNexusFremenProtection(input);
|
|
assert.ok(frame);
|
|
return frame;
|
|
}
|
|
|
|
void test('public storm order includes only unallied threatened seats holding any Nexus', () => {
|
|
const input = occurrence();
|
|
const players: NexusFremenProtectionPlayer[] = [
|
|
input.players[0],
|
|
{ ...input.players[1], ally: 'e' },
|
|
input.players[2],
|
|
{ id: 'c', faction: 'choam', hasNexus: false, forces: { 'hagga_basin:12': 8 } },
|
|
{ id: 'e', faction: 'emperor', ally: 'h', hasNexus: true, forces: {} },
|
|
{ id: 'g', faction: 'guild', hasNexus: true, forces: { 'carthag:11': 3 } },
|
|
];
|
|
const publicInput = { ...input, players, order: ['r', 'g', 'h', 'c', 'a', 'e'] };
|
|
const frame = created(publicInput);
|
|
assert.deepEqual(frame, {
|
|
event: input.event, turn: 3, territory: 'hagga_basin', origin: 'natural',
|
|
pile: 0, index: 4, stage: 'choose', order: ['r', 'a'], passed: [], protected: null,
|
|
});
|
|
assert.equal(currentNexusFremenProtectionOwner(frame), 'r');
|
|
validateNexusFremenProtection(publicInput, frame);
|
|
assert.equal(createNexusFremenProtection({
|
|
...publicInput,
|
|
players: players.map((player) => ({ ...player, hasNexus: false })),
|
|
}), null);
|
|
assert.equal(createNexusFremenProtection({
|
|
...publicInput,
|
|
players: players.map((player) => ({ ...player, forces: {}, noField: { deployed: null } })),
|
|
}), null);
|
|
assert.equal(createNexusFremenProtection({
|
|
...publicInput,
|
|
players: players.map((player) => player.id === 'g' ? { ...player, faction: 'fremen' } : player),
|
|
}), null, 'native Fremen suppresses this independent absent-Fremen queue even away from the worm');
|
|
});
|
|
|
|
void test('unrelated dynamic-board groups do not block protection of the actual printed worm territory', () => {
|
|
const input = occurrence();
|
|
const players = input.players.map(player => player.id === 'a' ? {
|
|
...player, forces: { ...player.forces, 'hidden_mobile_stronghold:0': 2 },
|
|
noField: { deployed: { location: { territory: 'hidden_mobile_stronghold', sector: 0 } } },
|
|
} : player);
|
|
let frame = created({ ...input, players });
|
|
frame = answerNexusFremenProtection(frame, 'h', true);
|
|
frame = answerNexusFremenProtection(frame, 'r', false);
|
|
frame = answerNexusFremenProtection(frame, 'a', false);
|
|
assert.equal(frame.protected, 'h');
|
|
assert.equal(frame.stage, 'complete');
|
|
validateNexusFremenProtection({ ...input, players }, frame);
|
|
});
|
|
|
|
void test('irrelevant faces and hidden No-Field contents are not read at any public transition', () => {
|
|
const input = occurrence();
|
|
const hiddenRead = () => { throw new Error('private material was read'); };
|
|
for (const player of input.players) {
|
|
Object.defineProperties(player, {
|
|
card: { enumerable: true, get: hiddenRead },
|
|
hand: { enumerable: true, get: hiddenRead },
|
|
nexusFace: { enumerable: true, get: hiddenRead },
|
|
tokens: { enumerable: true, get: hiddenRead },
|
|
reserves: { enumerable: true, get: hiddenRead },
|
|
});
|
|
}
|
|
const marker = input.players[2].noField!;
|
|
Object.defineProperty(marker, 'tokens', { enumerable: true, get: hiddenRead });
|
|
Object.defineProperties(marker.deployed!, {
|
|
value: { enumerable: true, get: hiddenRead },
|
|
token: { enumerable: true, get: hiddenRead },
|
|
});
|
|
let frame = created(input);
|
|
assert.deepEqual(frame.order, ['h', 'r', 'a']);
|
|
frame = answerNexusFremenProtection(frame, 'h', false);
|
|
assert.equal(currentNexusFremenProtectionOwner(frame), 'r');
|
|
frame = answerNexusFremenProtection(frame, 'r', true);
|
|
assert.equal(frame.protected, 'r');
|
|
assert.equal(currentNexusFremenProtectionOwner(frame), 'a');
|
|
frame = answerNexusFremenProtection(frame, 'a', false);
|
|
assert.equal(frame.stage, 'complete');
|
|
validateNexusFremenProtection(input, frame);
|
|
assert.ok('territory' in marker.deployed!.location);
|
|
assert.equal(marker.deployed!.location.territory, 'hagga_basin');
|
|
});
|
|
|
|
void test('Use protects just its original actor and leaves every remaining acknowledgement in order', () => {
|
|
const input = occurrence();
|
|
const start = created(input);
|
|
const first = answerNexusFremenProtection(start, 'h', true);
|
|
assert.equal(first.protected, 'h');
|
|
assert.deepEqual(first.order, ['h', 'r', 'a']);
|
|
assert.deepEqual(first.passed, ['h']);
|
|
assert.equal(first.stage, 'choose');
|
|
assert.equal(currentNexusFremenProtectionOwner(first), 'r');
|
|
assert.throws(() => answerNexusFremenProtection(first, 'r', true), /already has its protected owner/);
|
|
assert.deepEqual(first.passed, ['h'], 'a rejected second use cannot advance the queue');
|
|
const second = answerNexusFremenProtection(first, 'r', false);
|
|
assert.equal(currentNexusFremenProtectionOwner(second), 'a');
|
|
const complete = answerNexusFremenProtection(second, 'a', false);
|
|
assert.equal(complete.protected, 'h');
|
|
assert.deepEqual(complete.passed, ['h', 'r', 'a']);
|
|
assert.equal(complete.stage, 'complete');
|
|
assert.equal(currentNexusFremenProtectionOwner(complete), null);
|
|
validateNexusFremenProtection(input, complete);
|
|
assert.deepEqual(start.passed, []);
|
|
assert.equal(start.protected, null);
|
|
});
|
|
|
|
void test('wrong actor, repeated answer and nonboolean use cannot consume an acknowledgement', () => {
|
|
const start = created();
|
|
for (const actor of ['a', 'r', 'missing', ''])
|
|
assert.throws(() => answerNexusFremenProtection(start, actor, false), /current original owner/);
|
|
assert.throws(
|
|
() => answerNexusFremenProtection(start, 'h', 1 as unknown as boolean),
|
|
/Use or Pass/,
|
|
);
|
|
const next = answerNexusFremenProtection(start, 'h', false);
|
|
assert.throws(() => answerNexusFremenProtection(next, 'h', false), /current original owner/);
|
|
assert.equal(currentNexusFremenProtectionOwner(next), 'r');
|
|
assert.deepEqual(start.passed, []);
|
|
assert.equal(start.stage, 'choose');
|
|
});
|
|
|
|
void test('all passes close the queue only at the final original owner and completed frames cannot reopen', () => {
|
|
const input = occurrence();
|
|
let frame = created(input);
|
|
for (const [index, actor] of frame.order.entries()) {
|
|
assert.equal(currentNexusFremenProtectionOwner(frame), actor);
|
|
frame = answerNexusFremenProtection(frame, actor, false);
|
|
assert.equal(frame.stage, index === 2 ? 'complete' : 'choose');
|
|
}
|
|
assert.equal(frame.protected, null);
|
|
assert.equal(currentNexusFremenProtectionOwner(frame), null);
|
|
validateNexusFremenProtection(input, frame);
|
|
for (const actor of input.order) {
|
|
assert.throws(() => answerNexusFremenProtection(frame, actor, false), /current original owner/);
|
|
assert.throws(() => answerNexusFremenProtection(frame, actor, true), /current original owner/);
|
|
}
|
|
});
|
|
|
|
void test('one eligible original owner can close with Use without needing an impossible extra prompt', () => {
|
|
const input = occurrence();
|
|
const alone = {
|
|
...input,
|
|
players: input.players.map((player) => ({ ...player, hasNexus: player.id === 'r' })),
|
|
};
|
|
const frame = answerNexusFremenProtection(created(alone), 'r', true);
|
|
assert.equal(frame.stage, 'complete');
|
|
assert.equal(frame.protected, 'r');
|
|
assert.deepEqual(frame.order, ['r']);
|
|
assert.deepEqual(frame.passed, ['r']);
|
|
assert.equal(currentNexusFremenProtectionOwner(frame), null);
|
|
validateNexusFremenProtection(alone, frame);
|
|
});
|
|
|
|
void test('source event, turn, territory, origin, pile and original discard index cannot be retargeted', () => {
|
|
const input = occurrence();
|
|
const frame = created(input);
|
|
const changed: Partial<NexusFremenProtectionInput>[] = [
|
|
{ event: 'another-worm-at-the-same-place' }, { turn: 4 },
|
|
{ territory: 'the_great_flat' }, { origin: 'additional' }, { pile: 1 }, { index: 5 },
|
|
];
|
|
for (const replacement of changed)
|
|
assert.throws(() => validateNexusFremenProtection({ ...input, ...replacement }, frame), /original occurrence identity/);
|
|
assert.throws(() => validateNexusFremenProtection({ ...input, order: ['a', 'h', 'r'] }, frame), /original public candidates/);
|
|
assert.throws(() => validateNexusFremenProtection({
|
|
...input, players: input.players.map((player) => player.id === 'h' ? { ...player, hasNexus: false } : player),
|
|
}, frame), /original public candidates/);
|
|
assert.throws(() => validateNexusFremenProtection({
|
|
...input, players: input.players.map((player) => ({ ...player, forces: {}, noField: null })),
|
|
}, frame), /original public candidates/, 'post-devour presence cannot retrofit a queue');
|
|
});
|
|
|
|
void test('additional and summoned worms bind their own source, and printed rock spice territories are valid', () => {
|
|
const { pile: _pile, index: _index, ...base } = occurrence();
|
|
const additional = { ...base, origin: 'additional' as const };
|
|
const additionalFrame = created(additional);
|
|
assert.equal(Object.hasOwn(additionalFrame, 'pile'), false);
|
|
assert.equal(Object.hasOwn(additionalFrame, 'index'), false);
|
|
validateNexusFremenProtection(additional, additionalFrame);
|
|
const summoned = { ...base, origin: 'summoned' as const, parent: 'special-karama-original' };
|
|
const summonedFrame = created(summoned);
|
|
validateNexusFremenProtection(summoned, summonedFrame);
|
|
assert.throws(() => validateNexusFremenProtection({ ...summoned, parent: 'different-karama' }, summonedFrame), /original occurrence identity/);
|
|
const rock = {
|
|
...occurrence(), territory: 'rock_outcroppings',
|
|
players: base.players.map((player) => ({ ...player, forces: { 'rock_outcroppings:13': 1 }, noField: null })),
|
|
};
|
|
assert.deepEqual(created(rock).order, ['h', 'r', 'a']);
|
|
});
|
|
|
|
void test('creation rejects malformed public source and order rather than silently shrinking the queue', () => {
|
|
const input = occurrence();
|
|
const malformed: Record<string, unknown>[] = [
|
|
{ event: '' }, { event: ' ' }, { turn: 0 }, { turn: -1 }, { turn: 1.5 },
|
|
{ turn: Number.MAX_SAFE_INTEGER + 1 }, { territory: 'not-a-territory' },
|
|
{ territory: 'arrakeen' }, { territory: 'hidden_mobile_stronghold' }, { origin: 'revived' },
|
|
{ pile: undefined }, { pile: 2 }, { index: undefined }, { index: -1 },
|
|
{ index: 1.5 }, { index: NaN }, { index: Number.MAX_SAFE_INTEGER + 1 },
|
|
{ parent: 'unrelated-parent' }, { origin: 'summoned', pile: undefined, index: undefined },
|
|
{ origin: 'summoned', parent: 'karama', pile: 0, index: undefined },
|
|
{ origin: 'summoned', parent: 'karama', pile: undefined, index: 0 },
|
|
{ origin: 'additional', parent: 'karama' },
|
|
{ order: ['h', 'r'] }, { order: ['h', 'r', 'r'] }, { order: ['h', 'r', 'unknown'] },
|
|
{ order: ['h', 'r', ''] }, { order: sparseOrder }, { players: null },
|
|
];
|
|
for (const replacement of malformed)
|
|
assert.throws(() => createNexusFremenProtection({ ...input, ...replacement } as NexusFremenProtectionInput));
|
|
const malformedSeats: Record<string, unknown>[] = [
|
|
{ id: '' }, { id: 'h' }, { faction: '' }, { hasNexus: 'yes' },
|
|
{ ally: 'unknown' }, { ally: 'a' }, { forces: null },
|
|
{ forces: { 'hagga_basin:12': -1 } }, { forces: { 'hagga_basin:12': 1.5 } },
|
|
{ forces: { 'hagga_basin:99': 1 } }, { forces: { 'hagga_basin:012': 1 } },
|
|
{ noField: { deployed: {} } },
|
|
{ noField: { deployed: { location: { territory: 'hagga_basin', sector: 99 } } } },
|
|
];
|
|
for (const replacement of malformedSeats)
|
|
assert.throws(() => createNexusFremenProtection({
|
|
...input, players: [{ ...input.players[0], ...replacement } as NexusFremenProtectionPlayer, ...input.players.slice(1)],
|
|
}));
|
|
});
|
|
|
|
void test('saved frames require exact fields, a prefix of original acknowledgements and one acknowledged protected owner', () => {
|
|
const input = occurrence();
|
|
const frame = created(input);
|
|
const malformed: Record<string, unknown>[] = [
|
|
{ extra: true }, { parent: undefined }, { stage: 'devoured' }, { stage: 'complete' },
|
|
{ order: [] }, { order: ['h', 'r', 'r'] }, { order: sparseOrder },
|
|
{ passed: ['r'] }, { passed: ['h', 'a'] }, { passed: ['h', 'h'] },
|
|
{ passed: ['h', 'r', 'a', 'unknown'] },
|
|
{ passed: ['h', 'r', 'a'], stage: 'choose' },
|
|
{ protected: 'h' }, { passed: ['h'], protected: 'r' },
|
|
{ passed: ['h'], protected: 'unknown' }, { passed: ['h', 'r'], protected: ['h', 'r'] },
|
|
];
|
|
for (const replacement of malformed) {
|
|
const invalid = { ...frame, ...replacement } as NexusFremenProtectionFrame;
|
|
assert.throws(() => validateNexusFremenProtection(input, invalid));
|
|
assert.throws(() => currentNexusFremenProtectionOwner(invalid));
|
|
assert.throws(() => answerNexusFremenProtection(invalid, 'h', false));
|
|
}
|
|
assert.throws(() => validateNexusFremenProtection(input, { ...frame, order: ['r', 'h', 'a'] }), /original public candidates/);
|
|
assert.throws(() => validateNexusFremenProtection(input, { ...frame, order: ['h', 'r'] }), /original public candidates/);
|
|
});
|
|
|
|
void test('frozen public inputs and frames remain untouched and retain independent original queue arrays', () => {
|
|
const input = occurrence();
|
|
Object.freeze(input.order);
|
|
for (const player of input.players) {
|
|
Object.freeze(player.forces);
|
|
if (player.noField?.deployed) {
|
|
Object.freeze(player.noField.deployed.location);
|
|
Object.freeze(player.noField.deployed);
|
|
Object.freeze(player.noField);
|
|
}
|
|
Object.freeze(player);
|
|
}
|
|
Object.freeze(input.players);
|
|
Object.freeze(input);
|
|
const frame = created(input);
|
|
Object.freeze(frame.order);
|
|
Object.freeze(frame.passed);
|
|
Object.freeze(frame);
|
|
const next = answerNexusFremenProtection(frame, 'h', false);
|
|
assert.deepEqual(frame.passed, []);
|
|
assert.deepEqual(next.passed, ['h']);
|
|
validateNexusFremenProtection(input, next);
|
|
});
|