Files
Dune/tests/semuta-homeworld-public-start.test.ts
T
Quintine 49f9ebdea0
Verify and publish checkpoint container / container (push) Successful in 11m13s
Enable Semuta in public Homeworld games
2026-10-29 09:36:52 +11:00

125 lines
7.0 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 { 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);
});