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 { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam'; import { semutaIxAllowResponses, semutaIxInventory, semutaIxPassDiscard, semutaIxPlanSteps, semutaIxPlayer, semutaIxSubstitutionStep, type SemutaIxFixture, } from './fixture-semuta-ix-substitution'; /** A fresh public Basic setup with the three selected families and the * independent Ecaz cards. A later battle position is explicitly staged by * moving the original held cards and all original forces between legal pools; * battle choice, plans, traitor calls and subsequent effects are actual actions. */ function independentIxBattle(): SemutaIxFixture { let game = createGame('SEMUTAINDEPENDENTIX', newPlayer('r', 'Richese', 'richese'), false, ['choam', 'ix', 'ecaz']); joinGame(game, newPlayer('i', 'Ixians', 'ixians')); joinGame(game, newPlayer('e', 'Emperor', 'emperor')); game = applyAction(game, 'r', {type:'moduleOption', module:'ecazTreachery', enabled:true}); for (const player of game.players) player.bot = 'Medium'; for (const id of game.players.map(player => player.id)) game = applyAction(game, id, {type:'ready'}); game = applyAction(game, 'r', {type:'start'}); assert.equal(game.status, 'setup'); assert.equal(game.semutaRecruitsPreview, true); for (let step = 0; (game.status === 'setup' || game.phase === 1 || !game.mobileStronghold?.location) && step < 160; step++) { let next: Game | undefined; for (const player of game.players) { const action = botActions(viewGame(game, player.id))[0]; if (action) { next = applyAction(game, player.id, action); break; } } assert.ok(next, 'Native setup and first Storm need a legal action.'); game = next; } assert.equal(game.status, 'playing'); assert.ok(game.mobileStronghold?.location); const cards = semutaIxInventory(game); assert.equal(new Set(cards).size, cards.length); for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; player.reserves += player.tanks + Object.values(player.forces).reduce((sum,n) => sum + n,0); player.forces = {}; player.tanks = 0; player.battleLosses = 0; if (player.elites) { player.elites.reserves += player.elites.tanks + Object.values(player.elites.forces).reduce((sum,n) => sum + n,0); player.elites.forces = {}; player.elites.tanks = 0; } } const take = (id: string) => { for (const zone of [game.deck, game.discard, game.richeseCache!]) { const index = zone.findIndex(card => card.id === id); if (index >= 0) return zone.splice(index,1)[0]; } throw new Error(`Missing original physical card ${id}.`); }; const semuta = take(SEMUTA_DRUG_ID); const tooth = take(game.deck.find(card => card.kind === 'poisonTooth')!.id); const shield = take(game.deck.find(card => card.kind === 'shield')!.id); const snooper = take(game.deck.find(card => card.kind === 'snooper')!.id); semutaIxPlayer(game,'r').hand.push(semuta, take(game.deck.find(card => card.effect === 'karama')!.id)); semutaIxPlayer(game,'i').hand.push(tooth,shield); semutaIxPlayer(game,'e').hand.push(snooper); const location = 'arrakeen:10', ix = semutaIxPlayer(game,'i'), emperor = semutaIxPlayer(game,'e'); assert.equal(ix.reserves,20); assert.equal(ix.elites!.reserves,7); ix.reserves -= 3; ix.forces[location] = 3; ix.elites!.reserves--; ix.elites!.forces[location] = 1; emperor.reserves -= 5; emperor.forces[location] = 5; Object.assign(game,{turn:2,phase:6,active:'i',order:['i','e','r'],storm:18, ready:[],response:null,decision:null,phaseOpening:null,movementRemaining:[]}); assert.deepEqual(semutaIxInventory(game),cards); return {game, holder:'r', winner:'i', loser:'e', territory:'arrakeen', location, semuta:semuta.id, tooth:tooth.id, shield:shield.id, snooper:snooper.id, cards}; } function loserDiscard(): SemutaIxFixture { const fixture = independentIxBattle(); let game = applyAction(fixture.game,fixture.winner,{type:'chooseBattle',territory:fixture.territory,target:fixture.loser}); for (let step = 0; step < 40; step++) { if (game.response) { const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor); game = applyAction(game,actor.id,{type:'passResponse'}); } else if (game.battle?.preparation) game = applyAction(game,game.battle.preparation.owner,{type:'declineBattlePower'}); else if (game.decision?.kind === 'fullPlanOffer') game = applyAction(game,game.decision.player,{type:'decision',decline:true}); else break; } for (const actor of [fixture.winner,fixture.loser]) game = applyAction(game,actor,{type:'battlePreparationReady',event:game.battle!.preLeader!.event}); for (const step of semutaIxPlanSteps(game,fixture)) game = applyAction(game,step.actor,step.action); assert.equal(game.decision?.kind,'poisonTooth'); game = applyAction(game,fixture.winner,{type:'decision',activate:true}); for (const actor of [fixture.winner,fixture.loser]) game = applyAction(game,actor,{type:'traitorCall',call:false}); game = finishSemutaBattleOutcome(game); return {...fixture,game}; } for (const claim of [false,true]) for (const substitute of [false,true]) { void test(`Basic independent cards: Ixian loser discard ${claim ? 'claim' : 'pass'}, then ${substitute ? 'substitute' : 'decline'}`, () => { const fixture = loserDiscard(), pending = fixture.game; const batch = pending.pendingTreacheryDiscard; assert.ok(batch?.reaction); assert.equal(batch.reaction.stage,'offer'); assert.equal(batch.continuation.kind,'battleResolved'); assert.deepEqual(batch.batch.entries.map(entry => entry.card.id),[fixture.snooper]); assert.deepEqual(pending.pendingWinnerDiscards?.cards,[fixture.tooth]); assert.equal(pending.pendingIxSubstitution ?? null,null); const event = batch.batch.event; let game = claim ? applyAction(pending,fixture.holder,{type:'semutaCommit',event}) : semutaIxPassDiscard(pending); if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') game = applyAction(game,fixture.holder,{type:'semutaSelect',event,card:fixture.snooper}); assert.equal(game.decision?.kind,'ixSubstitution'); assert.equal(game.decision?.player,fixture.winner); assert.deepEqual(game.pendingIxSubstitution!.losses,{[fixture.location]:1}); assert.equal(semutaIxPlayer(game,fixture.winner).tanks,1); assert.equal(semutaIxPlayer(game,fixture.winner).elites!.tanks,1); assert.equal(semutaIxPlayer(game,fixture.holder).hand.some(card => card.id === fixture.snooper),claim); assert.equal(game.discard.some(card => card.id === fixture.tooth),false); const step = semutaIxSubstitutionStep(fixture,substitute); game = applyAction(game,step.actor,step.action); if (substitute) { assert.equal(game.response?.kind,'ixSubstitution'); game = semutaIxAllowResponses(game); } assert.equal(game.pendingIxSubstitution,null); assert.equal(semutaIxPlayer(game,fixture.winner).elites!.tanks,substitute ? 0 : 1); assert.equal(semutaIxPlayer(game,fixture.winner).battleLosses,1); assert.equal(game.pendingTreacheryDiscard?.continuation.kind,'winnerMandatoryDiscard'); assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(entry => entry.card.id),[fixture.tooth]); game = semutaIxPassDiscard(game); assert.equal(game.decision?.kind,'battleCards'); game = applyAction(game,fixture.winner,{type:'decision',discard:[fixture.shield]}); if (game.pendingTreacheryDiscard) game = semutaIxPassDiscard(game); assert.equal(game.phase,7); assert.deepEqual(semutaIxInventory(game),fixture.cards); assert.equal(game.discard.filter(card => card.id === fixture.semuta).length,Number(claim)); for (const player of game.players) assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum,n) => sum + n,0),20); }); } void test('all four AI profiles can answer the fresh discard before Ixian substitution', () => { const fixture = loserDiscard(); for (const difficulty of DIFFICULTIES) { const view = viewGame(fixture.game,fixture.holder); view.players.find(player => player.id === fixture.holder)!.bot = difficulty; const action = botActions(view).find(item => item.type === 'semutaCommit' || item.type === 'semutaPass'); assert.ok(action); assert.doesNotThrow(() => applyAction(fixture.game,fixture.holder,action)); } });