168 lines
8.7 KiB
TypeScript
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);
|
|
}
|
|
});
|