Files
Dune/tests/basic-early-leaderless-harkonnen.test.ts
T
Quintine 31c6e79c80
Verify and publish checkpoint container / container (push) Successful in 10m56s
Support public leader preparation with Harkonnen
2026-10-29 08:51:11 +11:00

168 lines
8.7 KiB
TypeScript

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<typeof createBasicLeaderlessHarkonnenFixture> | 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<typeof beforeChoice>[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);
}
});