Support public leader preparation with Harkonnen
Verify and publish checkpoint container / container (push) Successful in 10m56s
Verify and publish checkpoint container / container (push) Successful in 10m56s
This commit is contained in:
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`);
|
||||
}
|
||||
Reference in new issue
Block a user