From 474674f7fe240ab894e9b041fa676a95af58aaf6 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:24:21 +1100 Subject: [PATCH] Handle prior leaders in Basic battle explosions --- docs/CURRENT_STATUS.md | 19 ++++- docs/EFFECTIVE_BATTLE_WEAPONS.md | 35 ++++++++- docs/IMPLEMENTATION_STATUS.md | 19 +++++ docs/RULE_DECISIONS.md | 15 +++- game/engine.ts | 2 +- game/reference.ts | 2 +- .../battle-territory-explosion-basic.test.ts | 29 +++++++ tests/battle-territory-explosion-duke.test.ts | 77 +++++++++++++++++++ tests/fixture-battle-territory-explosion.ts | 9 ++- 9 files changed, 194 insertions(+), 13 deletions(-) create mode 100644 tests/battle-territory-explosion-basic.test.ts create mode 100644 tests/battle-territory-explosion-duke.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 22a99277..a276877d 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -20,12 +20,27 @@ open mode gates. Keep identified Basic-function gaps ahead of Advanced and wider interactions. Prototype labels and final acceptance gates remain. +**Basic explosion and prior surviving leaders — 27 October. Prototyped / Partial.** +The Advanced Arrakis prior-leader handling now also applies provisionally in +Basic: a later actual explosion kills surviving discs from earlier battles in +that territory. The Basic source composition and its uncertainty are recorded +in the [contract](EFFECTIVE_BATTLE_WEAPONS.md#basic-explosion-and-prior-leaders--27-october-2026). + +A public Basic Emperor/Atreides/Guild game uses real entry and two successive +battles to check prior and current leaders. An Advanced E3 supplement begins +with earned Duke acquisition but explicitly stages the later battle position +and cards: a set-aside Duke survives, while a Duke in the current plan dies. +Kwisatz's prior location remains unresolved; browser and deployed play are not +certified. +Eleven selected files pass 173/173 and type/lint checks pass. No build, browser, +mobile or deployed verification was performed for this focused rules change. + **Advanced explosion and prior surviving leaders — 27 October. Prototyped / Partial.** On Arrakis, an actual Lasgun–Shield explosion now also kills still-present living leader discs left by earlier battles in that territory, without repeating current leader deaths or awarding bounty. Traitor precedence and captured / -concealed exclusions remain. Basic, Homeworld, shared Duke, Kwisatz and special -custody interpretations are separate. [Source and scope](EFFECTIVE_BATTLE_WEAPONS.md#earlier-leaders-in-an-advanced-explosion--27-october-2026). +concealed exclusions remain. The later Basic interpretation is recorded above; +Homeworld, shared Duke, Kwisatz and special custody remain separate. [Source and scope](EFFECTIVE_BATTLE_WEAPONS.md#earlier-leaders-in-an-advanced-explosion--27-october-2026). An original Advanced Emperor/Atreides/Guild setup reaches two real battles in Imperial Basin: both surviving discs from the first battle die in the second diff --git a/docs/EFFECTIVE_BATTLE_WEAPONS.md b/docs/EFFECTIVE_BATTLE_WEAPONS.md index 8f1724a3..3a123f24 100644 --- a/docs/EFFECTIVE_BATTLE_WEAPONS.md +++ b/docs/EFFECTIVE_BATTLE_WEAPONS.md @@ -1,5 +1,34 @@ # Physical cards and effective battle weapons +## Basic explosion and prior leaders — 27 October 2026 + +**Prototyped / Partial.** Under the standing selected-interpretation authority, +apply the same prior-leader Arrakis explosion handling to Basic. The [GF9 base +rulebook, p11](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf) +places surviving leaders in their territory until battles finish. A +[separately indexed transcription](https://boardgamegeek.com/thread/2542307/lasgun-shield-explosion-do-leaders-who-previously) +attributes destruction of all leaders in the battle territory to the 2019 +physical Lasgun card, but its actual face was not independently inspected. +This Basic composition is provisional and is not an official clarification. +Current and earlier leaders die once; a successful Traitor prevents the explosion +and no leader bounty is awarded. Existing custody exclusions remain. + +The supplied Advanced source physical30/printed28 returns a surviving +Ambassador Duke next to the board after his battle, so his `usedAt` stamp alone +does not locate that shared disc in a later explosion. The Advanced Kwisatz +`usedAt` marker establishes a per-turn territory limit, but no retrieved primary +source establishes where the separate marker stays after its battle. Its +current-plan explosion behavior remains; a later explosion is not inferred from +the stamp. Homeworld and exceptional custody boundaries remain open. + +A native public Basic game uses actual entry, two battles and both previous +surviving discs; its unopened card/Spice order and scalar lotteries are selected. +The Duke supplement earns his original acquisition but explicitly stages later +battle state and conserved cards. These checks do not establish broad custody, +full-mode or deployed acceptance. +Eleven related files pass 173/173 with type checking and lint; the first +verification attempt caught an unused import before the full passing rerun. + ## Earlier leaders in an Advanced explosion — 27 October 2026 **Prototyped / Partial.** The user-authorized revised rulebook physical p11 / @@ -13,9 +42,9 @@ discs are excluded because their earlier stamp alone does not establish current physical presence. No bounty is added. Actual Traitor outcomes retain precedence and do not explode. Existing dead-leader Skill cleanup runs normally. -The separate shared Duke and Kwisatz marker, Homeworld explosions, Basic source -cutover and exceptional custody interactions remain outside this slice; this -does not certify complete modes or change their existing policy. +The separate shared Duke and Kwisatz marker, Homeworld explosions, and +exceptional custody interactions remain outside that slice; the later provisional +Basic policy is recorded above. This does not certify complete modes. The focused native regression uses an original Advanced Emperor/Atreides/Guild setup, paid shipments and movement to earn two successive Imperial Basin battles. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index e87dedac..e8eed5ec 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,24 @@ # Dune implementation status +## 27 October 2026 — Basic explosion and prior leaders + +Extend the Advanced Arrakis prior-leader explosion to Basic under the standing +selected-interpretation authority. Base GF9 rules leave surviving discs in their +territory, and a separately indexed card transcription supports territory-wide +leader deaths. The card face has not been independently confirmed; the Basic +composition is provisional. Shared Duke, Kwisatz, Homeworld and exceptional +custody remain separate. [Contract](EFFECTIVE_BATTLE_WEAPONS.md#basic-explosion-and-prior-leaders--27-october-2026). + +An original public Basic Emperor/Atreides/Guild sequence reaches two real +battles after actual shipments and movement; only unopened initial cards and +Spice positions plus scalar lotteries are selected. A separate Advanced E3 +supplement uses an earned Duke acquisition, then explicitly stages conserved +cards and later battle state to check the set-aside and current-plan cases. No +browser or deployment result is claimed. +Eleven focused files pass 173/173 and types/lint pass. The private proof is +`20261027-basic-explosion-leaders-proof-v2/`; the first attempt stopped at an +unused test import, which was removed before the complete second run. + ## 27 October 2026 — Advanced explosion and prior leaders Use the user-authorized Advanced rulebook physical11/printed9 to include living diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 1b0db7e0..7fd3be7b 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,16 @@ # Rule decision index +## Basic explosion and prior leaders — 27 October 2026 + +Provisionally extend the existing Advanced Arrakis casualty handling to Basic. +The [publisher base rulebook, p11](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf) +keeps surviving leaders in their battle territory; a separately indexed +transcription attributes territory-wide leader destruction to the physical +Lasgun card. The latter has not been independently confirmed from the card face, +so this composition is an explicit interpretation, not a publisher ruling. +Captured and concealed leaders, shared Duke, Homeworlds and Kwisatz retain their +separate boundaries. [Source and scope](EFFECTIVE_BATTLE_WEAPONS.md#basic-explosion-and-prior-leaders--27-october-2026). + ## Earlier leaders in an Advanced Arrakis explosion — 27 October 2026 Apply the supplied Advanced rulebook physical11/printed9: surviving leaders @@ -7,8 +18,8 @@ remain in their battle territory and suffer a subsequent territory-wide Lasgun–Shield explosion. An actual Arrakis explosion kills each still-present living prior-used roster disc once; Traitors retain precedence and no bounty is added. Earlier stamps on captured or concealed discs do not prove present -location. Basic, Homeworld, shared Duke, Kwisatz and exceptional custody remain -separate. [Source and scope](EFFECTIVE_BATTLE_WEAPONS.md#earlier-leaders-in-an-advanced-explosion--27-october-2026). +location. Homeworld, shared Duke, Kwisatz and exceptional custody remain +separate; the later Basic interpretation is recorded above. [Source and scope](EFFECTIVE_BATTLE_WEAPONS.md#earlier-leaders-in-an-advanced-explosion--27-october-2026). ## Distrans before Richese special bidding — 27 October 2026 diff --git a/game/engine.ts b/game/engine.ts index cfa63157..a85c64c7 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -31339,7 +31339,7 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) { // Surviving discs from earlier battles remain in their Arrakis territory // through the Battle phase. Captives have changed custody and do not use // their old battle-location stamp as proof of physical presence. - if (g.advanced && !b.territory.startsWith('homeworld:')) { + if (!b.territory.startsWith('homeworld:')) { let previousLeaders = 0; for (const owner of g.players) for (const leader of owner.leaders) diff --git a/game/reference.ts b/game/reference.ts index 8cea4dfc..49d272b6 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -2815,7 +2815,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 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.', - 'In Advanced 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. A successful Traitor call prevents the explosion.', + '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.', ], related: ['battle', 'card-karama', 'moritani-ally-retention'], diff --git a/tests/battle-territory-explosion-basic.test.ts b/tests/battle-territory-explosion-basic.test.ts new file mode 100644 index 00000000..117e668a --- /dev/null +++ b/tests/battle-territory-explosion-basic.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { actSalusaGhola, advanceSalusaGhola, salusaGholaClean, + type SalusaGholaStep } from './fixture-salusa-ghola'; +import { createBattleTerritoryExplosionFixture } from './fixture-battle-territory-explosion'; + +void test('Basic provisional territory-wide explosion kills earlier surviving leaders in a later native battle', () => { + const f = createBattleTerritoryExplosionFixture({ advanced: false }); + assert.equal(f.setup.advanced,false); + const firstLoser = f.afterFirst.players.find(p => p.id === 'atreides')!.leaders.find(l => l.id === f.priorLoser)!; + const firstWinner = f.afterFirst.players.find(p => p.id === f.priorWinnerOwner)!.leaders.find(l => l.id === f.priorWinner)!; + assert.equal(firstLoser.dead,false); assert.equal(firstLoser.usedAt,f.territory); + assert.equal(firstWinner.dead,false); assert.equal(firstWinner.usedAt,f.territory); + const actions: SalusaGholaStep[] = []; + let game = structuredClone(f.game); + for (const plan of f.plans) game = actSalusaGhola(game,plan.actor,plan.action,actions); + game = advanceSalusaGhola(game,g => !g.battle && salusaGholaClean(g),actions); + assert.ok(actions.filter(step => step.action.type === 'traitorCall').every(step => step.action.call === false)); + for (const [owner,id] of [['atreides',f.priorLoser],[f.priorWinnerOwner,f.priorWinner], + ['emperor',f.emperorLeader],['guild',f.guildLeader]] as const) { + const leader = game.players.find(p => p.id === owner)!.leaders.find(l => l.id === id)!; + assert.equal(leader.dead,true,`${owner}'s ${id} dies in the territory-wide explosion`); + assert.equal(leader.deaths,1); + } + assert.ok(game.log.some(entry => /Lasgun and shield exploded/.test(entry.text))); + assert.equal(game.discard.filter(card => card.id === f.shield || card.id === f.lasgun).length,2); + assert.equal(game.players.flatMap(p => Object.entries(p.forces).filter(([key]) => key.startsWith(`${f.territory}:`))) + .reduce((sum,[,amount]) => sum + amount,0),0); +}); diff --git a/tests/battle-territory-explosion-duke.test.ts b/tests/battle-territory-explosion-duke.test.ts new file mode 100644 index 00000000..1d52c96e --- /dev/null +++ b/tests/battle-territory-explosion-duke.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { handLimit } from '../game/engine'; +import { DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { createMoritaniEcazDukeWinner, settleMoritaniEcazDukeWinner } from './fixture-moritani-ecaz-duke-winner'; +import { pairedE3NexusInventory, pairedE3NexusPlayer, revealPairedE3Nexus, stepPairedE3Nexus } from './fixture-paired-e3-nexus-modules'; + +/** Controlled supplement on a genuine Advanced Ecaz/Moritani/Guild setup and + * earned Duke acquisition. Stage only the prior set-aside Duke history and + * physical Lasgun/Shield custody; battle choice, plans and votes are real. */ +function source(setAside: boolean) { + const f = createMoritaniEcazDukeWinner(); + const game = structuredClone(f.beforeBattle); + const duke = game.dukeVidal!; + assert.equal(duke.leader.id, DUKE_VIDAL_ID); + assert.equal(duke.leader.dead, false); + assert.equal(duke.controller, f.ecaz); + if (setAside) { + duke.controller = null; + duke.acquiredTurn = null; + duke.source = null; + duke.leader.usedAt = f.location; + } + const take = (kind: 'lasgun' | 'shield', actor: string) => { + const recipient = pairedE3NexusPlayer(game, actor); + const already = recipient.hand.find(card => card.kind === kind); + if (already) return already; + const pools = [game.deck, game.discard, ...game.players.map(player => player.hand)]; + const pool = pools.find(cards => cards.some(card => card.kind === kind)); + assert.ok(pool, `The original ${kind} remains in physical custody`); + const index = pool.findIndex(card => card.kind === kind); + const [card] = pool.splice(index, 1); + if (recipient.hand.length >= handLimit(recipient)) { + const replacement = recipient.hand.pop(); assert.ok(replacement); + pool.push(replacement); + } + recipient.hand.push(card); + return card; + }; + const lasgun = take('lasgun', f.ecaz), shield = take('shield', f.moritani); + pairedE3NexusInventory(game); + const native = pairedE3NexusPlayer(game, f.ecaz).leaders.find(leader => !leader.dead && !leader.usedAt); + assert.ok(native); + const chosen = stepPairedE3Nexus(game, f.choice); + const plans = f.plans.map(step => step.actor === f.ecaz + ? { ...step, action: { ...step.action, leader: setAside ? native.id : DUKE_VIDAL_ID, weapon: lasgun.id } } + : { ...step, action: { ...step.action, defense: shield.id } }); + const ready = revealPairedE3Nexus(chosen, plans); + return { f, game, ready, priorDuke: structuredClone(duke), lasgun: lasgun.id, shield: shield.id }; +} + +void test('Advanced later explosion does not kill a surviving Duke already set beside the board', () => { + const s = source(true); + assert.equal(s.priorDuke.controller, null); + assert.equal(s.priorDuke.leader.usedAt, s.f.location); + const done = settleMoritaniEcazDukeWinner(s.ready); + assert.equal(done.lastBattleContext?.result, 'explosion'); + assert.equal(done.dukeVidal!.leader.id, DUKE_VIDAL_ID); + assert.equal(done.dukeVidal!.leader.dead, false); + assert.equal(done.dukeVidal!.leader.deaths, s.priorDuke.leader.deaths); + assert.equal(done.dukeVidal!.controller, null); + assert.ok(done.players.every(player => player.leaders.every(leader => leader.id !== DUKE_VIDAL_ID))); + pairedE3NexusInventory(done); +}); + +void test('Advanced current-plan Duke still dies in a resolved Lasgun–Shield explosion', () => { + const s = source(false); + assert.equal(s.priorDuke.controller, s.f.ecaz); + const done = settleMoritaniEcazDukeWinner(s.ready); + assert.equal(done.lastBattleContext?.result, 'explosion'); + assert.equal(done.dukeVidal!.leader.id, DUKE_VIDAL_ID); + assert.equal(done.dukeVidal!.leader.dead, true); + assert.equal(done.dukeVidal!.leader.deaths, s.priorDuke.leader.deaths + 1); + assert.equal(done.dukeVidal!.controller, null); + assert.ok(done.players.every(player => player.leaders.every(leader => leader.id !== DUKE_VIDAL_ID))); + pairedE3NexusInventory(done); +}); diff --git a/tests/fixture-battle-territory-explosion.ts b/tests/fixture-battle-territory-explosion.ts index b96d37c3..054befa0 100644 --- a/tests/fixture-battle-territory-explosion.ts +++ b/tests/fixture-battle-territory-explosion.ts @@ -11,13 +11,14 @@ function livingLeader(g: Game, id: string, strongest: boolean, excluded?: string (!l.usedAt || l.usedAt === 'imperial_basin')).sort((a,b) => strongest ? b.strength-a.strength : a.strength-b.strength); assert.ok(leaders[0]); return leaders[0].id; } -/** Real public Advanced E/A/G source: three actual forces meet in Imperial +/** Real public E/A/G source: three actual forces meet in Imperial * Basin; the first battle's surviving leader discs then remain in that same * territory while the two remaining armies fight. Only the unopened three-card * starting deal, two unopened land Spice positions and scalar lotteries are * selected; no played forces, leaders, wallet, battle or clock is assigned. */ -export function createBattleTerritoryExplosionFixture() { - let game = createGame('TERRITORYEXPLOSION', newPlayer('emperor','Emperor','emperor'), true); +export function createBattleTerritoryExplosionFixture(options: { advanced?: boolean } = {}) { + const advanced = options.advanced ?? true; + let game = createGame('TERRITORYEXPLOSION', newPlayer('emperor','Emperor','emperor'), advanced); joinGame(game,newPlayer('atreides','Atreides','atreides')); joinGame(game,newPlayer('guild','Guild','guild')); const actions: SalusaGholaStep[] = []; @@ -25,7 +26,7 @@ export function createBattleTerritoryExplosionFixture() { const reach = (until: (g: Game) => boolean) => { game = advanceSalusaGhola(game,until,actions); }; for (const p of game.players) act(p.id,{type:'ready'}); const lobby = structuredClone(game); - act(game.host,{type:'start',advancedPreview:true}); + act(game.host,advanced ? {type:'start',advancedPreview:true} : {type:'start'}); const setup = structuredClone(game); assert.equal(game.status,'setup'); assert.ok(game.players.every(p => p.hand.length === 0)); const shield = game.deck.find(c => c.kind === 'shield');