import { FACTIONS, type FactionId } from './catalog'; export type NexusExchangeFaceDancer = { leader: string; revealed: boolean }; export type NexusExchangePlayer = { id: string; faction: FactionId; traitors: string[]; faceDancers?: NexusExchangeFaceDancer[]; }; export type NexusTraitorSnapshot = { reserve: string[]; players: NexusExchangePlayer[]; /** Physical cards permanently set aside, not available for any draw or return. */ removed?: string[]; }; export type NexusTraitorExchange = { version: 1; event: string; owner: string; mode: 'cunning' | 'secretAlly'; turn: number; phase: number; stage: 'return' | 'complete'; drawn: string[]; before: string[] | NexusExchangeFaceDancer[]; returned: string[]; source: { ownerFaction: FactionId; reserve: string[]; others: NexusExchangePlayer[]; removed?: string[]; }; settledReserve?: string[]; signature: string; }; export type NexusTraitorExchangeResult = { state: NexusTraitorSnapshot; exchange: NexusTraitorExchange; }; function requireExchange( condition: unknown, message: string, ): asserts condition { if (!condition) throw new Error(message); } function plain(value: unknown): value is Record { return ( !!value && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value)) ); } function exact(value: unknown, keys: string[]): boolean { return ( plain(value) && Object.keys(value).sort().join(',') === [...keys].sort().join(',') ); } function identifier(value: unknown): value is string { return ( typeof value === 'string' && value.trim().length > 0 && !['__proto__', 'constructor', 'prototype'].includes(value) ); } function ids(value: unknown): value is string[] { return ( Array.isArray(value) && value.every(identifier) && new Set(value).size === value.length ); } function equal(one: unknown, two: unknown): boolean { return JSON.stringify(one) === JSON.stringify(two); } function playerTuple(player: NexusExchangePlayer): unknown[] { return [ player.id, player.faction, player.traitors, player.faceDancers?.map((card) => [card.leader, card.revealed]) ?? null, ]; } function validatePlayer(player: NexusExchangePlayer): void { requireExchange( exact(player, [ 'id', 'faction', 'traitors', ...(Object.hasOwn(player, 'faceDancers') ? ['faceDancers'] : []), ]) && identifier(player.id) && FACTIONS.some((f) => f.id === player.faction) && ids(player.traitors), 'Invalid traitor exchange seat custody.', ); if (player.faction === 'tleilaxu') { requireExchange( player.traitors.length === 0 && Array.isArray(player.faceDancers) && player.faceDancers.every( (card) => exact(card, ['leader', 'revealed']) && identifier(card.leader) && typeof card.revealed === 'boolean', ) && new Set(player.faceDancers.map((card) => card.leader)).size === player.faceDancers.length, 'Tleilaxu must preserve physical Face Dancer identities and statuses.', ); } else requireExchange( !Object.hasOwn(player, 'faceDancers'), 'Only Tleilaxu may hold Face Dancers.', ); } export function validateNexusTraitorSnapshot( state: NexusTraitorSnapshot, universe: readonly string[], ): void { requireExchange( ids(universe) && universe.length > 0 && exact(state, ['reserve', 'players', ...(Object.hasOwn(state, 'removed') ? ['removed'] : [])]) && ids(state.reserve) && (!Object.hasOwn(state, 'removed') || ids(state.removed)) && Array.isArray(state.players) && state.players.length >= 2 && state.players.length <= 6, 'Invalid traitor exchange physical inventory.', ); for (const player of state.players) validatePlayer(player); requireExchange( new Set(state.players.map((p) => p.id)).size === state.players.length && new Set(state.players.map((p) => p.faction)).size === state.players.length, 'Traitor exchange seats must have distinct identities and factions.', ); const physical = [ ...state.reserve, ...(state.removed ?? []), ...state.players.flatMap((p) => [ ...p.traitors, ...(p.faceDancers ?? []).map((c) => c.leader), ]), ]; requireExchange( physical.length === universe.length && new Set(physical).size === physical.length && physical.every((card) => universe.includes(card)), 'The complete physical traitor census must remain intact.', ); } function count(mode: NexusTraitorExchange['mode']): number { return mode === 'cunning' ? 1 : 2; } function hand( player: NexusExchangePlayer, ): string[] | NexusExchangeFaceDancer[] { return player.faction === 'tleilaxu' ? player.faceDancers! : player.traitors; } function returnable(value: string[] | NexusExchangeFaceDancer[]): string[] { return value .filter((card) => typeof card === 'string' || !card.revealed) .map((card) => (typeof card === 'string' ? card : card.leader)); } function setHand( player: NexusExchangePlayer, value: (string | NexusExchangeFaceDancer)[], ): void { if (player.faction === 'tleilaxu') player.faceDancers = structuredClone(value) as NexusExchangeFaceDancer[]; else player.traitors = [...value] as string[]; } function augmented( exchange: NexusTraitorExchange, player: NexusExchangePlayer, ): string[] | NexusExchangeFaceDancer[] { return player.faction === 'tleilaxu' ? [ ...(structuredClone(exchange.before) as NexusExchangeFaceDancer[]), ...exchange.drawn.map((leader) => ({ leader, revealed: false })), ] : [...(exchange.before as string[]), ...exchange.drawn]; } function signature(exchange: Omit): string { return JSON.stringify([ 'nexusTraitorExchange', exchange.version, exchange.event, exchange.owner, exchange.mode, exchange.turn, exchange.phase, exchange.stage, exchange.drawn, exchange.before.map((card) => typeof card === 'string' ? card : [card.leader, card.revealed], ), exchange.returned, exchange.source.ownerFaction, exchange.source.reserve, exchange.source.others.map(playerTuple), exchange.settledReserve ?? null, ...(Object.hasOwn(exchange.source, 'removed') ? [exchange.source.removed] : []), ]); } function validateMode( state: NexusTraitorSnapshot, owner: string, mode: NexusTraitorExchange['mode'], ): NexusExchangePlayer { const player = state.players.find((p) => p.id === owner); requireExchange( player && ['cunning', 'secretAlly'].includes(mode), 'Choose a supported Harkonnen Nexus exchange.', ); requireExchange( mode === 'cunning' ? player.faction === 'harkonnen' : !state.players.some((p) => p.faction === 'harkonnen'), 'The Harkonnen Nexus exchange must match the seated factions.', ); return player; } export function validateNexusTraitorExchange( state: NexusTraitorSnapshot, universe: readonly string[], exchange: NexusTraitorExchange, ): void { validateNexusTraitorSnapshot(state, universe); requireExchange( exact(exchange, [ 'version', 'event', 'owner', 'mode', 'turn', 'phase', 'stage', 'drawn', 'before', 'returned', 'source', 'signature', ...(Object.hasOwn(exchange, 'settledReserve') ? ['settledReserve'] : []), ]) && exchange.version === 1 && identifier(exchange.event) && identifier(exchange.owner) && Number.isSafeInteger(exchange.turn) && exchange.turn > 0 && Number.isSafeInteger(exchange.phase) && exchange.phase >= 0 && exchange.phase <= 8 && ['return', 'complete'].includes(exchange.stage) && ids(exchange.drawn) && ids(exchange.returned) && Array.isArray(exchange.before) && exact(exchange.source, ['ownerFaction', 'reserve', 'others', ...(Object.hasOwn(exchange.source, 'removed') ? ['removed'] : [])]) && ids(exchange.source.reserve) && (!Object.hasOwn(exchange.source, 'removed') || ids(exchange.source.removed)) && Array.isArray(exchange.source.others), 'Invalid saved Nexus traitor exchange.', ); const player = validateMode(state, exchange.owner, exchange.mode); requireExchange( exchange.drawn.length === count(exchange.mode) && equal( exchange.drawn, exchange.source.reserve.slice(0, count(exchange.mode)), ), 'A Nexus exchange must draw its full original group before returning cards.', ); requireExchange( exchange.source.ownerFaction === player.faction, 'The exchange owner faction must match its original custody.', ); const originalOwner = structuredClone(player); setHand(originalOwner, exchange.before); validatePlayer(originalOwner); const original: NexusTraitorSnapshot = { reserve: exchange.source.reserve, ...(Object.hasOwn(exchange.source, 'removed') ? { removed: exchange.source.removed } : {}), players: state.players.map((p) => p.id === exchange.owner ? originalOwner : exchange.source.others.find((other) => other.id === p.id)!, ), }; requireExchange( Object.hasOwn(state, 'removed') === Object.hasOwn(exchange.source, 'removed') && equal(state.removed, exchange.source.removed) && exchange.source.others.length === state.players.length - 1 && equal( exchange.source.others.map(playerTuple), state.players.filter((p) => p.id !== exchange.owner).map(playerTuple), ), 'A Nexus exchange cannot alter another player’s held cards.', ); validateNexusTraitorSnapshot(original, universe); const pool = augmented(exchange, player); requireExchange( exchange.returned.every((card) => returnable(pool).includes(card)), 'Return only cards from the augmented owner hand.', ); if (exchange.stage === 'return') requireExchange( exchange.returned.length === 0 && !Object.hasOwn(exchange, 'settledReserve') && equal(hand(player), pool) && equal( state.reserve, exchange.source.reserve.slice(count(exchange.mode)), ), 'The pending exchange must preserve every drawn and originally held card.', ); else { const retained = pool.filter( (card) => !exchange.returned.includes( typeof card === 'string' ? card : card.leader, ), ); requireExchange( exchange.returned.length === count(exchange.mode) && ids(exchange.settledReserve) && equal(hand(player), retained) && equal(state.reserve, exchange.settledReserve) && equal( [...exchange.settledReserve].sort(), [ ...exchange.source.reserve.slice(count(exchange.mode)), ...exchange.returned, ].sort(), ), 'The completed exchange must retain its exact selection and shuffled reserve.', ); } requireExchange( exchange.signature === signature(exchange), 'The Nexus traitor exchange has lost its original history.', ); } export function beginNexusTraitorExchange( state: NexusTraitorSnapshot, universe: readonly string[], input: Pick< NexusTraitorExchange, 'event' | 'owner' | 'mode' | 'turn' | 'phase' >, ): NexusTraitorExchangeResult { validateNexusTraitorSnapshot(state, universe); const player = validateMode(state, input.owner, input.mode); requireExchange( state.reserve.length >= count(input.mode), 'The Traitor Deck must contain the full Nexus draw before any return.', ); const exchange: NexusTraitorExchange = { version: 1, ...input, stage: 'return', drawn: state.reserve.slice(0, count(input.mode)), before: structuredClone(hand(player)), returned: [], source: { ownerFaction: player.faction, reserve: [...state.reserve], ...(Object.hasOwn(state, 'removed') ? { removed: [...state.removed!] } : {}), others: structuredClone( state.players.filter((p) => p.id !== input.owner), ), }, signature: '', }; const next = structuredClone(state); next.reserve = next.reserve.slice(count(input.mode)); setHand( next.players.find((p) => p.id === input.owner)!, augmented(exchange, player), ); exchange.signature = signature(exchange); validateNexusTraitorExchange(next, universe, exchange); return { state: next, exchange }; } export function finishNexusTraitorExchange( state: NexusTraitorSnapshot, universe: readonly string[], exchange: NexusTraitorExchange, selected: readonly string[], rng: () => number, ): NexusTraitorExchangeResult { validateNexusTraitorExchange(state, universe, exchange); requireExchange( exchange.stage === 'return', 'This Nexus exchange is already complete.', ); const player = state.players.find((p) => p.id === exchange.owner)!; requireExchange( ids(selected) && selected.length === count(exchange.mode) && selected.every((card) => returnable(hand(player)).includes(card)), 'Return the exact number of distinct cards from your augmented hand.', ); const next = structuredClone(state); const retained = hand(player).filter( (card) => !selected.includes(typeof card === 'string' ? card : card.leader), ); setHand( next.players.find((p) => p.id === exchange.owner)!, retained, ); next.reserve.push(...selected); for (let i = next.reserve.length - 1; i > 0; i--) { const random = rng(); requireExchange( typeof random === 'number' && Number.isFinite(random) && random >= 0 && random < 1, 'Invalid Nexus exchange shuffle randomness.', ); const j = Math.floor(random * (i + 1)); [next.reserve[i], next.reserve[j]] = [next.reserve[j], next.reserve[i]]; } const complete: NexusTraitorExchange = { ...structuredClone(exchange), stage: 'complete', returned: [...selected], settledReserve: [...next.reserve], signature: '', }; complete.signature = signature(complete); validateNexusTraitorExchange(next, universe, complete); return { state: next, exchange: complete }; } /** Completed receipts prove the original transition without freezing later * global custody. Validate the live census separately after subsequent play. */ export function validateNexusTraitorHistory( universe: readonly string[], exchange: NexusTraitorExchange, ): void { requireExchange( exchange?.stage === 'complete' && exchange.source && Array.isArray(exchange.before) && Array.isArray(exchange.returned), 'Only a completed Nexus exchange has an independent history.', ); const owner: NexusExchangePlayer = { id: exchange.owner, faction: exchange.source.ownerFaction, traitors: [], }; setHand(owner, exchange.before); const retained = augmented(exchange, owner).filter( (card) => !exchange.returned.includes( typeof card === 'string' ? card : card.leader, ), ); setHand(owner, retained); validateNexusTraitorExchange( { reserve: exchange.settledReserve!, ...(Object.hasOwn(exchange.source, 'removed') ? { removed: [...exchange.source.removed!] } : {}), players: [owner, ...structuredClone(exchange.source.others)], }, universe, exchange, ); } export function nexusTraitorReturnChoices( state: NexusTraitorSnapshot, universe: readonly string[], exchange: NexusTraitorExchange, ): string[] { validateNexusTraitorExchange(state, universe, exchange); return exchange.stage === 'return' ? returnable(hand(state.players.find((p) => p.id === exchange.owner)!)) : []; }