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)); const player = (game:Game, id:string) => { const seat = game.players.find(candidate => candidate.id === id); assert.ok(seat); return seat; }; /** Ordinary public lobby actions and real setup, including each player's * actual skill offer and native legal leader selection. The optional modules * are chosen before anyone readies; no saved game is converted. */ function start(profile: typeof profiles[number]): Game { let game = createGame('SEMUTASKILLSPUBLIC', 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:'leaderSkills', 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.ok(game.leaderSkills); assert.equal(game.semutaPreview, true); assert.equal(!!game.techTokens, profile.tech); assert.equal(!!game.strongholdCards, profile.strongholds); for (let n=0; game.status === 'setup' && n<250; n++) { if (game.setupStage === 'leaderSkills') { const actor = Object.keys(game.leaderSkills!.offers)[0]; const offer = game.leaderSkills!.offers[actor], own = viewGame(game,actor).leaderSkills!; const skill = offer.cards.find(id => !own.unavailableSkills?.[id]); const leader = own.eligibleLeaders[0]; assert.ok(skill && leader); game = applyAction(game,actor,{type:'leaderSkill', event:offer.event, skill, leader:leader.id}); } else { const next = nextRicheseSkillsTechNativeStep(game); game = applyAction(game,next.actor,next.action); } } assert.equal(game.status,'playing'); assert.equal(game.leaderSkills?.assignments.length,game.players.length); return game; } /** Reach real Movement first; only the physical cards are rearranged afterward. * Move each chosen card from its original deck/cache/hand/discard slot, and * return a recipient's prior card to the same source when its hand is full. */ function movement(profile: typeof profiles[number]) { let game = start(profile); for (let n=0; n<1500 && !(game.phase === 5 && !game.phaseOpening && !game.response && !game.decision); n++) { assert.equal(game.turn,1); const next = nextRicheseSkillsTechNativeStep(game); game = applyAction(game,next.actor,next.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) { // Earn Advanced Hajr's immediate window through an actual paid shipment // to Polar Sink followed by the original legal adjacent Force Movement. 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 next=nextRicheseSkillsTechNativeStep(game); game=applyAction(game,next.actor,next.action); } assert.equal(game.phase,5); assert.equal(game.active,'a'); const own=viewGame(game,'a'), seat=own.players.find(candidate=>candidate.id==='a')!; const from='polar_sink:0'; const destination=TERRITORIES.filter(place=>place.type==='sand' && !game.players.some(candidate=>Object.keys(candidate.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,seat,from,`${place.territory}:${place.sector}`)===1 && botGroundMoveAllowed(own,seat,from,`${place.territory}:${place.sector}`,0)); assert.ok(destination,'The shipped counter must have a clear adjacent sand movement.'); 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 next=nextRicheseSkillsTechNativeStep(game); game=applyAction(game,next.actor,next.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 Skills start preserves the real Hajr to Semuta claim/pass 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 next = clone(pending); if (claim) next = applyAction(next,'r',{type:'semutaCommit',event}); else for (const seat of next.players) next = applyAction(next,seat.id,{type:'semutaPass',event}); assert.equal(next.pendingTreacheryDiscard,null); assert.equal(next.active,'a'); assert.equal(next.phase,5); assert.equal(next.hajr.filter(id=>id==='a').length,1); assert.equal(player(next,'r').hand.some(card=>card.id===f.hajr),claim); assert.equal(player(next,'r').hand.some(card=>card.id===f.semuta),!claim); assert.deepEqual(semutaSkillsInventory(next),f.inventory); assert.deepEqual(next.leaderSkills,before.leaderSkills); const original = clone(next); assert.throws(()=>applyAction(next,'r',{type:'semutaCommit',event})); assert.deepEqual(clone(next),original); } 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 original discard reaction legally`); assert.doesNotThrow(()=>applyAction(clone(pending),'r',action)); } }); } void test('a public Skills game without native Richese does not gain a Semuta preview', () => { let game = createGame('SEMUTASKILLSOTHER',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:'leaderSkills',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.ok(game.leaderSkills); assert.equal(game.semutaPreview,undefined); });