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