Both combatants have an opportunity to use available preparations before
either commits a leader. Continue when you are ready to choose leaders.
- {' '}A held Juice of Sapho can make you aggressor during this opportunity.
+ {' '}You can use a held Ghola from your hand to revive an eligible leader
+ before continuing.
+ {game.players.some(player => player.faction === 'richese') &&
+ ' A held Juice of Sapho can make you aggressor during this opportunity.'}
{combatants.map((player) => (
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index d996403a..23afdd74 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -17,6 +17,21 @@ open mode gates.
## Current checkpoint and work
+**Basic dead-leader battle preparation — 29 October. Prototyped / Partial.**
+Fresh classic Basic battles without Harkonnen open the existing neutral
+pre-leader opportunity when a combatant has a dead leader and no available
+living leader. The trigger does not depend on private Hero or Ghola holdings;
+unaffected opponents are already ready. After battle powers, a still-leaderless
+player is announced before plans. A later legal Ghola revival corrects the
+current notice without changing sealed plans. Real battle journeys with staged
+leader and conserved card state cover mixed and dead leaders, both combatants,
+Prescience, Ghola before and after readiness, sealed plans and all four legal
+bot profiles. Eight related files pass 59 cases, including 15 new runtime and
+control cases; type checking, lint and the production build pass. The ordering
+is provisional; Harkonnen's concealed captures, other factions and modules,
+and deployed acceptance remain open.
+[Scope](RULE_DECISIONS.md#basic-leaderless-battle-announcement--29-october-2026).
+
**Basic market Ghola with printed Kull — 29 October. Prototyped / Partial.**
The default printed-Kull preview now works with the Semuta market-Ghola
reaction. Kull decline, success and a distinct counter preserve the parked
@@ -185,8 +200,9 @@ 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.
+38 cases; type checking, lint and the production build pass. The newer
+dead-leader preparation above extends part of the formerly excluded
+revival-capable scope; other profiles and deployed acceptance remain open.
[Scope](RULE_DECISIONS.md#basic-leaderless-battle-announcement--29-october-2026).
**Basic leaderless battle announcement — 29 October. Prototyped / Partial.**
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 849e1989..6743482b 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -65,6 +65,16 @@ Great Maker rides share this destination check; Polar Sink remains clear.
## Basic leaderless battle announcement — 29 October 2026
+In fresh classic Basic games without Harkonnen, a publicly unavailable living
+leader pool containing at least one dead leader now opens a neutral pre-leader
+preparation for that combatant, independent of their private hand. An unaffected
+opponent is already ready. The player can use Ghola from their hand or continue;
+after battle powers resolve, an inability is announced before either plan. If a
+later public Ghola revival changes the available leader, the current notice is
+withdrawn and a correction logged; already sealed plans stay sealed. This is a
+provisional online timing composition, not a publisher ruling. Captures and other
+faction/module combinations remain open.
+
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.
A fresh classic Basic battle without optional modules now announces before plans
@@ -73,10 +83,13 @@ 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
+earlier timing remains open except for the newer dead-leader case above. The
+original no-death case introduces neither a deadline nor an extra confirmation;
+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),
+[dead-leader runtime](../tests/basic-early-leaderless-dead.test.ts),
+[dead-leader controls](../tests/basic-early-leaderless-dead-controls.test.tsx),
[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
diff --git a/game/engine.ts b/game/engine.ts
index 8dd41405..a020215c 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -991,6 +991,8 @@ export type Battle = {
/** Fresh clean Basic battles announce a bounded no-revival case before plans. */
basicEarlyLeaderlessVersion?: 1;
basicEarlyLeaderless?: string[];
+ /** Fresh classic battles with a neutral dead-leader preparation opportunity. */
+ basicDeadLeaderPreparation?: true;
revealed: boolean;
traitorCalls: Record;
traitorDecisionPromises?: TraitorDecisionPromise[];
@@ -43024,9 +43026,7 @@ 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. */
+/** Publish a clean Basic inability once preparation and battle powers finish. */
function announceEarlyBasicLeaderless(g: Game) {
const b = g.battle;
if (!b || b.basicEarlyLeaderlessVersion !== 1 || g.status !== 'playing' || g.advanced ||
@@ -43034,11 +43034,20 @@ function announceEarlyBasicLeaderless(g: Game) {
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;
+ if (b.plans[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;
+ const availableLeader = leaders.some(l => !l.dead && !l.capturedBy && (!l.usedAt || l.usedAt === b.territory));
+ const availableHero = p.hand.some(c => c.kind === 'hero' && !(b.voice?.target === id && !b.voice.must &&
+ playedVoiceMatch(c, 'leader', b.voice.kind)));
+ if (b.basicEarlyLeaderless?.includes(id)) {
+ if (b.basicDeadLeaderPreparation && availableLeader) {
+ b.basicEarlyLeaderless = b.basicEarlyLeaderless.filter(player => player !== id);
+ log(g, `${p.name} now has an available leader for this battle.`);
+ }
+ continue;
+ }
+ if (!leaders.length || availableLeader || availableHero ||
+ !b.basicDeadLeaderPreparation && leaders.some(l => l.dead || l.capturedBy)) continue;
(b.basicEarlyLeaderless ??= []).push(id);
log(g, `${p.name} has no available leader or Cheap Hero for this battle.`);
}
@@ -47358,15 +47367,27 @@ function applyActionInner(
'Combined Occupy requires source-selected native factions; optional overlays remain gated.');
}
const battleEvent = crypto.randomUUID();
+ const earlyBasic = !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));
+ // The candidate depends only on public native leaders, never either hand.
+ // Harkonnen's concealed captures need a separate public-projection rule.
+ const deadLeaderCandidates = earlyBasic && !byFaction(g, 'harkonnen')
+ ? [choice.attacker, choice.defender].filter(playerId => {
+ const leaders = getPlayer(g, playerId).leaders;
+ return leaders.some(leader => leader.dead) && !leaders.some(leader =>
+ !leader.dead && !leader.capturedBy && (!leader.usedAt || leader.usedAt === choice.territory));
+ }) : [];
g.battle = {
...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 } : {}),
+ ...(earlyBasic ? { basicEarlyLeaderlessVersion: 1 as const } : {}),
+ ...(deadLeaderCandidates.length ? { basicDeadLeaderPreparation: true as const,
+ preLeader: { event: battleEvent,
+ ready: [choice.attacker, choice.defender].filter(playerId => !deadLeaderCandidates.includes(playerId)),
+ closed: false } } : {}),
stoneAllocationVersion: 1,
...(g.leaderSkills ? { smugglerCollectionVersion: 1 as const } : {}),
...(g.leaderSkills && mentatQuestionModeSupported(g) ? { mentatQuestionVersion: 1 as const } : {}),
diff --git a/game/reference.ts b/game/reference.ts
index e700cc04..247c9ae4 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -2847,7 +2847,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 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.',
+ 'Choose an available leader or Cheap Hero when required. Without either, battle cards cannot be played. In supported fresh classic Basic battles, an inability is announced before plans. When dead leaders may be revived, a preparation opportunity lets you play Ghola from your hand before continuing; a later revival updates the public notice. 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
index eb1ecd54..b228c9f2 100644
--- a/tests/basic-early-leaderless-controls.test.tsx
+++ b/tests/basic-early-leaderless-controls.test.tsx
@@ -100,14 +100,15 @@ void test('both combatants and an observer see the early Basic leaderless status
});
for (const kind of ['hero','ghola'] as const) void test(
- `a real held ${kind} prevents a premature Basic leaderless status`, () => {
+ `a held ${kind} has the correct Basic leaderless status after preparation`, () => {
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.equal((view.battle!.earlyLeaderless ?? []).includes('guild'),kind === 'ghola');
assert.deepEqual(view.battle!.plans,{}); assert.deepEqual(view.battle!.cards,[]);
- assert.doesNotMatch(render(game,id),/Guild currently has no available leader or Cheap Hero/);
+ if (kind === 'ghola') assert.match(render(game,id),/Guild currently has no available leader or Cheap Hero/);
+ else assert.doesNotMatch(render(game,id),/Guild currently has no available leader or Cheap Hero/);
}
},
);
diff --git a/tests/basic-early-leaderless-dead-controls.test.tsx b/tests/basic-early-leaderless-dead-controls.test.tsx
new file mode 100644
index 00000000..a0e2b409
--- /dev/null
+++ b/tests/basic-early-leaderless-dead-controls.test.tsx
@@ -0,0 +1,58 @@
+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 type { GameView } from '../game/engine';
+
+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 { BattleLeaderOpportunity } = await import('../components/residual-poison');
+aliases.deregister();
+
+/** This component consumes only public readiness, roster and interaction state. */
+function view(options: {richese?: boolean; ready?: boolean; interrupted?: boolean} = {}) {
+ return {
+ me:'a', players:[{id:'a',name:'Atreides',faction:'atreides'},
+ {id:'g',name:'Guild',faction:'guild'},
+ ...(options.richese ? [{id:'r',name:'Richese',faction:'richese'}] : [])],
+ battle:{attacker:'a',defender:'g',preLeader:{event:'battle',ready:options.ready ? ['a'] : [],closed:false}},
+ response:options.interrupted ? {kind:'voice'} : null,
+ truthtrance:null, decision:null, phaseOpening:null,
+ } as unknown as GameView;
+}
+const render = (game:GameView, busy = false) => renderToStaticMarkup(
+ createElement(BattleLeaderOpportunity, {game,busy,act() { assert.fail('Rendering must not act.'); }}),
+);
+
+void test('the neutral pre-leader opportunity explains Ghola without claiming a player holds it', () => {
+ const html = render(view());
+ assert.match(html, /use a held Ghola from your hand to revive an eligible leader/);
+ assert.match(html, /Ready to choose leaders/);
+ assert.doesNotMatch(html, /Juice of Sapho/);
+ assert.doesNotMatch(html, /You hold|has a Ghola/);
+});
+
+void test('Sapho guidance appears only when Richese is seated', () => {
+ assert.match(render(view({richese:true})), /A held Juice of Sapho can make you aggressor/);
+ assert.doesNotMatch(render(view()), /Juice of Sapho/);
+});
+
+void test('ready, busy and interrupted states retain the existing shared-stage controls', () => {
+ assert.match(render(view({ready:true})), /You are ready\. Waiting for the other combatant/);
+ assert.match(render(view(), true), /