import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import type { Card } from '../game/cards'; import { resolveSemutaMoritaniBattle, finishSemutaMoritaniWinner, chooseSemutaMoritaniRetention, allowSemutaMoritaniResponse, passSemutaMoritaniDiscard, semutaMoritaniInventory as inventory, semutaMoritaniResources as resources, semutaMoritaniPlayer as seat, type SemutaMoritaniFixture } from './fixture-semuta-moritani-retention'; const act = (game: Game, id: string, action: Action) => applyAction(game, id, action); /** Actual public selected-deck setup with independent Ecaz cards. The later * reciprocal alliance, held physical cards and conserved battle position are * deliberately staged; battle plans, retention and Semuta use real actions. */ function fixture(): SemutaMoritaniFixture & { hero: string } { let game = createGame('SEMUTANATIVERETENTION', newPlayer('r','Richese','richese'), false, ['choam','ecaz']); joinGame(game,newPlayer('m','Moritani','moritani')); joinGame(game,newPlayer('w','Emperor','emperor')); joinGame(game,newPlayer('l','Guild','guild')); game = act(game,'r',{type:'moduleOption',module:'ecazTreachery',enabled:true}); for (const player of game.players) game = act(game,player.id,{type:'ready'}); game = act(game,'r',{type:'start'}); assert.equal(game.status,'setup'); assert.equal(game.semutaRecruitsPreview,true); for (let count=0;game.status === 'setup' && count<160;count++) { let next: {actor:string;action:Action}|undefined; for (const player of game.players) { const view = viewGame(game,player.id); view.players.find(candidate => candidate.id === player.id)!.bot = 'Medium'; const action = botActions(view)[0]; if (action) {next = {actor:player.id,action};break;} } assert.ok(next, 'Original setup must offer a legal decision.'); game = act(game,next.actor,next.action); } assert.equal(game.status,'playing'); assert.equal(game.ecazTreachery,true); assert.equal(game.semutaPreview,undefined); game.deck.push(...game.players.flatMap(player => player.hand)); for (const player of game.players) player.hand = []; const hold = (owner:string,predicate:(card:Card)=>boolean) => { for (const zone of [game.deck,game.discard,game.richeseCache!]) { const index = zone.findIndex(predicate); if (index >= 0) {const [card] = zone.splice(index,1);seat(game,owner).hand.push(card);return card.id;} } throw Error('The original physical card is unavailable.'); }; const semuta = hold('r',card=>card.id === SEMUTA_DRUG_ID); const weapon = hold('l',card=>card.kind === 'projectile'); const defense = hold('l',card=>card.kind === 'snooper'); const unrelated = hold('l',card=>card.kind === 'worthless'); const hero = hold('l',card=>card.kind === 'hero'); const winnerWeapon = hold('w',card=>card.kind === 'projectile'); const winnerDefense = hold('w',card=>card.kind === 'shield'); for (const player of game.players) { player.reserves += Object.values(player.forces).reduce((sum,count)=>sum+count,0); player.forces = {}; assert.equal(player.tanks,0);assert.equal(player.reserves,20); } for (const id of ['w','l']) {seat(game,id).reserves-=5;seat(game,id).forces['arrakeen:10']=5;} seat(game,'m').ally='l';seat(game,'l').ally='m'; Object.assign(game,{turn:2,phase:6,storm:18,active:'w',order:['w','l','m','r'], ready:[],response:null,decision:null,phaseOpening:null,stormPending:null,movementRemaining:[]}); const winnerLeader = seat(game,'w').leaders.reduce((best,leader)=>leader.strength > best.strength ? leader : best).id; return {game,semuta,weapon,defense,unrelated,hero,winnerWeapon,winnerDefense,winnerHero:null,karama:null, cards:inventory(game),winnerLeader,loserLeader:hero,loserStrength:0}; } for (const initialClaim of [false,true]) for (const finalClaim of [false,true]) { if (initialClaim && finalClaim) continue; // Claiming spends the only Semuta. void test(`Basic independent cards: Moritani retention ${initialClaim ? 'claims' : 'passes'} initial Hero and ${finalClaim ? 'claims' : 'passes'} later discard`,()=>{ const f = fixture(); let game = resolveSemutaMoritaniBattle(f); const initial = game.pendingTreacheryDiscard;assert.ok(initial?.reaction); assert.equal(initial.batch.cause,'battle:mandatory'); assert.deepEqual(initial.batch.entries.map(entry=>entry.card.id),[f.hero]); assert.deepEqual(game.moritaniRetention?.eligible,[f.weapon,f.defense]); const committed = structuredClone(resources(game)); if (!initialClaim) for (const difficulty of DIFFICULTIES) { const view = viewGame(game,'r');view.players.find(player=>player.id === 'r')!.bot=difficulty; assert.ok(botActions(view).some(action=>action.type === 'semutaCommit' || action.type === 'semutaPass')); } game = initialClaim ? act(game,'r',{type:'semutaCommit',event:initial.batch.event}) : passSemutaMoritaniDiscard(game); assert.deepEqual(resources(game),committed); assert.equal(seat(game,'r').hand.some(card=>card.id === f.hero),initialClaim); game = finishSemutaMoritaniWinner(game); assert.equal(game.decision?.kind,'moritaniRetention'); game = chooseSemutaMoritaniRetention(game,f.weapon); game = allowSemutaMoritaniResponse(game); const later = game.pendingTreacheryDiscard;assert.ok(later?.reaction); assert.equal(later.batch.cause,'battle:moritani'); assert.deepEqual(later.batch.entries.map(entry=>entry.card.id),[f.defense]); game = finalClaim ? act(game,'r',{type:'semutaCommit',event:later.batch.event}) : passSemutaMoritaniDiscard(game); assert.equal(game.pendingTreacheryDiscard,null); assert.equal(game.moritaniRetention,null); assert.equal(seat(game,'l').hand.some(card=>card.id === f.weapon),true); assert.equal(seat(game,'r').hand.some(card=>card.id === f.defense),finalClaim); assert.equal(game.discard.some(card=>card.id === f.defense),!finalClaim); assert.deepEqual(resources(game),committed); assert.deepEqual(inventory(game),f.cards); }); }