Files
Dune/tests/semuta-discovery-public-start.test.ts
T
Quintine 98ead3a4b5
Verify and publish checkpoint container / container (push) Canceled after 0s
Enable Semuta in public Discovery games
2026-10-29 09:31:40 +11:00

157 lines
8.5 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 { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
import { semutaSkillsInventory } from './fixture-semuta-skills';
const profiles = [
{advanced:false, tech:false, strongholds:false},
{advanced:false, tech:true, strongholds:false},
{advanced:true, tech:false, strongholds:false},
{advanced:true, tech:true, strongholds:false},
{advanced:true, tech:false, strongholds:true},
{advanced:true, tech:true, strongholds:true},
];
const clone = (game: Game): Game => JSON.parse(JSON.stringify(game));
function player(game: Game, id: string) {
const seat = game.players.find(candidate => candidate.id === id);
assert.ok(seat); return seat;
}
function next(game: Game) {
if (!game.phaseOpening && !game.response && !game.decision && game.phase === 2 && game.charityOrder) {
const actor = game.charityOrder.remaining[0]; assert.ok(actor);
return {actor, action:{type:'ready'}};
}
return nextRicheseSkillsTechNativeStep(game);
}
/** Genuine public lobby and faction setup. Only unopened non-Discovery Spice
* Card order and later physical card slots are controlled; no game clock,
* Discovery token, wallet, force, or module owner is fabricated. */
function movement(profile: typeof profiles[number]) {
let game = createGame('SEMUTADISCOVERYPUBLIC', newPlayer('r','Richese','richese'), profile.advanced, ['choam']);
joinGame(game, newPlayer('a','Atreides','atreides'));
joinGame(game, newPlayer('e','Emperor','emperor'));
game = applyAction(game, game.host, {type:'moduleOption',module:'discoveries',enabled:true});
if (profile.tech) game = applyAction(game,game.host,{type:'techTokens',enabled:true});
if (profile.strongholds) game = applyAction(game,game.host,{type:'strongholdCards',enabled:true});
for (const seat of game.players) game = applyAction(game,seat.id,{type:'ready'});
game = applyAction(game,game.host,{type:'start',...(profile.advanced ? {advancedPreview:true} : {})});
assert.equal(game.status,'setup');
assert.equal(game.semutaPreview,true);
assert.equal(game.discoveryEnabled,true);
assert.equal(game.discoveries?.tokens.length,8);
assert.equal(!!game.techTokens,profile.tech);
assert.equal(!!game.strongholdCards,profile.strongholds);
for (let n=0;game.status==='setup' && n<250;n++) {
const action=next(game); assert.ok(action);
game=applyAction(game,action.actor,action.action);
}
assert.equal(game.status,'playing');
for (const position of [0,1]) {
const index=game.spiceDeck.findIndex((card,i)=>i>=position && 'territory' in card && !card.discovery);
assert.ok(index>=position);
game.spiceDeck.splice(position,0,game.spiceDeck.splice(index,1)[0]);
}
for (let n=0;n<1500 && !(game.phase===5 && !game.phaseOpening && !game.response && !game.decision);n++) {
assert.equal(game.turn,1);
const action=next(game); assert.ok(action);
game=applyAction(game,action.actor,action.action);
}
assert.equal(game.phase,5);
assert.ok(!game.phaseOpening && !game.response && !game.decision);
for (let n=0;game.active!=='a' && n<3;n++)
game=applyAction(game,game.active!,{type:'endMovement'});
assert.equal(game.active,'a');
const inventory=semutaSkillsInventory(game);
const give = (id:string, identity:string) => {
const target=player(game,id);
const zones=[game.deck,game.discard,game.richeseCache!,...game.players.map(seat=>seat.hand)];
const source=zones.find(zone=>zone.some(card=>card.id===identity || card.effect===identity));
assert.ok(source,`Original ${identity} must remain in a physical card slot`);
const index=source.findIndex(card=>card.id===identity || card.effect===identity);
const [card]=source.splice(index,1);
if (target.hand.length>=4) source.push(target.hand.pop()!);
target.hand.push(card); return card.id;
};
const semuta=give('r',SEMUTA_DRUG_ID), hajr=give('a','hajr');
assert.deepEqual(semutaSkillsInventory(game),inventory);
if (profile.advanced) {
// Advanced Hajr requires an actual completed Force Movement immediately
// before its discard, so make a native paid shipment and adjacent move.
game=applyAction(game,'a',{type:'ship',territory:'polar_sink',sector:0,amount:1,elite:0,allyPayment:0});
for (let n=0;(game.phaseOpening || game.response || game.decision) && n<100;n++) {
const action=next(game); assert.ok(action);
game=applyAction(game,action.actor,action.action);
}
const own=viewGame(game,'a'), actor=own.players.find(candidate=>candidate.id==='a')!;
const from='polar_sink:0';
const destination=TERRITORIES.filter(place=>place.type==='sand' &&
!game.players.some(seat=>Object.keys(seat.forces).some(key=>key.startsWith(`${place.id}:`))) &&
!Object.keys(game.spice).some(key=>key.startsWith(`${place.id}:`) && game.spice[key]>0))
.flatMap(place=>place.sectors.map(sector=>({territory:place.id,sector})))
.find(place=>groundMoveRouteDistance(own,actor,from,`${place.territory}:${place.sector}`)===1 &&
botGroundMoveAllowed(own,actor,from,`${place.territory}:${place.sector}`,0));
assert.ok(destination);
game=applyAction(game,'a',{type:'move',from,...destination,amount:1,elite:0});
for (let n=0;(game.phaseOpening || game.response || game.decision) && n<100;n++) {
const action=next(game); assert.ok(action);
game=applyAction(game,action.actor,action.action);
}
assert.equal(player(game,'a').hajrWindow?.turn,game.turn);
assert.equal(player(game,'a').hajrWindow?.move,player(game,'a').moved);
}
return {game,semuta,hajr,inventory};
}
for (const profile of profiles) {
const label=`${profile.advanced?'Advanced':'Basic'}${profile.tech?' Tech':''}${profile.strongholds?' Strongholds':''}`;
void test(`${label} public Discovery start retains the native Hajr and Semuta continuation`,()=>{
const f=movement(profile), before=clone(f.game);
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');
assert.equal(pending.active,'a'); assert.equal(pending.phase,5);
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 seat of result.players) result=applyAction(result,seat.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(card=>card.id===f.hajr),claim);
assert.equal(player(result,'r').hand.some(card=>card.id===f.semuta),!claim);
assert.deepEqual(result.discoveries,before.discoveries);
assert.deepEqual(semutaSkillsInventory(result),f.inventory);
const unchanged=clone(result);
assert.throws(()=>applyAction(result,'r',{type:'semutaCommit',event}));
assert.deepEqual(clone(result),unchanged);
}
for (const level of DIFFICULTIES) {
const own=viewGame(pending,'r');
own.players.find(seat=>seat.id==='r')!.bot=level;
const action=botActions(own).find(candidate=>candidate.type==='semutaCommit' || candidate.type==='semutaPass');
assert.ok(action,`${level} must answer the actual reaction`);
assert.doesNotThrow(()=>applyAction(clone(pending),'r',action));
}
});
}
void test('public Discovery without native Richese does not gain a Semuta preview',()=>{
let game=createGame('SEMUTADISCOVERYOTHER',newPlayer('c','CHOAM','choam'),false,['choam']);
joinGame(game,newPlayer('a','Atreides','atreides'));
joinGame(game,newPlayer('e','Emperor','emperor'));
game=applyAction(game,game.host,{type:'moduleOption',module:'discoveries',enabled:true});
for (const seat of game.players) game=applyAction(game,seat.id,{type:'ready'});
game=applyAction(game,game.host,{type:'start'});
assert.equal(game.status,'setup');
assert.equal(game.discoveryEnabled,true);
assert.equal(game.semutaPreview,undefined);
});