Files
Dune/game/nexus-borrowed-voice.ts

170 lines
5.8 KiB
TypeScript

import { voiceChoices } from './battle-cards';
import type { Battle } from './engine';
export type NexusBorrowedVoiceFrame = {
event: string;
turn: number;
battle: string;
order: [string, string];
passed: string[];
stage: 'choose' | 'declared' | 'active' | 'canceled' | 'passed';
owner: string | null;
/** A canceled command remains a receipt; only stage 'active' applies it. */
voice: {
target: string;
kind: NonNullable<Battle['voice']>['kind'];
must: boolean;
} | null;
};
export type NexusBorrowedVoiceContext = {
turn: number;
battle: { event: string; attacker: string; defender: string };
};
function requireVoice(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function validId(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function event(turn: number, battle: string): string {
return JSON.stringify(['nexusBorrowedVoice', turn, battle]);
}
function validateContext(context: NexusBorrowedVoiceContext): void {
requireVoice(
context && Number.isSafeInteger(context.turn) && context.turn > 0 &&
context.battle && validId(context.battle.event) &&
validId(context.battle.attacker) && validId(context.battle.defender) &&
context.battle.attacker !== context.battle.defender,
'Borrowed Voice needs its original turn and distinct combatants.',
);
}
function validateFrame(frame: NexusBorrowedVoiceFrame): void {
requireVoice(
frame && typeof frame === 'object' && !Array.isArray(frame) &&
Object.keys(frame).sort().join(',') === 'battle,event,order,owner,passed,stage,turn,voice' &&
Number.isSafeInteger(frame.turn) && frame.turn > 0 &&
validId(frame.battle) && frame.event === event(frame.turn, frame.battle) &&
Array.isArray(frame.order) && frame.order.length === 2 &&
validId(frame.order[0]) && validId(frame.order[1]) && frame.order[0] !== frame.order[1] &&
Array.isArray(frame.passed) && frame.passed.length <= 2 &&
(frame.passed.length === 0 || frame.passed[0] === frame.order[0]) &&
(frame.passed.length < 2 || frame.passed[1] === frame.order[1]) &&
['choose', 'declared', 'active', 'canceled', 'passed'].includes(frame.stage),
'Borrowed Voice lost its original event or ordered opportunity.',
);
if (frame.stage === 'choose' || frame.stage === 'passed') {
requireVoice(
frame.owner === null && frame.voice === null &&
(frame.stage === 'passed' ? frame.passed.length === 2 : frame.passed.length < 2),
'An undecided or all-passed borrowed Voice cannot contain a declaration.',
);
return;
}
requireVoice(
frame.passed.length < 2 && frame.owner === frame.order[frame.passed.length] &&
frame.voice && typeof frame.voice === 'object' && !Array.isArray(frame.voice) &&
Object.keys(frame.voice).sort().join(',') === 'kind,must,target' &&
frame.voice.target === frame.order[1 - frame.passed.length] &&
typeof frame.voice.kind === 'string' && voiceChoices(true).includes(frame.voice.kind) &&
typeof frame.voice.must === 'boolean',
'Borrowed Voice needs one ordered owner and a canonical opposing command.',
);
}
export function validateNexusBorrowedVoice(
context: NexusBorrowedVoiceContext,
frame: NexusBorrowedVoiceFrame,
): void {
validateContext(context);
validateFrame(frame);
requireVoice(
frame.turn === context.turn && frame.battle === context.battle.event &&
frame.order[0] === context.battle.attacker && frame.order[1] === context.battle.defender,
'Borrowed Voice must remain bound to its original battle and combatants.',
);
}
export function createNexusBorrowedVoice(
context: NexusBorrowedVoiceContext,
): NexusBorrowedVoiceFrame {
validateContext(context);
return {
event: event(context.turn, context.battle.event),
turn: context.turn,
battle: context.battle.event,
order: [context.battle.attacker, context.battle.defender],
passed: [],
stage: 'choose',
owner: null,
voice: null,
};
}
export function currentNexusBorrowedVoiceOwner(frame: NexusBorrowedVoiceFrame): string | null {
validateFrame(frame);
return frame.stage === 'choose' ? frame.order[frame.passed.length] : null;
}
export function passNexusBorrowedVoice(
frame: NexusBorrowedVoiceFrame,
actor: string,
): NexusBorrowedVoiceFrame {
requireVoice(
currentNexusBorrowedVoiceOwner(frame) === actor && frame.stage === 'choose',
'Only the current combatant may continue past borrowed Voice.',
);
const passed = [...frame.passed, actor];
return {
...frame,
order: [...frame.order],
passed,
stage: passed.length === 2 ? 'passed' : 'choose',
};
}
export function declareNexusBorrowedVoice(
frame: NexusBorrowedVoiceFrame,
actor: string,
voice: NonNullable<NexusBorrowedVoiceFrame['voice']>,
): NexusBorrowedVoiceFrame {
requireVoice(
currentNexusBorrowedVoiceOwner(frame) === actor && frame.stage === 'choose',
'Only the current combatant may declare borrowed Voice once.',
);
const next: NexusBorrowedVoiceFrame = {
...frame,
order: [...frame.order],
passed: [...frame.passed],
stage: 'declared',
owner: actor,
voice: voice && { ...voice },
};
validateFrame(next);
return next;
}
/** Settle the original Voice response exactly once; cancellation never reopens the queue. */
export function finishNexusBorrowedVoice(
frame: NexusBorrowedVoiceFrame,
canceled: boolean,
): NexusBorrowedVoiceFrame {
validateFrame(frame);
requireVoice(
frame.stage === 'declared' && typeof canceled === 'boolean',
'Only an unsettled borrowed Voice declaration can finish its response.',
);
return {
...frame,
order: [...frame.order],
passed: [...frame.passed],
stage: canceled ? 'canceled' : 'active',
voice: { ...frame.voice! },
};
}