From 2bac45b29476f130092b2385a3b5c33449fa797a Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:52:13 +1100 Subject: [PATCH] Announce leaderless Basic battle plans --- docs/CURRENT_STATUS.md | 11 +++ docs/RULE_DECISIONS.md | 12 +++ game/engine.ts | 8 +- game/reference.ts | 2 +- ...-leaderless-announcement-controls.test.tsx | 94 ++++++++++++++++++ tests/basic-leaderless-announcement.test.ts | 97 +++++++++++++++++++ 6 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 tests/basic-leaderless-announcement-controls.test.tsx create mode 100644 tests/basic-leaderless-announcement.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index d917dfce..63f1eac0 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 3baa9d0b..7eb5c3c8 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -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 diff --git a/game/engine.ts b/game/engine.ts index d38b3512..aaa76249 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -983,6 +983,8 @@ export type Battle = { attacker: string; defender: string; plans: Record; + /** Basic declarations recorded only when a new leaderless plan is sealed. */ + basicLeaderlessAnnouncements?: string[]; revealed: boolean; traitorCalls: Record; 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] diff --git a/game/reference.ts b/game/reference.ts index 30e79911..655287b7 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 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.', ], diff --git a/tests/basic-leaderless-announcement-controls.test.tsx b/tests/basic-leaderless-announcement-controls.test.tsx new file mode 100644 index 00000000..cc0c04ff --- /dev/null +++ b/tests/basic-leaderless-announcement-controls.test.tsx @@ -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/); + } +}); diff --git a/tests/basic-leaderless-announcement.test.ts b/tests/basic-leaderless-announcement.test.ts new file mode 100644 index 00000000..9104d42b --- /dev/null +++ b/tests/basic-leaderless-announcement.test.ts @@ -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']); + } +});