Files
Dune/tests/nexus-ixian-selected-decks-runtime.test.ts
T
Quintine 7680e1ee69
Verify and publish checkpoint container / container (push) Canceled after 0s
Fix mixed-deck Nexus Betrayal continuations
2026-10-30 18:12:45 +11:00

113 lines
6.5 KiB
TypeScript

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);
}
});