import { baseDeck, leaders, type Card } from './cards'; import { ecazTreacheryCards } from './ecaz-cards'; import { nexusCardMode, validateNexusCards, validateNexusPlayers, type NexusPlayer, type NexusState, } from './nexus-cards'; import { validateNexusTraitorSnapshot, type NexusTraitorSnapshot } from './nexus-traitor-exchange'; import { validateTraitorDeclaration, type TraitorDeclaration, type TraitorDeclarationContext, } from './traitor-declarations'; import { traitorDeck } from './traitors'; export type HarkonnenBetrayalAuthority = { provider: string; declaration: TraitorDeclaration; nativeWindow: 'direct' | 'harkonnenTraitor'; nativeContext: string; nativeRequired: readonly string[]; nativePassed: readonly string[]; }; type Battle = Omit; export type HarkonnenBetrayalContext = { status: string; phase: number; turn: number; advanced: boolean; sequence: number; /** Public selection; absence retains the original base33 census. */ ecazTreachery?: boolean; players: readonly NexusPlayer[]; cards: NexusState; physicalCards: readonly Card[]; traitors: NexusTraitorSnapshot; universe: readonly string[]; battle: Battle; }; export type HarkonnenBetrayalSource = HarkonnenBetrayalAuthority & { version: 1; event: string; turn: number; phase: 6; advanced: boolean; sequence: number; required: readonly string[]; eligible: string | null; players: readonly NexusPlayer[]; battle: Battle; universe: readonly string[]; /** Current committed census only, never a requirement on later custody. */ inventory: string; parent: string; signature: string; }; export type HarkonnenBetrayalCursor = { sequence: number; signature: string }; export type HarkonnenBetrayalReceipt = { source: HarkonnenBetrayalSource; outcome: 'pass' | 'use'; holder: string | null; previous: string; signature: string; }; export type HarkonnenBetrayalReplacement = { version: 1; event: string; sequence: number; provider: string; turn: number; identity: string; /** Bounded head of the exact successful Use, not the latest cursor. */ receipt: string; status: 'due' | 'drawn'; drawn: string | null; signature: string; }; export class HarkonnenBetrayalError extends Error { constructor(message: string) { super(message); this.name = 'HarkonnenBetrayalError'; } } const genesis = 'harkonnen-betrayal:0'; const classic: Readonly> = { atreides: true, harkonnen: true, emperor: true, fremen: true, guild: true, beneGesserit: true, }; const canonicalCards = new Map(baseDeck().map(card => [card.id, card])); const independentCards = new Map(ecazTreacheryCards().map(card => [card.id, card])); const declarationKeys = ['event', 'voter', 'beneficiary', 'target', 'leader', 'identity', 'signature'] as const; 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 HarkonnenBetrayalError(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; } const equal = (one: unknown, two: unknown): boolean => JSON.stringify(one) === JSON.stringify(two); function validateRoster(players: readonly NexusPlayer[]): void { validateNexusPlayers(players); requireBetrayal(players.every(player => Object.hasOwn(classic, player.faction)) && players.filter(player => player.faction === 'harkonnen').length === 1, 'Harkonnen Betrayal requires native Harkonnen and two through six classic seats.'); } function roster(players: readonly NexusPlayer[]): NexusPlayer[] { return players.map(({ id, faction, ally }) => ({ id, faction, ally: ally ?? null })); } function canonicalUniverse(players: readonly NexusPlayer[]): string[] { return traitorDeck(players.map(player => ({ leaders: leaders(player.faction) }))); } function validateUniverse(universe: readonly string[], players: readonly NexusPlayer[]): void { const expected = canonicalUniverse(players); requireBetrayal(members(universe) && universe.length === expected.length && universe.every(identity => expected.includes(identity)), 'Harkonnen Betrayal needs the original complete canonical classic Traitor universe.'); } function validatePhysical(cards: readonly Card[], independent = false): void { const count = canonicalCards.size + (independent ? independentCards.size : 0); requireBetrayal(Array.isArray(cards) && cards.length === count && new Set(cards.map(card => card?.id)).size === count && cards.every(card => { if (!plain(card) || !text(card.id)) return false; const expected = canonicalCards.get(card.id) ?? (independent ? independentCards.get(card.id) : undefined); return !!expected && exact(card, ['id', 'name', 'kind', ...(expected.effect === undefined ? [] : ['effect'])]) && card.name === expected.name && card.kind === expected.kind && card.effect === expected.effect; }), 'Harkonnen Betrayal requires complete unique canonical selected Treachery custody.'); } function validateBattle(battle: Battle): void { requireBetrayal(exact(battle, ['event', 'attacker', 'defender', 'plans', 'heroLeaderIds']) && text(battle.event) && text(battle.attacker) && text(battle.defender) && battle.attacker !== battle.defender && exact(battle.plans, [battle.attacker, battle.defender]) && Array.isArray(battle.heroLeaderIds) && battle.heroLeaderIds.length === 0 && Object.values(battle.plans).every(plan => plain(plan) && exact(plan, ['leader', ...(Object.hasOwn(plan, 'kwisatz') ? ['kwisatz'] : [])]) && (plan.leader === null || text(plan.leader)) && (plan.kwisatz === undefined || typeof plan.kwisatz === 'boolean')), 'Harkonnen Betrayal requires original sealed classic battle plans, without Ix heroes.'); } function declarationContext( battle: Battle, players: readonly NexusPlayer[], traitors?: NexusTraitorSnapshot, ): TraitorDeclarationContext { return { ...battle, players: players.map(player => ({ ...player, traitors: traitors?.players.find(seat => seat.id === player.id)?.traitors ?? [], })) }; } function validateAuthority( authority: HarkonnenBetrayalAuthority, players: readonly NexusPlayer[], battle: Battle, ): void { requireBetrayal(exact(authority, ['provider', 'declaration', '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.nativeRequired, ...authority.nativePassed].every(id => players.some(p => p.id === id)) && players.find(player => player.id === authority.provider)?.faction === 'harkonnen' && authority.declaration?.voter === authority.provider, 'Harkonnen Betrayal must follow its original allowed native Harkonnen declaration.'); try { validateTraitorDeclaration(declarationContext(battle, players), authority.declaration); } catch { throw new HarkonnenBetrayalError('Harkonnen Betrayal lost its canonical native declaration or roles.'); } const direct = authority.declaration.beneficiary === authority.provider; requireBetrayal(direct ? authority.nativeWindow === 'direct' && authority.nativeRequired.length === 0 && authority.nativePassed.length === 0 : authority.nativeWindow === 'harkonnenTraitor', 'Harkonnen Betrayal cannot confuse personal calls with native allowed allied calls.'); requireBetrayal(players.some(player => leaders(player.faction).some(leader => leader.id === authority.declaration.leader)) && authority.declaration.identity === authority.declaration.leader, 'Harkonnen Betrayal requires the opposing canonical classic leader identity.'); } export function harkonnenBetrayalResponders(cards: NexusState, players: readonly NexusPlayer[]): string[] { validateRoster(players); validateNexusCards(cards, players); return players.filter(player => !player.ally && player.faction !== 'harkonnen' && cards.hands[player.id] !== null).map(player => player.id); } export function harkonnenBetrayalEligible( cards: NexusState, players: readonly NexusPlayer[], holder: string, ): boolean { validateRoster(players); validateNexusCards(cards, players); const player = players.find(seat => seat.id === holder); return !!player && !player.ally && player.faction !== 'harkonnen' && cards.hands[holder] === 'harkonnen' && nexusCardMode('harkonnen', player.faction, players.map(seat => seat.faction)) === 'betrayal'; } export function harkonnenBetrayalEvent( turn: number, sequence: number, declaration: TraitorDeclaration, provider: string, ): string { requireBetrayal(whole(turn) && turn > 0 && whole(sequence) && text(provider) && plain(declaration) && declarationKeys.every(key => text(declaration[key])) && declaration.voter === provider, 'Harkonnen Betrayal requires its original public declared-card event.'); return JSON.stringify(['nexusHarkonnenBetrayal', turn, sequence, declaration.event, provider, declaration.beneficiary, declaration.target, declaration.leader, declaration.identity]); } function validateContext(ctx: HarkonnenBetrayalContext): void { requireBetrayal(plain(ctx) && ctx.status === 'playing' && ctx.phase === 6 && whole(ctx.turn) && ctx.turn > 0 && whole(ctx.sequence) && typeof ctx.advanced === 'boolean' && (ctx.ecazTreachery === undefined || typeof ctx.ecazTreachery === 'boolean'), 'Harkonnen Betrayal requires its current Battle phase and independent cursor.'); validateRoster(ctx.players); validateNexusCards(ctx.cards, ctx.players); validatePhysical(ctx.physicalCards, ctx.ecazTreachery); validateUniverse(ctx.universe, ctx.players); validateNexusTraitorSnapshot(ctx.traitors, ctx.universe); requireBetrayal(ctx.traitors.players.length === ctx.players.length && ctx.players.every(player => ctx.traitors.players.some(seat => seat.id === player.id && seat.faction === player.faction)), 'Harkonnen Betrayal physical Traitor custody must match its original roster.'); validateBattle(ctx.battle); } function sourceAuthority(source: HarkonnenBetrayalSource): HarkonnenBetrayalAuthority { return { provider: source.provider, declaration: source.declaration, nativeWindow: source.nativeWindow, nativeContext: source.nativeContext, nativeRequired: source.nativeRequired, nativePassed: source.nativePassed }; } function inventory(ctx: HarkonnenBetrayalContext): string { const census: unknown[] = [ctx.cards, ctx.traitors, ctx.physicalCards]; if (ctx.ecazTreachery) census.push(true); return JSON.stringify(census); } function sourceSignature(source: HarkonnenBetrayalSource): string { return JSON.stringify(['harkonnenBetrayalSource', source.version, source.event, source.turn, source.phase, source.advanced, source.sequence, sourceAuthority(source), source.required, source.eligible, source.players, source.battle, source.universe, source.inventory, source.parent]); } function validateSavedSource(value: unknown): asserts value is HarkonnenBetrayalSource { requireBetrayal(exact(value, ['provider', 'declaration', 'nativeWindow', 'nativeContext', 'nativeRequired', 'nativePassed', 'version', 'event', 'turn', 'phase', 'advanced', 'sequence', 'required', 'eligible', 'players', 'battle', 'universe', 'inventory', 'parent', 'signature']) && value.version === 1 && whole(value.turn) && value.turn > 0 && value.phase === 6 && typeof value.advanced === 'boolean' && whole(value.sequence) && members(value.required) && (value.eligible === null || text(value.eligible)) && text(value.event) && text(value.parent) && text(value.signature) && text(value.inventory) && Array.isArray(value.players) && plain(value.battle) && members(value.universe), 'Harkonnen Betrayal lost its original canonical source.'); // Shape checked above; nested values are validated by their native protocols. const source = value as HarkonnenBetrayalSource; validateRoster(source.players); requireBetrayal(source.players.every(player => exact(player, ['id', 'faction', 'ally'])), 'Harkonnen Betrayal history must retain only the original public seat roles.'); validateUniverse(source.universe, source.players); validateBattle(source.battle); validateAuthority(sourceAuthority(source), source.players, source.battle); // This is the original census, not today's holdings. Returning or redrawing // the declared card later cannot invalidate this historical declaration. let stored: unknown; try { stored = JSON.parse(source.inventory); } catch { throw new HarkonnenBetrayalError('Harkonnen Betrayal lost its original physical census.'); } requireBetrayal(Array.isArray(stored) && (stored.length === 3 || (stored.length === 4 && stored[3] === true)), 'Harkonnen Betrayal lost its original physical census.'); type SavedInventory = [NexusState, NexusTraitorSnapshot, readonly Card[], true?]; const [cards, traitors, physicalCards, independent] = stored as SavedInventory; validateNexusCards(cards, source.players); validateNexusTraitorSnapshot(traitors, source.universe); validatePhysical(physicalCards, independent); requireBetrayal(traitors.players.length === source.players.length && source.players.every(player => traitors.players.some(seat => seat.id === player.id && seat.faction === player.faction)) && traitors.players.find(player => player.id === source.provider)?.traitors.includes(source.declaration.identity) && equal(source.required, harkonnenBetrayalResponders(cards, source.players)) && source.eligible === (source.players.find(player => harkonnenBetrayalEligible(cards, source.players, player.id))?.id ?? null), 'Harkonnen Betrayal changed its original canonical custody or response eligibility.'); requireBetrayal(source.required.every(id => source.players.some(player => player.id === id && !player.ally && player.faction !== 'harkonnen')) && (source.eligible === null || source.required.includes(source.eligible)) && source.universe.includes(source.declaration.identity) && source.event === harkonnenBetrayalEvent(source.turn, source.sequence, source.declaration, source.provider) && source.signature === sourceSignature(source), 'Harkonnen Betrayal changed its original event, roles or native source.'); } export function validateHarkonnenBetrayalSource( ctx: HarkonnenBetrayalContext, source: HarkonnenBetrayalSource, authority: HarkonnenBetrayalAuthority, parent: string, ): void { validateSavedSource(source); validateContext(ctx); validateAuthority(authority, ctx.players, ctx.battle); requireBetrayal(source.turn === ctx.turn && source.sequence === ctx.sequence && source.advanced === ctx.advanced && source.parent === parent && text(parent) && source.provider === authority.provider && source.nativeWindow === authority.nativeWindow && source.nativeContext === authority.nativeContext && equal(source.declaration, authority.declaration) && equal(source.nativeRequired, authority.nativeRequired) && equal(source.nativePassed, authority.nativePassed) && equal(source.players, roster(ctx.players)) && equal(source.battle, ctx.battle) && equal(source.universe, ctx.universe) && source.inventory === inventory(ctx) && ctx.traitors.players.find(player => player.id === source.provider)?.traitors.includes(source.declaration.identity) && equal(source.required, harkonnenBetrayalResponders(ctx.cards, ctx.players)) && source.eligible === (ctx.players.find(player => harkonnenBetrayalEligible(ctx.cards, ctx.players, player.id))?.id ?? null), 'Harkonnen Betrayal no longer matches its current native attempt, custody or public responders.'); } export function createHarkonnenBetrayalSource( ctx: HarkonnenBetrayalContext, authority: HarkonnenBetrayalAuthority, parent: string, ): HarkonnenBetrayalSource { validateContext(ctx); validateAuthority(authority, ctx.players, ctx.battle); const source: HarkonnenBetrayalSource = { ...structuredClone(authority), version: 1, event: harkonnenBetrayalEvent(ctx.turn, ctx.sequence, authority.declaration, authority.provider), turn: ctx.turn, phase: 6, advanced: ctx.advanced, sequence: ctx.sequence, required: harkonnenBetrayalResponders(ctx.cards, ctx.players), eligible: ctx.players.find(player => harkonnenBetrayalEligible(ctx.cards, ctx.players, player.id))?.id ?? null, players: roster(ctx.players), battle: structuredClone(ctx.battle), universe: [...ctx.universe], inventory: inventory(ctx), parent, signature: '', }; source.signature = sourceSignature(source); validateHarkonnenBetrayalSource(ctx, source, authority, parent); return source; } export function validateHarkonnenBetrayalFrame( ctx: HarkonnenBetrayalContext, source: HarkonnenBetrayalSource, authority: HarkonnenBetrayalAuthority, parent: string, required: readonly string[], passed: readonly string[], ): void { validateHarkonnenBetrayalSource(ctx, source, authority, parent); requireBetrayal(members(required) && members(passed) && equal(required, source.required) && passed.every(id => required.includes(id)), 'Harkonnen Betrayal lost its exact public required or passed membership.'); } export function initialHarkonnenBetrayalCursor(): HarkonnenBetrayalCursor { return { sequence: 0, signature: genesis }; } function validateCursor(value: unknown): asserts value is HarkonnenBetrayalCursor { requireBetrayal(exact(value, ['sequence', 'signature']) && whole(value.sequence) && text(value.signature) && (value.sequence !== 0 || value.signature === genesis), 'Harkonnen Betrayal lost its independent closed-event cursor.'); if (value.sequence === 0) return; let head: unknown, event: unknown; try { head = JSON.parse(value.signature); if (Array.isArray(head) && typeof head[1] === 'string') event = JSON.parse(head[1]); } catch { throw new HarkonnenBetrayalError('Harkonnen Betrayal has a forged or recursive cursor head.'); } requireBetrayal(Array.isArray(head) && head.length === 4 && head[0] === 'harkonnenBetrayalHead' && (head[2] === 'pass' ? head[3] === null : head[2] === 'use' && text(head[3])) && Array.isArray(event) && event.length === 9 && event[0] === 'nexusHarkonnenBetrayal' && whole(event[1]) && event[1] > 0 && event[2] === value.sequence - 1 && event.slice(3).every(text) && event[4] !== event[6] && event[5] !== event[6] && event[7] === event[8] && value.signature === JSON.stringify(head) && head[1] === JSON.stringify(event), 'Harkonnen Betrayal has a forged or recursive cursor head.'); } function receiptSignature(receipt: HarkonnenBetrayalReceipt): string { return JSON.stringify(['harkonnenBetrayalReceipt', receipt.source.signature, receipt.outcome, receipt.holder, receipt.previous]); } function receiptHead(receipt: HarkonnenBetrayalReceipt): string { return JSON.stringify(['harkonnenBetrayalHead', receipt.source.event, receipt.outcome, receipt.holder]); } export function closeHarkonnenBetrayalSource( source: HarkonnenBetrayalSource, outcome: 'pass' | 'use', holder: string | null, cursor: HarkonnenBetrayalCursor, ): { receipt: HarkonnenBetrayalReceipt; cursor: HarkonnenBetrayalCursor } { validateSavedSource(source); validateCursor(cursor); requireBetrayal(source.sequence === cursor.sequence && cursor.sequence < Number.MAX_SAFE_INTEGER && (outcome === 'pass' ? holder === null : outcome === 'use' && text(holder) && holder === source.eligible), 'Harkonnen Betrayal cannot close twice or lose its actual response outcome.'); const receipt: HarkonnenBetrayalReceipt = { source: structuredClone(source), outcome, holder, previous: cursor.signature, signature: '', }; receipt.signature = receiptSignature(receipt); return { receipt, cursor: { sequence: cursor.sequence + 1, signature: receiptHead(receipt) } }; } /** Historical declarations do not claim continued possession of recycled cards. * Signatures establish consistency, not authentication of rewritten saves. */ export function validateHarkonnenBetrayalHistory( history: readonly HarkonnenBetrayalReceipt[], cursor: HarkonnenBetrayalCursor, universe?: readonly string[], ): void { validateCursor(cursor); requireBetrayal(array(history) && history.length === cursor.sequence, 'Harkonnen Betrayal history has missing or duplicated closed attempts.'); let previous = genesis, turn = 0; let originalRoster: readonly NexusPlayer[] | undefined; let originalUniverse: readonly string[] | undefined; for (const [index, receipt] of history.entries()) { requireBetrayal(exact(receipt, ['source', 'outcome', 'holder', 'previous', 'signature']), 'Harkonnen Betrayal history has a malformed receipt.'); validateSavedSource(receipt.source); if (universe !== undefined) { validateUniverse(universe, receipt.source.players); requireBetrayal(equal(universe, receipt.source.universe), 'Harkonnen Betrayal changed its original universe.'); } const identities = receipt.source.players.map(({ id, faction }) => ({ id, faction })); requireBetrayal(receipt.source.sequence === index && receipt.source.turn >= turn && receipt.previous === previous && (originalRoster === undefined || equal(originalRoster, identities)) && (originalUniverse === undefined || equal(originalUniverse, receipt.source.universe)) && (receipt.outcome === 'pass' ? receipt.holder === null : receipt.outcome === 'use' && text(receipt.holder) && receipt.holder === receipt.source.eligible) && receipt.signature === receiptSignature(receipt), 'Harkonnen Betrayal history changed its native attempt, outcome or continuation chain.'); originalRoster = identities; originalUniverse = receipt.source.universe; previous = receiptHead(receipt); turn = receipt.source.turn; } requireBetrayal(cursor.signature === previous, 'Harkonnen Betrayal history no longer matches its independent cursor.'); } function replacementSignature(replacement: HarkonnenBetrayalReplacement): string { return JSON.stringify(['harkonnenBetrayalReplacement', replacement.version, replacement.event, replacement.sequence, replacement.provider, replacement.turn, replacement.identity, replacement.receipt, replacement.status, replacement.drawn]); } export function createHarkonnenBetrayalReplacement(receipt: HarkonnenBetrayalReceipt): HarkonnenBetrayalReplacement { requireBetrayal(exact(receipt, ['source', 'outcome', 'holder', 'previous', 'signature']), 'Harkonnen Betrayal replacement requires its exact closed native receipt.'); validateSavedSource(receipt.source); validateCursor({ sequence: receipt.source.sequence, signature: receipt.previous }); requireBetrayal(receipt.outcome === 'use' && text(receipt.holder) && receipt.holder === receipt.source.eligible && text(receipt.previous) && receipt.signature === receiptSignature(receipt), 'Only a successful Harkonnen Betrayal Use creates a Mentat replacement obligation.'); const replacement: HarkonnenBetrayalReplacement = { version: 1, event: receipt.source.event, sequence: receipt.source.sequence, provider: receipt.source.provider, turn: receipt.source.turn, identity: receipt.source.declaration.identity, receipt: receiptHead(receipt), status: 'due', drawn: null, signature: '', }; replacement.signature = replacementSignature(replacement); return replacement; } function validateReplacement( replacement: HarkonnenBetrayalReplacement, history: readonly HarkonnenBetrayalReceipt[], ): void { requireBetrayal(exact(replacement, ['version', 'event', 'sequence', 'provider', 'turn', 'identity', 'receipt', 'status', 'drawn', 'signature']) && replacement.version === 1 && whole(replacement.sequence) && whole(replacement.turn) && replacement.turn > 0 && text(replacement.event) && text(replacement.provider) && text(replacement.identity) && text(replacement.receipt) && text(replacement.signature) && (replacement.status === 'due' ? replacement.drawn === null : replacement.status === 'drawn' && text(replacement.drawn)), 'Harkonnen Betrayal has a malformed or repeated Mentat replacement.'); const receipt = history[replacement.sequence]; requireBetrayal(receipt?.outcome === 'use' && replacement.receipt === receiptHead(receipt) && replacement.event === receipt.source.event && replacement.provider === receipt.source.provider && replacement.turn === receipt.source.turn && replacement.identity === receipt.source.declaration.identity && (replacement.drawn === null || receipt.source.universe.includes(replacement.drawn)) && replacement.signature === replacementSignature(replacement), 'Harkonnen Betrayal replacement lost its exact successful Use obligation or actual drawn identity.'); if (replacement.drawn !== null) { const [, originalTraitors] = JSON.parse(receipt.source.inventory) as [unknown, NexusTraitorSnapshot]; requireBetrayal(!originalTraitors.removed?.includes(replacement.drawn), 'Permanently set-aside Traitor Cards cannot fund a Betrayal replacement.'); } } export function validateHarkonnenBetrayalReplacement( replacement: HarkonnenBetrayalReplacement, history: readonly HarkonnenBetrayalReceipt[], cursor: HarkonnenBetrayalCursor, universe?: readonly string[], ): void { validateHarkonnenBetrayalHistory(history, cursor, universe); validateReplacement(replacement, history); } export function drawHarkonnenBetrayalReplacement( replacement: HarkonnenBetrayalReplacement, history: readonly HarkonnenBetrayalReceipt[], cursor: HarkonnenBetrayalCursor, drawn: string, universe?: readonly string[], ): HarkonnenBetrayalReplacement { validateHarkonnenBetrayalReplacement(replacement, history, cursor, universe); requireBetrayal(replacement.status === 'due' && text(drawn) && history[replacement.sequence].source.universe.includes(drawn), 'Harkonnen Betrayal replacement draws exactly once from its actual canonical Traitor Deck.'); const result: HarkonnenBetrayalReplacement = { ...replacement, status: 'drawn', drawn, signature: '' }; result.signature = replacementSignature(result); validateReplacement(result, history); return result; } export function validateHarkonnenBetrayalReplacements( history: readonly HarkonnenBetrayalReceipt[], cursor: HarkonnenBetrayalCursor, replacements: readonly HarkonnenBetrayalReplacement[], universe?: readonly string[], ): void { validateHarkonnenBetrayalHistory(history, cursor, universe); requireBetrayal(Array.isArray(replacements), 'Harkonnen Betrayal lost its replacement provenance.'); for (const replacement of replacements) validateReplacement(replacement, history); const used = history.filter(receipt => receipt.outcome === 'use'); requireBetrayal(replacements.length === used.length && new Set(replacements.map(replacement => replacement.sequence)).size === replacements.length && replacements.filter(replacement => replacement.status === 'due').length <= 1, 'Every successful Harkonnen Betrayal Use requires exactly one due or completed private draw.'); }