diff --git a/README.md b/README.md index 0dad18b2..bb5c3f44 100644 --- a/README.md +++ b/README.md @@ -248,8 +248,8 @@ the specific Karama effect; Basic retains phase-wide prevention. Advanced phase expiry is provisional. Unpayable overbid recovery remains guarded. See the [selected policy and boundaries](docs/CHOAM_KULL_SOURCE_UPDATE.md). This does not enable public expansion starts or certify full CHOAM. -Fresh Basic CHOAM-plus-classic games with `choam,ix` decks and Nexus alone now -enable the **CHOAM Nexus Cunning Kull** preview by default; the host can turn +Fresh Basic CHOAM games with classic or Ixian/Tleilaxu factions, `choam,ix` +decks and Nexus alone enable the **CHOAM Nexus Cunning Kull** preview by default; the host can turn it off. The original Ix Sandtrout remains in the Spice deck. The separate local Basic/Advanced audit uses `node --import tsx tools/start-prototype.ts --profile nexus-kull` with the same exact-version/private-backup safeguards. Choose printed Kull or a canonical own Treachery fuel: diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index b15541db..c00526b6 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,16 @@ open mode gates. ## Current checkpoint and work +**Basic Ixian Nexus Kull and Sandtrout — 30 October. Prototyped / Partial.** +Public Basic CHOAM/Ixian/Tleilaxu Nexus games now retain Kull and the original +Ixian Bidding/Nexus continuation. A waiting Sandtrout no longer suppresses an +earned Emperor Nexus ordinary bank purchase. Eight related files pass 108 +cases. Public journeys use real draws and actions with conserved card order, +Nexus identity, force positions and revival Tanks staged. Richese/Semuta, +other modules, other Sandtrout purchase sources, complete games and deployed +acceptance remain open. +[Scope](NEXUS_CARD_RULES.md#basic-ixian-nexus-kull-and-sandtrout--30-october-2026). + **Basic native Nexus reactions and Kull — 30 October. Prototyped / Partial.** Fresh Basic E1/E2 Nexus starts now initialize native reactions when all selected families are seated. Harkonnen Use/Pass reaches the original Mentat diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index 629d13b8..ab4b50f1 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -877,6 +877,23 @@ the Cheap Hero identity. Other rosters, nested effects, complete games and deployed acceptance remain open. The later public Semuta entry below removes the stated Emperor and Sapho blockers for its narrower CHOAM-only Basic scope. +## Basic Ixian Nexus Kull and Sandtrout — 30 October 2026 + +**Prototyped / Partial.** Fresh Basic Nexus games with CHOAM, Ixians and/or +Tleilaxu and the Ix and CHOAM decks now enable the existing Kull preview by +default, with host opt-out. An Ixian Bidding attempt retains its native +response through printed or Nexus-fueled Kull, a distinct counter or decline, +and the subsequent Ixian Nexus decision and auction. An unallied holder of +the Emperor Nexus can also use its existing ordinary bank-auction benefit +while Sandtrout is waiting; the original purchase and revival controls remain. + +Public-start fixtures use genuine Nexus and Sandtrout draws and actual auction +and response actions. Unopened card order, earned Nexus identities, physical +card custody, force positions and revival Tanks are staged. The existing +provisional Kull response ordering remains. Richese/Semuta with Nexus Kull, +other modules, Emperor seller/Ambassador purchases during Sandtrout, wider +nested effects, complete games and deployed acceptance remain open. + ## Basic native Nexus reactions and Kull — 30 October 2026 **Prototyped / Partial.** Fresh Basic Nexus games with selected Ix and/or diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index af51fcb1..393b0e0f 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,12 @@ # Rule decision index +## Basic Ixian Nexus Kull and Sandtrout — 30 October 2026 + +The existing Basic Kull and Ixian Nexus responses compose through the native +Bidding continuation. A waiting Sandtrout does not remove an earned Emperor +Nexus ordinary bank benefit. No new publisher timing ruling is implied. +[Scope](NEXUS_CARD_RULES.md#basic-ixian-nexus-kull-and-sandtrout--30-october-2026). + ## Basic native Nexus reactions and Kull — 30 October 2026 Fresh Basic E1/E2 Nexus starts retain native Harkonnen, Ixian and Guild diff --git a/game/engine.ts b/game/engine.ts index c244b29e..41ebe53f 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -12825,7 +12825,8 @@ function ixianNexusBetrayalIntegrity(g: Game): void { const continuation = g.pendingTreacheryDiscard?.continuation; const contexts = [g, g.pendingExchange, g.pendingNullentropy?.resume, g.pendingRicheseGift?.resume, g.pendingRichesePurchaseIncome?.resume, - g.summonedWorm?.resume, continuation && 'resume' in continuation ? continuation.resume : null]; + g.summonedWorm?.resume, g.pendingKull?.resume, + continuation && 'resume' in continuation ? continuation.resume : null]; const provider = byFaction(g, 'ixians')!.id; const ownsWindow = (kind: 'ixAuction' | 'ixTechnology') => contexts.some(context => { const pending = context && 'pendingKarama' in context ? context.pendingKarama as Game['pendingKarama'] : null; @@ -13724,8 +13725,9 @@ export function initializeNexusKullGameForAudit(state: Game): Game { typeof state.advanced === 'boolean' && state.expansions.length === 2 && state.expansions.includes('choam') && state.expansions.includes('ix') && state.players.some(p => p.faction === 'choam') && - state.players.every(p => p.faction === 'choam' || faction(p.faction).expansion === 'base'), - 'Nexus Kull requires a fresh native CHOAM/classic lobby, CHOAM and Ix decks, and only Nexus Cards.'); + state.players.every(p => p.faction === 'choam' || faction(p.faction).expansion === 'base' || + !state.advanced && (p.faction === 'ixians' || p.faction === 'tleilaxu')), + 'Nexus Kull requires a fresh CHOAM lobby with classic or Basic Ixian/Tleilaxu factions, CHOAM and Ix decks, and only Nexus Cards.'); requireFreshBaseRuntime(state); const g = initializeSetupGameForAudit(state, false, true, false, false, false, false, true); g.kullPreview = true; @@ -20270,7 +20272,8 @@ function kullIntegrity(g: Game): void { g.kullPreview === true && !!g.nexusCards?.cards && g.expansions.length === 2 && g.expansions.includes('choam') && g.expansions.includes('ix') && g.players.some(p => p.faction === 'choam') && - g.players.every(p => p.faction === 'choam' || faction(p.faction).expansion === 'base') && + g.players.every(p => p.faction === 'choam' || faction(p.faction).expansion === 'base' || + !g.advanced && (p.faction === 'ixians' || p.faction === 'tleilaxu')) && !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.techTokens && !g.strongholdCards && !g.ecazTreachery && !g.semutaPreview && diff --git a/game/faction-module-profile.ts b/game/faction-module-profile.ts index 88075434..4ae366b5 100644 --- a/game/faction-module-profile.ts +++ b/game/faction-module-profile.ts @@ -43,7 +43,8 @@ function nexusKullCore(game: NexusCoreConfiguration): boolean { game.kullPreview === true && game.nexusKullPreview === true && !game.semutaPreview && game.expansions.length === 2 && game.expansions.includes('ix') && game.expansions.includes('choam') && !!game.players?.some(player => player.faction === 'choam') && - game.players.every(player => player.faction === 'choam' || + game.players.every(player => player.faction === 'choam' || !game.advanced && + (player.faction === 'ixians' || player.faction === 'tleilaxu') || FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base')) && !game.homeworlds && !game.leaderSkills && !game.techTokens && !game.strongholdCards && !game.ecazTreachery && !game.discoveryEnabled && !game.discoveries && !game.discoveryStash && @@ -108,7 +109,7 @@ function basicNativeNexusCore(game: NexusCoreConfiguration): boolean { if (game.kullPreview !== true || game.nexusKullPreview !== true || game.semutaPreview || game.expansions.length !== 2 || !game.expansions.includes('ix') || !game.expansions.includes('choam') || !players.some(player => player.faction === 'choam') || - players.some(player => player.faction !== 'choam' && + players.some(player => player.faction !== 'choam' && player.faction !== 'ixians' && player.faction !== 'tleilaxu' && !FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'))) return false; } return true; diff --git a/game/kull-default-profile.ts b/game/kull-default-profile.ts index 7262178e..16d1a3f7 100644 --- a/game/kull-default-profile.ts +++ b/game/kull-default-profile.ts @@ -61,12 +61,12 @@ export function kullDefaultProfile(game: KullDefaultProfile): KullDefaultEligibi const options = game.lobbyOptions, projected = game.lobbyModules; if (game.nexusCards || projected?.nexusCards) { if (game.expansions.length !== 2 || - !game.players.every(player => player.faction === 'choam' || classic.has(player.faction)) || + !game.players.every(player => player.faction === 'choam' || classic.has(player.faction) || ix.has(player.faction)) || options?.leaderSkills || options?.ecazTreachery || projected?.leaderSkills || projected?.ecazTreachery || projected?.discoveries || game.leaderSkills || game.ecazTreachery || game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker || game.homeworlds || game.techTokens || game.strongholdCards || game.sandtrout) - return blocked('Kull with Nexus currently requires Basic CHOAM and classic factions, the Ix and CHOAM decks, and no other optional modules.'); + return blocked('Kull with Nexus currently requires Basic CHOAM, Ixian/Tleilaxu or classic factions, the Ix and CHOAM decks, and no other optional modules.'); return { eligible: true, reason: null }; } if (options?.leaderSkills || projected?.leaderSkills || diff --git a/game/nexus-emperor-purchase.ts b/game/nexus-emperor-purchase.ts index 6c49b4f5..0694a7a6 100644 --- a/game/nexus-emperor-purchase.ts +++ b/game/nexus-emperor-purchase.ts @@ -387,9 +387,9 @@ function nativeBankNexusProfile(g: Game): boolean { * to the caller. */ export function emperorNexusBankPurchaseModeSupported(g: Game): boolean { if (nexusCoreProfile(g)) - return !g.players.some(p => p.faction === 'emperor') && !g.sandtrout; + return !g.players.some(p => p.faction === 'emperor'); if (!g.nexusCards || g.players.some(p => p.faction === 'emperor') || - g.ecazTreachery || g.sandtrout) return false; + g.ecazTreachery) return false; return emperorNexusLegacyRevivalModeSupported(g) || nativeBankNexusProfile(g); } diff --git a/game/reference.ts b/game/reference.ts index d46673d4..f134b2fa 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -5993,7 +5993,8 @@ export const RULE_TOPICS: RuleTopic[] = [ developmentStage: 'Verified', summary: 'In the explicit Nexus Kull preview, choose one actual Treachery fuel card at a Karama attempt; the CHOAM Nexus is spent when the declaration is accepted, even if Kull is prevented.', steps: [ - 'Fresh Basic CHOAM/classic public games with the CHOAM and Ix decks and Nexus alone enable this preview by default; the host may turn it off. Original Ix Sandtrout stays in the Spice deck. The separate local Advanced audit remains available. Other optional modules, expansion factions, Semuta and Richese Betrayal stay outside this profile; existing games are not converted.', + 'Fresh Basic CHOAM games with classic or Ixian/Tleilaxu factions, the CHOAM and Ix decks and Nexus alone enable this preview by default; the host may turn it off. Original Ix Sandtrout stays in the Spice deck. The separate local Advanced audit remains available. Other optional modules, Richese, Semuta and Richese Betrayal stay outside this profile; existing games are not converted.', + 'The native Ixian Bidding counter remains bound while Kull resolves, then its original Ixian Nexus opportunity and auction proceed. A waiting Sandtrout does not disable an earned Emperor Nexus ordinary bank-auction benefit; other Sandtrout purchase sources retain their separate limits.', 'The CHOAM Nexus face says: Discard any Treachery Card to obtain a Worthless Card special effect of your choice. Basic uses the CHOAM phase-wide scope; new Advanced attempts use the supplied specific-effect scope with provisional phase expiry. Cunning changes the fuel identity, not the effect, target or timing. Neither text supplies a nested counter priority: distinct counters and BG-before-conversion custody are the recorded user-selected policy, not a publisher clarification.', 'Wait for another player’s validated Karama attempt. There is no proactive Kull choice in the generic Nexus panel. The public event, attempted player and purpose are the same whether CHOAM holds CHOAM Nexus, another Nexus, no eligible fuel or no Nexus at all. Only CHOAM receives its private source-aware plays and blocking reasons; opponents do not see the cost list or reserved original card.', 'Choose a cost through the shared selector and inspect that actual card before Use. Printed source requires a held printed Kull Wahad. Nexus source requires unallied native CHOAM holding the actual CHOAM Nexus in this profile and one uniquely held canonical Treachery Card: a weapon, defense, special, Karama or Worthless card can be fuel. Reserved or promise-incompatible fuel is unavailable. The card does not become a manufactured Kull, and Karama used as fuel does not activate its own power.', diff --git a/tests/kull-nexus-default.test.ts b/tests/kull-nexus-default.test.ts index 05026cd4..5cdd119c 100644 --- a/tests/kull-nexus-default.test.ts +++ b/tests/kull-nexus-default.test.ts @@ -11,6 +11,7 @@ const lobby: KullDefaultProfile = { void test('Basic CHOAM and classic Nexus lobby receives the same public Kull default quote', () => { assert.deepEqual(kullDefaultProfile(lobby), { eligible: true, reason: null }); assert.equal(kullDefaultEligible({ ...lobby, players: [{ faction: 'choam' }] }), true); + assert.equal(kullDefaultEligible({ ...lobby, players: [{ faction: 'choam' }, { faction: 'ixians' }, { faction: 'tleilaxu' }] }), true); // GameView carries Nexus selection as a public boolean instead of Game state. assert.equal(kullDefaultEligible({ ...lobby, nexusCards: undefined, lobbyModules: { nexusCards: true } }), true); // The host's explicit opt-out belongs to the caller, not the eligibility quote. @@ -23,7 +24,6 @@ void test('Nexus Kull default retains its native roster and module boundaries', { advanced: true }, { status: 'playing' }, { expansions: ['choam'] }, { expansions: ['choam', 'ix', 'ecaz'] }, { players: [{ faction: 'choam' }, { faction: 'richese' }] }, - { players: [{ faction: 'choam' }, { faction: 'ixians' }] }, { players: [{ faction: 'choam' }, { faction: 'atreides' }, { faction: 'atreides' }] }, { players: [{ faction: 'atreides' }, { faction: 'guild' }] }, { lobbyOptions: { leaderSkills: true } }, { lobbyOptions: { ecazTreachery: true } }, diff --git a/tests/nexus-emperor-sandtrout-public.test.ts b/tests/nexus-emperor-sandtrout-public.test.ts new file mode 100644 index 00000000..ca759215 --- /dev/null +++ b/tests/nexus-emperor-sandtrout-public.test.ts @@ -0,0 +1,87 @@ +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 { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { advanceSemutaNexusTrade, swapSemutaNexusTradeIdentity } from './fixture-semuta-nexus-trade'; +import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards'; + +const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action); +const player = (game: Game, id: string) => { const p = game.players.find(row => row.id === id); assert.ok(p); return p; }; +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard; +const physical = (game: Game) => [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), + ...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])] + .map(card => card.id).sort(); + +/** Genuine public setup, earned Nexus and actual subsequent Sandtrout. The + * unopened Spice/Nexus identities and physical force positions are staged; + * every card and counter remains in its original physical inventory. */ +function fixture(kull: boolean) { + let game = createGame('EMPEROR-SANDTROUT', newPlayer('c', 'Buyer', kull ? 'choam' : 'atreides'), false, + kull ? ['choam', 'ix'] : ['ix']); + for (const [id, faction] of [['a', 'guild'], ['h', 'harkonnen']] as const) + joinGame(game, newPlayer(id, faction, faction)); + game = act(game, 'c', {type:'moduleOption',module:'nexusCards',enabled:true}); + for (const p of game.players) game = act(game, p.id, {type:'ready'}); + game = act(game, 'c', {type:'start'}); + assert.equal(!!game.nexusKullPreview, kull); + game = advanceSemutaNexusTrade(game, state => state.status === 'playing'); + const cards = physical(game); + for (const p of game.players) {p.reserves += Object.values(p.forces).reduce((a,b) => a+b,0); p.forces = {};} + game = advanceSemutaNexusTrade(game, beforeIxSpiceDraw); + orderIxSpiceCards(game, ['land','land']); + game = advanceSemutaNexusTrade(game, state => state.turn === 2 && beforeIxSpiceDraw(state)); + orderIxSpiceCards(game, ['worm','land','land']); + game = advanceSemutaNexusTrade(game, state => state.nexus && !state.spiceWindow && clean(state)); + game = act(game,'a',{type:'alliance',target:'h'}); game = act(game,'h',{type:'alliance',target:'a'}); + game = advanceSemutaNexusTrade(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(state)); + assert.ok(game.nexusCards!.phase!.eligible.includes('c')); + game = act(game,'c',{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0}); + swapSemutaNexusTradeIdentity(game,'c','emperor'); + game = advanceSemutaNexusTrade(game, state => state.turn === 3 && beforeIxSpiceDraw(state)); + orderIxSpiceCards(game,['sandtrout','land','land']); + game = advanceSemutaNexusTrade(game, state => state.phase === 3 && !!state.auction && clean(state)); + assert.equal(game.sandtrout,true); + assert.equal(game.nexusCards!.cards!.hands.c,'emperor'); + assert.ok(game.players.every(p => p.ally === null)); + assert.deepEqual(physical(game), cards); + return {game, cards}; +} + +for (const kull of [false,true]) for (const use of [false,true]) void test( + `Basic ${kull ? 'Kull/Nexus' : 'Ix/Nexus'} Emperor bank ${use ? 'Use' : 'Pay'} after actual Sandtrout`, () => { + const f = fixture(kull); let game = f.game; + while (game.auction!.active !== 'c') game = act(game, game.auction!.active, {type:'passBid'}); + const card = game.auction!.cards[game.auction!.index], before = player(game,'c').spice; + assert.ok(before >= 1); + game = act(game,'c',{type:'bid',amount:1,allyPayment:0}); + game = advanceSemutaNexusTrade(game, state => state.decision?.kind === 'auctionPayment'); + const offer = viewGame(game,'c').nexusEmperorSecretAlly; + assert.ok(offer?.purchase && !offer.purchase.blocked); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game,'c'); view.players.find(p => p.id === 'c')!.bot = difficulty; + const choice = botActions(view)[0]; assert.ok(choice); assert.doesNotThrow(() => act(game,'c',choice)); + } + game = act(game,'c',use ? {type:'nexusEmperorPurchase',event:offer.event} : {type:'decision',karama:false}); + assert.equal(player(game,'c').spice,before - (use ? 0 : 1)); + assert.equal(game.nexusCards!.cards!.hands.c,use ? null : 'emperor'); + if (game.pendingNexusIxianReplacement) + game = act(game,'c',{type:'nexusIxianReplacementPass',event:game.pendingNexusIxianReplacement.event}); + assert.equal(player(game,'c').hand.filter(row => row.id === card.id).length,1); + assert.equal(game.sandtrout,true); assert.deepEqual(physical(game),f.cards); + }); + +for (const kull of [false,true]) void test( + `Basic ${kull ? 'Kull/Nexus' : 'Ix/Nexus'} Emperor extra revival after actual Sandtrout`, () => { + const f = fixture(kull); let game = f.game; + game = advanceSemutaNexusTrade(game,state => state.phase === 4 && clean(state)); + const owner = player(game,'c'); assert.ok(owner.reserves >= 3); owner.reserves -= 3; owner.tanks += 3; + const before = {reserves:owner.reserves,tanks:owner.tanks,revived:owner.revived,free:owner.freeForcesRevived}; + const offer = viewGame(game,'c').nexusEmperorSecretAlly; assert.ok(offer && !offer.revival.blocked); + game = act(game,'c',{type:'nexusEmperorRevive',event:offer.event,elite:0}); + assert.equal(player(game,'c').reserves,before.reserves+3); assert.equal(player(game,'c').tanks,before.tanks-3); + assert.equal(player(game,'c').revived,before.revived); + assert.equal(player(game,'c').freeForcesRevived,before.free); + assert.equal(game.nexusCards!.cards!.hands.c,null); assert.equal(game.sandtrout,true); + assert.deepEqual(physical(game),f.cards); + }); diff --git a/tests/nexus-kull-native-ix-public.test.ts b/tests/nexus-kull-native-ix-public.test.ts new file mode 100644 index 00000000..4f33bd0b --- /dev/null +++ b/tests/nexus-kull-native-ix-public.test.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { takeKullCard } from './fixture-choam-kull'; +import { nextHarkonnenBetrayalNativeStep } from './fixture-nexus-harkonnen-betrayal'; +import { swapSemutaNexusTradeIdentity } from './fixture-semuta-nexus-trade'; + +const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action); +const seat = (game: Game, id: string) => { const player = game.players.find(row => row.id === id); assert.ok(player); return player; }; +const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening; +function stock(game: Game): string[] { + return [...game.deck, ...game.discard, ...(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: (game: Game) => boolean): Game { + for (let n = 0; n < 1800; n++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + if (game.pendingGuildBetrayal) { + const pending = game.pendingGuildBetrayal, actor = pending.required.find(id => !pending.passed.includes(id)); assert.ok(actor); + game = act(game, actor, {type:'guildBetrayalPass',event:pending.invoice.event}); + continue; + } + const next = nextHarkonnenBetrayalNativeStep(game); + try { game = act(game, next.actor, next.action); } + catch (error) { throw Error(`Native ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}: ${JSON.stringify(next)}: ${String(error)}`); } + } + throw Error(`Native Ixian Nexus Kull journey stalled: ${game.turn}/${game.phase}`); +} +function orderSpice(game: Game, kinds: readonly ('land'|'worm')[]): void { + for (const [position, kind] of kinds.entries()) { + const index = game.spiceDeck.findIndex((card, i) => i >= position && + (kind === 'land' ? 'territory' in card : 'worm' in card && !card.greatMaker && !card.suppressed)); + assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); + } +} + +/** Public native setup, real alliance and two earned Nexus draws. Unopened + * Spice order, identities of genuinely drawn Nexus cards, board positions and + * physical Treachery holdings are staged while conserving every component. */ +function fixture() { + let game = createGame('NXKULLIX', newPlayer('ix','Ixians','ixians'), false, ['ix','choam']); + for (const [id,faction] of [['t','tleilaxu'],['c','choam'],['a','atreides'],['g','guild']] as const) + joinGame(game, newPlayer(id,faction,faction)); + game = act(game,'ix',{type:'moduleOption',module:'nexusCards',enabled:true}); + for (const player of game.players) game = act(game,player.id,{type:'ready'}); + game = act(game,'ix',{type:'start'}); + assert.equal(game.kullPreview,true); assert.equal(game.nexusKullPreview,true); + assert.equal(game.nexusIxianBetrayalPreview,true); + game = advance(game,state => state.status === 'playing'); + const original = stock(game); + for (const player of game.players) { + player.reserves += Object.values(player.forces).reduce((sum,n)=>sum+n,0); player.forces = {}; + if (player.elites) { player.elites.reserves += Object.values(player.elites.forces).reduce((sum,n)=>sum+n,0); player.elites.forces={}; } + } + orderSpice(game,['land','land']); + game = advance(game,state=>state.turn===2 && state.phase===1 && clean(state)); + orderSpice(game,['worm','land','land']); + game = advance(game,state=>state.nexus && !state.spiceWindow && !state.spiceResolution && clean(state)); + game = act(game,'ix',{type:'alliance',target:'t'}); game = act(game,'t',{type:'alliance',target:'ix'}); + game = advance(game,state=>state.nexusCards?.phase?.stage==='drawing' && clean(state)); + for (const id of ['a','c']) { + assert.ok(game.nexusCards!.phase!.eligible.includes(id)); + game = act(game,id,{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0}); + swapSemutaNexusTradeIdentity(game,id,id==='a'?'ixians':'choam'); + } + game = advance(game,state=>state.phase===2 && clean(state)); + for (const player of game.players) game.deck.push(...player.hand.splice(0)); + const originalKarama = takeKullCard(game,'karama'); seat(game,'g').hand.push(originalKarama); + const counter = takeKullCard(game,'karama'); seat(game,'a').hand.push(counter); + game = advance(game,state=>state.response?.kind==='ixAuction'); + assert.equal(game.ixAuction?.cards.length,0); + // Stage conserved held cards at the open native response, after CHOAM's + // earlier market opportunity cannot sell the intended physical Kull. + const printed = takeKullCard(game,'ix-kull-wahad'); seat(game,'c').hand.push(printed); + const fuel = takeKullCard(game,'ix-hunter-seeker'); seat(game,'c').hand.push(fuel); + assert.deepEqual(stock(game),original); + game = act(game,'g',{type:'card',mode:'cancel',card:originalKarama.id}); + assert.ok(game.pendingKull); assert.equal(game.pendingKull.resume.response?.kind,'ixAuction'); + return {game,original,printed:printed.id,fuel:fuel.id,karama:originalKarama.id,counter:counter.id,event:game.pendingKull.event}; +} +function passKullCounter(state: Game): Game { + let game=state; + while (game.pendingKull) { + const actor = game.players.find(player => { const controls=viewGame(game,player.id).responseControls; + return controls && !controls.hasPassed && controls.cancelCards.length > 0; }); + game = actor ? act(game,actor.id,{type:'passResponse'}) : normalizeAutomaticGame(game); + } + return game; +} + +for (const source of ['printed','nexus'] as const) void test( + `Basic native Ixian Nexus auction survives ${source} Kull success and original Nexus response`, () => { + const f=fixture(); + for (const difficulty of DIFFICULTIES) { + const view=viewGame(f.game,'c'); view.players.find(player=>player.id==='c')!.bot=difficulty; + const choice=botActions(view).find(action=>action.type==='kullDecision'); assert.ok(choice); + assert.doesNotThrow(()=>act(f.game,'c',choice)); + } + assert.ok(viewGame(f.game,'c').kullReaction?.plays.some(play => play.source === source && play.card.id === (source==='printed'?f.printed:f.fuel)), + `Missing ${source} option; hand: ${seat(f.game,'c').hand.map(card=>card.id).join(', ')}; discard: ${f.game.discard.map(card=>card.id).join(', ')}`); + let game=act(f.game,'c',{type:'kullDecision',event:f.event,source,card:source==='printed'?f.printed:f.fuel}); + game=passKullCounter(game); + assert.equal(game.pendingKull,null); + assert.equal(seat(game,'g').hand.some(card=>card.id===f.karama),true); + assert.equal(game.discard.filter(card=>card.id===(source==='printed'?f.printed:f.fuel)).length,1); + game=advance(game,state=>!!state.pendingNexusIxianBetrayal); + const nexus=game.pendingNexusIxianBetrayal!; + assert.equal(viewGame(game,'a').nexusIxianBetrayalReaction?.canUse,true); + for (const use of [false,true]) { + let resumed = structuredClone(game); + if (use) resumed=act(resumed,'a',{type:'nexusIxianBetrayalUse',event:nexus.source.event}); + else while (resumed.pendingNexusIxianBetrayal) { + const pending=resumed.pendingNexusIxianBetrayal; + const actor=pending.required.find(id=>!pending.passed.includes(id)); assert.ok(actor); + resumed=act(resumed,actor,{type:'nexusIxianBetrayalPass',event:pending.source.event}); + } + assert.equal(resumed.pendingNexusIxianBetrayal,null); + if (!use) { + assert.equal(resumed.decision?.kind,'ixAuction'); assert.ok(resumed.ixAuction?.cards.length); + resumed=act(resumed,'ix',{type:'decision',card:resumed.ixAuction.cards[0].id,position:'top'}); + } + assert.equal(resumed.ixAuction,null); assert.ok(resumed.auction); + assert.deepEqual(stock(resumed),f.original); + } + }); + +for (const counter of [false,true]) void test( + `Basic native Ixian auction resumes original cancellation after Kull ${counter?'counter':'decline'}`, () => { + const f=fixture(); + let game=counter ? act(f.game,'c',{type:'kullDecision',event:f.event,source:'nexus',card:f.fuel}) + : act(f.game,'c',{type:'kullDecision',event:f.event,decline:true}); + if (counter) game=act(game,'a',{type:'card',mode:'cancel',card:f.counter}); + assert.equal(game.pendingKull,null); assert.equal(game.pendingNexusIxianBetrayal,null); + assert.equal(game.ixAuction,null); assert.ok(game.auction); + assert.equal(game.discard.some(card=>card.id===f.karama),true); + assert.equal(seat(game,'c').hand.some(card=>card.id===f.fuel),true); + assert.equal(game.nexusCards!.cards!.hands.c,counter?null:'choam'); + assert.deepEqual(stock(game),f.original); + });