Files
Dune/game/nexus-choam.ts

152 lines
4.3 KiB
TypeScript

import type { FactionId } from './catalog';
export const CHOAM_NEXUS_EFFECTS = Object.freeze({
kulon: 'Kulon',
laLaLa: 'La La La',
gamont: 'Trip to Gamont',
baliset: 'Baliset',
jubba: 'Jubba Cloak',
kull: 'Kull Wahad',
} as const);
export type NexusChoamEffect = keyof typeof CHOAM_NEXUS_EFFECTS;
export type NexusChoamContext = {
turn: number;
players: readonly { id: string; faction: FactionId }[];
};
export type NexusChoamReceipt = {
version: 1;
event: string;
turn: number;
phase: number;
owner: string;
card: string;
effect: NexusChoamEffect;
roster: { id: string; faction: FactionId }[];
signature: string;
};
function requireChoam(value: unknown, message: string): asserts value {
if (!value) throw new Error(message);
}
function identifier(value: unknown): value is string {
return (
typeof value === 'string' &&
value.trim().length > 0 &&
!['__proto__', 'constructor', 'prototype'].includes(value)
);
}
function roster(context: NexusChoamContext) {
requireChoam(
Number.isSafeInteger(context.turn) &&
context.turn > 0 &&
Array.isArray(context.players) &&
context.players.length > 0 &&
context.players.every(
(p) => p && identifier(p.id) && identifier(p.faction),
) &&
new Set(context.players.map((p) => p.id)).size ===
context.players.length &&
new Set(context.players.map((p) => p.faction)).size ===
context.players.length,
'CHOAM Nexus needs its original faction roster and turn.',
);
return context.players
.map(({ id, faction }) => ({ id, faction }))
.sort((a, b) => a.id.localeCompare(b.id));
}
function phaseMatches(effect: NexusChoamEffect, phase: number): boolean {
if (!Number.isSafeInteger(phase) || phase < 0 || phase > 8) return false;
switch (effect) {
case 'kulon':
case 'baliset':
return phase === 5;
case 'laLaLa':
return phase === 4;
case 'gamont':
return phase === 8;
case 'jubba':
return phase === 0;
// Any phase can contain a Karama attempt; the caller authorizes that timing.
case 'kull':
return true;
default:
return false;
}
}
function signature(receipt: NexusChoamReceipt): string {
return JSON.stringify([
receipt.version,
receipt.event,
receipt.turn,
receipt.phase,
receipt.owner,
receipt.card,
receipt.effect,
receipt.roster.map((p) => [p.id, p.faction]),
]);
}
/** Historical evidence is independent of later card custody or alliances.
* Current eligibility, canonical card inventory and progress belong to the engine. */
export function validateNexusChoam(
context: NexusChoamContext,
receipt: NexusChoamReceipt,
): void {
const seats = roster(context);
requireChoam(
receipt &&
typeof receipt === 'object' &&
!Array.isArray(receipt) &&
[Object.prototype, null].includes(Object.getPrototypeOf(receipt)) &&
Object.keys(receipt).sort().join(',') ===
'card,effect,event,owner,phase,roster,signature,turn,version' &&
receipt.version === 1 &&
Number.isSafeInteger(receipt.turn) &&
receipt.turn > 0 &&
receipt.turn <= context.turn &&
identifier(receipt.card) &&
Object.hasOwn(CHOAM_NEXUS_EFFECTS, receipt.effect) &&
phaseMatches(receipt.effect, receipt.phase) &&
seats.some((p) => p.id === receipt.owner && p.faction === 'choam') &&
receipt.event ===
JSON.stringify([
'nexusChoam',
receipt.turn,
receipt.phase,
receipt.owner,
receipt.card,
receipt.effect,
]) &&
JSON.stringify(receipt.roster) === JSON.stringify(seats) &&
receipt.signature === signature(receipt),
'CHOAM Nexus has lost its original card, chosen effect, timing or owner.',
);
}
export function createNexusChoam(
context: NexusChoamContext,
owner: string,
phase: number,
card: string,
effect: NexusChoamEffect,
): NexusChoamReceipt {
const receipt: NexusChoamReceipt = {
version: 1,
event: JSON.stringify([
'nexusChoam',
context.turn,
phase,
owner,
card,
effect,
]),
turn: context.turn,
phase,
owner,
card,
effect,
roster: roster(context),
signature: '',
};
receipt.signature = signature(receipt);
validateNexusChoam(context, receipt);
return receipt;
}