Announce leaderless Basic battle plans
Verify and publish checkpoint container / container (push) Successful in 10m50s

This commit is contained in:
Quintine committed 2026-10-29 06:52:13 +11:00
1 parent 3f4925e6e1
commit 2bac45b294
6 files changed
+221 -3

No files matched your search

+11
View File
@@ -17,6 +17,17 @@ open mode gates.
## Current checkpoint and work
**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
plan details stay private until reveal. The five-file related batch passes 31
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).
**Basic Semuta after Ixian ally replacement with independent Ecaz cards —
29 October. Prototyped / Partial.** The existing neutral private-discard offer
now covers a supported paid ordinary Ixian ally replacement when the independent
+12
View File
@@ -1,5 +1,17 @@
# Rule decision index
## Basic leaderless battle announcement — 29 October 2026
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).
## Basic Semuta after Ixian ally replacement with independent cards — 29 October 2026
The existing private Semuta exchange now applies to the discarded card from a
+6 -2
View File
@@ -983,6 +983,8 @@ export type Battle = {
attacker: string;
defender: string;
plans: Record<string, Plan>;
/** Basic declarations recorded only when a new leaderless plan is sealed. */
basicLeaderlessAnnouncements?: string[];
revealed: boolean;
traitorCalls: Record<string, boolean>;
traitorDecisionPromises?: TraitorDecisionPromise[];
@@ -47147,8 +47149,10 @@ function applyActionInner(
'Wait for the plan requested by special prescience.',
);
b.plans[id] = validatePlan(g, p, action);
if (g.advanced && b.plans[id].leader === null)
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.fullPlan?.target === id) {
recordFullPlanInspection(g, b.fullPlan.owner);
}
@@ -49029,7 +49033,7 @@ export function viewGame(state: Game, id: string, now?: number) {
submitted: Object.keys(b.plans),
leaderless: g.advanced
? Object.keys(b.plans).filter(playerId => b.plans[playerId].leader === null)
: [],
: (b.basicLeaderlessAnnouncements ?? []).filter(playerId => b.plans[playerId]?.leader === null),
plans: b.revealed
? b.plans
: b.plans[id]
+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 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 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.',
'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,94 @@
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('Reading the announcement must not submit an action.'); }, onExit() {}}));
/** Public Basic setup, native shipments and a completed first battle create
* the real second Guild/Emperor fight. Stage only Guild's leader availability
* and a conserved Hero hand transfer when needed for the paired control. */
function ready(cheapHero: boolean) {
const f = createBattleTerritoryExplosionFixture({advanced:false});
const game = structuredClone(f.game);
assert.equal(game.advanced,false); assert.ok(game.battle && !game.battle.revealed);
assert.deepEqual(new Set([game.battle.attacker,game.battle.defender]),new Set(['guild','emperor']));
const guild = game.players.find(player => player.id === 'guild'); assert.ok(guild);
// These living discs were already used in another territory; no deaths or
// replacements are fabricated. The first real battle remains in history.
for (const leader of guild.leaders) leader.usedAt = 'carthag';
if (cheapHero) {
let hero = guild.hand.find(card => card.kind === 'hero');
if (!hero) {
for (const zone of [game.deck,game.discard,...game.players.filter(player => player.id !== 'guild').map(player => player.hand)]) {
const index = zone.findIndex(card => card.kind === 'hero');
if (index >= 0) { [hero] = zone.splice(index,1); guild.hand.push(hero); break; }
}
}
assert.ok(hero,'One real physical Cheap Hero must be available');
return {game, hero:hero.id};
}
// A real cheap hero held by Guild would make a null leader illegal; return
// any such card to the original deck rather than deleting it.
const heroes = guild.hand.filter(card => card.kind === 'hero');
guild.hand = guild.hand.filter(card => card.kind !== 'hero');
game.deck.push(...heroes);
return {game,hero:null};
}
void test('a Basic sealed leaderless plan announces publicly and keeps the plan private across rendering and reload', () => {
const f = ready(false);
const game = applyAction(f.game,'guild',{type:'battlePlan',dial:1,support:0,leader:null,weapon:null,defense:null});
assert.ok(game.battle && !game.battle.revealed);
assert.deepEqual(game.battle.basicLeaderlessAnnouncements,['guild']);
assert.equal(viewGame(game,'guild').battle!.plans.guild.dial,1);
for (const id of ['emperor','atreides']) {
const view = viewGame(game,id);
assert.deepEqual(view.battle!.leaderless,['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 announced a Battle Plan without a leader or Cheap Hero\./);
assert.match(html,/No battle cards can be played; other plan choices remain sealed\./);
}
}
const saved = JSON.stringify(game), restored = JSON.parse(saved) as Game;
assert.deepEqual(viewGame(restored,'emperor').battle!.leaderless,['guild']);
assert.match(render(restored,'atreides'),/Guild announced a Battle Plan without a leader or Cheap Hero\./);
assert.equal(JSON.stringify(restored),saved);
});
void test('a real Cheap Hero plan does not announce the faction as leaderless', () => {
const f = ready(true); assert.ok(f.hero);
const game = applyAction(f.game,'guild',{type:'battlePlan',dial:1,support:0,leader:f.hero,weapon:null,defense:null});
assert.ok(game.battle && !game.battle.revealed);
assert.ok(!game.battle.basicLeaderlessAnnouncements?.includes('guild'));
for (const id of ['emperor','atreides']) {
const view = viewGame(game,id);
assert.deepEqual(view.battle!.leaderless,[]);
assert.deepEqual(view.battle!.plans,{});
assert.deepEqual(view.battle!.cards,[]);
assert.doesNotMatch(render(game,id),/Guild announced a Battle Plan without a leader or Cheap Hero/);
}
});
@@ -0,0 +1,97 @@
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 announce = (game: Game) => game.log.filter(entry => entry.text.includes('announced a Battle Plan without a leader or Cheap Hero'));
const leaderless: Action = {type:'battlePlan', dial:0, support:0, leader:null, weapon:null, defense:null};
/** Public three-seat Basic source plays both entries and the first battle, then
* chooses the real second contest in Imperial Basin. Only the Emperor's five
* leader-use locations are staged as previously used in another territory;
* the actual second battle, dealt hands and all plans stay native. */
function fixture() {
const source = createBattleTerritoryExplosionFixture({advanced:false});
const game = structuredClone(source.game);
assert.equal(game.status, 'playing'); assert.equal(game.advanced, false);
assert.equal(game.phase, 6); assert.ok(game.battle && !game.battle.revealed);
assert.deepEqual([game.battle.attacker, game.battle.defender].sort(), ['emperor', 'guild']);
assert.equal(seat(game, 'emperor').hand.some(card => card.kind === 'hero'), false);
for (const leader of seat(game, 'emperor').leaders) leader.usedAt = 'carthag';
return {game, source};
}
void test('Basic actual battle announces the newly sealed leaderless plan without exposing its dial or hand', () => {
const f = fixture(), initial = f.game, beforeLogs = announce(initial).length;
let game = act(initial, 'emperor', leaderless);
assert.equal(game.battle?.revealed, false);
assert.deepEqual(game.battle?.basicLeaderlessAnnouncements, ['emperor']);
assert.equal(announce(game).length, beforeLogs + 1);
for (const viewer of ['guild', 'atreides']) {
const battle = viewGame(game, viewer).battle!;
assert.deepEqual(battle.leaderless, ['emperor']);
assert.deepEqual(battle.plans, {}); assert.deepEqual(battle.cards, []);
}
assert.equal(viewGame(game, 'emperor').battle!.plans.emperor.dial, 0);
const saved = JSON.stringify(game); game = JSON.parse(saved);
assert.deepEqual(viewGame(game, 'guild').battle!.leaderless, ['emperor']);
assert.deepEqual(viewGame(game, 'atreides').battle!.plans, {});
assert.equal(JSON.stringify(game), saved);
assert.throws(() => act(game, 'emperor', leaderless));
assert.equal(JSON.stringify(game), saved);
game = act(game, 'guild', {type:'battlePlan', dial:0, support:0,
leader:f.source.guildLeader, weapon:null, defense:null});
assert.equal(game.battle?.revealed, true);
assert.equal(announce(game).length, beforeLogs + 1);
assert.deepEqual(viewGame(game, 'atreides').battle!.leaderless, ['emperor']);
assert.equal(viewGame(game, 'atreides').battle!.plans.emperor.dial, 0);
});
void test('a held Cheap Hero prevents a Basic leaderless plan and its legal use does not announce one', () => {
const {game} = fixture();
const heroIndex = game.deck.findIndex(card => card.kind === 'hero'); assert.ok(heroIndex >= 0);
const [hero] = game.deck.splice(heroIndex, 1); seat(game, 'emperor').hand.push(hero);
const before = structuredClone(game), logs = announce(game).length;
assert.throws(() => act(game, 'emperor', leaderless));
assert.deepEqual(game, before);
const planned = act(game, 'emperor', {...leaderless, leader:hero.id});
assert.equal(announce(planned).length, logs);
assert.deepEqual(viewGame(planned, 'guild').battle!.leaderless, []);
assert.deepEqual(viewGame(planned, 'guild').battle!.plans, {});
});
void test('an already-sealed Basic legacy plan receives no synthetic announcement', () => {
const {game} = fixture();
// Explicit legacy supplement: install the plan format an older save could
// contain, without the new announcement receipt or a historical log entry.
game.battle!.plans.emperor = {dial:0, support:0, leader:null, weapon:null, defense:null};
const saved = JSON.stringify(game);
assert.equal(game.battle!.basicLeaderlessAnnouncements, undefined);
assert.deepEqual(viewGame(game, 'guild').battle!.leaderless, []);
assert.deepEqual(viewGame(game, 'atreides').battle!.plans, {});
assert.equal(JSON.stringify(game), saved);
const restored: Game = JSON.parse(saved);
assert.deepEqual(viewGame(restored, 'guild').battle!.leaderless, []);
assert.equal(announce(restored).length, announce(game).length);
});
void test('all four minimal bot profiles seal a legal Basic leaderless plan and announce it', () => {
const {game} = fixture();
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'emperor');
view.players.find(player => player.id === 'emperor')!.bot = difficulty;
const actions = botActions(view).filter(action => action.type === 'battlePlan');
assert.ok(actions.length > 0, `${difficulty} must offer a legal plan`);
const next = act(structuredClone(game), 'emperor', actions[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.deepEqual(viewGame(next, 'guild').battle!.leaderless, ['emperor']);
}
});