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 announce = (game: Game) => game.log.filter(entry => entry.text.includes('announced a Battle Plan without a leader or Cheap Hero')); const leaderless: Action = {type:'battlePlan', dial:0, support:0, leader:null, weapon:null, defense:null}; /** Public three-seat Basic source plays both entries and the first battle, then * chooses the real second contest in Imperial Basin. Only the Emperor's five * leader-use locations are staged as previously used in another territory; * the actual second battle, dealt hands and all plans stay native. */ function fixture() { const source = createBattleTerritoryExplosionFixture({advanced:false}); const game = structuredClone(source.game); assert.equal(game.status, 'playing'); assert.equal(game.advanced, false); assert.equal(game.phase, 6); assert.ok(game.battle && !game.battle.revealed); assert.deepEqual([game.battle.attacker, game.battle.defender].sort(), ['emperor', 'guild']); assert.equal(seat(game, 'emperor').hand.some(card => card.kind === 'hero'), false); for (const leader of seat(game, 'emperor').leaders) leader.usedAt = 'carthag'; return {game, source}; } void test('Basic actual battle announces the newly sealed leaderless plan without exposing its dial or hand', () => { const f = fixture(), initial = f.game, beforeLogs = announce(initial).length; let game = act(initial, 'emperor', leaderless); assert.equal(game.battle?.revealed, false); assert.deepEqual(game.battle?.basicLeaderlessAnnouncements, ['emperor']); assert.equal(announce(game).length, beforeLogs + 1); for (const viewer of ['guild', 'atreides']) { const battle = viewGame(game, viewer).battle!; assert.deepEqual(battle.leaderless, ['emperor']); assert.deepEqual(battle.plans, {}); assert.deepEqual(battle.cards, []); } assert.equal(viewGame(game, 'emperor').battle!.plans.emperor.dial, 0); const saved = JSON.stringify(game); game = JSON.parse(saved); assert.deepEqual(viewGame(game, 'guild').battle!.leaderless, ['emperor']); assert.deepEqual(viewGame(game, 'atreides').battle!.plans, {}); assert.equal(JSON.stringify(game), saved); assert.throws(() => act(game, 'emperor', leaderless)); assert.equal(JSON.stringify(game), saved); game = act(game, 'guild', {type:'battlePlan', dial:0, support:0, leader:f.source.guildLeader, weapon:null, defense:null}); assert.equal(game.battle?.revealed, true); assert.equal(announce(game).length, beforeLogs + 1); assert.deepEqual(viewGame(game, 'atreides').battle!.leaderless, ['emperor']); assert.equal(viewGame(game, 'atreides').battle!.plans.emperor.dial, 0); }); void test('a held Cheap Hero prevents a Basic leaderless plan and its legal use does not announce one', () => { const {game} = fixture(); const heroIndex = game.deck.findIndex(card => card.kind === 'hero'); assert.ok(heroIndex >= 0); const [hero] = game.deck.splice(heroIndex, 1); seat(game, 'emperor').hand.push(hero); const before = structuredClone(game), logs = announce(game).length; assert.throws(() => act(game, 'emperor', leaderless)); assert.deepEqual(game, before); const planned = act(game, 'emperor', {...leaderless, leader:hero.id}); assert.equal(announce(planned).length, logs); assert.deepEqual(viewGame(planned, 'guild').battle!.leaderless, []); assert.deepEqual(viewGame(planned, 'guild').battle!.plans, {}); }); void test('an already-sealed Basic legacy plan receives no synthetic announcement', () => { const {game} = fixture(); // Explicit legacy supplement: install the plan format an older save could // contain, without the new announcement receipt or a historical log entry. game.battle!.plans.emperor = {dial:0, support:0, leader:null, weapon:null, defense:null}; const saved = JSON.stringify(game); assert.equal(game.battle!.basicLeaderlessAnnouncements, undefined); assert.deepEqual(viewGame(game, 'guild').battle!.leaderless, []); assert.deepEqual(viewGame(game, 'atreides').battle!.plans, {}); assert.equal(JSON.stringify(game), saved); const restored: Game = JSON.parse(saved); assert.deepEqual(viewGame(restored, 'guild').battle!.leaderless, []); assert.equal(announce(restored).length, announce(game).length); }); void test('all four minimal bot profiles seal a legal Basic leaderless plan and announce it', () => { const {game} = fixture(); for (const difficulty of DIFFICULTIES) { const view = viewGame(game, 'emperor'); view.players.find(player => player.id === 'emperor')!.bot = difficulty; const actions = botActions(view).filter(action => action.type === 'battlePlan'); assert.ok(actions.length > 0, `${difficulty} must offer a legal plan`); const next = act(structuredClone(game), 'emperor', actions[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.deepEqual(viewGame(next, 'guild').battle!.leaderless, ['emperor']); } });