162 lines
10 KiB
TypeScript
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);
|
|
});
|