Support public leader preparation with Harkonnen
Verify and publish checkpoint container / container (push) Successful in 10m56s

This commit is contained in:
Quintine committed 2026-10-29 08:51:11 +11:00
1 parent f9ca7ed9a9
commit 31c6e79c80
6 files changed
+288 -11

No files matched your search

@@ -0,0 +1,167 @@
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);
}
});
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { actSalusaGhola, advanceSalusaGhola, salusaGholaClean } from './fixture-salusa-ghola';
export function leaderlessSeat(game: Game, id: string) {
const seat = game.players.find(player => player.id === id); assert.ok(seat); return seat;
}
/** Public Basic start with Harkonnen and Emperor's actual paid shipments to
* Imperial Basin. Only unopened Spice order is selected. Later tests openly
* stage leader history/capture and conserved physical hand custody. */
export function createBasicLeaderlessHarkonnenFixture() {
let game = createGame('BASICHARKLEADER', newPlayer('h','Harkonnen','harkonnen'), false);
for (const [id,faction] of [['e','emperor'],['g','guild'],['a','atreides']] as const)
joinGame(game, newPlayer(id, faction, faction));
const act = (id:string, action:Action) => {game = actSalusaGhola(game,id,action);};
const reach = (until:(g:Game)=>boolean) => {game = advanceSalusaGhola(game,until);};
for (const seat of game.players) act(seat.id,{type:'ready'});
act('h',{type:'start'});
assert.equal(game.status,'setup');
reach(g => g.status === 'playing');
for (let position = 0; position < 2; position++) {
const index = game.spiceDeck.findIndex((card,i) => i >= position && 'territory' in card &&
!card.discovery && card.territory !== 'imperial_basin');
assert.ok(index >= position);
game.spiceDeck.splice(position,0,game.spiceDeck.splice(index,1)[0]);
}
reach(g => g.phase === 5 && salusaGholaClean(g));
for (let n = 0; game.phase === 5 && n < 100; n++) {
if (!salusaGholaClean(game)) {reach(salusaGholaClean); continue;}
const owner = game.active!;
if (owner === 'h' || owner === 'e') {
act(owner,{type:'ship',territory:'imperial_basin',sector:9,amount:1,elite:0,allyPayment:0});
reach(salusaGholaClean);
}
act(owner,{type:'endMovement'});
}
assert.equal(game.phase,6);
reach(g => salusaGholaClean(g) && !g.battle && !!g.active);
const chooser = game.active!;
const choice = viewGame(game,chooser).battleChoices.find(c => c.territory === 'imperial_basin' &&
[c.attacker,c.defender].includes('h') && [c.attacker,c.defender].includes('e'));
assert.ok(choice);
return {game,chooser,choice,action:{type:'chooseBattle',territory:choice.territory,
target:choice.attacker === chooser ? choice.defender : choice.attacker} as Action};
}
/** Replace a held card with one physical matching card from a real zone. */
export function stageLeaderlessCard(game: Game, owner: string, effect: 'hero'|'ghola'|'none') {
const seat = leaderlessSeat(game,owner);
const selected = (card: {kind:string;effect?:string}) => card.kind === 'hero' || card.effect === 'ghola';
for (let i=0; i<seat.hand.length; i++) if (selected(seat.hand[i])) {
const j=game.deck.findIndex(card => !selected(card)); assert.ok(j >= 0);
[seat.hand[i],game.deck[j]] = [game.deck[j],seat.hand[i]];
}
if (effect === 'none') return;
const wanted = (card:{kind:string;effect?:string}) => effect === 'hero' ? card.kind === 'hero' : card.effect === 'ghola';
const zones = [game.deck,...game.players.filter(p => p.id !== owner).map(p => p.hand),game.discard];
for (const zone of zones) {
const i = zone.findIndex(wanted);
if (i < 0) continue;
if (seat.hand.length) [seat.hand[0],zone[i]] = [zone[i],seat.hand[0]];
else seat.hand.push(zone.splice(i,1)[0]);
return;
}
assert.fail(`Physical ${effect} must be in a known zone`);
}