Files
Dune/tests/basic-early-leaderless.test.ts
Quintine 685bd8bdfc
Verify and publish checkpoint container / container (push) Successful in 10m34s
Announce eligible Basic leaderless battles before plans
2026-10-29 06:59:11 +11:00

152 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 { createBattleTerritoryExplosionFixture } from './fixture-battle-territory-explosion';
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
const seat = (game: Game, id: string) => {
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
};
const announcements = (game: Game, id: string) =>
game.log.filter(entry => entry.text === `${seat(game, id).name} has no available leader or Cheap Hero for this battle.`);
let nativeSource: ReturnType<typeof createBattleTerritoryExplosionFixture> | undefined;
const source = () => nativeSource ??= createBattleTerritoryExplosionFixture({advanced:false});
const leaderless: Action = {type:'battlePlan', leader:null, dial:0, support:0, weapon:null, defense:null};
/** A genuine public Basic Emperor/Atreides/Guild setup plays real movement and
* the first battle. On the earned next-battle source only the Emperor's five
* leader-use locations, and an optional conserved Cheap Hero/death history,
* are staged before the actual second battle choice. */
function beforeChoice(options: {hero?: boolean; dead?: boolean} = {}) {
const game = structuredClone(source().afterFirst);
assert.equal(game.advanced, false); assert.equal(game.phase, 6); assert.equal(game.battle, null);
const emperor = seat(game, 'emperor');
assert.ok(emperor.leaders.every(leader => !leader.dead && !leader.capturedBy));
for (const leader of emperor.leaders) leader.usedAt = 'carthag';
if (options.dead) {
emperor.leaders[0].dead = true; emperor.leaders[0].deaths = Math.max(1, emperor.leaders[0].deaths);
}
if (options.hero) {
const index = game.deck.findIndex(card => card.kind === 'hero'); assert.ok(index >= 0);
emperor.hand.push(game.deck.splice(index, 1)[0]);
}
const chooser = game.active!;
const choice = viewGame(game, chooser).battleChoices.find(row => row.territory === 'imperial_basin' &&
[row.attacker, row.defender].includes('emperor') && [row.attacker, row.defender].includes('guild'));
assert.ok(choice);
const action: Action = {type:'chooseBattle', territory:choice.territory,
target:choice.attacker === chooser ? choice.defender : choice.attacker};
return {game, chooser, action};
}
function selected(options: {hero?: boolean; dead?: boolean} = {}) {
const f = beforeChoice(options), game = act(f.game, f.chooser, f.action);
assert.ok(game.battle && !game.battle.revealed && !game.battle.preparation && !game.response && !game.decision);
assert.equal(game.battle.basicEarlyLeaderlessVersion, 1);
return {...f, game};
}
const guildPlan = () => ({type:'battlePlan', leader:source().guildLeader, dial:0, support:0, weapon:null, defense:null});
for (const first of ['emperor', 'guild'] as const) void test(
`Basic early leaderless announcement precedes either plan when ${first} seals first`, () => {
let {game} = selected();
assert.deepEqual(game.battle!.plans, {});
assert.deepEqual(game.battle!.basicEarlyLeaderless, ['emperor']);
assert.equal(announcements(game, 'emperor').length, 1);
for (const viewer of ['emperor','guild','atreides']) {
const battle = viewGame(game, viewer).battle!;
assert.deepEqual(battle.earlyLeaderless, ['emperor']);
assert.deepEqual(battle.plans, {}); assert.deepEqual(battle.cards, []);
}
const saved = JSON.stringify(game); game = JSON.parse(saved);
assert.equal(JSON.stringify(game), saved);
assert.deepEqual(viewGame(game, 'atreides').battle!.earlyLeaderless, ['emperor']);
game = act(game, first, first === 'emperor' ? leaderless : guildPlan());
assert.equal(game.battle!.revealed, false);
assert.equal(announcements(game, 'emperor').length, 1);
assert.deepEqual(viewGame(game, 'atreides').battle!.plans, {});
assert.deepEqual(viewGame(game, 'atreides').battle!.earlyLeaderless,
first === 'emperor' ? undefined : ['emperor']);
if (first === 'emperor') assert.deepEqual(viewGame(game, 'guild').battle!.leaderless, ['emperor']);
const duplicate = structuredClone(game);
assert.throws(() => act(game, first, first === 'emperor' ? leaderless : guildPlan()));
assert.deepEqual(game, duplicate);
game = act(game, first === 'emperor' ? 'guild' : 'emperor', first === 'emperor' ? guildPlan() : leaderless);
assert.equal(game.battle!.revealed, true);
assert.equal(announcements(game, 'emperor').length, 1);
assert.deepEqual(viewGame(game, 'atreides').battle!.leaderless, ['emperor']);
},
);
void test('a held Cheap Hero prevents the early announcement and remains a legal plan', () => {
const {game} = selected({hero:true});
assert.equal(game.battle!.basicEarlyLeaderless?.includes('emperor') ?? false, false);
assert.equal(viewGame(game, 'guild').battle!.earlyLeaderless, undefined);
assert.equal(announcements(game, 'emperor').length, 0);
const hero = seat(game, 'emperor').hand.find(card => card.kind === 'hero'); assert.ok(hero);
const before = structuredClone(game); assert.throws(() => act(game, 'emperor', leaderless));
assert.deepEqual(game, before);
const next = act(game, 'emperor', {...leaderless, leader:hero.id});
assert.deepEqual(viewGame(next, 'guild').battle!.leaderless, []);
assert.equal(announcements(next, 'emperor').length, 0);
});
void test('a dead leader leaves no early announcement while an owned Ghola may change eligibility', () => {
const {game} = selected({dead:true});
assert.equal(game.battle!.basicEarlyLeaderless?.includes('emperor') ?? false, false);
assert.equal(viewGame(game, 'guild').battle!.earlyLeaderless, undefined);
assert.equal(announcements(game, 'emperor').length, 0);
});
void test('older unstamped Basic battles do not acquire a synthetic early announcement', () => {
let {game} = selected();
// Simulate a prior already-open battle: no new-version marker, receipt or
// newly created log entry. Keep all other real battle and piece state.
delete game.battle!.basicEarlyLeaderlessVersion; delete game.battle!.basicEarlyLeaderless;
game.log = game.log.filter(entry => entry.text !== `${seat(game, 'emperor').name} has no available leader or Cheap Hero for this battle.`);
const saved = JSON.stringify(game); game = JSON.parse(saved);
assert.equal(viewGame(game, 'guild').battle!.earlyLeaderless, undefined);
assert.equal(JSON.stringify(game), saved);
game = act(game, 'guild', guildPlan());
assert.equal(viewGame(game, 'atreides').battle!.earlyLeaderless, undefined);
assert.equal(announcements(game, 'emperor').length, 0);
});
void test('pending Basic Prescience resolves before the early announcement', () => {
let game = structuredClone(source().beforeFirst);
const chooser = game.active!;
const choice = viewGame(game, chooser).battleChoices.find(row => row.territory === 'imperial_basin' &&
[row.attacker,row.defender].includes('atreides'));
assert.ok(choice);
const target = choice.attacker === 'atreides' ? choice.defender : choice.attacker;
assert.ok(seat(game, target).leaders.every(leader => !leader.dead && !leader.capturedBy));
assert.ok(!seat(game, target).hand.some(card => card.kind === 'hero'));
for (const leader of seat(game, target).leaders) leader.usedAt = 'carthag';
game = act(game, chooser, {type:'chooseBattle', territory:choice.territory,
target:choice.attacker === chooser ? choice.defender : choice.attacker});
assert.equal(game.battle?.preparation?.kind, 'prescience');
assert.equal(viewGame(game, 'atreides').battle!.earlyLeaderless, undefined);
assert.equal(announcements(game, target).length, 0);
game = act(game, 'atreides', {type:'declineBattlePower'});
assert.equal(game.battle?.preparation ?? null, null);
assert.deepEqual(viewGame(game, 'atreides').battle!.earlyLeaderless, [target]);
assert.equal(announcements(game, target).length, 1);
});
void test('all four minimal bots seal legally after a public early Basic announcement', () => {
const {game} = selected();
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'emperor');
view.players.find(player => player.id === 'emperor')!.bot = difficulty;
const plans = botActions(view).filter(action => action.type === 'battlePlan');
assert.ok(plans.length > 0, `${difficulty} must offer a legal plan`);
const next = act(structuredClone(game), 'emperor', plans[0]);
assert.equal(next.battle!.plans.emperor.leader, null);
assert.equal(next.battle!.plans.emperor.weapon, null);
assert.equal(next.battle!.plans.emperor.defense, null);
assert.equal(announcements(next, 'emperor').length, 1);
assert.deepEqual(viewGame(next, 'guild').battle!.leaderless, ['emperor']);
}
});