From eaaab566e244da7c2c606a13d1fc84040e17a801 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:22:31 +1100 Subject: [PATCH] Enable Advanced selected-deck Harkonnen Nexus reaction --- docs/CURRENT_STATUS.md | 10 ++ docs/NEXUS_HARKONNEN_RULES.md | 24 +++- docs/RULE_DECISIONS.md | 7 + game/engine.ts | 5 +- game/nexus-harkonnen-betrayal.ts | 50 ++++--- game/reference.ts | 2 +- tests/advanced-nexus-harkonnen-census.test.ts | 80 +++++++++++ .../advanced-nexus-harkonnen-selected.test.ts | 135 ++++++++++++++++++ .../nexus-harkonnen-betrayal-expanded.test.ts | 2 +- 9 files changed, 288 insertions(+), 27 deletions(-) create mode 100644 tests/advanced-nexus-harkonnen-census.test.ts create mode 100644 tests/advanced-nexus-harkonnen-selected.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 1d7dba23..a9532611 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,16 @@ open mode gates. ## Current checkpoint and work +**Advanced selected-deck Harkonnen Nexus Betrayal — 30 October. Prototyped / +Partial.** Supported Advanced selected-deck core games now open the original +traitor reaction and Mentat replacement for native Ixian, Tleilaxu, CHOAM and +Richese leaders, including the Auditor. The census also handles Ecaz Loyalty, +Face Dancers and Moritani's retired cards. Seven related files pass 63 cases; +type checking and lint pass. The latter E3 identities have helper evidence +only. Advanced Ecaz/Moritani roster initialization, broader combinations, +complete games and deployed +acceptance remain open. [Scope](NEXUS_HARKONNEN_RULES.md#advanced-selected-decks--30-october-2026). + **Basic mixed-deck Emperor gifts during Extortion — 30 October. Prototyped / Partial.** Supported Basic games with selected Ix and CHOAM decks now retain the Emperor's gift while an ally chooses Extortion payment. The original diff --git a/docs/NEXUS_HARKONNEN_RULES.md b/docs/NEXUS_HARKONNEN_RULES.md index e9dc5fb8..72d79145 100644 --- a/docs/NEXUS_HARKONNEN_RULES.md +++ b/docs/NEXUS_HARKONNEN_RULES.md @@ -2,6 +2,22 @@ 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. +## Advanced selected decks — 30 October 2026 + +Fresh Advanced selected-deck core games can now open the existing Harkonnen +Betrayal reaction for native expansion leaders, including CHOAM's Auditor, +and the Ix Cheap Hero. The original call, native counter, physical Traitor, +Use/Pass and private Mentat replacement retain their existing continuation. +The canonical Traitor census includes the Advanced Auditor, the exact public +Ecaz Loyalty exclusion, Face Dancers and retired Moritani cards; the latter +two E3 identities are helper evidence rather than public E3-start acceptance. +The selected-deck receipt stores Loyalty only when present; earlier receipts +retain their existing format. [Public Ix/CHOAM journeys](../tests/advanced-nexus-harkonnen-selected.test.ts) +and [census cases](../tests/advanced-nexus-harkonnen-census.test.ts) stage +selected physical inventory and Nexus draws. The existing uniform reaction +priority remains provisional. Advanced Ecaz/Moritani roster initialization, +wider module combinations, complete games and deployed acceptance remain open. + ## Basic selected decks — 30 October 2026 Supported fresh selected-deck core games now retain the existing Harkonnen @@ -9,9 +25,11 @@ 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). +existing provisional acknowledgement convention is unchanged. At that point +Advanced selected-deck reactions, uninitialized profiles, wider nested +interactions and complete acceptance remained open; the newer section above +records the subsequent Advanced core support. +[Scope and evidence](NEXUS_CARD_RULES.md#basic-mixed-deck-nexus-reactions--30-october-2026). ## Earned phase cards and original replacement — 18 October 2026 diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 2de40798..951d3154 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,12 @@ # Rule decision index +## Advanced selected-deck Harkonnen Nexus reaction — 30 October 2026 + +The existing native-counter-first and neutral acknowledgement convention +extends to supported Advanced selected-deck core games. Auditor and public +Loyalty identities use the original physical census; no new publisher timing +ruling is implied. [Scope](NEXUS_HARKONNEN_RULES.md#advanced-selected-decks--30-october-2026). + ## Basic mixed-deck Emperor gift during Extortion — 30 October 2026 The existing provisional allied-gift and Continue ordering applies to diff --git a/game/engine.ts b/game/engine.ts index f5bb8b7c..a3e47361 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -12657,6 +12657,7 @@ function harkonnenNexusBetrayalContext(g: Game) { 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} : {}), + ...(g.ecazLoyalty ? {ecazLoyalty:g.ecazLoyalty} : {}), sequence:g.nexusHarkonnenBetrayalCursor!.sequence,players:g.players, cards:g.nexusCards!.cards!,physicalCards:physicalTreacheryCards(g), traitors:nexusTraitorSnapshot(g),universe:nexusTraitorUniverse(g),battle}; @@ -12752,10 +12753,6 @@ 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; diff --git a/game/nexus-harkonnen-betrayal.ts b/game/nexus-harkonnen-betrayal.ts index ef63d6b3..04ac93ae 100644 --- a/game/nexus-harkonnen-betrayal.ts +++ b/game/nexus-harkonnen-betrayal.ts @@ -1,4 +1,4 @@ -import { leaders, treacheryDeck, type Card } from './cards'; +import { createAuditorLeader, leaders, treacheryDeck, type Card } from './cards'; import { FACTIONS } from './catalog'; import { ecazTreacheryCards } from './ecaz-cards'; import { richeseCards } from './richese-cards'; @@ -29,7 +29,9 @@ export type HarkonnenBetrayalContext = { sequence: number; /** Public selection; absence retains the original base33 census. */ ecazTreachery?: boolean; - /** Selected physical decks for a fresh Basic expansion game. */ + /** Original public Loyalty marker; absent on older games that never set it. */ + ecazLoyalty?: { player: string; card: string | null }; + /** Selected physical decks for the original game. */ expansions?: readonly string[]; players: readonly NexusPlayer[]; cards: NexusState; @@ -111,18 +113,25 @@ function validateRoster(players: readonly NexusPlayer[]): void { function roster(players: readonly NexusPlayer[]): NexusPlayer[] { return players.map(({ id, faction, ally }) => ({ id, faction, ally: ally ?? null })); } -function selectedExpansions(value: unknown, players: readonly NexusPlayer[], advanced: boolean): string[] { +function selectedExpansions(value: unknown, players: readonly NexusPlayer[]): 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) && + new Set(expansions).size === expansions.length && 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.'); + }), 'Harkonnen Betrayal requires registered selected faction decks.'); return expansions; } -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')); +function validateUniverse(universe: readonly string[], players: readonly NexusPlayer[], expansions: readonly string[], + advanced: boolean, loyalty?: {player:string;card:string | null}): void { + if (loyalty !== undefined) requireBetrayal(advanced && exact(loyalty, ['player', 'card']) && + players.some(player => player.id === loyalty.player && player.faction === 'ecaz') && + typeof loyalty.card === 'string' && leaders('ecaz').some(leader => leader.id === loyalty.card), + 'Harkonnen Betrayal needs the original public Ecaz Loyalty identity.'); + const expected = traitorDeck(players.map(player => ({ leaders: [ + ...leaders(player.faction), ...(advanced && player.faction === 'choam' ? [createAuditorLeader()] : []), + ] })), expansions.includes('ix')).filter(identity => identity !== loyalty?.card); requireBetrayal(members(universe) && universe.length === expected.length && universe.every(identity => expected.includes(identity)), 'Harkonnen Betrayal needs the original complete canonical Traitor universe.'); @@ -161,7 +170,7 @@ function declarationContext( })) }; } function validateAuthority( - authority: HarkonnenBetrayalAuthority, players: readonly NexusPlayer[], battle: Battle, + authority: HarkonnenBetrayalAuthority, players: readonly NexusPlayer[], battle: Battle, advanced: boolean, ): void { requireBetrayal(exact(authority, ['provider', 'declaration', 'nativeWindow', 'nativeContext', 'nativeRequired', 'nativePassed']) && text(authority.provider) && text(authority.nativeContext) && @@ -181,7 +190,8 @@ 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 => + requireBetrayal((players.some(player => [...leaders(player.faction), + ...(advanced && player.faction === 'choam' ? [createAuditorLeader()] : [])].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.'); @@ -216,10 +226,10 @@ 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); + const expansions = selectedExpansions(ctx.expansions, ctx.players); validateNexusCards(ctx.cards, ctx.players); validatePhysical(ctx.physicalCards, ctx.ecazTreachery === true, expansions, ctx.players); - validateUniverse(ctx.universe, ctx.players, expansions); + validateUniverse(ctx.universe, ctx.players, expansions, ctx.advanced, ctx.ecazLoyalty); validateNexusTraitorSnapshot(ctx.traitors, ctx.universe); requireBetrayal(ctx.traitors.players.length === ctx.players.length && ctx.players.every(player => ctx.traitors.players.some(seat => @@ -234,7 +244,8 @@ 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] }); + if (ctx.expansions?.length) census.push({ expansions: [...ctx.expansions], + ...(ctx.ecazLoyalty ? {ecazLoyalty:structuredClone(ctx.ecazLoyalty)} : {}) }); return JSON.stringify(census); } function sourceSignature(source: HarkonnenBetrayalSource): string { @@ -271,12 +282,15 @@ function validateSavedSource(value: unknown): asserts value is HarkonnenBetrayal 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)), + (selected === undefined || (plain(selected) && exact(selected, ['expansions', + ...(Object.hasOwn(selected, 'ecazLoyalty') ? ['ecazLoyalty'] : [])]) && + 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); + const expansions = selectedExpansions(selected?.expansions, source.players); + validateUniverse(source.universe, source.players, expansions, source.advanced, + selected?.ecazLoyalty as {player:string;card:string | null} | undefined); validateBattle(source.battle, expansions); - validateAuthority(sourceAuthority(source), source.players, source.battle); + validateAuthority(sourceAuthority(source), source.players, source.battle, source.advanced); validateNexusCards(cards, source.players); validateNexusTraitorSnapshot(traitors, source.universe); validatePhysical(physicalCards, independent, expansions, source.players); @@ -302,7 +316,7 @@ export function validateHarkonnenBetrayalSource( ): void { validateSavedSource(source); validateContext(ctx); - validateAuthority(authority, ctx.players, ctx.battle); + validateAuthority(authority, ctx.players, ctx.battle, ctx.advanced); 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 && @@ -320,7 +334,7 @@ export function createHarkonnenBetrayalSource( ctx: HarkonnenBetrayalContext, authority: HarkonnenBetrayalAuthority, parent: string, ): HarkonnenBetrayalSource { validateContext(ctx); - validateAuthority(authority, ctx.players, ctx.battle); + validateAuthority(authority, ctx.players, ctx.battle, ctx.advanced); const source: HarkonnenBetrayalSource = { ...structuredClone(authority), version: 1, event: harkonnenBetrayalEvent(ctx.turn, ctx.sequence, authority.declaration, authority.provider), diff --git a/game/reference.ts b/game/reference.ts index 69ea1caa..2766aa84 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -6238,7 +6238,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: [ - 'Fresh Basic Nexus starts with selected Ix and/or CHOAM decks and no other modules initialize this reaction even when every selected family is seated, including the supported Richese/Semuta start. Native expansion leaders and the Ix Cheap Hero Traitor can be declared. 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.', + 'Fresh supported Basic and Advanced Nexus core starts with selected faction decks initialize this reaction for native expansion leaders and the Ix Cheap Hero Traitor. Advanced CHOAM’s Auditor has its real Traitor identity. An owed replacement waits through Ix phase-opening acknowledgements before the original Mentat draw. Advanced Ecaz/Moritani roster initialization and wider combinations remain unfinished.', '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/advanced-nexus-harkonnen-census.test.ts b/tests/advanced-nexus-harkonnen-census.test.ts new file mode 100644 index 00000000..b5de6062 --- /dev/null +++ b/tests/advanced-nexus-harkonnen-census.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createAuditorLeader, leaders, treacheryDeck } from '../game/cards'; +import { NEXUS_FACTIONS } from '../game/nexus-cards'; +import { createTraitorDeclaration } from '../game/traitor-declarations'; +import { traitorDeck } from '../game/traitors'; +import { closeHarkonnenBetrayalSource, createHarkonnenBetrayalReplacement, + createHarkonnenBetrayalSource, drawHarkonnenBetrayalReplacement, + initialHarkonnenBetrayalCursor, validateHarkonnenBetrayalHistory, + validateHarkonnenBetrayalSource, type HarkonnenBetrayalContext } from '../game/nexus-harkonnen-betrayal'; + +function context(advanced = true): HarkonnenBetrayalContext { + const players: HarkonnenBetrayalContext['players'] = [ + {id:'h',faction:'harkonnen',ally:null}, {id:'c',faction:'choam',ally:null}, + {id:'e',faction:'ecaz',ally:null}, {id:'t',faction:'tleilaxu',ally:null}, + {id:'m',faction:'moritani',ally:null}, {id:'a',faction:'atreides',ally:null}, + ]; + const expansions = ['ix','choam','ecaz']; + const loyalty = advanced ? {player:'e',card:'ecaz-0'} : undefined; + const universe = traitorDeck(players.map(player => ({leaders:[...leaders(player.faction), + ...(advanced && player.faction === 'choam' ? [createAuditorLeader()] : [])]})), true) + .filter(card => card !== loyalty?.card); + const target = advanced ? 'choam-auditor' : 'choam-0'; + const dancers = ['atreides-0','tleilaxu-0','ecaz-1']; + const removed = ['moritani-0']; + const traitorPlayers = players.map(player => ({id:player.id,faction:player.faction, + traitors:player.id === 'h' ? [target] : [], + ...(player.id === 't' ? {faceDancers:dancers.map(leader => ({leader,revealed:false}))} : {})})); + const occupied = [target,...dancers,...removed]; + const physicalCards = treacheryDeck(expansions); + return {status:'playing',phase:6,turn:2,advanced,sequence:0,expansions,players,physicalCards, + ...(loyalty ? {ecazLoyalty:loyalty} : {}), universe, + cards:{version:1,deck:NEXUS_FACTIONS.filter(card => card !== 'harkonnen'),discard:[], + hands:{h:null,c:null,e:null,t:null,m:null,a:'harkonnen'}}, + traitors:{reserve:universe.filter(card => !occupied.includes(card)),players:traitorPlayers,removed}, + battle:{event:'advanced-auditor-battle',attacker:'h',defender:'c', + plans:{h:{leader:'harkonnen-0'},c:{leader:target}}, + heroLeaderIds:physicalCards.filter(card => card.kind === 'hero').map(card => card.id)}}; +} +function authority(ctx: HarkonnenBetrayalContext) { + const declaration = createTraitorDeclaration({...ctx.battle,players:ctx.players.map(player => ({...player, + traitors:ctx.traitors.players.find(seat => seat.id === player.id)!.traitors}))},'h'); + return {provider:'h',declaration,nativeWindow:'direct' as const,nativeContext:'native',nativeRequired:[],nativePassed:[]}; +} + +for (const advanced of [false,true]) void test(`${advanced?'Advanced Auditor and Loyalty':'Basic native'} selected census preserves the original traitor`, () => { + const ctx=context(advanced), native=authority(ctx), source=createHarkonnenBetrayalSource(ctx,native,'parent'); + assert.equal(source.declaration.identity,advanced?'choam-auditor':'choam-0'); + assert.equal(source.universe.includes('ecaz-0'),!advanced); + assert.equal(source.universe.includes('choam-auditor'),advanced); + 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 replacement=createHarkonnenBetrayalReplacement(closed.receipt); + assert.equal(drawHarkonnenBetrayalReplacement(replacement,[closed.receipt],closed.cursor,ctx.traitors.reserve[0],ctx.universe).status,'drawn'); + } + } + const stored=JSON.parse(source.inventory); + assert.deepEqual(stored.at(-1),advanced?{expansions:ctx.expansions,ecazLoyalty:ctx.ecazLoyalty}:{expansions:ctx.expansions}); +}); + +void test('Advanced census rejects altered Loyalty, Auditor, Face Dancers and retired cards', () => { + const ctx=context(), native=authority(ctx), source=createHarkonnenBetrayalSource(ctx,native,'parent'); + for (const mutate of [ + (g:HarkonnenBetrayalContext) => { g.ecazLoyalty!.card='ecaz-2'; }, + (g:HarkonnenBetrayalContext) => { g.ecazLoyalty!.player='h'; }, + (g:HarkonnenBetrayalContext) => { g.ecazLoyalty!.card=null; }, + (g:HarkonnenBetrayalContext) => { g.universe=g.universe.filter(card => card !== 'choam-auditor'); }, + (g:HarkonnenBetrayalContext) => { g.traitors.reserve.push(g.traitors.players.find(p => p.id==='t')!.faceDancers![0].leader); }, + (g:HarkonnenBetrayalContext) => { g.traitors.removed!.push(g.traitors.reserve[0]); }, + ]) { + const bad=structuredClone(ctx); mutate(bad); + assert.throws(() => createHarkonnenBetrayalSource(bad,native,'parent')); + assert.throws(() => validateHarkonnenBetrayalSource(bad,source,native,'parent')); + } + const altered=structuredClone(source), inventory=JSON.parse(altered.inventory); + inventory.at(-1).ecazLoyalty.card='ecaz-2'; altered.inventory=JSON.stringify(inventory); + assert.throws(() => validateHarkonnenBetrayalSource(ctx,altered,native,'parent')); +}); diff --git a/tests/advanced-nexus-harkonnen-selected.test.ts b/tests/advanced-nexus-harkonnen-selected.test.ts new file mode 100644 index 00000000..74084125 --- /dev/null +++ b/tests/advanced-nexus-harkonnen-selected.test.ts @@ -0,0 +1,135 @@ +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 {withoutEcazLoyalty} from '../game/ecaz-loyalty'; +import {validateNexusTraitorSnapshot} from '../game/nexus-traitor-exchange'; +import {botActions} from '../game/bots'; +import {DIFFICULTIES} from '../game/bot-profiles'; +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}: {})}))},withoutEcazLoyalty(traitorDeck(game.players,game.expansions.includes('ix')),game.ecazLoyalty)); +} +/** 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(target:'ixians'|'tleilaxu'|'choam'|'richese'|'auditor') { + const faction = target === 'auditor' ? 'choam' : target; + let game=createGame('ADVHARKEXPANDED',newPlayer('i',faction,faction),true,['ix','choam','ecaz']); + const partner = target === 'richese' ? 'choam' : 'richese'; + for(const [id,faction] of [['h','harkonnen'],['r',partner],['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',advancedPreview:true}); + 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 && leader.id!==game.ecazLoyalty?.card && + (target === 'auditor' ? leader.id === 'choam-auditor' : leader.id !== 'choam-auditor'))!.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]); + // Keep Sandtrout from breaking the alliance during the second Spice Blow + // before the genuine Nexus closes and awards its held card. + for(let pos=1;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.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.turn>2||state.nexusCards?.phase?.stage==='drawing'); + assert.equal(game.turn,2,`Nexus drawing was skipped: ${JSON.stringify(game.log.slice(-25))}`); + 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 target of ['ixians','tleilaxu','choam','richese','auditor'] as const) for(const use of [false,true]) void test(`Advanced selected-deck ${target} leader: Harkonnen Nexus Betrayal ${use?'Use':'Pass'}`,()=>{ + const f=fixture(target);let game=f.game; + if(use) for(const difficulty of DIFFICULTIES) { + const view=viewGame(game,'i');view.players.find(p=>p.id==='i')!.bot=difficulty; + const action=botActions(view).find(a=>a.type==='nexusHarkonnenBetrayalUse'); + assert.ok(action,`${difficulty} offers its legal Use`); + assert.doesNotThrow(()=>act(structuredClone(game),'i',action)); + } + 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); + if (game.phaseOpening) 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-harkonnen-betrayal-expanded.test.ts b/tests/nexus-harkonnen-betrayal-expanded.test.ts index ca1ca212..4e9c52b0 100644 --- a/tests/nexus-harkonnen-betrayal-expanded.test.ts +++ b/tests/nexus-harkonnen-betrayal-expanded.test.ts @@ -60,6 +60,6 @@ for (const hero of [false, true]) void test(`Basic selected Ix and CHOAM census {...ctx, physicalCards:ctx.physicalCards.slice(1)}, {...ctx, universe:ctx.universe.filter(id => id !== CHEAP_HERO_TRAITOR)}, {...ctx, battle:{...ctx.battle, heroLeaderIds:[]}}, - {...ctx, advanced:true}, + {...ctx, advanced:true, ecazLoyalty:{player:'i', card:'ixians-0'}}, ]) assert.throws(() => createHarkonnenBetrayalSource(bad, authority, 'original-battle')); });