Files
Dune/tests/nexus-borrowed-voice.test.ts

272 lines
13 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { VOICE_KINDS } from '../game/battle-cards';
import {
createNexusBorrowedVoice,
currentNexusBorrowedVoiceOwner,
declareNexusBorrowedVoice,
finishNexusBorrowedVoice,
passNexusBorrowedVoice,
validateNexusBorrowedVoice,
type NexusBorrowedVoiceContext,
type NexusBorrowedVoiceFrame,
} from '../game/nexus-borrowed-voice';
const context: NexusBorrowedVoiceContext = {
turn: 2,
battle: { event: 'original-battle-7', attacker: 'a', defender: 'd' },
};
const command: NonNullable<NexusBorrowedVoiceFrame['voice']> = {
target: 'd', kind: 'poison', must: true,
};
function freeze(value: unknown): void {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return;
for (const child of Object.values(value)) freeze(child);
Object.freeze(value);
}
void test('creation binds a deterministic original turn/battle and attacker-first queue', () => {
const frame = createNexusBorrowedVoice(context);
assert.deepEqual(frame, {
event: JSON.stringify(['nexusBorrowedVoice', 2, 'original-battle-7']),
turn: 2, battle: 'original-battle-7', order: ['a', 'd'], passed: [],
stage: 'choose', owner: null, voice: null,
});
assert.deepEqual(createNexusBorrowedVoice(structuredClone(context)), frame);
assert.equal(currentNexusBorrowedVoiceOwner(frame), 'a');
validateNexusBorrowedVoice(context, frame);
assert.notEqual(createNexusBorrowedVoice({ ...context, turn: 3 }).event, frame.event);
assert.notEqual(createNexusBorrowedVoice({ ...context,
battle: { ...context.battle, event: 'original-battle-8' } }).event, frame.event);
});
void test('all-pass closes the opportunity without a declaration or response', () => {
const original = createNexusBorrowedVoice(context);
const first = passNexusBorrowedVoice(original, 'a');
assert.deepEqual(first.passed, ['a']);
assert.equal(first.stage, 'choose');
assert.equal(currentNexusBorrowedVoiceOwner(first), 'd');
validateNexusBorrowedVoice(context, first);
const closed = passNexusBorrowedVoice(first, 'd');
assert.deepEqual(closed.passed, ['a', 'd']);
assert.equal(closed.stage, 'passed');
assert.equal(closed.owner, null);
assert.equal(closed.voice, null);
assert.equal(currentNexusBorrowedVoiceOwner(closed), null);
assert.equal(closed.event, original.event);
validateNexusBorrowedVoice(context, closed);
for (const actor of ['a', 'd']) {
assert.throws(() => passNexusBorrowedVoice(closed, actor));
assert.throws(() => declareNexusBorrowedVoice(closed, actor,
{ ...command, target: actor === 'a' ? 'd' : 'a' }));
}
assert.throws(() => finishNexusBorrowedVoice(closed, false));
});
for (const actor of ['a', 'd']) {
void test(`${actor === 'a' ? 'first' : 'second'} combatant may declare once and close the queue`, () => {
const original = createNexusBorrowedVoice(context);
const ready = actor === 'a' ? original : passNexusBorrowedVoice(original, 'a');
const voice = { ...command, target: actor === 'a' ? 'd' : 'a', must: actor === 'a' };
const declared = declareNexusBorrowedVoice(ready, actor, voice);
assert.equal(declared.stage, 'declared');
assert.equal(declared.owner, actor);
assert.deepEqual(declared.voice, voice);
assert.deepEqual(declared.passed, actor === 'a' ? [] : ['a']);
assert.equal(declared.event, original.event);
assert.equal(currentNexusBorrowedVoiceOwner(declared), null);
validateNexusBorrowedVoice(context, declared);
for (const second of ['a', 'd']) {
assert.throws(() => passNexusBorrowedVoice(declared, second));
assert.throws(() => declareNexusBorrowedVoice(declared, second,
{ ...command, target: second === 'a' ? 'd' : 'a' }));
}
for (const canceled of [false, true]) {
const settled = finishNexusBorrowedVoice(declared, canceled);
assert.equal(settled.stage, canceled ? 'canceled' : 'active');
assert.equal(settled.owner, actor);
assert.deepEqual(settled.voice, voice);
assert.deepEqual(settled.passed, declared.passed);
assert.equal(settled.event, declared.event);
assert.equal(currentNexusBorrowedVoiceOwner(settled), null);
validateNexusBorrowedVoice(context, settled);
for (const repeat of [false, true]) assert.throws(() => finishNexusBorrowedVoice(settled, repeat));
for (const second of ['a', 'd']) {
assert.throws(() => passNexusBorrowedVoice(settled, second));
assert.throws(() => declareNexusBorrowedVoice(settled, second,
{ ...command, target: second === 'a' ? 'd' : 'a' }));
}
}
});
}
void test('wrong, foreign, passed and stale actors cannot advance the prefix queue', () => {
const first = createNexusBorrowedVoice(context);
for (const actor of ['d', 'foreign', '', null, undefined]) {
assert.throws(() => passNexusBorrowedVoice(first, actor as string));
assert.throws(() => declareNexusBorrowedVoice(first, actor as string, command));
}
const second = passNexusBorrowedVoice(first, 'a');
for (const actor of ['a', 'foreign', '', null, undefined]) {
assert.throws(() => passNexusBorrowedVoice(second, actor as string));
assert.throws(() => declareNexusBorrowedVoice(second, actor as string, { ...command, target: 'a' }));
}
assert.throws(() => finishNexusBorrowedVoice(first, false));
assert.throws(() => finishNexusBorrowedVoice(second, true));
});
void test('context validation rejects stale turn/event and changed original combatants at every stage', () => {
const first = createNexusBorrowedVoice(context);
const second = passNexusBorrowedVoice(first, 'a');
const declared = declareNexusBorrowedVoice(second, 'd', { ...command, target: 'a' });
const frames = [first, second, passNexusBorrowedVoice(second, 'd'), declared,
finishNexusBorrowedVoice(declared, false), finishNexusBorrowedVoice(declared, true)];
const changed: NexusBorrowedVoiceContext[] = [
{ ...context, turn: 3 },
{ ...context, battle: { ...context.battle, event: 'different-battle' } },
{ ...context, battle: { ...context.battle, attacker: 'foreign' } },
{ ...context, battle: { ...context.battle, defender: 'foreign' } },
{ ...context, battle: { ...context.battle, attacker: 'd', defender: 'a' } },
];
for (const frame of frames) {
validateNexusBorrowedVoice(context, frame);
for (const stale of changed) assert.throws(() => validateNexusBorrowedVoice(stale, frame));
}
});
void test('invalid contexts cannot manufacture a queue', () => {
const invalid: unknown[] = [
null, {}, { ...context, turn: 0 }, { ...context, turn: -1 },
{ ...context, turn: 1.5 }, { ...context, turn: NaN },
{ ...context, turn: Number.MAX_SAFE_INTEGER + 1 },
{ ...context, turn: '2' }, { ...context, battle: null },
{ ...context, battle: { ...context.battle, event: ' ' } },
{ ...context, battle: { ...context.battle, attacker: '' } },
{ ...context, battle: { ...context.battle, defender: ' ' } },
{ ...context, battle: { ...context.battle, defender: 'a' } },
];
const frame = createNexusBorrowedVoice(context);
for (const bad of invalid) {
assert.throws(() => createNexusBorrowedVoice(bad as NexusBorrowedVoiceContext));
assert.throws(() => validateNexusBorrowedVoice(bad as NexusBorrowedVoiceContext, frame));
}
});
void test('saved frames require the bound event, exact shape and a genuine pass prefix', () => {
const frame = createNexusBorrowedVoice(context);
const sparseOrder: string[] = [];
sparseOrder.length = 2;
const sparsePassed: string[] = [];
sparsePassed.length = 1;
sparseOrder[1] = 'd';
const invalid: unknown[] = [
null, {}, { ...frame, event: '' }, { ...frame, event: 'stale-event' },
{ ...frame, turn: 3 }, { ...frame, battle: 'other-battle' },
{ ...frame, source: 'private-card' }, { ...frame, stage: 'unknown' },
{ ...frame, order: ['a'] }, { ...frame, order: ['a', 'a'] },
{ ...frame, order: ['a', 'd', 'foreign'] }, { ...frame, order: sparseOrder },
{ ...frame, passed: null }, { ...frame, passed: ['d'] },
{ ...frame, passed: ['a', 'a'] }, { ...frame, passed: ['foreign'] },
{ ...frame, passed: ['a', 'd', 'foreign'] }, { ...frame, passed: sparsePassed },
{ ...frame, passed: ['a', 'd'] }, { ...frame, stage: 'passed' },
{ ...frame, owner: 'a' }, { ...frame, voice: command },
];
for (const bad of invalid) {
const malformed = bad as NexusBorrowedVoiceFrame;
assert.throws(() => validateNexusBorrowedVoice(context, malformed));
assert.throws(() => currentNexusBorrowedVoiceOwner(malformed));
assert.throws(() => passNexusBorrowedVoice(malformed, 'a'));
assert.throws(() => declareNexusBorrowedVoice(malformed, 'a', command));
assert.throws(() => finishNexusBorrowedVoice(malformed, false));
}
});
void test('every native Voice category and both require/forbid commands are canonical', () => {
for (const kind of VOICE_KINDS) {
for (const must of [false, true]) {
const voice = { ...command, kind: kind as typeof command.kind, must };
const declared = declareNexusBorrowedVoice(createNexusBorrowedVoice(context), 'a', voice);
assert.deepEqual(declared.voice, voice);
validateNexusBorrowedVoice(context, declared);
validateNexusBorrowedVoice(context, finishNexusBorrowedVoice(declared, false));
validateNexusBorrowedVoice(context, finishNexusBorrowedVoice(declared, true));
}
}
});
void test('declarations reject missing/noncanonical commands, nonbooleans and wrong targets', () => {
const frame = createNexusBorrowedVoice(context);
const invalid: unknown[] = [
null, undefined, {}, { ...command, target: 'a' }, { ...command, target: 'foreign' },
{ ...command, kind: 'karama' }, { ...command, kind: 'named:Imaginary Card' },
{ ...command, kind: 'Poison' }, { ...command, kind: null },
{ target: 'd', must: true }, { target: 'd', kind: 'poison' },
{ ...command, must: 'false' }, { ...command, must: 0 },
{ ...command, must: null }, { ...command, source: 'nexus' },
];
for (const bad of invalid) {
assert.throws(() => declareNexusBorrowedVoice(frame, 'a', bad as typeof command));
}
assert.deepEqual(frame, createNexusBorrowedVoice(context));
});
void test('declared, active and canceled receipts require exactly one ordered owner and command', () => {
const declared = declareNexusBorrowedVoice(createNexusBorrowedVoice(context), 'a', command);
for (const stage of ['declared', 'active', 'canceled'] as const) {
const base = { ...declared, stage };
validateNexusBorrowedVoice(context, base);
const invalid: unknown[] = [
{ ...base, owner: null }, { ...base, owner: 'd' }, { ...base, owner: 'foreign' },
{ ...base, voice: null }, { ...base, voice: { ...command, target: 'a' } },
{ ...base, voice: { ...command, must: 'true' } },
{ ...base, voice: { ...command, kind: 'karama' } },
{ ...base, passed: ['a'] }, { ...base, passed: ['a', 'd'] },
];
for (const bad of invalid) assert.throws(() => validateNexusBorrowedVoice(context, bad as NexusBorrowedVoiceFrame));
}
for (const bad of ['false', 0, null, undefined]) {
assert.throws(() => finishNexusBorrowedVoice(declared, bad as unknown as boolean));
}
});
void test('successful and rejected transitions leave inputs untouched and return independent receipts', () => {
const ctx = structuredClone(context);
const voice = structuredClone(command);
freeze(ctx);
freeze(voice);
const first = createNexusBorrowedVoice(ctx);
const original = structuredClone(first);
freeze(first);
const second = passNexusBorrowedVoice(first, 'a');
freeze(second);
const allPassed = passNexusBorrowedVoice(second, 'd');
const firstDeclared = declareNexusBorrowedVoice(first, 'a', voice);
freeze(firstDeclared);
const canceled = finishNexusBorrowedVoice(firstDeclared, true);
const active = finishNexusBorrowedVoice(firstDeclared, false);
const secondDeclared = declareNexusBorrowedVoice(second, 'd', { ...voice, target: 'a' });
assert.throws(() => passNexusBorrowedVoice(first, 'd'));
assert.throws(() => declareNexusBorrowedVoice(first, 'a', { ...voice, target: 'a' }));
assert.deepEqual(first, original);
assert.deepEqual(ctx, context);
assert.deepEqual(voice, command);
assert.deepEqual(second.passed, ['a']);
assert.equal(firstDeclared.stage, 'declared');
for (const result of [second, allPassed, firstDeclared, secondDeclared, canceled, active]) {
assert.notEqual(result, first);
assert.notEqual(result.order, first.order);
assert.notEqual(result.passed, first.passed);
}
assert.notEqual(firstDeclared.voice, voice);
assert.notEqual(canceled.voice, firstDeclared.voice);
assert.notEqual(active.voice, firstDeclared.voice);
canceled.voice!.must = false;
canceled.order[0] = 'changed';
canceled.passed.push('changed');
active.voice!.kind = 'shield';
assert.deepEqual(firstDeclared.voice, command);
assert.deepEqual(firstDeclared.order, ['a', 'd']);
assert.deepEqual(firstDeclared.passed, []);
});