281 lines
8.5 KiB
TypeScript
281 lines
8.5 KiB
TypeScript
import { FACTIONS, type FactionId } from './catalog';
|
|
|
|
/** Physical inventory is independent of seated factions and enabled modules. */
|
|
export const NEXUS_FACTIONS: readonly FactionId[] = Object.freeze(
|
|
FACTIONS.map((faction) => faction.id),
|
|
);
|
|
export type NexusPlayer = {
|
|
id: string;
|
|
faction: FactionId;
|
|
ally?: string | null;
|
|
};
|
|
export type NexusState = {
|
|
version: 1;
|
|
deck: FactionId[];
|
|
discard: FactionId[];
|
|
hands: Record<string, FactionId | null>;
|
|
};
|
|
export type NexusCardMode = 'cunning' | 'betrayal' | 'secretAlly';
|
|
export type NexusCardsView = {
|
|
card: FactionId | null;
|
|
deckCount: number;
|
|
discardCount: number;
|
|
held: Record<string, boolean>;
|
|
};
|
|
export class NexusCardsError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'NexusCardsError';
|
|
}
|
|
}
|
|
function requireCards(condition: unknown, message: string): asserts condition {
|
|
if (!condition) throw new NexusCardsError(message);
|
|
}
|
|
const faction = (value: unknown): value is FactionId =>
|
|
NEXUS_FACTIONS.includes(value as FactionId);
|
|
const identifier = (value: unknown): value is string =>
|
|
typeof value === 'string' &&
|
|
value.trim().length > 0 &&
|
|
!['__proto__', 'constructor', 'prototype'].includes(value);
|
|
function plain(value: unknown): value is Record<string, unknown> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
const prototype: unknown = Object.getPrototypeOf(value);
|
|
return prototype === Object.prototype || prototype === null;
|
|
}
|
|
export function validateNexusPlayers(players: readonly NexusPlayer[]): void {
|
|
requireCards(
|
|
Array.isArray(players) && players.length >= 2 && players.length <= 6,
|
|
'Nexus custody needs two through six seated players.',
|
|
);
|
|
const ids = new Set<string>(),
|
|
factions = new Set<FactionId>();
|
|
for (const player of players) {
|
|
requireCards(
|
|
plain(player) &&
|
|
identifier(player.id) &&
|
|
faction(player.faction) &&
|
|
!ids.has(player.id) &&
|
|
!factions.has(player.faction),
|
|
'Nexus custody needs distinct valid seated identities and factions.',
|
|
);
|
|
ids.add(player.id);
|
|
factions.add(player.faction);
|
|
}
|
|
for (const player of players)
|
|
requireCards(
|
|
player.ally === undefined ||
|
|
player.ally === null ||
|
|
(identifier(player.ally) &&
|
|
player.ally !== player.id &&
|
|
ids.has(player.ally)),
|
|
'A Nexus player has an invalid seated alliance partner.',
|
|
);
|
|
for (const player of players)
|
|
requireCards(
|
|
player.ally === undefined ||
|
|
player.ally === null ||
|
|
players.find((partner) => partner.id === player.ally)!.ally ===
|
|
player.id,
|
|
'Nexus alliances must identify reciprocal seated partners.',
|
|
);
|
|
}
|
|
|
|
/** No alias zones, unknown card, missing seat, duplicated identity or extra
|
|
* private fields may be reconstructed or silently normalized on read. */
|
|
export function validateNexusCards(
|
|
state: NexusState,
|
|
players: readonly NexusPlayer[],
|
|
): void {
|
|
validateNexusPlayers(players);
|
|
requireCards(
|
|
plain(state) &&
|
|
Object.keys(state).length === 4 &&
|
|
['version', 'deck', 'discard', 'hands'].every((key) =>
|
|
Object.hasOwn(state, key),
|
|
) &&
|
|
state.version === 1 &&
|
|
Array.isArray(state.deck) &&
|
|
Array.isArray(state.discard) &&
|
|
plain(state.hands),
|
|
'The saved Nexus inventory has an invalid shape.',
|
|
);
|
|
requireCards(
|
|
Object.keys(state.hands).length === players.length &&
|
|
players.every((player) => Object.hasOwn(state.hands, player.id)),
|
|
'Nexus hands must match the complete seated roster.',
|
|
);
|
|
const physical: FactionId[] = [];
|
|
for (const pile of [state.deck, state.discard])
|
|
for (const card of pile) {
|
|
requireCards(
|
|
faction(card),
|
|
'The Nexus inventory contains an unknown physical card.',
|
|
);
|
|
physical.push(card);
|
|
}
|
|
for (const player of players) {
|
|
const card = state.hands[player.id];
|
|
requireCards(
|
|
card === null || faction(card),
|
|
'Each Nexus hand holds at most one physical card.',
|
|
);
|
|
if (card !== null) physical.push(card);
|
|
}
|
|
requireCards(
|
|
physical.length === NEXUS_FACTIONS.length &&
|
|
new Set(physical).size === NEXUS_FACTIONS.length,
|
|
'The Nexus inventory must conserve exactly one card for each of the twelve factions.',
|
|
);
|
|
}
|
|
function shuffle(
|
|
cards: readonly FactionId[],
|
|
random: () => number,
|
|
): FactionId[] {
|
|
requireCards(
|
|
typeof random === 'function',
|
|
'Nexus shuffling needs server randomness.',
|
|
);
|
|
const result = [...cards];
|
|
for (let index = result.length - 1; index > 0; index--) {
|
|
const value = random();
|
|
requireCards(
|
|
Number.isFinite(value) && value >= 0 && value < 1,
|
|
'Nexus random draws must be finite from zero inclusive to one exclusive.',
|
|
);
|
|
const selected = Math.floor(value * (index + 1));
|
|
[result[index], result[selected]] = [result[selected], result[index]];
|
|
}
|
|
return result;
|
|
}
|
|
export function createNexusCards(
|
|
players: readonly NexusPlayer[],
|
|
random: () => number,
|
|
): NexusState {
|
|
validateNexusPlayers(players);
|
|
return {
|
|
version: 1,
|
|
deck: shuffle(NEXUS_FACTIONS, random),
|
|
discard: [],
|
|
hands: Object.fromEntries(players.map((player) => [player.id, null])),
|
|
};
|
|
}
|
|
function owner(
|
|
state: NexusState,
|
|
player: string,
|
|
players: readonly NexusPlayer[],
|
|
): NexusPlayer {
|
|
validateNexusCards(state, players);
|
|
const seat = players.find((candidate) => candidate.id === player);
|
|
requireCards(seat && identifier(player), 'Choose a seated Nexus card owner.');
|
|
return seat;
|
|
}
|
|
function requireUnallied(player: NexusPlayer): void {
|
|
requireCards(
|
|
player.ally === undefined || player.ally === null,
|
|
'An allied player cannot draw or replace a Nexus card.',
|
|
);
|
|
}
|
|
function drawInto(
|
|
state: NexusState,
|
|
player: string,
|
|
random: () => number,
|
|
): void {
|
|
if (!state.deck.length) {
|
|
requireCards(
|
|
state.discard.length > 0,
|
|
'There is no physical Nexus card to draw.',
|
|
);
|
|
state.deck = shuffle(state.discard, random);
|
|
state.discard = [];
|
|
}
|
|
state.hands[player] = state.deck.shift()!;
|
|
}
|
|
|
|
/** The engine authorizes the phase opportunity or immediate own-faction redraw.
|
|
* Drawing one's faction never discards it automatically in either rules mode. */
|
|
export function drawNexusCard(
|
|
state: NexusState,
|
|
player: string,
|
|
players: readonly NexusPlayer[],
|
|
random: () => number,
|
|
): NexusState {
|
|
requireUnallied(owner(state, player, players));
|
|
requireCards(
|
|
state.hands[player] === null,
|
|
'Discard a held Nexus card before drawing its replacement.',
|
|
);
|
|
const result = structuredClone(state);
|
|
drawInto(result, player, random);
|
|
return result;
|
|
}
|
|
export function replaceNexusCard(
|
|
state: NexusState,
|
|
player: string,
|
|
players: readonly NexusPlayer[],
|
|
random: () => number,
|
|
): NexusState {
|
|
requireUnallied(owner(state, player, players));
|
|
requireCards(
|
|
state.hands[player] !== null,
|
|
'Hold a Nexus card before replacing it.',
|
|
);
|
|
const result = structuredClone(state);
|
|
result.discard.push(result.hands[player]!);
|
|
result.hands[player] = null;
|
|
drawInto(result, player, random);
|
|
return result;
|
|
}
|
|
/** Used cards and cards forfeited upon joining any alliance take the same
|
|
* physical route. The caller separately proves which discard is mandatory. */
|
|
export function discardNexusCard(
|
|
state: NexusState,
|
|
player: string,
|
|
players: readonly NexusPlayer[],
|
|
): NexusState {
|
|
owner(state, player, players);
|
|
requireCards(
|
|
state.hands[player] !== null,
|
|
'Hold a Nexus card before discarding it.',
|
|
);
|
|
const result = structuredClone(state);
|
|
result.discard.push(result.hands[player]!);
|
|
result.hands[player] = null;
|
|
return result;
|
|
}
|
|
|
|
export function nexusCardMode(
|
|
card: FactionId,
|
|
ownerFaction: FactionId,
|
|
seatedFactions: readonly FactionId[],
|
|
): NexusCardMode {
|
|
requireCards(
|
|
faction(card) &&
|
|
faction(ownerFaction) &&
|
|
Array.isArray(seatedFactions) &&
|
|
seatedFactions.every(faction) &&
|
|
new Set(seatedFactions).size === seatedFactions.length &&
|
|
seatedFactions.includes(ownerFaction),
|
|
'Nexus effects need a known card and its actual seated faction roster.',
|
|
);
|
|
return card === ownerFaction
|
|
? 'cunning'
|
|
: seatedFactions.includes(card)
|
|
? 'betrayal'
|
|
: 'secretAlly';
|
|
}
|
|
export function projectNexusCards(
|
|
state: NexusState,
|
|
viewer: string,
|
|
players: readonly NexusPlayer[],
|
|
): NexusCardsView {
|
|
owner(state, viewer, players);
|
|
return {
|
|
card: state.hands[viewer],
|
|
deckCount: state.deck.length,
|
|
discardCount: state.discard.length,
|
|
held: Object.fromEntries(
|
|
players.map((player) => [player.id, state.hands[player.id] !== null]),
|
|
),
|
|
};
|
|
}
|