Files
Dune/game/nexus-face-dancers.ts

219 lines
6.5 KiB
TypeScript

import {
validateNexusTraitorSnapshot,
type NexusTraitorSnapshot,
} from './nexus-traitor-exchange';
export type NexusFaceDancerReceipt = {
version: 1;
event: string;
owner: string;
turn: number;
phase: number;
stage: 'complete';
source: NexusTraitorSnapshot;
replaced: string[];
drawn: string[];
settledReserve: string[];
signature: string;
};
export type NexusFaceDancerResult = {
state: NexusTraitorSnapshot;
receipt: NexusFaceDancerReceipt;
};
function requireDancers(
condition: unknown,
message: string,
): asserts condition {
if (!condition) 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 exact(value: unknown, keys: readonly string[]): boolean {
return (
!!value &&
typeof value === 'object' &&
!Array.isArray(value) &&
[Object.prototype, null].includes(Object.getPrototypeOf(value)) &&
Object.keys(value).sort().join(',') === [...keys].sort().join(',')
);
}
function identities(value: unknown): value is string[] {
return (
Array.isArray(value) &&
value.every(identifier) &&
new Set(value).size === value.length
);
}
function same(one: readonly string[], two: readonly string[]): boolean {
return one.length === two.length && one.every((value, i) => value === two[i]);
}
function validateInput(
input: Pick<NexusFaceDancerReceipt, 'event' | 'owner' | 'turn' | 'phase'>,
): void {
requireDancers(
exact(input, ['event', 'owner', 'turn', 'phase']) &&
identifier(input.event) &&
identifier(input.owner) &&
Number.isSafeInteger(input.turn) &&
input.turn > 0 &&
Number.isSafeInteger(input.phase) &&
input.phase >= 0 &&
input.phase <= 8,
'Invalid Tleilaxu Nexus replacement event.',
);
}
function replacementGroup(
state: NexusTraitorSnapshot,
owner: string,
): string[] {
const player = state.players.find((p) => p.id === owner);
requireDancers(
player?.faction === 'tleilaxu' && player.faceDancers,
'Tleilaxu Cunning requires its native Face Dancer owner.',
);
const revealed = player.faceDancers
.filter((card) => card.revealed)
.map((card) => card.leader);
requireDancers(
revealed.length > 0,
'Tleilaxu Cunning requires a revealed Face Dancer.',
);
requireDancers(
state.reserve.length >= revealed.length,
'Draw every replacement before returning the revealed Face Dancers.',
);
return revealed;
}
function signature(receipt: Omit<NexusFaceDancerReceipt, 'signature'>): string {
return JSON.stringify([
'nexusFaceDancers',
receipt.version,
receipt.event,
receipt.owner,
receipt.turn,
receipt.phase,
receipt.stage,
receipt.source.reserve,
receipt.source.players.map((p) => [
p.id,
p.faction,
p.traitors,
p.faceDancers?.map((card) => [card.leader, card.revealed]) ?? null,
]),
receipt.replaced,
receipt.drawn,
receipt.settledReserve,
...(Object.hasOwn(receipt.source, 'removed') ? [receipt.source.removed] : []),
]);
}
function resultingState(receipt: NexusFaceDancerReceipt): NexusTraitorSnapshot {
const state = structuredClone(receipt.source);
const owner = state.players.find((p) => p.id === receipt.owner)!;
owner.faceDancers = [
...owner.faceDancers!.filter((card) => !card.revealed),
...receipt.drawn.map((leader) => ({ leader, revealed: false })),
];
state.reserve = [...receipt.settledReserve];
return state;
}
/** Intrinsic original-event evidence, independent of later live custody. */
export function validateNexusFaceDancerHistory(
universe: readonly string[],
receipt: NexusFaceDancerReceipt,
): void {
requireDancers(
exact(receipt, [
'version',
'event',
'owner',
'turn',
'phase',
'stage',
'source',
'replaced',
'drawn',
'settledReserve',
'signature',
]) &&
receipt.version === 1 &&
receipt.stage === 'complete' &&
identities(receipt.replaced) &&
identities(receipt.drawn) &&
identities(receipt.settledReserve),
'Invalid saved Tleilaxu Nexus replacement.',
);
validateInput({
event: receipt.event,
owner: receipt.owner,
turn: receipt.turn,
phase: receipt.phase,
});
validateNexusTraitorSnapshot(receipt.source, universe);
const group = replacementGroup(receipt.source, receipt.owner);
requireDancers(
same(receipt.replaced, group) &&
same(receipt.drawn, receipt.source.reserve.slice(0, group.length)),
'The Nexus replacement must preserve the entire original revealed batch and its prior draw.',
);
requireDancers(
same(
[...receipt.settledReserve].sort(),
[...receipt.source.reserve.slice(group.length), ...group].sort(),
),
'The revealed Face Dancers return only after their replacements leave the deck.',
);
requireDancers(
receipt.signature === signature(receipt),
'The Tleilaxu Nexus replacement has lost its original history.',
);
validateNexusTraitorSnapshot(resultingState(receipt), universe);
}
/** Replace the full revealed batch atomically. No optional subset and no draw
* from the just-set-aside cards. Timing and Nexus-card use belong to the engine. */
export function replaceNexusFaceDancers(
state: NexusTraitorSnapshot,
universe: readonly string[],
input: Pick<NexusFaceDancerReceipt, 'event' | 'owner' | 'turn' | 'phase'>,
rng: () => number,
): NexusFaceDancerResult {
validateInput(input);
validateNexusTraitorSnapshot(state, universe);
const replaced = replacementGroup(state, input.owner);
requireDancers(
typeof rng === 'function',
'A Nexus replacement needs valid shuffle randomness.',
);
const drawn = state.reserve.slice(0, replaced.length);
const reserve = [...state.reserve.slice(replaced.length), ...replaced];
for (let i = reserve.length - 1; i > 0; i--) {
const value = rng();
requireDancers(
typeof value === 'number' &&
Number.isFinite(value) &&
value >= 0 &&
value < 1,
'Invalid Tleilaxu Nexus shuffle randomness.',
);
const j = Math.floor(value * (i + 1));
[reserve[i], reserve[j]] = [reserve[j], reserve[i]];
}
const receipt: NexusFaceDancerReceipt = {
version: 1,
...input,
stage: 'complete',
source: structuredClone(state),
replaced,
drawn,
settledReserve: reserve,
signature: '',
};
receipt.signature = signature(receipt);
validateNexusFaceDancerHistory(universe, receipt);
return { state: resultingState(receipt), receipt };
}