From 7680e1ee69d7515535a058537f87c7222c655964 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:12:45 +1100 Subject: [PATCH] Fix mixed-deck Nexus Betrayal continuations --- docs/CURRENT_STATUS.md | 12 ++ docs/NEXUS_CARD_RULES.md | 22 ++++ docs/NEXUS_HARKONNEN_RULES.md | 11 ++ docs/RULE_DECISIONS.md | 7 ++ game/engine.ts | 16 ++- game/nexus-harkonnen-betrayal.ts | 92 ++++++++------ game/nexus-ixian-betrayal.ts | 5 +- game/reference.ts | 2 + tests/fixture-nexus-harkonnen-betrayal.ts | 4 + tests/fixture-nexus-ixian-betrayal.ts | 21 ++++ .../nexus-harkonnen-betrayal-expanded.test.ts | 65 ++++++++++ .../nexus-harkonnen-betrayal-runtime.test.ts | 7 +- .../nexus-harkonnen-expanded-runtime.test.ts | 117 ++++++++++++++++++ ...exus-ixian-betrayal-selected-decks.test.ts | 40 ++++++ ...nexus-ixian-selected-decks-runtime.test.ts | 112 +++++++++++++++++ 15 files changed, 487 insertions(+), 46 deletions(-) create mode 100644 tests/nexus-harkonnen-betrayal-expanded.test.ts create mode 100644 tests/nexus-harkonnen-expanded-runtime.test.ts create mode 100644 tests/nexus-ixian-betrayal-selected-decks.test.ts create mode 100644 tests/nexus-ixian-selected-decks-runtime.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 6186a3b5..be305a77 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,18 @@ open mode gates. ## Current checkpoint and work +**Basic mixed-deck Nexus reactions — 30 October. Prototyped / Partial.** +In supported selected-deck Nexus starts, Ixian Betrayal now recognizes the +actual selected stock and Richese cache, while Harkonnen Betrayal recognizes +Basic expansion leaders and the Ix Cheap Hero identity. An owed replacement +survives the Ix phase-opening window and draws at the original Mentat opening. +Advanced selected-deck Harkonnen reactions remain unfinished and no longer +block the original call. Public Semuta/Nexus activation remains deferred: +its marker would suppress existing Emperor and Sapho abilities. Twelve related +files pass 105 cases; older Advanced drivers were updated for ordered Charity +and Revival, with one failure reproduced on the previous revision. +[Scope](NEXUS_CARD_RULES.md#basic-mixed-deck-nexus-reactions--30-october-2026). + **Basic Ambassador discards with Discovery and Skills — 30 October. Prototyped / Partial.** Selected Richese/Discovery/Skills games now offer Semuta for supported CHOAM and Ixian Ambassador discards on ordinary arrivals, paired Terror entries and diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index 6c7f00ae..09b43099 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -856,6 +856,28 @@ new collect action or played-game conversion is added. Existing supported ordinary module/native envelopes now inherit the same normal band; no new combination is admitted. [Current activation and limits](SPICE_BANKER_RUNTIME.md#mandatory-ordinary-skills-default--17-october-2026). +## Basic mixed-deck Nexus reactions — 30 October 2026 + +**Prototyped / Partial.** Supported public selected-deck Nexus starts can +contain native Ixians, Harkonnen and Richese. Ixian Betrayal now validates +the selected Ix/CHOAM/Ecaz deck and the separate Richese cache even without +the independent Ecaz card variant. Basic Harkonnen Betrayal validates the +selected physical cards, native leader Traitors and the Ix Cheap Hero Traitor. +The existing provisional counter and neutral acknowledgement ordering applies. +An owed Harkonnen replacement remains pending through the initial Ix +phase-opening window and is drawn once when Mentat actually begins. Advanced +selected-deck Harkonnen reactions remain unfinished: their original traitor +calls continue without this Nexus interruption. Starts that never initialized +the reaction still retain their existing boundary. + +Public-start journeys use genuine setup, worm, Nexus draw, Ixian auction and +Harkonnen battle; unopened deck order and, for battle, a conserved traitor +transfer and force placement are staged. Pure helper tests additionally cover +the Cheap Hero identity. Other rosters, nested effects, complete games and +deployed acceptance remain open. Public Semuta/Nexus activation is deferred +because the marker currently suppresses existing Emperor Nexus effects and +Sapho order; the separate audit initializer is unchanged. + ## Semuta with original native Nexus — 11 October 2026 Fresh `semuta --nexus-cards` composes native Richese with the original diff --git a/docs/NEXUS_HARKONNEN_RULES.md b/docs/NEXUS_HARKONNEN_RULES.md index 325f4613..e9dc5fb8 100644 --- a/docs/NEXUS_HARKONNEN_RULES.md +++ b/docs/NEXUS_HARKONNEN_RULES.md @@ -2,6 +2,17 @@ Source audit, 10 September 2026. This is a rules contract, not a runtime completion claim. The [common Nexus lifecycle](NEXUS_CARD_RULES.md) and existing expansion release gates still apply. The pending reaction-privacy question remains unresolved; this audit does not authorize a Betrayal reaction policy. +## Basic selected decks — 30 October 2026 + +Supported fresh selected-deck core games now retain the existing Harkonnen +reaction for Basic native expansion leaders and the Ix Cheap Hero Traitor. +Physical stock includes the selected decks and any Richese cache; older classic +receipt signatures retain their prior format. The Ix phase-opening window can +precede the mandatory Mentat replacement without losing the obligation. The +existing provisional acknowledgement convention is unchanged. Advanced +selected-deck reactions, uninitialized profiles, wider nested interactions and +complete acceptance remain open. [Scope and evidence](NEXUS_CARD_RULES.md#basic-mixed-deck-nexus-reactions--30-october-2026). + ## Earned phase cards and original replacement — 18 October 2026 Fresh classic core now retains Tech3+ and Advanced Strongholds2+ with diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 933d48d5..f4c81326 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,12 @@ # Rule decision index +## Basic mixed-deck Nexus reactions — 30 October 2026 + +Previously initialized Ixian and Harkonnen reactions use the actual selected +physical stock and Basic Traitor identities; their existing provisional +acknowledgement ordering is unchanged. Advanced selected-deck Harkonnen +interruption remains unfinished. [Scope](NEXUS_CARD_RULES.md#basic-mixed-deck-nexus-reactions--30-october-2026). + ## Basic Ambassador discards with Discovery and Skills — 30 October 2026 The existing private Semuta reaction now composes with supported CHOAM and diff --git a/game/engine.ts b/game/engine.ts index 9f7cd0f9..bc58b345 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -12535,8 +12535,10 @@ function harkonnenNexusBetrayalContext(g: Game) { const plans = Object.fromEntries([b.attacker,b.defender].map(id => [id,{ leader:b.plans[id].leader,...(b.plans[id].kwisatz === undefined ? {} : {kwisatz:b.plans[id].kwisatz}), }])); - const battle = {event:b.event!,attacker:b.attacker,defender:b.defender,plans,heroLeaderIds:[]}; + const battle = {event:b.event!,attacker:b.attacker,defender:b.defender,plans, + heroLeaderIds:g.expansions.includes('ix') ? physicalTreacheryCards(g).filter(card => card.kind === 'hero').map(card => card.id) : []}; return {status:g.status,phase:g.phase,turn:g.turn,advanced:g.advanced,ecazTreachery:g.ecazTreachery === true, + ...(g.expansions.length ? {expansions:g.expansions} : {}), sequence:g.nexusHarkonnenBetrayalCursor!.sequence,players:g.players, cards:g.nexusCards!.cards!,physicalCards:physicalTreacheryCards(g), traitors:nexusTraitorSnapshot(g),universe:nexusTraitorUniverse(g),battle}; @@ -12601,7 +12603,9 @@ function harkonnenNexusBetrayalIntegrity(g: Game, mentatOpening = false): void { (!g.pendingNexusHarkonnenReplacement || (g.status === 'playing' && g.pendingNexusHarkonnenReplacement.status === 'due' && g.pendingNexusHarkonnenReplacement.turn === g.turn && - (g.phase === 6 || g.phase === 7 || (mentatOpening && g.phase === 8)))), + (g.phase === 6 || g.phase === 7 || (g.phase === 8 && + (mentatOpening || g.phaseOpening?.initialize === true || + g.response?.kind === 'emperorGift' && g.response.emperorPhaseOpeningGift?.opening.initialize === true))))), 'The private replacement lost its current native Mentat obligation.'); if (g.status !== 'setup' && g.status !== 'lobby') nexusRule(() => validateNexusTraitorSnapshot(nexusTraitorSnapshot(g),universe)); @@ -12630,6 +12634,10 @@ function harkonnenNexusBetrayalIntegrity(g: Game, mentatOpening = false): void { } function offerHarkonnenNexusBetrayal(g: Game, provider: string, response?: ResponseWindow): boolean { if (!g.nexusHarkonnenBetrayalPreview || getPlayer(g,provider).faction !== 'harkonnen') return false; + // The expanded physical census below currently covers Basic. Avoid blocking + // the original call in Advanced selected-deck games until their distinct + // Auditor and Loyalty inventories are supported. + if (g.advanced && g.expansions.length) return false; const b = g.battle!; const required = nexusRule(() => harkonnenBetrayalResponders(g.nexusCards!.cards!,g.players)); if (!required.length) return false; @@ -12744,11 +12752,11 @@ function ixianNexusBetrayalModeSupported(g: Game): boolean { function ixianNexusBetrayalContext(g: Game) { return {status:g.status,phase:g.phase,turn:g.turn,advanced:g.advanced, sequence:g.nexusIxianBetrayalCursor!.sequence,players:g.players, - ...(g.ecazTreachery ? {expansions:g.expansions,ecazTreachery:true} : {}), + expansions:g.expansions,ecazTreachery:g.ecazTreachery === true, cards:g.nexusCards!.cards!,physicalCards:physicalTreacheryCards(g)}; } function ixianNexusCardConfiguration(g: Game) { - return g.ecazTreachery ? {expansions:g.expansions,ecazTreachery:true,richese:!!byFaction(g,'richese')} : undefined; + return {expansions:g.expansions,ecazTreachery:g.ecazTreachery === true,richese:!!byFaction(g,'richese')}; } function ixianNexusBetrayalAuthority(g: Game, response: ResponseWindow) { requireRule(response.kind === 'ixAuction' || response.kind === 'ixTechnology', diff --git a/game/nexus-harkonnen-betrayal.ts b/game/nexus-harkonnen-betrayal.ts index 54b24919..ef63d6b3 100644 --- a/game/nexus-harkonnen-betrayal.ts +++ b/game/nexus-harkonnen-betrayal.ts @@ -1,5 +1,7 @@ -import { baseDeck, leaders, type Card } from './cards'; +import { leaders, 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, @@ -8,7 +10,7 @@ import { validateNexusTraitorSnapshot, type NexusTraitorSnapshot } from './nexus import { validateTraitorDeclaration, type TraitorDeclaration, type TraitorDeclarationContext, } from './traitor-declarations'; -import { traitorDeck } from './traitors'; +import { CHEAP_HERO_TRAITOR, traitorDeck } from './traitors'; export type HarkonnenBetrayalAuthority = { provider: string; @@ -27,6 +29,8 @@ export type HarkonnenBetrayalContext = { sequence: number; /** Public selection; absence retains the original base33 census. */ ecazTreachery?: boolean; + /** Selected physical decks for a fresh Basic expansion game. */ + expansions?: readonly string[]; players: readonly NexusPlayer[]; cards: NexusState; physicalCards: readonly Card[]; @@ -79,11 +83,6 @@ export class HarkonnenBetrayalError extends Error { } } 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) && @@ -105,43 +104,54 @@ function members(value: unknown): value is readonly string[] { 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)) && + requireBetrayal(players.every(player => FACTIONS.some(faction => faction.id === player.faction)) && players.filter(player => player.faction === 'harkonnen').length === 1, - 'Harkonnen Betrayal requires native Harkonnen and two through six classic seats.'); + 'Harkonnen Betrayal requires native Harkonnen and registered faction 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 selectedExpansions(value: unknown, players: readonly NexusPlayer[], advanced: boolean): string[] { + const expansions = value === undefined ? [] : value; + requireBetrayal(Array.isArray(expansions) && expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) && + new Set(expansions).size === expansions.length && (!advanced || expansions.length === 0) && + players.every(player => { + const faction = FACTIONS.find(item => item.id === player.faction); + return !!faction && (faction.expansion === 'base' || expansions.includes(faction.expansion)); + }), 'Harkonnen Betrayal requires registered Basic selected faction decks.'); + return expansions; } -function validateUniverse(universe: readonly string[], players: readonly NexusPlayer[]): void { - const expected = canonicalUniverse(players); +function validateUniverse(universe: readonly string[], players: readonly NexusPlayer[], expansions: readonly string[]): void { + const expected = traitorDeck(players.map(player => ({ leaders: leaders(player.faction) })), expansions.includes('ix')); requireBetrayal(members(universe) && universe.length === expected.length && universe.every(identity => expected.includes(identity)), - 'Harkonnen Betrayal needs the original complete canonical classic Traitor universe.'); + 'Harkonnen Betrayal needs the original complete canonical 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 => { +function validatePhysical(cards: readonly Card[], independent: boolean, expansions: readonly string[], players: readonly NexusPlayer[]): void { + const expectedCards = [...treacheryDeck(expansions), ...(independent ? ecazTreacheryCards() : []), + ...(players.some(player => player.faction === 'richese') ? richeseCards() : [])]; + const canonicalCards = new Map(expectedCards.map(card => [card.id, card])); + requireBetrayal(Array.isArray(cards) && cards.length === expectedCards.length && + new Set(cards.map(card => card?.id)).size === expectedCards.length && cards.every(card => { if (!plain(card) || !text(card.id)) return false; - const expected = canonicalCards.get(card.id) ?? (independent ? independentCards.get(card.id) : undefined); + const expected = canonicalCards.get(card.id); 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 { +function validateBattle(battle: Battle, expansions: readonly string[]): void { + const heroes = expansions.includes('ix') ? treacheryDeck(expansions).filter(card => card.kind === 'hero').map(card => card.id) : []; 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 && + Array.isArray(battle.heroLeaderIds) && battle.heroLeaderIds.length === heroes.length && + new Set(battle.heroLeaderIds).size === heroes.length && battle.heroLeaderIds.every(id => heroes.includes(id)) && 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.'); + 'Harkonnen Betrayal requires original sealed battle plans and selected physical heroes.'); } function declarationContext( battle: Battle, players: readonly NexusPlayer[], traitors?: NexusTraitorSnapshot, @@ -171,9 +181,10 @@ function validateAuthority( 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.'); + requireBetrayal((players.some(player => leaders(player.faction).some(leader => + leader.id === authority.declaration.leader)) && authority.declaration.identity === authority.declaration.leader) || + (battle.heroLeaderIds.includes(authority.declaration.leader) && authority.declaration.identity === CHEAP_HERO_TRAITOR), + 'Harkonnen Betrayal requires the opposing canonical leader or physical hero identity.'); } export function harkonnenBetrayalResponders(cards: NexusState, players: readonly NexusPlayer[]): string[] { validateRoster(players); @@ -205,15 +216,16 @@ function validateContext(ctx: HarkonnenBetrayalContext): void { (ctx.ecazTreachery === undefined || typeof ctx.ecazTreachery === 'boolean'), 'Harkonnen Betrayal requires its current Battle phase and independent cursor.'); validateRoster(ctx.players); + const expansions = selectedExpansions(ctx.expansions, ctx.players, ctx.advanced); validateNexusCards(ctx.cards, ctx.players); - validatePhysical(ctx.physicalCards, ctx.ecazTreachery); - validateUniverse(ctx.universe, ctx.players); + validatePhysical(ctx.physicalCards, ctx.ecazTreachery === true, expansions, ctx.players); + validateUniverse(ctx.universe, ctx.players, expansions); 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); + validateBattle(ctx.battle, expansions); } function sourceAuthority(source: HarkonnenBetrayalSource): HarkonnenBetrayalAuthority { return { provider: source.provider, declaration: source.declaration, nativeWindow: source.nativeWindow, @@ -222,6 +234,7 @@ function sourceAuthority(source: HarkonnenBetrayalSource): HarkonnenBetrayalAuth function inventory(ctx: HarkonnenBetrayalContext): string { const census: unknown[] = [ctx.cards, ctx.traitors, ctx.physicalCards]; if (ctx.ecazTreachery) census.push(true); + if (ctx.expansions?.length) census.push({ expansions: [...ctx.expansions] }); return JSON.stringify(census); } function sourceSignature(source: HarkonnenBetrayalSource): string { @@ -244,9 +257,6 @@ function validateSavedSource(value: unknown): asserts value is HarkonnenBetrayal 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; @@ -255,13 +265,21 @@ function validateSavedSource(value: unknown): asserts value is HarkonnenBetrayal } catch { throw new HarkonnenBetrayalError('Harkonnen Betrayal lost its original physical census.'); } - requireBetrayal(Array.isArray(stored) && (stored.length === 3 || (stored.length === 4 && stored[3] === true)), + requireBetrayal(Array.isArray(stored) && stored.length >= 3 && stored.length <= 5, 'Harkonnen Betrayal lost its original physical census.'); - type SavedInventory = [NexusState, NexusTraitorSnapshot, readonly Card[], true?]; - const [cards, traitors, physicalCards, independent] = stored as SavedInventory; + const [cards, traitors, physicalCards] = stored as [NexusState, NexusTraitorSnapshot, readonly Card[]]; + const independent = stored[3] === true; + const selected = stored[independent ? 4 : 3]; + requireBetrayal(stored.length === 3 + Number(independent) + Number(selected !== undefined) && + (selected === undefined || (exact(selected, ['expansions']) && Array.isArray(selected.expansions) && selected.expansions.length > 0)), + 'Harkonnen Betrayal lost its selected physical inventory.'); + const expansions = selectedExpansions(selected?.expansions, source.players, source.advanced); + validateUniverse(source.universe, source.players, expansions); + validateBattle(source.battle, expansions); + validateAuthority(sourceAuthority(source), source.players, source.battle); validateNexusCards(cards, source.players); validateNexusTraitorSnapshot(traitors, source.universe); - validatePhysical(physicalCards, independent); + validatePhysical(physicalCards, independent, expansions, source.players); requireBetrayal(traitors.players.length === source.players.length && source.players.every(player => traitors.players.some(seat => seat.id === player.id && seat.faction === player.faction)) && @@ -345,7 +363,8 @@ function validateCursor(value: unknown): asserts value is HarkonnenBetrayalCurso (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] && + event[4] !== event[6] && event[5] !== event[6] && (event[7] === event[8] || + (event[8] === CHEAP_HERO_TRAITOR && treacheryDeck(['ix']).some(card => card.kind === 'hero' && card.id === event[7]))) && value.signature === JSON.stringify(head) && head[1] === JSON.stringify(event), 'Harkonnen Betrayal has a forged or recursive cursor head.'); } @@ -389,7 +408,6 @@ export function validateHarkonnenBetrayalHistory( '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 })); diff --git a/game/nexus-ixian-betrayal.ts b/game/nexus-ixian-betrayal.ts index 57e7b102..2796ed58 100644 --- a/game/nexus-ixian-betrayal.ts +++ b/game/nexus-ixian-betrayal.ts @@ -91,10 +91,9 @@ 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) && + (expansion === 'ix' || expansion === 'choam' || expansion === 'ecaz') && 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.'); + 'Ixian Betrayal requires the selected Ixian deck and distinct registered expansion decks.'); return expansions; } function canonicalCard(value: unknown, canonical: ReadonlyMap): value is Card { diff --git a/game/reference.ts b/game/reference.ts index 8a1ff6ea..ff8fc2fe 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -6197,6 +6197,7 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'In the fresh local ixian-betrayal preview, an unallied rival may spend the physical Ixian Nexus to prevent one original native Bidding inspection/draw attempt or one Advanced Technology exchange before its effect.', searchText: 'Ixian Nexus Betrayal Bidding extra card inspection draw Advanced Technology exchange native counter neutral acknowledgement', steps: [ + 'Supported public selected-deck Nexus games with native Ixians validate the actual selected Ix/CHOAM/Ecaz stock and separate Richese cache, whether or not independent Ecaz cards are selected. Basic Bidding can retain its original Betrayal response and auction continuation; wider combinations remain incomplete.', 'This fresh local profile admits two to six unique Basic or Advanced seats with native Ixians and otherwise classic factions or Tleilaxu, the physical forty-seven-card Ix Treachery Deck and Nexus alone. CHOAM, Richese, Ecaz, Moritani, Homeworlds, Leader Skills, Semuta, Discoveries and other optional overlays are excluded. It is not a public start, reset or upgrade to an old save.', 'Nexus has no initial deal or automatic draw. Finish a genuine qualifying closing Nexus with an alliance before an unallied seat may draw. One natural receiver needs at least three actual seats; two simultaneous receivers need four. A two-seat profile can continue ordinary play without fabricating an unreachable Betrayal card.', 'The photographed publisher face grants both alternatives, but one card cannot prevent both. Remain unallied and hold the actual singleton Ixian Nexus while another seat is native Ixians. Use discards that physical Nexus once, without a Treachery Card, Karama, activation spice, hand slot or force cost. Cunning and absent-Ixian Secret Ally remain separate modes.', @@ -6233,6 +6234,7 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'Fresh supported classic starts cancel one original native Harkonnen traitor call, return that physical card immediately and give Harkonnen a private replacement during Mentat Pause.', searchText: 'Harkonnen Nexus Betrayal revealed declared traitor ally caller beneficiary shuffle replacement Mentat neutral acknowledgement', steps: [ + 'In supported Basic selected-deck Nexus starts that initialize this reaction, native expansion leaders and the Ix Cheap Hero Traitor can also be declared. The selected stock and Richese cache remain physical; an owed replacement waits through the Ix phase-opening acknowledgements before the original Mentat draw. Advanced selected-deck reactions remain unfinished and their original calls continue without this interruption.', 'Original standalone preview requires native Harkonnen, classic2–6 Basic/Advanced, base33 and Nexus alone. Fresh classic Nexus core initializes the same effect with base33 or selected36, optional14, original Tech3+ and Advanced Strongholds2+, alongside actual Guild/Ixian reactions. Public selection binds the census; real held-card effects and cleanup stay native. No played game is reflagged; other native/module envelopes remain separate.', 'Nexus has no initial deal. An actual alliance-qualified closing Nexus must supply the unallied holder. An effect scenario needs at least three seats; two seats can continue ordinary play without inventing a card. Two simultaneous receivers need four actual seats.', 'The photographed publisher face cancels a revealed Harkonnen traitor, shuffles that exact card back immediately, and gives Harkonnen one replacement during Mentat Pause. It is not a draw-now exchange, whole-battle ban or refund of an already resolved outcome. Native Cunning and absent-Harkonnen Secret Ally remain separate effects.', diff --git a/tests/fixture-nexus-harkonnen-betrayal.ts b/tests/fixture-nexus-harkonnen-betrayal.ts index a88e78ee..bb7c2374 100644 --- a/tests/fixture-nexus-harkonnen-betrayal.ts +++ b/tests/fixture-nexus-harkonnen-betrayal.ts @@ -31,6 +31,10 @@ export function nextHarkonnenBetrayalNativeStep(game:Game):HarkonnenBetrayalNati return {actor:game.battle.preparation.owner,action:{type:'declineBattlePower'}}; if(game.decision?.kind==='fullPlanOffer') return {actor:game.decision.player,action:{type:'decision',decline:true}}; + if(!game.response&&!game.phaseOpening&&!game.decision&&!game.truthtrance&&!game.pendingNullentropy) { + if(game.phase===2&&game.charityOrder?.remaining[0]) return {actor:game.charityOrder.remaining[0],action:{type:'ready'}}; + if(game.phase===4&&game.revivalOrder?.remaining[0]) return {actor:game.revivalOrder.remaining[0],action:{type:'ready'}}; + } if(game.status==='playing'&&!game.decision) { if(game.phase===0&&game.stormPending===null) return {actor:game.stormDialers.find(id=>game.stormDials[id]===undefined)!,action:{type:'stormDial',amount:game.turn===1?0:1}}; if(game.phase===3&&game.auction) return {actor:game.auction.active,action:{type:'passBid'}}; diff --git a/tests/fixture-nexus-ixian-betrayal.ts b/tests/fixture-nexus-ixian-betrayal.ts index b0746c0e..cab5ada7 100644 --- a/tests/fixture-nexus-ixian-betrayal.ts +++ b/tests/fixture-nexus-ixian-betrayal.ts @@ -31,6 +31,10 @@ function nativeStep(game:Game):{actor:string;action:Action} { if(game.decision?.kind==='ixTechnology') return {actor:game.decision.player,action:{type:'decision',decline:true}}; if(game.status==='playing' && !game.decision) { + if(game.phase===2 && game.charityOrder?.remaining[0]) + return {actor:game.charityOrder.remaining[0],action:{type:'ready'}}; + if(game.phase===4 && game.revivalOrder?.remaining[0]) + return {actor:game.revivalOrder.remaining[0],action:{type:'ready'}}; if(game.phase===0 && game.stormPending===null) return {actor:game.stormDialers.find(id=>game.stormDials[id]===undefined)!,action:{type:'stormDial',amount:game.turn===1?0:1}}; if(game.phase===3 && game.auction) return {actor:game.auction.active,action:{type:'passBid'}}; if(game.phase===5) return {actor:game.active!,action:{type:'endMovement'}}; @@ -103,6 +107,23 @@ export function createIxianNexusBetrayalFixture(options:IxianBetrayalFixtureOpti } else game=step(game); } assert.equal(game.status,'playing'); + // Ixian setup shuffles the remaining starting cards before dealing, so the + // earlier conserved deck order alone cannot guarantee Harkonnen's printed + // Technology counter and a different card to return. Swap actual dealt + // cards with another original hand or the deck, without creating cards. + const harkonnen = game.players.find(p => p.faction === 'harkonnen'); + if (kind === 'technology' && harkonnen) { + const sources = [game.deck, ...game.players.filter(p => p.id !== harkonnen.id).map(p => p.hand)]; + const ensure = (predicate: (card: Card) => boolean) => { + if (harkonnen.hand.some(predicate)) return; + const source = sources.find(cards => cards.some(predicate)); assert.ok(source); + const index = source.findIndex(predicate); + const replacement = harkonnen.hand.findIndex(card => !predicate(card)); assert.ok(replacement >= 0); + [source[index], harkonnen.hand[replacement]] = [harkonnen.hand[replacement], source[index]]; + }; + ensure(card => card.effect === 'karama'); + ensure(card => card.effect !== 'karama'); + } for(let pos=0;pos<2;pos++) { const index=game.spiceDeck.findIndex((c,i)=>i>=pos&&'territory'in c); assert.ok(index>=pos);game.spiceDeck.splice(pos,0,game.spiceDeck.splice(index,1)[0]); diff --git a/tests/nexus-harkonnen-betrayal-expanded.test.ts b/tests/nexus-harkonnen-betrayal-expanded.test.ts new file mode 100644 index 00000000..ca1ca212 --- /dev/null +++ b/tests/nexus-harkonnen-betrayal-expanded.test.ts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { leaders, treacheryDeck } from '../game/cards'; +import { ecazTreacheryCards } from '../game/ecaz-cards'; +import { richeseCards } from '../game/richese-cards'; +import { NEXUS_FACTIONS } from '../game/nexus-cards'; +import { createTraitorDeclaration } from '../game/traitor-declarations'; +import { CHEAP_HERO_TRAITOR, traitorDeck } from '../game/traitors'; +import { + closeHarkonnenBetrayalSource, createHarkonnenBetrayalReplacement, + createHarkonnenBetrayalSource, drawHarkonnenBetrayalReplacement, + initialHarkonnenBetrayalCursor, validateHarkonnenBetrayalFrame, + validateHarkonnenBetrayalHistory, type HarkonnenBetrayalContext, +} from '../game/nexus-harkonnen-betrayal'; + +function expanded(hero: boolean): HarkonnenBetrayalContext { + const players: HarkonnenBetrayalContext['players'] = [ + {id:'h', faction:'harkonnen', ally:null}, {id:'i', faction:'ixians', ally:null}, + {id:'r', faction:'richese', ally:null}, {id:'a', faction:'atreides', ally:null}, + ]; + const expansions = ['ix', 'choam']; + const physicalCards = [...treacheryDeck(expansions), ...ecazTreacheryCards(), ...richeseCards()]; + const heroLeaderIds = physicalCards.filter(card => card.kind === 'hero').map(card => card.id); + const identity = hero ? CHEAP_HERO_TRAITOR : leaders('ixians')[0].id; + const universe = traitorDeck(players.map(player => ({leaders:leaders(player.faction)})), true); + return { + status:'playing', phase:6, turn:2, advanced:false, sequence:0, + expansions, ecazTreachery:true, players, physicalCards, universe, + cards:{version:1, deck:NEXUS_FACTIONS.filter(faction => faction !== 'harkonnen'), + discard:[], hands:{h:null, i:null, r:null, a:'harkonnen'}}, + traitors:{reserve:universe.filter(card => card !== identity), players:players.map(player => ({ + id:player.id, faction:player.faction, traitors:player.id === 'h' ? [identity] : [], + }))}, + battle:{event:'expanded-battle', attacker:'h', defender:'i', heroLeaderIds, + plans:{h:{leader:leaders('harkonnen')[0].id}, i:{leader:hero ? heroLeaderIds[0] : identity}}}, + }; +} + +for (const hero of [false, true]) void test(`Basic selected Ix and CHOAM census binds a ${hero ? 'Cheap Hero' : 'native Ixian'} traitor and continues`, () => { + const ctx = expanded(hero); + const declaration = createTraitorDeclaration({...ctx.battle, players:ctx.players.map(player => ({...player, + traitors:ctx.traitors.players.find(seat => seat.id === player.id)!.traitors}))}, 'h'); + const authority = {provider:'h', declaration, nativeWindow:'direct' as const, + nativeContext:'original-direct-call', nativeRequired:[], nativePassed:[]}; + const source = createHarkonnenBetrayalSource(ctx, authority, 'original-battle'); + assert.equal(source.declaration.identity, hero ? CHEAP_HERO_TRAITOR : leaders('ixians')[0].id); + validateHarkonnenBetrayalFrame(ctx, source, authority, 'original-battle', ['a'], []); + for (const outcome of ['pass', 'use'] as const) { + const closed = closeHarkonnenBetrayalSource(source, outcome, outcome === 'use' ? 'a' : null, + initialHarkonnenBetrayalCursor()); + validateHarkonnenBetrayalHistory([closed.receipt], closed.cursor, ctx.universe); + if (outcome === 'use') { + const due = createHarkonnenBetrayalReplacement(closed.receipt); + const done = drawHarkonnenBetrayalReplacement(due, [closed.receipt], closed.cursor, ctx.traitors.reserve[0], ctx.universe); + assert.equal(done.status, 'drawn'); + } + } + for (const bad of [ + {...ctx, expansions:['choam']}, + {...ctx, physicalCards:ctx.physicalCards.slice(1)}, + {...ctx, universe:ctx.universe.filter(id => id !== CHEAP_HERO_TRAITOR)}, + {...ctx, battle:{...ctx.battle, heroLeaderIds:[]}}, + {...ctx, advanced:true}, + ]) assert.throws(() => createHarkonnenBetrayalSource(bad, authority, 'original-battle')); +}); diff --git a/tests/nexus-harkonnen-betrayal-runtime.test.ts b/tests/nexus-harkonnen-betrayal-runtime.test.ts index 90cb27f8..adf8b325 100644 --- a/tests/nexus-harkonnen-betrayal-runtime.test.ts +++ b/tests/nexus-harkonnen-betrayal-runtime.test.ts @@ -157,12 +157,15 @@ void test('A real retirement shuffle may return the same physical identity as th assert.equal('nexusHarkonnenReplacementHistory' in rival,false); stock(mentat); }); -void test('Fresh admission rejects replay, modules and ordinary-game retrofit while preserving lobby immutably',()=>{ +void test('Fresh admission rejects replay and accepts an unused selected Ix deck while preserving the lobby',()=>{ let lobby=createGame('HARKBOUND',newPlayer('h','H','harkonnen'),false);joinGame(lobby,newPlayer('g','G','guild')); for(const p of lobby.players) lobby=applyAction(lobby,p.id,{type:'ready'}); lobby.nexusCards={cards:null,phase:null}; const before=JSON.stringify(lobby),started=initializeHarkonnenNexusBetrayalGameForAudit(lobby); assert.equal(JSON.stringify(lobby),before);assert.equal(started.nexusHarkonnenBetrayalPreview,true); assert.throws(()=>initializeHarkonnenNexusBetrayalGameForAudit(started)); - const modular=structuredClone(lobby);modular.expansions=['ix'];assert.throws(()=>initializeHarkonnenNexusBetrayalGameForAudit(modular)); + const modular=structuredClone(lobby);modular.expansions=['ix']; + const modularBefore=JSON.stringify(modular),selected=initializeHarkonnenNexusBetrayalGameForAudit(modular); + assert.equal(JSON.stringify(modular),modularBefore);assert.equal(selected.nexusHarkonnenBetrayalPreview,true); + assert.deepEqual(selected.expansions,['ix']);assert.ok(selected.nexusCards?.cards); }); diff --git a/tests/nexus-harkonnen-expanded-runtime.test.ts b/tests/nexus-harkonnen-expanded-runtime.test.ts new file mode 100644 index 00000000..4acb481f --- /dev/null +++ b/tests/nexus-harkonnen-expanded-runtime.test.ts @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game} from '../game/engine'; +import {TERRITORIES} from '../game/board'; +import {traitorDeck} from '../game/traitors'; +import {validateNexusTraitorSnapshot} from '../game/nexus-traitor-exchange'; +import {nextHarkonnenBetrayalNativeStep, settleHarkonnenBetrayalNativeCounters, +} from './fixture-nexus-harkonnen-betrayal'; +import {nextRicheseStrongholdsNativeStep} from './fixture-richese-strongholds'; + +const act = (game:Game, actor:string, action:Action) => applyAction(game,actor,action); +const player = (game:Game, id:string) => {const seat=game.players.find(p=>p.id===id);assert.ok(seat);return seat;}; +const step = (game:Game) => { + if(game.pendingGuildBetrayal) {const frame=game.pendingGuildBetrayal;const actor=frame.required.find(id=>!frame.passed.includes(id));assert.ok(actor);return act(game,actor,{type:'guildBetrayalPass',event:frame.invoice.event});} + const next=(!game.response && !game.phaseOpening && (game.decision?.kind.startsWith('richese') || game.richeseAuction && !game.richeseAuction.outcome)) ? nextRicheseStrongholdsNativeStep(game)! : nextHarkonnenBetrayalNativeStep(game); try {return act(game,next.actor,next.action);} catch (error) {throw Error(`Step ${game.turn}/${game.phase}/${game.decision?.kind} ${JSON.stringify(next)}: ${String(error)}`);}}; +function advance(game:Game, until:(game:Game)=>boolean, limit=1800):Game { + for(let i=0;ip.hand)].map(card=>card.id).sort(); +} +function traitors(game:Game):void { + validateNexusTraitorSnapshot({reserve:game.traitorReserve!,players:game.players.map(({id,faction,traitors,faceDancers})=>({id,faction,traitors,...(faceDancers?{faceDancers}: {})}))},traitorDeck(game.players,game.expansions.includes('ix'))); +} +/** Native public selected-deck start, original setup and second-turn Nexus + * draw. Conserved unopened deck order, one held traitor and two physical + * reserve positions are staged; battle selection, plans and call are real. */ +function fixture() { + let game=createGame('HARKEXPANDED',newPlayer('i','Ixians','ixians'),false,['ix','choam','ecaz']); + for(const [id,faction] of [['h','harkonnen'],['r','richese'],['g','guild']] as const) + joinGame(game,newPlayer(id,faction,faction)); + game=act(game,'i',{type:'moduleOption',module:'nexusCards',enabled:true}); + for(const seat of game.players) game=act(game,seat.id,{type:'ready'}); + game=act(game,'i',{type:'start'}); + assert.equal(game.nexusHarkonnenBetrayalPreview,true); + game=advance(game,state=>state.status==='playing'); + const physical=inventory(game);traitors(game); + assert.ok(game.richeseCache?.length,'Native Richese retains its separate cache'); + assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card=>card===null)); + const identity=player(game,'i').leaders.find(leader=>!leader.dead)!.id; + const h=player(game,'h'); + if(!h.traitors.includes(identity)) { + const replacement=h.traitors[0]; + const owner=game.players.find(seat=>seat.id!=='h'&&seat.traitors.includes(identity)); + if(owner) owner.traitors[owner.traitors.indexOf(identity)]=replacement; + else { + const dancer=game.players.flatMap(seat=>seat.faceDancers??[]).find(card=>card.leader===identity); + if(dancer) dancer.leader=replacement; + else {const index=game.traitorReserve!.indexOf(identity);assert.ok(index>=0);game.traitorReserve![index]=replacement;} + } + h.traitors[0]=identity; + } + traitors(game); + const nexus=game.nexusCards!.cards!; + const nx=nexus.deck.indexOf('harkonnen');assert.ok(nx>=0); + nexus.deck.unshift(nexus.deck.splice(nx,1)[0]); + for(let pos=0;pos<2;pos++) { + const index=game.spiceDeck.findIndex((card,i)=>i>=pos&&'territory'in card);assert.ok(index>=pos); + game.spiceDeck.splice(pos,0,game.spiceDeck.splice(index,1)[0]); + } + game=advance(game,state=>state.turn===2&&state.phase===1&&!state.response&&!state.decision); + const worm=game.spiceDeck.findIndex(card=>'worm'in card&&!card.greatMaker&&!card.suppressed);assert.ok(worm>=0); + game.spiceDeck.unshift(game.spiceDeck.splice(worm,1)[0]); + game=advance(game,state=>state.nexus&&!state.spiceWindow&&!state.spiceResolution&&!state.response&&!state.decision); + game=act(game,'h',{type:'alliance',target:'r'});game=act(game,'r',{type:'alliance',target:'h'}); + game=advance(game,state=>state.nexusCards?.phase?.stage==='drawing'); + assert.ok(game.nexusCards!.phase!.eligible.includes('i')); + game=act(game,'i',{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0}); + assert.equal(game.nexusCards!.cards!.hands.i,'harkonnen'); + game=advance(game,state=>state.phase===5&&!state.response&&!state.decision); + const territory=TERRITORIES.find(t=>t.type==='sand'&&!t.sectors.includes(game.storm)&& + game.players.every(p=>!Object.keys(p.forces).some(key=>key.startsWith(`${t.id}:`))));assert.ok(territory); + for(const id of ['h','i']) {const seat=player(game,id);assert.ok(seat.reserves>0);seat.reserves--;seat.forces[`${territory.id}:${territory.sectors[0]}`]=1;} + game=advance(game,state=>state.phase===6&&!!state.active&&!state.phaseOpening&&!state.response&&!state.decision); + const chooser=game.active!;assert.ok(['h','i'].includes(chooser), `chooser ${chooser}; battle ${JSON.stringify(game.battle)}; decision ${JSON.stringify(game.decision)}; pending ${JSON.stringify(game.pendingGuildBetrayal)}; armies ${JSON.stringify(game.players.map(p=>[p.id,p.forces]))}`); + game=act(game,chooser,{type:'chooseBattle',territory:territory.id,target:chooser==='h'?'i':'h'}); + game=advance(game,state=>!!state.battle&&!state.response&&!state.decision&&!state.battle.preparation&& + (!state.battle.preLeader||state.battle.preLeader.closed)); + for(const id of [game.battle!.attacker,game.battle!.defender]) { + const leader=id==='i'?identity:player(game,id).leaders.find(l=>!l.dead&&!l.usedAt)!.id; + game=act(game,id,{type:'battlePlan',dial:0,leader}); + } + assert.equal(game.battle!.revealed,true); + game=advance(game,state=>!state.response&&!state.decision); + game=act(game,'h',{type:'traitorCall',call:true}); + game=settleHarkonnenBetrayalNativeCounters(game); + const reaction=viewGame(game,'i').nexusHarkonnenBetrayalReaction; + assert.ok(reaction,'The actual expansion-roster traitor call opens native Betrayal'); + assert.equal(reaction.canUse,true);assert.equal(reaction.identity,identity); + assert.deepEqual(inventory(game),physical);traitors(game); + return {game,event:reaction.event,identity,physical}; +} + +for(const use of [false,true]) void test(`Basic public Ix/CHOAM roster with unused Ecaz deck Harkonnen Nexus Betrayal ${use?'Use':'Pass'} after native Ixian leader traitor call`,()=>{ + const f=fixture();let game=f.game; + if(use) game=act(game,'i',{type:'nexusHarkonnenBetrayalUse',event:f.event}); + else { + for(const id of game.pendingNexusHarkonnenBetrayal!.required) + game=act(game,id,{type:'nexusHarkonnenBetrayalPass',event:f.event}); + } + assert.equal(game.pendingNexusHarkonnenBetrayal,null); + assert.equal(game.nexusCards!.cards!.hands.i,use?null:'harkonnen'); + assert.equal(game.battle!.traitorCalls.h,!use); + assert.equal(player(game,'h').traitors.includes(f.identity),!use); + assert.equal(game.pendingNexusHarkonnenReplacement?.status??null,use?'due':null); + assert.deepEqual(inventory(game),f.physical);traitors(game); + game=advance(game,state=>state.phase===8); + assert.equal(game.phaseOpening?.initialize,true, 'Ix opening precedes actual Mentat initialization'); + assert.equal(game.pendingNexusHarkonnenReplacement?.status??null,use?'due':null); + game=advance(game,state=>state.phase===8&&!state.pendingNexusHarkonnenReplacement&&!state.phaseOpening); + assert.equal(game.lastBattleContext!.result,use?'normal':'traitor'); + assert.equal(game.nexusHarkonnenReplacementHistory!.length,use?1:0); + assert.equal(player(game,'h').traitors.length,4); + assert.deepEqual(inventory(game),f.physical);traitors(game); +}); diff --git a/tests/nexus-ixian-betrayal-selected-decks.test.ts b/tests/nexus-ixian-betrayal-selected-decks.test.ts new file mode 100644 index 00000000..e1d81948 --- /dev/null +++ b/tests/nexus-ixian-betrayal-selected-decks.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { treacheryDeck } from '../game/cards'; +import { ecazTreacheryCards } from '../game/ecaz-cards'; +import { NEXUS_FACTIONS } from '../game/nexus-cards'; +import { richeseCards } from '../game/richese-cards'; +import { + closeIxianBetrayalSource, createIxianBetrayalSource, initialIxianBetrayalCursor, + ixianBetrayalResponders, validateIxianBetrayalFrame, validateIxianBetrayalHistory, + type IxianBetrayalContext, +} from '../game/nexus-ixian-betrayal'; + +for (const independent of [false, true]) void test( + `Basic Ixian Nexus uses actual selected decks with${independent ? '' : 'out'} independent Ecaz cards`, () => { + const expansions = ['ix', 'choam', 'ecaz']; + const ctx: IxianBetrayalContext = { + status:'playing', phase:3, turn:2, advanced:false, sequence:0, expansions, ecazTreachery:independent, + players:[{id:'ix',faction:'ixians'}, {id:'h',faction:'harkonnen'}, + {id:'r',faction:'richese'}, {id:'g',faction:'guild'}], + cards:{version:1, deck:NEXUS_FACTIONS.filter(id => id !== 'ixians'), + discard:[], hands:{ix:null,h:'ixians',r:null,g:null}}, + physicalCards:[...treacheryDeck(expansions), ...richeseCards(), ...(independent ? ecazTreacheryCards() : [])], + }; + const selection = {expansions, ecazTreachery:independent, richese:true}; + assert.deepEqual(ixianBetrayalResponders(ctx.cards, ctx.players, selection), ['h']); + validateIxianBetrayalHistory([], initialIxianBetrayalCursor(), ctx.physicalCards, selection); + const authority = {kind:'bidding' as const, provider:'ix', nativeWindow:'ixAuction' as const, + nativeContext:'actual-native-bidding', nativeRequired:[], nativePassed:[]}; + const source = createIxianBetrayalSource(ctx, authority, 'parent'); + validateIxianBetrayalFrame(ctx, source, authority, 'parent', ['h'], []); + for (const outcome of ['pass', 'use'] as const) { + const result = closeIxianBetrayalSource(source, outcome, outcome === 'use' ? 'h' : null, initialIxianBetrayalCursor()); + validateIxianBetrayalHistory([result.receipt], result.cursor, ctx.physicalCards, selection); + } + assert.throws(() => validateIxianBetrayalHistory([], initialIxianBetrayalCursor(), ctx.physicalCards.slice(1), selection)); + assert.throws(() => validateIxianBetrayalHistory([], initialIxianBetrayalCursor(), ctx.physicalCards, + {...selection, expansions:['ix', 'choam', 'ecaz', 'ecaz']})); + assert.throws(() => ixianBetrayalResponders(ctx.cards, ctx.players, {...selection, expansions:['ix', 'ecaz']})); + }, +); diff --git a/tests/nexus-ixian-selected-decks-runtime.test.ts b/tests/nexus-ixian-selected-decks-runtime.test.ts new file mode 100644 index 00000000..1b24962f --- /dev/null +++ b/tests/nexus-ixian-selected-decks-runtime.test.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { nextHarkonnenBetrayalNativeStep } from './fixture-nexus-harkonnen-betrayal'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; + +function inventory(game: Game): string[] { + return [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), + ...(game.auction?.cards.slice(game.auction.index) ?? []), ...(game.ixAuction?.cards ?? []), + ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(); +} + +function advance(game:Game, until:(state:Game)=>boolean):Game { + for(let i=0;i<1800;i++) { + if(until(game)) return game; + if(game.pendingGuildBetrayal) { + const pending=game.pendingGuildBetrayal; + const actor=pending.required.find(id=>!pending.passed.includes(id)); assert.ok(actor); + game=applyAction(game,actor,{type:'guildBetrayalPass',event:pending.invoice.event}); + continue; + } + const next=(!game.response && !game.phaseOpening && + (game.decision?.kind.startsWith('richese') || game.richeseAuction && !game.richeseAuction.outcome)) + ? nextRicheseStrongholdsNativeStep(game) : nextHarkonnenBetrayalNativeStep(game); + assert.ok(next); + try { game=applyAction(game,next.actor,next.action); } + catch(error) { throw Error(`Native step ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind} ${JSON.stringify(next)}: ${String(error)}`); } + } + throw Error(`Native Ixian journey stalled at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`); +} + +/** Public start and real setup, Nexus draw and Ixian Bidding attempt. Conserved + * unopened Spice and Nexus cards are reordered to reach this window; no hand, + * phase or pending event is injected. */ +function fixture() { + let game = createGame('IXSELECTED', newPlayer('ix', 'Ixians', 'ixians'), false, ['ix', 'choam', 'ecaz']); + for (const [id, faction] of [['a', 'atreides'], ['r', 'richese'], ['g', 'guild']] as const) + joinGame(game, newPlayer(id, faction, faction)); + game = applyAction(game, 'ix', {type:'moduleOption', module:'nexusCards', enabled:true}); + for (const player of game.players) game = applyAction(game, player.id, {type:'ready'}); + game = applyAction(game, 'ix', {type:'start'}); + assert.equal(game.nexusIxianBetrayalPreview, true); + assert.ok(game.richeseCache?.length); + assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null)); + game=advance(game,state=>state.status==='playing'); + const nx=game.nexusCards!.cards!; + const face=nx.deck.indexOf('ixians'); assert.ok(face>=0); + nx.deck.unshift(nx.deck.splice(face,1)[0]); + for(let pos=0;pos<2;pos++) { + const index=game.spiceDeck.findIndex((card,i)=>i>=pos&&'territory'in card); assert.ok(index>=pos); + game.spiceDeck.splice(pos,0,game.spiceDeck.splice(index,1)[0]); + } + game=advance(game,state=>state.turn===2&&state.phase===1&&!state.response&&!state.decision); + const worm=game.spiceDeck.findIndex(card=>'worm'in card&&!card.greatMaker&&!card.suppressed); assert.ok(worm>=0); + game.spiceDeck.unshift(game.spiceDeck.splice(worm,1)[0]); + game=advance(game,state=>state.nexus&&!state.spiceWindow&&!state.spiceResolution&&!state.response&&!state.decision); + game=applyAction(game,'ix',{type:'alliance',target:'r'}); + game=applyAction(game,'r',{type:'alliance',target:'ix'}); + game=advance(game,state=>state.nexusCards?.phase?.stage==='drawing'); + assert.ok(game.nexusCards!.phase!.eligible.includes('a')); + game=applyAction(game,'a',{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0}); + game=advance(game,state=>state.response?.kind==='ixAuction'||!!state.pendingNexusIxianBetrayal); + const nativeCount=game.ixAuction?.count; assert.ok(nativeCount); + game=advance(game,state=>!!state.pendingNexusIxianBetrayal); + const event=game.pendingNexusIxianBetrayal!.source.event; + return {game,event,nativeCount,provider:'ix',holder:'a'}; +} + +for (const use of [false, true]) void test( + `Basic public mixed selected decks and Richese retain Ixian Nexus Betrayal ${use ? 'use' : 'pass'}`, () => { + const f = fixture(), before = f.game, original = inventory(before); + assert.equal(f.provider, 'ix'); assert.equal(f.holder, 'a'); + assert.equal(before.nexusCards!.cards!.hands.a, 'ixians'); + assert.equal(viewGame(before, 'a').nexusIxianBetrayalReaction?.canUse, true); + const botView = viewGame(before, 'a'); + botView.players.find(player => player.id === 'a')!.bot = 'Easy'; + const bot = botActions(botView).find(action => action.type === 'nexusIxianBetrayalUse' || action.type === 'nexusIxianBetrayalPass'); + assert.ok(bot); assert.doesNotThrow(() => applyAction(before, 'a', bot)); + assert.ok(before.richeseCache?.length); + let game = structuredClone(before); + if (use) game = applyAction(game, f.holder, {type:'nexusIxianBetrayalUse', event:f.event}); + else while (game.pendingNexusIxianBetrayal) { + const pending = game.pendingNexusIxianBetrayal; + const actor = pending.required.find(id => !pending.passed.includes(id)); assert.ok(actor); + game = applyAction(game, actor, {type:'nexusIxianBetrayalPass', event:f.event}); + } + assert.equal(game.pendingNexusIxianBetrayal, null); + assert.equal(game.nexusIxianBetrayalHistory!.at(-1)!.outcome, use ? 'use' : 'pass'); + assert.equal(game.nexusIxianBetrayalCursor!.sequence, before.nexusIxianBetrayalCursor!.sequence + 1); + assert.equal(game.nexusCards!.cards!.hands.a, use ? null : 'ixians'); + assert.equal(game.nexusCards!.cards!.discard.length, before.nexusCards!.cards!.discard.length + Number(use)); + assert.equal(before.deck.length - game.deck.length, f.nativeCount + Number(!use)); + if (use) { + assert.equal(game.ixAuction, null); + assert.equal(game.auction!.cards.length, f.nativeCount); + } else { + assert.equal(game.decision?.kind, 'ixAuction'); + assert.equal(game.ixAuction!.cards.length, f.nativeCount + 1); + assert.equal(game.auction, null); + } + assert.deepEqual(inventory(game), original); + assert.deepEqual(game.richeseCache, before.richeseCache); + if (!use) { + const returned = game.ixAuction!.cards[0].id; + game = applyAction(game, 'ix', {type:'decision', card:returned, position:'top'}); + assert.equal(game.ixAuction, null); + assert.equal(game.auction!.cards.length, f.nativeCount); + assert.equal(game.deck[0].id, returned); + assert.deepEqual(inventory(game), original); + } + });