diff --git a/components/game-table.tsx b/components/game-table.tsx
index 63fd9497..1f886955 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -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 && (
+
+ {g.battle.earlyLeaderless.map(id => g.players.find(p => p.id === id)?.name).join(', ')}
+ {' '}currently has no available leader or Cheap Hero.
+
+ )}
{!g.battle.revealed && g.battle.leaderless.length > 0 && (
{g.battle.leaderless.map(id => g.players.find(p => p.id === id)?.name).join(', ')}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 63f1eac0..f0e67380 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -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
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 7eb5c3c8..d1de9455 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -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
diff --git a/game/engine.ts b/game/engine.ts
index aaa76249..6d2d4dba 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -985,6 +985,9 @@ export type Battle = {
plans: Record;
/** 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;
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),
diff --git a/game/reference.ts b/game/reference.ts
index 655287b7..cc473297 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -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.',
],
diff --git a/tests/basic-early-leaderless-controls.test.tsx b/tests/basic-early-leaderless-controls.test.tsx
new file mode 100644
index 00000000..eb1ecd54
--- /dev/null
+++ b/tests/basic-early-leaderless-controls.test.tsx
@@ -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/);
+ }
+ },
+);
diff --git a/tests/basic-early-leaderless-voice.test.ts b/tests/basic-early-leaderless-voice.test.ts
new file mode 100644
index 00000000..6edbe238
--- /dev/null
+++ b/tests/basic-early-leaderless-voice.test.ts
@@ -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 | 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);
+});
diff --git a/tests/basic-early-leaderless.test.ts b/tests/basic-early-leaderless.test.ts
new file mode 100644
index 00000000..38cc4c5c
--- /dev/null
+++ b/tests/basic-early-leaderless.test.ts
@@ -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 | 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']);
+ }
+});