import { treacheryDeck, type Card } from './cards'; import { FACTIONS } from './catalog'; import { ecazTreacheryCards } from './ecaz-cards'; import { richeseCards } from './richese-cards'; import { nexusCardMode, validateNexusCards, validateNexusPlayers, type NexusPlayer, type NexusState, } from './nexus-cards'; export type IxianBetrayalKind = 'bidding' | 'technology'; /** Independently produced by the native adapter after its original counters. * Native quotes, not the saved source, authorize draw/exchange continuations. */ export type IxianBetrayalAuthority = { kind: IxianBetrayalKind; provider: string; nativeWindow: 'ixAuction' | 'ixTechnology'; nativeContext: string; nativeRequired: readonly string[]; nativePassed: readonly string[]; }; /** Public selections, never inferred from a hidden hand or a historical receipt. * Omission retains the original native Ixian deck and roster contract. */ export type IxianBetrayalSelection = { expansions?: readonly string[]; ecazTreachery?: boolean; }; export type IxianBetrayalPhysicalSelection = IxianBetrayalSelection & { /** History has no live roster; its caller supplies public native Richese presence. */ richese?: boolean; }; export type IxianBetrayalContext = IxianBetrayalSelection & { status: string; phase: number; turn: number; advanced: boolean; sequence: number; players: readonly NexusPlayer[]; cards: NexusState; /** Every live physical Treachery location once; no auction receipt aliases. */ physicalCards: readonly Card[]; }; export type IxianBetrayalSource = IxianBetrayalAuthority & { version: 1; event: string; turn: number; phase: 3; advanced: boolean; sequence: number; required: readonly string[]; eligible: string | null; parent: string; signature: string; }; export type IxianBetrayalCursor = { sequence: number; signature: string }; export type IxianBetrayalReceipt = { source: IxianBetrayalSource; outcome: 'pass' | 'use'; holder: string | null; previous: string; signature: string; }; export class IxianBetrayalError extends Error { constructor(message: string) { super(message); this.name = 'IxianBetrayalError'; } } const genesis = 'ixian-betrayal:0'; const plain = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value)); const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0 && !['__proto__', 'constructor', 'prototype'].includes(value); const whole = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; const array = (value: unknown): boolean => Array.isArray(value); function requireBetrayal(value: unknown, message: string): asserts value { if (!value) throw new IxianBetrayalError(message); } function exact(value: unknown, keys: readonly string[]): value is Record { return plain(value) && Object.keys(value).sort().join(',') === [...keys].sort().join(','); } function members(value: unknown): value is readonly string[] { return Array.isArray(value) && value.every(text) && new Set(value).size === value.length; } function sameMembers(one: readonly string[], two: readonly string[]): boolean { return one.length === two.length && one.every((id, index) => id === two[index]); } function selectedExpansions(selection: IxianBetrayalSelection): readonly string[] { const expansions = selection.expansions ?? ['ix']; requireBetrayal(Array.isArray(expansions) && expansions.length >= 1 && expansions.length <= 3 && expansions.includes('ix') && expansions.every((expansion, index) => (expansion === 'ix' || expansion === 'choam' || (expansion === 'ecaz' && selection.ecazTreachery === true)) && expansions.indexOf(expansion) === index) && (selection.ecazTreachery === undefined || typeof selection.ecazTreachery === 'boolean'), 'Ixian Betrayal requires an original selected Ixian deck, optionally CHOAM or independent Ecaz cards and factions.'); return expansions; } function canonicalCard(value: unknown, canonical: ReadonlyMap): value is Card { if (!plain(value) || !text(value.id)) return false; const expected = canonical.get(value.id); return !!expected && exact(value, ['id', 'name', 'kind', ...(expected.effect === undefined ? [] : ['effect'])]) && value.name === expected.name && value.kind === expected.kind && value.effect === expected.effect; } function validatePhysical(cards: readonly Card[], selection: IxianBetrayalPhysicalSelection = {}): void { const expansions = selectedExpansions(selection); requireBetrayal((selection.richese === undefined || typeof selection.richese === 'boolean') && (!selection.richese || expansions.includes('choam')), 'Ixian Betrayal needs the selected CHOAM deck for its native Richese cache.'); const canonical = new Map([ ...treacheryDeck(expansions), ...(selection.ecazTreachery ? ecazTreacheryCards() : []), ...(selection.richese ? richeseCards() : []), ].map(card => [card.id, card])); requireBetrayal(Array.isArray(cards) && cards.length === canonical.size && cards.every(card => canonicalCard(card, canonical)) && new Set(cards.map(card => card.id)).size === canonical.size, 'Ixian Betrayal requires the complete canonical selected deck and native cache in unique physical custody.'); } function validateRoster(players: readonly NexusPlayer[], selection: IxianBetrayalPhysicalSelection = {}): void { const expansions = selectedExpansions(selection); validateNexusPlayers(players); requireBetrayal(players.every(player => FACTIONS.some(faction => faction.id === player.faction && (faction.expansion === 'base' || expansions.includes(faction.expansion)))) && players.filter(player => player.faction === 'ixians').length === 1 && (selection.richese === undefined || selection.richese === players.some(player => player.faction === 'richese')), 'Ixian Betrayal needs its native Ixian and registered classic or selected family seats.'); } /** A neutral acknowledgment depends on public held-card presence, never its face. */ export function ixianBetrayalResponders(cards: NexusState, players: readonly NexusPlayer[], selection: IxianBetrayalPhysicalSelection = {}): string[] { validateRoster(players, selection); validateNexusCards(cards, players); return players.filter(player => !player.ally && player.faction !== 'ixians' && cards.hands[player.id] !== null).map(player => player.id); } export function ixianBetrayalEligible( cards: NexusState, players: readonly NexusPlayer[], holder: string, selection: IxianBetrayalPhysicalSelection = {}, ): boolean { validateRoster(players, selection); validateNexusCards(cards, players); const seat = players.find(player => player.id === holder); return !!seat && !seat.ally && seat.faction !== 'ixians' && cards.hands[holder] === 'ixians' && nexusCardMode('ixians', seat.faction, players.map(player => player.faction)) === 'betrayal'; } /** Only public original native attempt facts appear in the event. */ export function ixianBetrayalEvent( turn: number, sequence: number, kind: IxianBetrayalKind, provider: string, ): string { requireBetrayal(whole(turn) && turn > 0 && whole(sequence) && text(provider) && (kind === 'bidding' || kind === 'technology'), 'Ixian Betrayal needs its original public event.'); return JSON.stringify(['nexusIxianBetrayal', turn, sequence, kind, provider]); } function validateAuthority(authority: IxianBetrayalAuthority): void { requireBetrayal(exact(authority, ['kind', 'provider', 'nativeWindow', 'nativeContext', 'nativeRequired', 'nativePassed']) && text(authority.provider) && text(authority.nativeContext) && members(authority.nativeRequired) && members(authority.nativePassed) && authority.nativeRequired.every(id => authority.nativePassed.includes(id)) && ((authority.kind === 'bidding' && authority.nativeWindow === 'ixAuction') || (authority.kind === 'technology' && authority.nativeWindow === 'ixTechnology')), 'Ixian Betrayal must follow the settled original native counter window.'); } function sourceAuthority(source: IxianBetrayalSource): IxianBetrayalAuthority { return { kind: source.kind, provider: source.provider, nativeWindow: source.nativeWindow, nativeContext: source.nativeContext, nativeRequired: source.nativeRequired, nativePassed: source.nativePassed }; } function sourceSignature(source: IxianBetrayalSource): string { return JSON.stringify(['ixianBetrayalSource', source.version, source.event, source.turn, source.phase, source.advanced, source.sequence, source.kind, source.provider, source.nativeWindow, source.nativeContext, source.nativeRequired, source.nativePassed, source.required, source.eligible, source.parent]); } function savedSource(value: unknown): value is IxianBetrayalSource { return exact(value, ['version', 'event', 'turn', 'phase', 'advanced', 'sequence', 'kind', 'provider', 'nativeWindow', 'nativeContext', 'nativeRequired', 'nativePassed', 'required', 'eligible', 'parent', 'signature']) && value.version === 1 && whole(value.turn) && value.turn > 0 && value.phase === 3 && whole(value.sequence) && typeof value.advanced === 'boolean' && text(value.event) && text(value.parent) && text(value.signature) && members(value.required) && (value.eligible === null || text(value.eligible)) && text(value.provider) && text(value.nativeContext) && members(value.nativeRequired) && members(value.nativePassed) && (value.kind === 'bidding' || value.kind === 'technology') && (value.nativeWindow === 'ixAuction' || value.nativeWindow === 'ixTechnology'); } function validateSavedSource(value: unknown): asserts value is IxianBetrayalSource { requireBetrayal(savedSource(value), 'Ixian Betrayal lost its original canonical source.'); const source = value; validateAuthority(sourceAuthority(source)); requireBetrayal((source.kind !== 'technology' || source.advanced) && !source.required.includes(source.provider) && (source.eligible === null || source.required.includes(source.eligible)) && source.event === ixianBetrayalEvent(source.turn, source.sequence, source.kind, source.provider) && source.signature === sourceSignature(source), 'Ixian Betrayal changed its original event or native source.'); } function validateContext(ctx: IxianBetrayalContext): void { requireBetrayal(plain(ctx) && ctx.status === 'playing' && ctx.phase === 3 && whole(ctx.turn) && ctx.turn > 0 && whole(ctx.sequence) && typeof ctx.advanced === 'boolean', 'Ixian Betrayal needs its current Bidding phase and independent cursor.'); validateRoster(ctx.players, ctx); validateNexusCards(ctx.cards, ctx.players); validatePhysical(ctx.physicalCards, {...ctx, richese: ctx.players.some(player => player.faction === 'richese')}); } export function validateIxianBetrayalSource( ctx: IxianBetrayalContext, source: IxianBetrayalSource, authority: IxianBetrayalAuthority, parent: string, ): void { validateSavedSource(source); validateContext(ctx); validateAuthority(authority); requireBetrayal(source.turn === ctx.turn && source.sequence === ctx.sequence && source.advanced === ctx.advanced && source.parent === parent && text(parent) && ctx.players.find(player => player.id === source.provider)?.faction === 'ixians' && source.nativeRequired.every(id => ctx.players.some(player => player.id === id)) && source.nativePassed.every(id => ctx.players.some(player => player.id === id)) && source.kind === authority.kind && source.provider === authority.provider && source.nativeWindow === authority.nativeWindow && source.nativeContext === authority.nativeContext && sameMembers(source.nativeRequired, authority.nativeRequired) && sameMembers(source.nativePassed, authority.nativePassed) && sameMembers(source.required, ixianBetrayalResponders(ctx.cards, ctx.players, ctx)) && source.eligible === (ctx.players.find(player => !player.ally && player.faction !== 'ixians' && ctx.cards.hands[player.id] === 'ixians')?.id ?? null), 'Ixian Betrayal no longer matches its current native attempt, parent or public responders.'); } export function createIxianBetrayalSource( ctx: IxianBetrayalContext, authority: IxianBetrayalAuthority, parent: string, ): IxianBetrayalSource { validateContext(ctx); validateAuthority(authority); const source: IxianBetrayalSource = { ...authority, nativeRequired: [...authority.nativeRequired], nativePassed: [...authority.nativePassed], version: 1, event: ixianBetrayalEvent(ctx.turn, ctx.sequence, authority.kind, authority.provider), turn: ctx.turn, phase: 3, advanced: ctx.advanced, sequence: ctx.sequence, required: ixianBetrayalResponders(ctx.cards, ctx.players, ctx), eligible: ctx.players.find(player => !player.ally && player.faction !== 'ixians' && ctx.cards.hands[player.id] === 'ixians')?.id ?? null, parent, signature: '', }; source.signature = sourceSignature(source); validateIxianBetrayalSource(ctx, source, authority, parent); return source; } /** Validate before any Nexus cost or native continuation, including Pass. */ export function validateIxianBetrayalFrame( ctx: IxianBetrayalContext, source: IxianBetrayalSource, authority: IxianBetrayalAuthority, parent: string, required: readonly string[], passed: readonly string[], ): void { validateIxianBetrayalSource(ctx, source, authority, parent); requireBetrayal(members(required) && members(passed) && sameMembers(required, source.required) && passed.every(id => required.includes(id)), 'Ixian Betrayal lost its exact public required or passed membership.'); } export function initialIxianBetrayalCursor(): IxianBetrayalCursor { return { sequence: 0, signature: genesis }; } function validateCursor(value: unknown): asserts value is IxianBetrayalCursor { requireBetrayal(exact(value, ['sequence', 'signature']) && whole(value.sequence) && text(value.signature) && (value.sequence !== 0 || value.signature === genesis), 'Ixian Betrayal lost its independent closed-event cursor.'); } function copySource(source: IxianBetrayalSource): IxianBetrayalSource { return { ...source, nativeRequired: [...source.nativeRequired], nativePassed: [...source.nativePassed], required: [...source.required] }; } function receiptSignature(receipt: IxianBetrayalReceipt): string { return JSON.stringify(['ixianBetrayalReceipt', receipt.source.signature, receipt.outcome, receipt.holder, receipt.previous]); } /** A bounded previous-event link, not the previous receipt's full signature. */ function receiptHead(receipt: IxianBetrayalReceipt): string { return JSON.stringify(['ixianBetrayalHead', receipt.source.event, receipt.outcome, receipt.holder]); } export function closeIxianBetrayalSource( source: IxianBetrayalSource, outcome: 'pass' | 'use', holder: string | null, previous: IxianBetrayalCursor, ): { receipt: IxianBetrayalReceipt; cursor: IxianBetrayalCursor } { validateSavedSource(source); validateCursor(previous); requireBetrayal(source.sequence === previous.sequence && previous.sequence < Number.MAX_SAFE_INTEGER && (outcome === 'pass' ? holder === null : outcome === 'use' && text(holder) && holder === source.eligible), 'Ixian Betrayal cannot close twice or lose its actual response outcome.'); const receipt: IxianBetrayalReceipt = { source: copySource(source), outcome, holder, previous: previous.signature, signature: '', }; receipt.signature = receiptSignature(receipt); return { receipt, cursor: { sequence: previous.sequence + 1, signature: receiptHead(receipt) } }; } /** Historical custody is not current ownership: the physical Nexus or Treachery * cards may since have been discarded, transferred and recycled. These links * check consistency, not authentication of arbitrary rewritten saved games. */ export function validateIxianBetrayalHistory( history: readonly IxianBetrayalReceipt[], cursor: IxianBetrayalCursor, physical?: readonly Card[], selection: IxianBetrayalPhysicalSelection = {}, ): void { validateCursor(cursor); requireBetrayal(array(history) && history.length === cursor.sequence, 'Ixian Betrayal history has missing or duplicate closed attempts.'); if (physical !== undefined) validatePhysical(physical, selection); let previous = genesis, turn = 0; const events = new Set(); for (const [index, receipt] of history.entries()) { requireBetrayal(exact(receipt, ['source', 'outcome', 'holder', 'previous', 'signature']), 'Ixian Betrayal history has a malformed receipt.'); validateSavedSource(receipt.source); requireBetrayal(receipt.source.sequence === index && receipt.source.turn >= turn && !events.has(receipt.source.event) && receipt.previous === previous && (receipt.outcome === 'pass' ? receipt.holder === null : receipt.outcome === 'use' && text(receipt.holder) && receipt.holder === receipt.source.eligible) && receipt.signature === receiptSignature(receipt), 'Ixian Betrayal history changed its native attempt, outcome or continuation chain.'); events.add(receipt.source.event); previous = receiptHead(receipt); turn = receipt.source.turn; } requireBetrayal(cursor.signature === previous, 'Ixian Betrayal history no longer matches its independent cursor.'); }