import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, type Action, type Game } from '../game/engine'; import type { Card } from '../game/cards'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { createBattleTerritoryExplosionFixture } from './fixture-battle-territory-explosion'; const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action); const player = (game: Game, id: string) => { const seat = game.players.find(row => row.id === id); assert.ok(seat); return seat; }; let native: ReturnType | undefined; const source = () => native ??= createBattleTerritoryExplosionFixture({advanced:false}); const leaderless: Action = {type:'battlePlan', leader:null, dial:0, support:0, weapon:null, defense:null}; const announcements = (game: Game) => game.log.filter(entry => entry.text === `${player(game, 'emperor').name} has no available leader or Cheap Hero for this battle.`); /** Real public Basic setup, movement and first battle. Only Emperor's leader * death/usage and conserved hand/deck custody are staged before choosing the * actual second battle. Hand size remains the same for private-card variants. */ function beforeChoice(death: 'all' | 'mixed', card: 'none' | 'ghola' | 'hero' = 'none') { const game = structuredClone(source().afterFirst), emperor = player(game,'emperor'); assert.equal(game.phase,6); assert.equal(game.battle,null); for (const [index, leader] of emperor.leaders.entries()) { leader.usedAt = 'carthag'; if (death === 'all' || index === 0) { leader.dead = true; leader.deaths = Math.max(1, leader.deaths ?? 0); delete leader.usedAt; } } // Put any accidental Hero/Ghola back into the deck, replacing it with an // ordinary physical card so variant views retain the same hand count. const privateChoice = (candidate: Card) => candidate.kind === 'hero' || candidate.effect === 'ghola'; for (let index = 0; index < emperor.hand.length; index++) if (privateChoice(emperor.hand[index])) { const replacement = game.deck.findIndex(candidate => !privateChoice(candidate)); assert.ok(replacement >= 0); [emperor.hand[index], game.deck[replacement]] = [game.deck[replacement], emperor.hand[index]]; } if (card !== 'none') { const index = game.deck.findIndex(candidate => card === 'hero' ? candidate.kind === 'hero' : candidate.effect === 'ghola'); assert.ok(index >= 0); assert.ok(emperor.hand.length > 0); [emperor.hand[0], game.deck[index]] = [game.deck[index], emperor.hand[0]]; } const chooser = game.active; assert.ok(chooser); const choice = viewGame(game,chooser).battleChoices.find(row => row.territory === 'imperial_basin' && [row.attacker,row.defender].includes('emperor') && [row.attacker,row.defender].includes('guild')); assert.ok(choice); const action: Action = {type:'chooseBattle', territory:choice.territory, target:choice.attacker === chooser ? choice.defender : choice.attacker}; return {game,chooser,action}; } function selected(death: 'all' | 'mixed', card: 'none' | 'ghola' | 'hero' = 'none') { const f = beforeChoice(death, card), game = act(f.game,f.chooser,f.action), battle = game.battle; assert.ok(battle && !battle.revealed && battle.preLeader && !battle.preLeader.closed); assert.ok(!battle.preLeader.ready.includes('emperor')); assert.ok(battle.preLeader.ready.includes('guild')); assert.equal(announcements(game).length,0); return {...f,game}; } function ready(game:Game) { const event = game.battle?.preLeader?.event; assert.ok(event); return act(game,'emperor',{type:'battlePreparationReady',event}); } const guildPlan = ():Action => ({type:'battlePlan', leader:source().guildLeader, dial:0, support:0, weapon:null, defense:null}); for (const death of ['all','mixed'] as const) void test( `${death} dead leaders wait for neutral preparation before the public Basic announcement`, () => { let {game} = selected(death); assert.deepEqual(game.battle!.plans,{}); const before = structuredClone(game); assert.throws(() => act(game,'guild',guildPlan())); assert.deepEqual(game,before); const event = game.battle!.preLeader!.event; game = ready(game); assert.equal(game.battle!.preLeader!.closed,true); assert.equal(announcements(game).length,1); assert.deepEqual(viewGame(game,'guild').battle!.earlyLeaderless,['emperor']); const closed = structuredClone(game); assert.throws(() => act(game,'emperor',{type:'battlePreparationReady',event})); assert.deepEqual(game,closed); game = act(game,'guild',guildPlan()); game = act(game,'emperor',leaderless); assert.equal(game.battle!.revealed,true); assert.equal(announcements(game).length,1); }, ); void test('Ghola and Cheap Hero holdings do not change the public preparation opportunity', () => { const games = (['none','ghola','hero'] as const).map(card => selected('all',card).game); for (const viewer of ['guild','atreides']) { const projected = games.map(game => { const view = viewGame(game,viewer), battle = view.battle!; return {ready:battle.preLeader?.ready, closed:battle.preLeader?.closed, earlyLeaderless:battle.earlyLeaderless, plans:battle.plans, cards:battle.cards, handCounts:view.players.map(seat => seat.handCount)}; }); assert.deepEqual(projected[1],projected[0]); assert.deepEqual(projected[2],projected[0]); } }); void test('a physical Ghola can revive a leader during preparation and suppress the announcement', () => { let {game} = selected('mixed','ghola'); const emperor = player(game,'emperor'), leader = emperor.leaders.find(candidate => candidate.dead); const ghola = emperor.hand.find(candidate => candidate.effect === 'ghola'); assert.ok(leader && ghola); game = act(game,'emperor',{type:'card',card:ghola.id,leader:leader.id}); assert.equal(player(game,'emperor').leaders.find(candidate => candidate.id === leader.id)?.dead,false); assert.equal(game.discard.filter(card => card.id === ghola.id).length,1); assert.equal(announcements(game).length,0); game = ready(game); assert.equal(announcements(game).length,0); game = act(game,'guild',guildPlan()); game = act(game,'emperor',{...leaderless,leader:leader.id}); assert.equal(game.battle!.revealed,true); }); for (const opponentSeals of [false,true]) void test( `late Ghola corrects the early announcement ${opponentSeals ? 'after the opponent seals' : 'before either plan'}`, () => { let {game} = selected('mixed','ghola'); const leader = player(game,'emperor').leaders.find(candidate => candidate.dead); const ghola = player(game,'emperor').hand.find(candidate => candidate.effect === 'ghola'); assert.ok(leader && ghola); game = ready(game); assert.deepEqual(viewGame(game,'guild').battle!.earlyLeaderless,['emperor']); assert.equal(announcements(game).length,1); if (opponentSeals) game = act(game,'guild',guildPlan()); const sealed = structuredClone(game.battle!.plans.guild ?? null); game = act(game,'emperor',{type:'card',card:ghola.id,leader:leader.id}); assert.deepEqual(game.battle!.plans.guild ?? null,sealed); assert.equal(game.battle!.revealed,false); assert.equal(player(game,'emperor').leaders.find(candidate => candidate.id === leader.id)?.dead,false); assert.ok(!(viewGame(game,'guild').battle!.earlyLeaderless ?? []).includes('emperor')); assert.equal(game.log.filter(entry => entry.text === `${player(game,'emperor').name} now has an available leader for this battle.`).length,1); assert.equal(announcements(game).length,1); if (!opponentSeals) game = act(game,'guild',guildPlan()); game = act(game,'emperor',{...leaderless,leader:leader.id}); assert.equal(game.battle!.revealed,true); }, ); void test('both combatants with dead leaders must finish the neutral window before either plan', () => { const f = beforeChoice('mixed'); const guild = player(f.game,'guild'); for (const leader of guild.leaders) { leader.dead = true; leader.deaths = Math.max(1,leader.deaths ?? 0); delete leader.usedAt; } for (let index = 0; index < guild.hand.length; index++) if (guild.hand[index].kind === 'hero') { const replacement = f.game.deck.findIndex(card => card.kind !== 'hero'); assert.ok(replacement >= 0); [guild.hand[index],f.game.deck[replacement]] = [f.game.deck[replacement],guild.hand[index]]; } let game = act(f.game,f.chooser,f.action); const event = game.battle?.preLeader?.event; assert.ok(event); assert.deepEqual(game.battle!.preLeader!.ready,[]); game = act(game,'guild',{type:'battlePreparationReady',event}); assert.equal(game.battle!.preLeader!.closed,false); const before = structuredClone(game); assert.throws(() => act(game,'guild',leaderless)); assert.deepEqual(game,before); game = act(game,'emperor',{type:'battlePreparationReady',event}); assert.equal(game.battle!.preLeader!.closed,true); assert.deepEqual(new Set(viewGame(game,'atreides').battle!.earlyLeaderless),new Set(['emperor','guild'])); game = act(game,'guild',leaderless); game = act(game,'emperor',leaderless); assert.equal(game.battle!.revealed,true); }); void test('dead-leader readiness precedes completion of Atreides Prescience and its announcement', () => { let game = structuredClone(source().beforeFirst); const chooser = game.active; assert.ok(chooser); const choice = viewGame(game,chooser).battleChoices.find(row => row.territory === 'imperial_basin' && [row.attacker,row.defender].includes('atreides')); assert.ok(choice); const target = choice.attacker === 'atreides' ? choice.defender : choice.attacker; const seat = player(game,target); for (const [index, leader] of seat.leaders.entries()) { leader.usedAt = 'carthag'; if (index === 0) { leader.dead = true; leader.deaths = Math.max(1,leader.deaths ?? 0); delete leader.usedAt; } } for (let index = 0; index < seat.hand.length; index++) if (seat.hand[index].kind === 'hero') { const replacement = game.deck.findIndex(card => card.kind !== 'hero'); assert.ok(replacement >= 0); [seat.hand[index],game.deck[replacement]] = [game.deck[replacement],seat.hand[index]]; } game = act(game,chooser,{type:'chooseBattle',territory:choice.territory, target:choice.attacker === chooser ? choice.defender : choice.attacker}); assert.equal(game.battle?.preparation?.kind,'prescience'); const event = game.battle?.preLeader?.event; assert.ok(event); assert.ok(!game.battle!.preLeader!.ready.includes(target)); assert.ok(!(viewGame(game,'atreides').battle!.earlyLeaderless ?? []).includes(target)); game = act(game,target,{type:'battlePreparationReady',event}); assert.equal(game.battle!.preLeader!.closed,true); assert.equal(game.battle?.preparation?.kind,'prescience'); assert.ok(!(viewGame(game,'atreides').battle!.earlyLeaderless ?? []).includes(target)); game = act(game,'atreides',{type:'declineBattlePower'}); assert.equal(game.battle?.preparation ?? null,null); assert.deepEqual(viewGame(game,'atreides').battle!.earlyLeaderless,[target]); const atreidesLeader = player(game,'atreides').leaders.find(leader => !leader.dead && !leader.capturedBy && (!leader.usedAt || leader.usedAt === choice.territory)); assert.ok(atreidesLeader); game = act(game,'atreides',{...leaderless,leader:atreidesLeader.id}); game = act(game,target,leaderless); assert.equal(game.battle!.revealed,true); }); void test('Ghola after an owned leaderless plan preserves the sealed plan and its public announcement', () => { let {game} = selected('mixed','ghola'); const leader = player(game,'emperor').leaders.find(candidate => candidate.dead); const ghola = player(game,'emperor').hand.find(candidate => candidate.effect === 'ghola'); assert.ok(leader && ghola); game = ready(game); game = act(game,'emperor',leaderless); const sealed = structuredClone(game.battle!.plans.emperor); assert.deepEqual(viewGame(game,'guild').battle!.leaderless,['emperor']); game = act(game,'emperor',{type:'card',card:ghola.id,leader:leader.id}); assert.equal(player(game,'emperor').leaders.find(candidate => candidate.id === leader.id)?.dead,false); assert.deepEqual(game.battle!.plans.emperor,sealed); assert.deepEqual(viewGame(game,'guild').battle!.leaderless,['emperor']); assert.equal(game.log.filter(entry => entry.text === `${player(game,'emperor').name} now has an available leader for this battle.`).length,0); game = act(game,'guild',guildPlan()); assert.equal(game.battle!.revealed,true); assert.deepEqual(game.battle!.plans.emperor,sealed); }); void test('a held Cheap Hero remains a legal leader and is not announced as leaderless', () => { let {game} = selected('all','hero'); const hero = player(game,'emperor').hand.find(card => card.kind === 'hero'); assert.ok(hero); game = ready(game); assert.equal(announcements(game).length,0); game = act(game,'emperor',{...leaderless,leader:hero.id}); assert.equal(game.battle!.plans.emperor.leader,hero.id); assert.equal(announcements(game).length,0); }); void test('older already-open unstamped battles do not acquire a preparation wait', () => { let {game} = selected('all'); delete game.battle!.basicEarlyLeaderlessVersion; delete game.battle!.preLeader; const saved = JSON.stringify(game); game = JSON.parse(saved); assert.equal(game.battle!.preLeader,undefined); game = act(game,'guild',guildPlan()); assert.equal(game.battle!.revealed,false); assert.equal(announcements(game).length,0); }); void test('all four bot profiles can finish the neutral preparation window', () => { const {game} = selected('mixed'); for (const difficulty of DIFFICULTIES) { const view = viewGame(game,'emperor'); view.players.find(seat => seat.id === 'emperor')!.bot = difficulty; const action = botActions(view).find(candidate => candidate.type === 'battlePreparationReady'); assert.ok(action,`${difficulty} must leave preparation legally`); const next = act(structuredClone(game),'emperor',action); assert.equal(next.battle!.preLeader!.closed,true); assert.equal(announcements(next).length,1); } });