Announce eligible Basic leaderless battles before plans
Verify and publish checkpoint container / container (push) Successful in 10m34s

This commit is contained in:
Quintine committed 2026-10-29 06:59:11 +11:00
1 parent 2bac45b294
commit 685bd8bdfc
8 files changed
+417 -11

No files matched your search

+6
View File
@@ -5467,6 +5467,12 @@ export function GameTable({
players={g.players} busy={busy || !!g.decision || !!g.response || !!g.truthtrance || !!g.phaseOpening}
onReady={event => act({type: 'battleOutcomeReady', event})} />
)}
{!g.battle.revealed && !!g.battle.earlyLeaderless?.length && (
<p className="notice block" role="status">
{g.battle.earlyLeaderless.map(id => g.players.find(p => p.id === id)?.name).join(', ')}
{' '}currently has no available leader or Cheap Hero.
</p>
)}
{!g.battle.revealed && g.battle.leaderless.length > 0 && (
<p className="notice block" role="status">
{g.battle.leaderless.map(id => g.players.find(p => p.id === id)?.name).join(', ')}
+14 -2
View File
@@ -17,6 +17,18 @@ open mode gates.
## Current checkpoint and work
**Early Basic leaderless announcement — 29 October. Prototyped / Partial.**
Fresh classic Basic battles without optional modules now announce before either
plan when all of the combatant's controlled leaders are alive but unavailable
elsewhere and no legal Cheap Hero is held. The original battle preparation
finishes first, no confirmation is added and sealing does not announce twice.
Real battle selection with staged prior leader use covers both plan orders,
Prescience, Voice and its Karama cancellation, public controls, Cheap Hero,
dead-leader exclusions and minimal bots. The related seven-file batch passes
38 cases; type checking, lint and the production build pass. Revival-capable and other profiles still announce only
at seal, so earlier timing remains open there; deployed acceptance is unverified.
[Scope](RULE_DECISIONS.md#basic-leaderless-battle-announcement--29-october-2026).
**Basic leaderless battle announcement — 29 October. Prototyped / Partial.**
The publisher's Basic rules require a player without a usable leader or Cheap
Hero to announce it. New Basic plans now make that public when sealed; other
@@ -25,8 +37,8 @@ cases, including real Basic battle actions with staged leader availability,
Cheap Hero, rendered controls, saved announcement and four minimal bot plans;
type checking, lint and production build pass. Already-sealed Basic plans in
older saves do not acquire a new announcement. Sealing may occur after the
opponent's plan, so earlier announcement timing and deployed acceptance remain
open. [Source and scope](RULE_DECISIONS.md#basic-leaderless-battle-announcement--29-october-2026).
opponent's plan; the early classic Basic case above now covers one subset.
Other earlier timing and deployed acceptance remain open. [Source and scope](RULE_DECISIONS.md#basic-leaderless-battle-announcement--29-october-2026).
**Basic Semuta after Ixian ally replacement with independent Ecaz cards —
29 October. Prototyped / Partial.** The existing neutral private-discard offer
+14 -7
View File
@@ -4,13 +4,20 @@
The [publisher base rulebook, printed p. 10](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
requires a player unable to play a leader or Cheap Hero to announce that fact.
New Basic leaderless plans now publish the announcement when sealed, using the
existing Advanced timing, while the rest of the plan stays private. Sealing is
an online interpretation of the announcement timing; it may occur after the
other player has already sealed, which remains an open timing limitation. An
already-sealed Basic plan from an older save is not retroactively announced.
[Runtime](../tests/basic-leaderless-announcement.test.ts) and
[controls](../tests/basic-leaderless-announcement-controls.test.tsx).
A fresh classic Basic battle without optional modules now announces before plans
when all controlled leaders are alive but already used elsewhere and no legal
Cheap Hero is held. This clean case has no dead leader to revive or private
card-transfer preparation. The public notice gives no card identity or plan
choice. The same plan does not announce twice. Other cases retain the existing
seal-time announcement, which can occur after an opponent has sealed; their
earlier timing remains open. Neither a deadline nor an extra confirmation is
introduced, and old battles keep their saved behavior. This online ordering is
a provisional interpretation rather than a specific publisher timing ruling.
[Early runtime](../tests/basic-early-leaderless.test.ts),
[early controls](../tests/basic-early-leaderless-controls.test.tsx),
[Voice cases](../tests/basic-early-leaderless-voice.test.ts),
[seal-time runtime](../tests/basic-leaderless-announcement.test.ts) and
[seal-time controls](../tests/basic-leaderless-announcement-controls.test.tsx).
## Basic Semuta after Ixian ally replacement with independent cards — 29 October 2026
+32 -1
View File
@@ -985,6 +985,9 @@ export type Battle = {
plans: Record<string, Plan>;
/** Basic declarations recorded only when a new leaderless plan is sealed. */
basicLeaderlessAnnouncements?: string[];
/** Fresh clean Basic battles announce a bounded no-revival case before plans. */
basicEarlyLeaderlessVersion?: 1;
basicEarlyLeaderless?: string[];
revealed: boolean;
traitorCalls: Record<string, boolean>;
traitorDecisionPromises?: TraitorDecisionPromise[];
@@ -42679,6 +42682,25 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
charityOrderIntegrity(g);
return g;
}
/** The published inability is safe before a plan only when no leader can be
* revived and this clean Basic roster cannot privately transfer a Cheap Hero.
* Other cases retain the original seal-time announcement. */
function announceEarlyBasicLeaderless(g: Game) {
const b = g.battle;
if (!b || b.basicEarlyLeaderlessVersion !== 1 || g.status !== 'playing' || g.advanced ||
g.phase !== 6 || b.revealed || !b.prepared || b.preparation || b.preLeader && !b.preLeader.closed ||
g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama ||
g.pendingKull || g.pendingTreacheryDiscard || g.pendingNullentropy) return;
for (const id of [b.attacker, b.defender]) {
if (b.plans[id] || b.basicEarlyLeaderless?.includes(id)) continue;
const p = getPlayer(g, id), leaders = controlledLeaders(g, p);
if (!leaders.length || leaders.some(l => l.dead || l.capturedBy || !l.usedAt || l.usedAt === b.territory) ||
p.hand.some(c => c.kind === 'hero' && !(b.voice?.target === id && !b.voice.must &&
playedVoiceMatch(c, 'leader', b.voice.kind)))) continue;
(b.basicEarlyLeaderless ??= []).push(id);
log(g, `${p.name} has no available leader or Cheap Hero for this battle.`);
}
}
function finishActionContinuations(g: Game) {
if (g.victoryContinuation) return;
if (g.pendingNexusHarkonnenBetrayal) return;
@@ -42751,6 +42773,7 @@ function finishActionContinuations(g: Game) {
!g.decision && !g.response && !g.truthtrance && !g.phaseOpening &&
!g.pendingKarama && !g.pendingTerrorEntry && !g.pendingAmbassador)
finishMoritaniPlacement(g);
announceEarlyBasicLeaderless(g);
}
function settleAutomaticContinuations(g: Game) {
if (arrivalOrderWaiting(g)) return;
@@ -46984,6 +47007,11 @@ function applyActionInner(
...choice,
chooserEvent: battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder?.uses.length ?? 0),
event: battleEvent,
...(!g.advanced && g.expansions.length === 0 && !g.ecazTreachery && !g.techTokens &&
!g.strongholdCards && !g.leaderSkills && !g.homeworlds && !g.nexusCards && !g.discoveryEnabled &&
!g.sandtrout && !g.coalitionState && g.players.every(seat =>
['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction))
? { basicEarlyLeaderlessVersion: 1 as const } : {}),
stoneAllocationVersion: 1,
...(g.leaderSkills ? { smugglerCollectionVersion: 1 as const } : {}),
...(g.leaderSkills && mentatQuestionModeSupported(g) ? { mentatQuestionVersion: 1 as const } : {}),
@@ -47151,7 +47179,8 @@ function applyActionInner(
b.plans[id] = validatePlan(g, p, action);
if (b.plans[id].leader === null) {
if (!g.advanced) (b.basicLeaderlessAnnouncements ??= []).push(id);
log(g, `${p.name} announced a Battle Plan without a leader or Cheap Hero; no battle cards can be played.`);
if (!b.basicEarlyLeaderless?.includes(id))
log(g, `${p.name} announced a Battle Plan without a leader or Cheap Hero; no battle cards can be played.`);
}
if (b.fullPlan?.target === id) {
recordFullPlanInspection(g, b.fullPlan.owner);
@@ -49031,6 +49060,8 @@ export function viewGame(state: Game, id: string, now?: number) {
})
: [],
submitted: Object.keys(b.plans),
...(b.basicEarlyLeaderless?.some(playerId => !b.plans[playerId])
? { earlyLeaderless: b.basicEarlyLeaderless.filter(playerId => !b.plans[playerId]) } : {}),
leaderless: g.advanced
? Object.keys(b.plans).filter(playerId => b.plans[playerId].leader === null)
: (b.basicLeaderlessAnnouncements ?? []).filter(playerId => b.plans[playerId]?.leader === null),
+1 -1
View File
@@ -2839,7 +2839,7 @@ export const RULE_TOPICS: RuleTopic[] = [
summary: 'Build a legal combination and decide what to keep after winning.',
steps: [
'Choose one weapon, one defense, both, or neither. A worthless card can occupy a weapon or defense slot.',
'Choose an available leader or Cheap Hero when required. Without either, battle cards cannot be played. In Basic and Advanced, sealing a legal leaderless plan announces that fact to the whole table before reveal; its dial, spice and other choices remain sealed. Refresh retains the same announcement without submitting another plan.',
'Choose an available leader or Cheap Hero when required. Without either, battle cards cannot be played. In fresh classic Basic battles without optional modules, when all your leaders are alive but unavailable elsewhere and you lack a legal Cheap Hero, your inability is announced before plans. Other Basic and Advanced leaderless plans announce on sealing; its dial, spice and other choices remain sealed. Refresh retains the same announcement without submitting another plan.',
'On Arrakis, a surviving leader remains in its battle territory for the rest of the Battle phase. A later Lasgun–Shield explosion there also kills previously surviving leader discs still present, even if a different leader fights that later battle. The Basic application is provisional; a successful Traitor call prevents the explosion.',
'A loser normally discards all played cards. If allied with Moritani and the battle has a winner, the defeated ally may retain one played card that a winner could keep. A winner may discard selected played cards; Cheap Heroes are discarded after use.',
],
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type Game } from '../game/engine';
import { createBattleTerritoryExplosionFixture } from './fixture-battle-territory-explosion';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' :
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true};
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
const render = (game: Game, actor: string, busy = false) =>
renderToStaticMarkup(createElement(GameTable, {game:viewGame(game,actor),busy,
send:async () => { assert.fail('A status must not submit a game action.'); },onExit() {}}));
const player = (game: Game, id: string) => {
const seat = game.players.find(row => row.id === id); assert.ok(seat); return seat;
};
/** Real public Basic setup, native movements, first battle and native second
* chooser. Before choosing that second battle, stage only Guild's prior leader
* usage/deaths and conserved exact Hero/Ghola custody for each control case. */
function battle(kind: 'none' | 'hero' | 'ghola'): Game {
const source = createBattleTerritoryExplosionFixture({advanced:false});
let game = structuredClone(source.afterFirst);
assert.equal(game.advanced,false); assert.equal(game.phase,6); assert.equal(game.battle,null);
const guild = player(game,'guild');
for (const leader of guild.leaders) {
if (kind === 'ghola') { leader.dead = true; leader.deaths = Math.max(1,leader.deaths ?? 0); }
else leader.usedAt = 'carthag';
}
// Remove an accidental Guild Hero without fabricating or deleting a card.
const heroes = guild.hand.filter(card => card.kind === 'hero');
guild.hand = guild.hand.filter(card => card.kind !== 'hero'); game.deck.push(...heroes);
if (kind !== 'none') {
const needed = kind === 'hero' ? (card: {kind:string}) => card.kind === 'hero'
: (card: {effect?:string}) => card.effect === 'ghola';
let held = guild.hand.some(needed);
if (!held) for (const zone of [game.deck,game.discard,...game.players.filter(seat => seat.id !== 'guild').map(seat => seat.hand)]) {
const index = zone.findIndex(needed);
if (index >= 0) { guild.hand.push(zone.splice(index,1)[0]); held = true; break; }
}
assert.ok(held, `Retain the real physical ${kind} card`);
}
const chooser = game.active; assert.ok(chooser);
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);
game = applyAction(game,chooser,{type:'chooseBattle',territory:'imperial_basin',
target:chooser === 'emperor' ? 'guild' : 'emperor'});
for (let i = 0; i < 30; i++) {
if (game.response) {
const actor = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(actor);
game = applyAction(game,actor.id,{type:'passResponse'});
} else if (game.battle?.preparation) {
game = applyAction(game,game.battle.preparation.owner,{type:'declineBattlePower'});
} else if (game.battle?.preLeader && !game.battle.preLeader.closed) {
const actor = [game.battle.attacker,game.battle.defender].find(id => !game.battle!.preLeader!.ready.includes(id)); assert.ok(actor);
game = applyAction(game,actor,{type:'battlePreparationReady',event:game.battle.preLeader.event});
} else break;
}
assert.ok(game.battle && !game.battle.revealed && !game.battle.preparation && !game.response);
assert.deepEqual(game.battle.plans,{});
return game;
}
void test('both combatants and an observer see the early Basic leaderless status without sealed details', () => {
const game = battle('none'), before = JSON.stringify(game);
for (const id of ['guild','emperor','atreides']) {
const view = viewGame(game,id);
assert.deepEqual(view.battle!.earlyLeaderless,['guild']);
assert.deepEqual(view.battle!.plans,{}); assert.deepEqual(view.battle!.cards,[]);
for (const busy of [false,true]) {
const html = render(game,id,busy);
assert.match(html,/Guild currently has no available leader or Cheap Hero\./);
assert.doesNotMatch(html,/Guild announced a Battle Plan without a leader or Cheap Hero/);
}
}
assert.equal(JSON.stringify(game),before);
const reloaded = JSON.parse(before) as Game;
assert.deepEqual(viewGame(reloaded,'atreides').battle!.earlyLeaderless,['guild']);
assert.match(render(reloaded,'emperor'),/Guild currently has no available leader or Cheap Hero\./);
const sealed = applyAction(reloaded,'guild',{type:'battlePlan',dial:1,support:0,leader:null,weapon:null,defense:null});
assert.equal(sealed.battle?.revealed,false);
assert.deepEqual(viewGame(sealed,'emperor').battle!.plans,{});
const sealedHtml = render(sealed,'emperor');
assert.match(sealedHtml,/Guild announced a Battle Plan without a leader or Cheap Hero\./);
assert.doesNotMatch(sealedHtml,/Guild currently has no available leader or Cheap Hero/);
});
for (const kind of ['hero','ghola'] as const) void test(
`a real held ${kind} prevents a premature Basic leaderless status`, () => {
const game = battle(kind);
assert.equal(player(game,'guild').hand.some(card => kind === 'hero' ? card.kind === 'hero' : card.effect === 'ghola'),true);
for (const id of ['guild','emperor','atreides']) {
const view = viewGame(game,id);
assert.ok(!(view.battle!.earlyLeaderless ?? []).includes('guild'));
assert.deepEqual(view.battle!.plans,{}); assert.deepEqual(view.battle!.cards,[]);
assert.doesNotMatch(render(game,id),/Guild currently has no available leader or Cheap Hero/);
}
},
);
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { advanceTruthTurnPower, createTruthTurnPowerFixture, truthTurnPowerClean } from './fixture-truth-turn-power';
const act = (game: Game, player: string, action: Action) => applyAction(game, player, action);
const seat = (game: Game, id: string) => {
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
};
const notices = (game: Game) => game.log.filter(entry =>
entry.text === 'Atreides has no available leader or Cheap Hero for this battle.');
const nullPlan: Action = {type:'battlePlan', leader:null, dial:0, support:0, weapon:null, defense:null};
let fixture: ReturnType<typeof createTruthTurnPowerFixture> | undefined;
const source = () => fixture ??= createTruthTurnPowerFixture({power:'voice', remote:false});
/** A real public Basic game: BG has shipped into Atreides' Arrakeen. Stage
* only Atreides' alive leader-use history and custody of one physical Cheap
* Hero before making the real battle choice and native Voice decision. */
function beforeVoice() {
const f = source();
let game = advanceTruthTurnPower(structuredClone(f.shipment.after), state =>
state.phase === 6 && !!state.active && !state.battle && truthTurnPowerClean(state), [], true);
const target = seat(game, 'atreides');
assert.ok(target.leaders.every(leader => !leader.dead && !leader.capturedBy));
for (const leader of target.leaders) leader.usedAt = 'carthag';
const heroIndex = game.deck.findIndex(card => card.kind === 'hero'); assert.ok(heroIndex >= 0);
const hero = game.deck.splice(heroIndex, 1)[0]; target.hand.push(hero);
const chooser = game.active!;
const choice = viewGame(game, chooser).battleChoices.find(row => row.territory === 'arrakeen' &&
[row.attacker,row.defender].includes('atreides') && [row.attacker,row.defender].includes('beneGesserit'));
assert.ok(choice);
game = act(game, chooser, {type:'chooseBattle', territory:choice.territory,
target:choice.attacker === chooser ? choice.defender : choice.attacker});
assert.equal(game.battle?.basicEarlyLeaderlessVersion, 1);
assert.equal(game.battle?.preparation?.kind, 'voice');
assert.equal(game.battle.preparation.owner, 'beneGesserit');
assert.equal(viewGame(game, 'emperor').battle!.earlyLeaderless, undefined);
assert.equal(notices(game).length, 0);
return {game, hero:hero.id, karama:f.karama};
}
function finishPreparation(game: Game) {
for (let count = 0; count < 20 && (game.response || game.battle?.preparation); count++) {
const step = nextStrongholdFactionsNativeStep(game); assert.ok(step);
game = act(game, step.actor, step.action);
}
assert.equal(game.response, null);
assert.equal(game.battle?.preparation ?? null, null);
return game;
}
void test('Basic Voice forbidding a held Cheap Hero announces leaderlessness only after preparation', () => {
let {game} = beforeVoice();
game = act(game, 'beneGesserit', {type:'voice', kind:'hero', must:false});
if (game.response || game.battle?.preparation) {
assert.equal(viewGame(game, 'emperor').battle!.earlyLeaderless, undefined);
assert.equal(notices(game).length, 0);
}
game = finishPreparation(game);
assert.equal(game.battle!.voice?.target, 'atreides');
assert.deepEqual(game.battle!.plans, {});
for (const viewer of ['atreides','beneGesserit','emperor'])
assert.deepEqual(viewGame(game, viewer).battle!.earlyLeaderless, ['atreides']);
assert.equal(notices(game).length, 1);
game = act(game, 'atreides', nullPlan);
assert.equal(game.battle!.plans.atreides.leader, null);
assert.equal(notices(game).length, 1);
});
void test('Basic Karama cancellation of Voice leaves the Cheap Hero legal and no early notice', () => {
const {hero, karama, game: initial} = beforeVoice();
let game = initial;
assert.ok(seat(game, 'emperor').hand.some(card => card.id === karama));
game = act(game, 'beneGesserit', {type:'voice', kind:'hero', must:false});
assert.equal(game.response?.kind, 'voice');
game = act(game, 'emperor', {type:'card', card:karama, mode:'cancel'});
game = finishPreparation(game);
assert.equal(game.battle!.voice, undefined);
assert.equal(viewGame(game, 'emperor').battle!.earlyLeaderless, undefined);
assert.equal(notices(game).length, 0);
game = act(game, 'atreides', {...nullPlan, leader:hero});
assert.equal(game.battle!.plans.atreides.leader, hero);
assert.equal(notices(game).length, 0);
});
+151
View File
@@ -0,0 +1,151 @@
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']);
}
});