Files
Dune/tests/semuta-nexus-public-basic.test.ts
T
Quintine 5b66628399
Verify and publish checkpoint container / container (push) Successful in 10m35s
Enable Basic Richese Semuta in Nexus games
2026-10-30 18:18:45 +11:00

162 lines
10 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 { DIFFICULTIES } from '../game/bot-profiles';
import { nexusChoamTradeAction } from '../game/nexus-choam-trade-options';
import { nexusChoamBetrayalAction } from '../game/nexus-choam-betrayal-options';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { advanceSemutaNexusTrade, semutaNexusTradeInventory, swapSemutaNexusTradeIdentity } from './fixture-semuta-nexus-trade';
import { finishOriginalTruthQuestions, finishTruthOrder } from './fixture-sapho-truth-order';
const SAPHO = 'richese-juice-of-sapho';
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
const seat = (game: Game, id: string) => { const p = game.players.find(row => row.id === id); assert.ok(p); return p; };
const act = (game: Game, id: string, action: Parameters<typeof applyAction>[2]) => applyAction(game,id,action);
/** Genuine public lobby and setup; original unopened Spice order is staged to
* avoid random unrelated first-turn events. Physical card locations are staged
* later, with inventory conserved. */
function publicStart(paired = false) {
let game = createGame('SEMUTANEXUSPUBLIC',newPlayer('r','Richese','richese'),false,['choam']);
for (const [id, faction] of (paired
? [['a','atreides'],['e','emperor'],['c','choam']] as const
: [['a','atreides'],['e','emperor'],['g','guild']] as const))
joinGame(game,newPlayer(id,faction,faction));
game = act(game,'r',{type:'moduleOption',module:'nexusCards',enabled:true});
for (const p of game.players) game = act(game,p.id,{type:'ready'});
game = act(game,'r',{type:'start'});
assert.equal(game.semutaPreview,true);
game = advanceSemutaNexusTrade(game,state => state.status === 'playing');
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
const inventory = semutaNexusTradeInventory(game);
for (const position of [0,1]) {
const index=game.spiceDeck.findIndex((card,i)=>i>=position && 'territory' in card);
assert.ok(index>=position); game.spiceDeck.splice(position,0,game.spiceDeck.splice(index,1)[0]);
}
return {game,inventory};
}
function hold(game: Game, id: string, match: (card: Game['deck'][number])=>boolean) {
const owner=seat(game,id);
const sources=[game.deck,game.discard,game.richeseCache!,...game.players.map(p=>p.hand).filter(hand=>hand!==owner.hand)];
const own=owner.hand.find(match); if (own) return own;
const source=sources.find(zone=>zone.some(match)); assert.ok(source);
const index=source.findIndex(match), card=source.splice(index,1)[0]; owner.hand.push(card); return card;
}
for (const claim of [false,true]) void test(`Basic public Nexus CHOAM Secret Ally discard offers Semuta ${claim?'claim':'pass'} and continues`,()=>{
const f=publicStart(); let game=f.game;
// Return starting forces to their original reserves to avoid unrelated battles.
for (const p of game.players) {p.reserves+=Object.values(p.forces).reduce((sum,n)=>sum+n,0);p.forces={};}
game=advanceSemutaNexusTrade(game,state=>state.turn===2 && state.phase===1 && clean(state));
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=advanceSemutaNexusTrade(game,state=>state.nexus&&!state.spiceWindow&&clean(state));
game=act(game,'e',{type:'alliance',target:'g'});game=act(game,'g',{type:'alliance',target:'e'});
game=advanceSemutaNexusTrade(game,state=>state.nexusCards?.phase?.stage==='drawing');
assert.ok(game.nexusCards!.phase!.eligible.includes('a'));
game=act(game,'a',{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
assert.ok(game.nexusCards!.cards!.hands.a);
swapSemutaNexusTradeIdentity(game,'a','choam');
game=advanceSemutaNexusTrade(game,state=>state.phase===7&&clean(state));
for (const id of ['r','a']) game.deck.push(...seat(game,id).hand.splice(0));
const semuta=hold(game,'r',card=>card.id===SEMUTA_DRUG_ID);
const cost=hold(game,'a',card=>card.kind==='worthless');
assert.deepEqual(semutaNexusTradeInventory(game),f.inventory);
const action=nexusChoamTradeAction(viewGame(game,'a'),cost.id);assert.ok(action);
const before=seat(game,'a').spice;
game=act(game,'a',action);
assert.equal(seat(game,'a').spice,before+2);
assert.equal(game.nexusCards!.cards!.hands.a,null);
assert.equal(game.pendingTreacheryDiscard?.continuation.kind,'nexusChoamTrade');
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage,'offer');
const event=game.pendingTreacheryDiscard!.batch.event;
for (const difficulty of DIFFICULTIES) {
const view=viewGame(game,'r'); view.players.find(p=>p.id==='r')!.bot=difficulty;
const choice=botActions(view).find(row=>row.type==='semutaCommit'||row.type==='semutaPass');assert.ok(choice);
assert.doesNotThrow(()=>act(game,'r',choice));
}
if (claim) game=act(game,'r',{type:'semutaCommit',event});
else for (const p of game.players) game=act(game,p.id,{type:'semutaPass',event});
assert.equal(game.pendingTreacheryDiscard,null);
assert.equal(game.nexusChoamTrades![0].stage,'complete');
assert.equal(seat(game,'a').spice,before+2);
assert.equal(seat(game,'r').hand.some(card=>card.id===cost.id),claim);
assert.equal(game.discard.some(card=>card.id===semuta.id),claim);
assert.deepEqual(semutaNexusTradeInventory(game),f.inventory);
game=advanceSemutaNexusTrade(game,state=>state.phase===8&&clean(state));
assert.equal(game.nexusChoamTrades![0].stage,'complete');
});
void test('Basic public Nexus Sapho Truth order offers Semuta and resumes both original questions',()=>{
const f=publicStart(); let game=f.game;
game=advanceSemutaNexusTrade(game,state=>state.phase===5&&clean(state));
for (const p of game.players) game.deck.push(...p.hand.splice(0));
const holders=game.order.filter(id=>id==='a'||id==='e');assert.equal(holders.length,2);
const holder=holders[1],other=holders[0];
hold(game,'r',card=>card.id===SEMUTA_DRUG_ID);
hold(game,holder,card=>card.id===SAPHO);
const first=hold(game,holder,card=>card.effect==='truthtrance');
const second=hold(game,other,card=>card.effect==='truthtrance');
assert.deepEqual(semutaNexusTradeInventory(game),f.inventory);
const parent={turn:game.turn,phase:game.phase,active:game.active,order:[...game.order],movementRemaining:structuredClone(game.movementRemaining)};
game=act(game,holder,{type:'card',card:first.id});game=act(game,other,{type:'card',card:second.id});
while(game.truthtrance?.stage==='priority') {
const p=game.players.find(row=>!game.truthtrance!.passed.includes(row.id));assert.ok(p);game=act(game,p.id,{type:'truthPass'});
}
assert.equal(game.truthtrance?.stage,'order');const frame=game.truthtrance!.order!;
assert.ok(viewGame(game,holder).saphoTruthOrderChoices.some(choice=>choice.card===SAPHO&&choice.mode==='first'));
const pending=act(game,holder,{type:'saphoTruthOrder',event:frame.event,revision:frame.revision,card:SAPHO,mode:'first'});
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind,'saphoTruthOrderDiscard');
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage,'offer');
assert.equal(pending.truthtrance,null);
const event=pending.pendingTreacheryDiscard!.batch.event;
for (const claim of [false,true]) {
let next=structuredClone(pending);
if (claim) next=act(next,'r',{type:'semutaCommit',event});
else for (const p of next.players) next=act(next,p.id,{type:'semutaPass',event});
assert.equal(next.pendingTreacheryDiscard,null);
assert.equal(seat(next,'r').hand.some(card=>card.id===SAPHO),claim);
next=finishTruthOrder(next);
assert.equal(next.truthtrance!.queue[0].player,holder);
assert.deepEqual(next.order,parent.order);assert.equal(next.turn,parent.turn);assert.equal(next.phase,parent.phase);
assert.equal(next.active,parent.active);assert.deepEqual(next.movementRemaining,parent.movementRemaining);
next=finishOriginalTruthQuestions(next);
assert.equal(next.truthtrance,null);assert.deepEqual(semutaNexusTradeInventory(next),f.inventory);
}
});
for (const claim of [false,true]) void test(`Basic public paired CHOAM Nexus Betrayal offers Semuta ${claim?'claim':'pass'} and retires once`,()=>{
const f=publicStart(true); let game=f.game;
for (const p of game.players) {p.reserves+=Object.values(p.forces).reduce((sum,n)=>sum+n,0);p.forces={};}
game=advanceSemutaNexusTrade(game,state=>state.turn===2&&state.phase===1&&clean(state));
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=advanceSemutaNexusTrade(game,state=>state.nexus&&!state.spiceWindow&&clean(state));
game=act(game,'r',{type:'alliance',target:'c'});game=act(game,'c',{type:'alliance',target:'r'});
game=advanceSemutaNexusTrade(game,state=>state.nexusCards?.phase?.stage==='drawing');
assert.ok(game.nexusCards!.phase!.eligible.includes('a'));
game=act(game,'a',{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
assert.ok(game.nexusCards!.cards!.hands.a);swapSemutaNexusTradeIdentity(game,'a','choam');
game=advanceSemutaNexusTrade(game,state=>state.phase===2&&clean(state));
for(const p of game.players) game.deck.push(...p.hand.splice(0));
hold(game,'r',card=>card.id===SEMUTA_DRUG_ID);
const target=hold(game,'c',card=>card.kind==='worthless');
assert.deepEqual(semutaNexusTradeInventory(game),f.inventory);
const action=nexusChoamBetrayalAction(viewGame(game,'a'));assert.ok(action);
const wallet=seat(game,'c').spice;
game=act(game,'a',action);
assert.equal(game.nexusCards!.cards!.hands.a,null);
assert.equal(game.pendingTreacheryDiscard?.continuation.kind,'nexusChoamBetrayal');
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage,'offer');
assert.equal(game.pendingTreacheryDiscard?.batch.entries[0].card.id,target.id);
const event=game.pendingTreacheryDiscard!.batch.event;
if(claim) game=act(game,'r',{type:'semutaCommit',event});
else for(const p of game.players) game=act(game,p.id,{type:'semutaPass',event});
assert.equal(game.pendingTreacheryDiscard,null);
assert.equal(seat(game,'r').hand.some(card=>card.id===target.id),claim);
assert.equal(seat(game,'c').spice,wallet);
assert.equal(game.nexusCards!.cards!.discard.filter(card=>card==='choam').length,1);
assert.deepEqual(semutaNexusTradeInventory(game),f.inventory);
});