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 { createBasicLeaderlessHarkonnenFixture, leaderlessSeat as seat, stageLeaderlessCard } from './fixture-basic-leaderless-harkonnen'; const act = (game:Game,id:string,action:Action) => applyAction(game,id,action); let fixture: ReturnType | undefined; const source = () => fixture ??= createBasicLeaderlessHarkonnenFixture(); const notice = (game:Game) => game.log.filter(entry => entry.text === `${seat(game,'h').name} has no available leader or Cheap Hero for this battle.`); const empty:Action = {type:'battlePlan',leader:null,dial:0,support:0,weapon:null,defense:null}; /** Public setup and shipments are real. Leader use/death, concealed captive * custody and conserved Ghola/Hero cards are explicitly staged before the * actual choice; hidden variants retain the same public leader snapshots. */ function beforeChoice(options:{card?:'none'|'hero'|'ghola'; captive?:number; captiveAvailable?:boolean} = {}) { const f = source(), game = structuredClone(f.game), harkonnen = seat(game,'h'); for (const [index,leader] of harkonnen.leaders.entries()) { if (index === 0) {leader.dead = true; leader.deaths = Math.max(1,leader.deaths); delete leader.usedAt;} else {leader.dead = false; leader.usedAt = 'carthag';} } stageLeaderlessCard(game,'h',options.card ?? 'none'); let captive:string | undefined; if (options.captive !== undefined) { const owner = seat(game,'e'); // The two candidate discs have the same public history. Their private // post-capture use can differ without changing the recorded public face. for (const leader of owner.leaders.slice(0,2)) {assert.ok(!leader.dead); leader.usedAt = 'carthag';} const leader = owner.leaders[options.captive]; assert.ok(leader); assert.ok(!leader.dead && !leader.capturedBy); leader.concealed = {captor:'h',controller:'e',dead:leader.dead,deaths:leader.deaths,usedAt:leader.usedAt}; if (options.captiveAvailable !== false) leader.usedAt = 'imperial_basin'; leader.capturedBy = 'h'; captive = leader.id; } return {game, action:f.action, chooser:f.chooser, captive}; } function selected(options:Parameters[0] = {}) { const f = beforeChoice(options), game = act(f.game,f.chooser,f.action); assert.ok(game.battle?.preLeader && !game.battle.preLeader.closed); assert.ok(!game.battle.preLeader.ready.includes('h')); assert.ok(game.battle.preLeader.ready.includes('e')); return {...f,game}; } function ready(game:Game):Game { const event = game.battle?.preLeader?.event; assert.ok(event); return act(game,'h',{type:'battlePreparationReady',event}); } function emperorPlan(game:Game):Action { const leader = seat(game,'e').leaders.find(l => !l.dead && !l.capturedBy && (!l.usedAt || l.usedAt === game.battle!.territory)); assert.ok(leader); return {...empty,leader:leader.id}; } void test('Harkonnen with dead and used native leaders gets an early preparation and announcement', () => { let {game} = selected(); const before = structuredClone(game); assert.throws(() => act(game,'e',emperorPlan(game))); assert.deepEqual(game,before); game = ready(game); assert.deepEqual(viewGame(game,'g').battle!.earlyLeaderless,['h']); assert.equal(notice(game).length,1); game = act(game,'e',emperorPlan(game)); game = act(game,'h',empty); assert.equal(game.battle!.revealed,true); assert.equal(notice(game).length,1); }); void test('a concealed usable captive prevents a false Harkonnen leaderless announcement', () => { const {game: initial,captive} = selected({captive:0}); let game = initial; assert.ok(captive); assert.equal(viewGame(game,'g').players.find(p => p.id === 'e')!.leaders.find(l => l.id === captive)!.capturedBy,undefined); game = ready(game); assert.equal(notice(game).length,0); assert.ok(!(viewGame(game,'g').battle!.earlyLeaderless ?? []).includes('h')); game = act(game,'h',{...empty,leader:captive}); assert.equal(game.battle!.plans.h.leader,captive); assert.equal(game.battle!.revealed,false); }); void test('concealed capture identity does not affect the public pre-leader opportunity', () => { const forks = [selected({captive:0}),selected({captive:1,captiveAvailable:false})]; assert.notEqual(forks[0].captive,forks[1].captive); const publicFields = (game:Game) => { const view = viewGame(game,'g'); return {leaders:view.players.map(player => ({id:player.id,leaders:player.leaders})), stage:{ready:view.battle!.preLeader!.ready,closed:view.battle!.preLeader!.closed}, early:view.battle!.earlyLeaderless}; }; assert.deepEqual(publicFields(forks[0].game),publicFields(forks[1].game)); const closed = forks.map(f => ready(f.game)); assert.deepEqual(publicFields(closed[0]),publicFields(closed[1])); assert.equal(notice(closed[0]).length,0); assert.equal(notice(closed[1]).length,0); const playable = act(closed[0],'h',{...empty,leader:forks[0].captive}); assert.equal(playable.battle!.plans.h.leader,forks[0].captive); const leaderless = act(closed[1],'h',empty); assert.equal(leaderless.battle!.plans.h.leader,null); }); void test('concealed capture or execution of the only public available leader does not cause an early notice', () => { const f = source(); const forks = ([{captured:0,executed:false},{captured:1,executed:false}, {captured:0,executed:true},{captured:1,executed:true}] as const).map(({captured,executed}) => { const game = structuredClone(f.game), emperor = seat(game,'e'); for (const leader of emperor.leaders) {leader.dead = false; leader.usedAt = 'carthag';} delete emperor.leaders[0].usedAt; stageLeaderlessCard(game,'e','none'); const leader = emperor.leaders[captured]; leader.concealed = {captor:'h',controller:'e',dead:leader.dead,deaths:leader.deaths, ...(leader.usedAt ? {usedAt:leader.usedAt} : {})}; if (executed) { leader.dead = true; leader.deaths = Math.max(2,leader.deaths + 1); delete leader.usedAt; delete leader.capturedBy; } else leader.capturedBy = 'h'; return act(game,f.chooser,f.action); }); const publicFields = (game:Game) => { const view = viewGame(game,'g'); return {leaders:view.players.map(player => ({id:player.id,leaders:player.leaders})), stage:view.battle!.preLeader ? {ready:view.battle!.preLeader.ready, closed:view.battle!.preLeader.closed} : null, early:view.battle!.earlyLeaderless}; }; for (const fork of forks.slice(1)) assert.deepEqual(publicFields(forks[0]),publicFields(fork)); for (const game of forks) { assert.ok(!(viewGame(game,'g').battle!.earlyLeaderless ?? []).includes('e')); assert.equal(game.log.filter(entry => entry.text === `${seat(game,'e').name} has no available leader or Cheap Hero for this battle.`).length,0); } // The owner may make the normal public announcement by actually sealing a // leaderless plan, while the other privately held leader remains required. const sealed = act(forks[0],'e',empty); assert.equal(sealed.battle!.plans.e.leader,null); assert.throws(() => act(forks[1],'e',empty)); const executed = act(forks[2],'e',empty); assert.equal(executed.battle!.plans.e.leader,null); assert.throws(() => act(forks[3],'e',empty)); }); void test('Harkonnen can use physical Ghola during or after preparation', () => { for (const late of [false,true]) { let {game} = selected({card:'ghola'}); const dead = seat(game,'h').leaders.find(l => l.dead), card = seat(game,'h').hand.find(c => c.effect === 'ghola'); assert.ok(dead && card); if (late) {game = ready(game); assert.equal(notice(game).length,1);} game = act(game,'h',{type:'card',card:card.id,leader:dead.id}); assert.equal(seat(game,'h').leaders.find(l => l.id === dead.id)!.dead,false); assert.equal(game.discard.filter(c => c.id === card.id).length,1); if (!late) game = ready(game); assert.ok(!(viewGame(game,'g').battle!.earlyLeaderless ?? []).includes('h')); game = act(game,'h',{...empty,leader:dead.id}); assert.equal(game.battle!.plans.h.leader,dead.id); } }); void test('all four minimal bot profiles can leave Harkonnen preparation legally', () => { const {game} = selected(); for (const difficulty of DIFFICULTIES) { const view = viewGame(game,'h'); view.players.find(p => p.id === 'h')!.bot = difficulty; const action = botActions(view).find(a => a.type === 'battlePreparationReady'); assert.ok(action); const next = act(structuredClone(game),'h',action); assert.equal(next.battle!.preLeader!.closed,true); assert.equal(notice(next).length,1); } });