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 { TERRITORIES } from '../game/board'; import { botGroundMoveAllowed, groundMoveRouteDistance } from '../game/bot-mobility'; import { nativeShipmentSources } from '../game/homeworld-options'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { nextSemutaGuildIncomeNativeStep, semutaGuildIncomeInventory } from './fixture-semuta-guild-income'; const clone = (g:Game):Game => JSON.parse(JSON.stringify(g)); function player(g:Game,id:string) { const p=g.players.find(row=>row.id===id); assert.ok(p); return p; } function next(g:Game) { if (!g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard) { const ordered = g.phase===2 ? g.charityOrder : g.phase===4 ? g.revivalOrder : null; if (ordered) { const actor=ordered.remaining[0]; assert.ok(actor); return {actor,action:{type:'ready'}}; } } return nextSemutaGuildIncomeNativeStep(g); } /** Public lobby selection, genuine setup and phase actions through Movement. * We reorder original unplayed Spice cards to avoid incidental worms and move * only conserved physical Hajr/Semuta cards after reaching the real turn. */ function fixture(advanced:boolean) { let g=createGame('SEMUTAWORLDSPUBLIC',newPlayer('r','Richese','richese'),advanced,['choam']); joinGame(g,newPlayer('a','Atreides','atreides')); joinGame(g,newPlayer('e','Emperor','emperor')); g=applyAction(g,'r',{type:'homeworlds',enabled:true}); for (const p of g.players) g=applyAction(g,p.id,{type:'ready'}); g=applyAction(g,'r',{type:'start',...(advanced ? {advancedPreview:true} : {})}); assert.equal(g.status,'setup'); assert.equal(g.semutaPreview,true); assert.ok(g.homeworlds); for (let n=0;g.status==='setup' && n<250;n++) { const step=next(g); g=applyAction(g,step.actor,step.action); } assert.equal(g.status,'playing'); for (const pos of [0,1]) { const index=g.spiceDeck.findIndex((card,i)=>i>=pos && 'territory' in card && !card.discovery); assert.ok(index>=pos); g.spiceDeck.splice(pos,0,g.spiceDeck.splice(index,1)[0]); } for (let n=0;n<1500 && !(g.phase===5 && !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard);n++) { assert.equal(g.turn,1); const step=next(g); g=applyAction(g,step.actor,step.action); } assert.equal(g.phase,5); for (let n=0;g.active!=='a' && n<3;n++) g=applyAction(g,g.active!,{type:'endMovement'}); assert.equal(g.active,'a'); const inventory=semutaGuildIncomeInventory(g); const moveCard=(id:string,identity:string)=>{ const target=player(g,id), zones=[g.deck,g.discard,g.richeseCache!,...g.players.map(p=>p.hand)]; const source=zones.find(zone=>zone.some(c=>c.id===identity || c.effect===identity)); assert.ok(source); const index=source.findIndex(c=>c.id===identity || c.effect===identity), [card]=source.splice(index,1); if (target.hand.length>=4) source.push(target.hand.pop()!); target.hand.push(card); return card.id; }; const semuta=moveCard('r',SEMUTA_DRUG_ID), hajr=moveCard('a','hajr'); assert.deepEqual(semutaGuildIncomeInventory(g),inventory); if (advanced) { const sources=nativeShipmentSources(viewGame(g,'a'),1,0); assert.ok(sources); g=applyAction(g,'a',{type:'ship',territory:'polar_sink',sector:0,amount:1,elite:0,allyPayment:0,homeworldSources:sources}); for (let n=0;(g.phaseOpening || g.response || g.decision || g.pendingTreacheryDiscard) && n<100;n++) { const step=next(g); g=applyAction(g,step.actor,step.action); } const own=viewGame(g,'a'), actor=own.players.find(p=>p.id==='a')!, from='polar_sink:0'; const destination=TERRITORIES.filter(t=>t.type==='sand' && !g.players.some(p=>Object.keys(p.forces).some(key=>key.startsWith(`${t.id}:`))) && !Object.keys(g.spice).some(key=>key.startsWith(`${t.id}:`) && g.spice[key]>0)) .flatMap(t=>t.sectors.map(sector=>({territory:t.id,sector}))) .find(t=>groundMoveRouteDistance(own,actor,from,`${t.territory}:${t.sector}`)===1 && botGroundMoveAllowed(own,actor,from,`${t.territory}:${t.sector}`,0)); assert.ok(destination); g=applyAction(g,'a',{type:'move',from,...destination,amount:1,elite:0}); for (let n=0;(g.phaseOpening || g.response || g.decision || g.pendingTreacheryDiscard) && n<100;n++) { const step=next(g); g=applyAction(g,step.actor,step.action); } assert.equal(player(g,'a').hajrWindow?.turn,g.turn); assert.equal(player(g,'a').hajrWindow?.move,player(g,'a').moved); } return {g,semuta,hajr,inventory}; } for (const advanced of [false,true]) void test( `${advanced?'Advanced':'Basic'} public Richese Homeworld start retains Hajr and Semuta claim/pass`,()=>{ const f=fixture(advanced), before=clone(f.g); const pending=applyAction(clone(before),'a',{type:'card',card:f.hajr}); assert.equal(pending.pendingTreacheryDiscard?.batch.cause,'ordinary:hajr'); assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage,'offer'); const event=pending.pendingTreacheryDiscard!.batch.event; for (const claim of [false,true]) { let result=clone(pending); if (claim) result=applyAction(result,'r',{type:'semutaCommit',event}); else for (const p of result.players) result=applyAction(result,p.id,{type:'semutaPass',event}); assert.equal(result.pendingTreacheryDiscard,null); assert.equal(result.active,'a'); assert.equal(result.phase,5); assert.equal(result.hajr.filter(id=>id==='a').length,1); assert.equal(player(result,'r').hand.some(c=>c.id===f.hajr),claim); assert.equal(player(result,'r').hand.some(c=>c.id===f.semuta),!claim); assert.deepEqual(result.homeworlds,before.homeworlds); assert.deepEqual(result.homeworldOccupationHistory,before.homeworldOccupationHistory); assert.deepEqual(semutaGuildIncomeInventory(result),f.inventory); const original=clone(result); assert.throws(()=>applyAction(result,'r',{type:'semutaCommit',event})); assert.deepEqual(clone(result),original); } for (const level of DIFFICULTIES) { const view=viewGame(pending,'r'); view.players.find(p=>p.id==='r')!.bot=level; const action=botActions(view).find(a=>a.type==='semutaCommit' || a.type==='semutaPass'); assert.ok(action,`${level} can answer the original discard reaction`); assert.doesNotThrow(()=>applyAction(clone(pending),'r',action)); } }, ); void test('public Homeworlds without native Richese do not gain the Semuta preview',()=>{ let g=createGame('SEMUTAWORLDSOTHER',newPlayer('c','CHOAM','choam'),false,['choam']); joinGame(g,newPlayer('a','Atreides','atreides')); joinGame(g,newPlayer('e','Emperor','emperor')); g=applyAction(g,'c',{type:'homeworlds',enabled:true}); for (const p of g.players) g=applyAction(g,p.id,{type:'ready'}); g=applyAction(g,'c',{type:'start'}); assert.equal(g.status,'setup'); assert.ok(g.homeworlds); assert.equal(g.semutaPreview,undefined); });