import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { createBattleTerritoryExplosionFixture } from './fixture-battle-territory-explosion'; const act = (game: Game, id: string, action: Action) => applyAction(game, id, action); const seat = (game: Game, id: string) => { const player = game.players.find(row => row.id === id); assert.ok(player); return player; }; const announcements = (game: Game, id: string) => game.log.filter(entry => entry.text === `${seat(game, id).name} has no available leader or Cheap Hero for this battle.`); let nativeSource: ReturnType | undefined; const source = () => nativeSource ??= createBattleTerritoryExplosionFixture({advanced:false}); const leaderless: Action = {type:'battlePlan', leader:null, dial:0, support:0, weapon:null, defense:null}; /** A genuine public Basic Emperor/Atreides/Guild setup plays real movement and * the first battle. On the earned next-battle source only the Emperor's five * leader-use locations, and an optional conserved Cheap Hero/death history, * are staged before the actual second battle choice. */ function beforeChoice(options: {hero?: boolean; dead?: boolean} = {}) { const game = structuredClone(source().afterFirst); assert.equal(game.advanced, false); assert.equal(game.phase, 6); assert.equal(game.battle, null); const emperor = seat(game, 'emperor'); assert.ok(emperor.leaders.every(leader => !leader.dead && !leader.capturedBy)); for (const leader of emperor.leaders) leader.usedAt = 'carthag'; if (options.dead) { emperor.leaders[0].dead = true; emperor.leaders[0].deaths = Math.max(1, emperor.leaders[0].deaths); } if (options.hero) { const index = game.deck.findIndex(card => card.kind === 'hero'); assert.ok(index >= 0); emperor.hand.push(game.deck.splice(index, 1)[0]); } const chooser = game.active!; 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(options: {hero?: boolean; dead?: boolean} = {}) { const f = beforeChoice(options), game = act(f.game, f.chooser, f.action); assert.ok(game.battle && !game.battle.revealed && !game.battle.preparation && !game.response && !game.decision); assert.equal(game.battle.basicEarlyLeaderlessVersion, 1); return {...f, game}; } const guildPlan = () => ({type:'battlePlan', leader:source().guildLeader, dial:0, support:0, weapon:null, defense:null}); for (const first of ['emperor', 'guild'] as const) void test( `Basic early leaderless announcement precedes either plan when ${first} seals first`, () => { let {game} = selected(); assert.deepEqual(game.battle!.plans, {}); assert.deepEqual(game.battle!.basicEarlyLeaderless, ['emperor']); assert.equal(announcements(game, 'emperor').length, 1); for (const viewer of ['emperor','guild','atreides']) { const battle = viewGame(game, viewer).battle!; assert.deepEqual(battle.earlyLeaderless, ['emperor']); assert.deepEqual(battle.plans, {}); assert.deepEqual(battle.cards, []); } const saved = JSON.stringify(game); game = JSON.parse(saved); assert.equal(JSON.stringify(game), saved); assert.deepEqual(viewGame(game, 'atreides').battle!.earlyLeaderless, ['emperor']); game = act(game, first, first === 'emperor' ? leaderless : guildPlan()); assert.equal(game.battle!.revealed, false); assert.equal(announcements(game, 'emperor').length, 1); assert.deepEqual(viewGame(game, 'atreides').battle!.plans, {}); assert.deepEqual(viewGame(game, 'atreides').battle!.earlyLeaderless, first === 'emperor' ? undefined : ['emperor']); if (first === 'emperor') assert.deepEqual(viewGame(game, 'guild').battle!.leaderless, ['emperor']); const duplicate = structuredClone(game); assert.throws(() => act(game, first, first === 'emperor' ? leaderless : guildPlan())); assert.deepEqual(game, duplicate); game = act(game, first === 'emperor' ? 'guild' : 'emperor', first === 'emperor' ? guildPlan() : leaderless); assert.equal(game.battle!.revealed, true); assert.equal(announcements(game, 'emperor').length, 1); assert.deepEqual(viewGame(game, 'atreides').battle!.leaderless, ['emperor']); }, ); void test('a held Cheap Hero prevents the early announcement and remains a legal plan', () => { const {game} = selected({hero:true}); assert.equal(game.battle!.basicEarlyLeaderless?.includes('emperor') ?? false, false); assert.equal(viewGame(game, 'guild').battle!.earlyLeaderless, undefined); assert.equal(announcements(game, 'emperor').length, 0); const hero = seat(game, 'emperor').hand.find(card => card.kind === 'hero'); assert.ok(hero); const before = structuredClone(game); assert.throws(() => act(game, 'emperor', leaderless)); assert.deepEqual(game, before); const next = act(game, 'emperor', {...leaderless, leader:hero.id}); assert.deepEqual(viewGame(next, 'guild').battle!.leaderless, []); assert.equal(announcements(next, 'emperor').length, 0); }); void test('a dead leader leaves no early announcement while an owned Ghola may change eligibility', () => { const {game} = selected({dead:true}); assert.equal(game.battle!.basicEarlyLeaderless?.includes('emperor') ?? false, false); assert.equal(viewGame(game, 'guild').battle!.earlyLeaderless, undefined); assert.equal(announcements(game, 'emperor').length, 0); }); void test('older unstamped Basic battles do not acquire a synthetic early announcement', () => { let {game} = selected(); // Simulate a prior already-open battle: no new-version marker, receipt or // newly created log entry. Keep all other real battle and piece state. delete game.battle!.basicEarlyLeaderlessVersion; delete game.battle!.basicEarlyLeaderless; game.log = game.log.filter(entry => entry.text !== `${seat(game, 'emperor').name} has no available leader or Cheap Hero for this battle.`); const saved = JSON.stringify(game); game = JSON.parse(saved); assert.equal(viewGame(game, 'guild').battle!.earlyLeaderless, undefined); assert.equal(JSON.stringify(game), saved); game = act(game, 'guild', guildPlan()); assert.equal(viewGame(game, 'atreides').battle!.earlyLeaderless, undefined); assert.equal(announcements(game, 'emperor').length, 0); }); void test('pending Basic Prescience resolves before the early announcement', () => { let game = structuredClone(source().beforeFirst); const chooser = game.active!; 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; assert.ok(seat(game, target).leaders.every(leader => !leader.dead && !leader.capturedBy)); assert.ok(!seat(game, target).hand.some(card => card.kind === 'hero')); for (const leader of seat(game, target).leaders) leader.usedAt = 'carthag'; game = act(game, chooser, {type:'chooseBattle', territory:choice.territory, target:choice.attacker === chooser ? choice.defender : choice.attacker}); assert.equal(game.battle?.preparation?.kind, 'prescience'); assert.equal(viewGame(game, 'atreides').battle!.earlyLeaderless, undefined); assert.equal(announcements(game, target).length, 0); game = act(game, 'atreides', {type:'declineBattlePower'}); assert.equal(game.battle?.preparation ?? null, null); assert.deepEqual(viewGame(game, 'atreides').battle!.earlyLeaderless, [target]); assert.equal(announcements(game, target).length, 1); }); void test('all four minimal bots seal legally after a public early Basic announcement', () => { const {game} = selected(); for (const difficulty of DIFFICULTIES) { const view = viewGame(game, 'emperor'); view.players.find(player => player.id === 'emperor')!.bot = difficulty; const plans = botActions(view).filter(action => action.type === 'battlePlan'); assert.ok(plans.length > 0, `${difficulty} must offer a legal plan`); const next = act(structuredClone(game), 'emperor', plans[0]); assert.equal(next.battle!.plans.emperor.leader, null); assert.equal(next.battle!.plans.emperor.weapon, null); assert.equal(next.battle!.plans.emperor.defense, null); assert.equal(announcements(next, 'emperor').length, 1); assert.deepEqual(viewGame(next, 'guild').battle!.leaderless, ['emperor']); } });