Fix mixed-deck Nexus Betrayal continuations
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
6204efb7e4
commit
7680e1ee69
15 files changed
+487
-46
No files matched your search
@@ -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'}};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;i<limit;i++) {if(until(game)) return game; assert.notEqual(game.status,'finished'); game=step(game);}
|
||||
throw Error(`Native expanded Nexus journey stalled at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
}
|
||||
function inventory(game:Game):string[] {
|
||||
return [...game.deck,...game.discard,...(game.richeseCache??[]),...(game.richeseRemoved??[]),
|
||||
...(game.auction?.cards.slice(game.auction.index)??[]),...game.players.flatMap(p=>p.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);
|
||||
});
|
||||
@@ -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']}));
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user