Resolve prior leaders in Advanced battle explosions

This commit is contained in:
Quintine committed 2026-10-27 17:20:39 +11:00
1 parent f50e436506
commit c3576b2deb
11 files changed
+332

No files matched your search

+16
View File
@@ -20,6 +20,22 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**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).
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
explosion, together with the two newly selected leaders. Separate controlled
leader-history cases cover other territories, prior deaths, Traitor precedence
and concealed captive custody. No browser, deployed or complete-mode claim.
Eight focused files pass 162/162 and type/lint checks pass. The older controlled
Ghola/Revival test now clears its phase-owned ordering frame when explicitly
skipping to Mentat; its elite-limit assertions are unchanged.
**Distrans before Richese special bidding — 27 October. Prototyped / Partial.**
An untouched Once Around or Silent cache/Black Market lot or normal Black Market
lot provisionally allows the existing transfer before the first bid, pass or sealed submission. Preserve
+27
View File
@@ -1,5 +1,32 @@
# Physical cards and effective battle weapons
## Earlier leaders in an Advanced explosion — 27 October 2026
**Prototyped / Partial.** The user-authorized revised rulebook physical p11 /
printed p9 leaves surviving leader discs in their battle territory and makes
them subject to later territory-wide effects, explicitly including a
Lasgun–Shield explosion. In an Advanced Arrakis battle that actually explodes,
living prior-used player leaders at that territory die once alongside the two
current plan leaders, including prior losing leaders. Prior-used native and
foreign-ghola roster discs follow the same location marker; captive or concealed
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 focused native regression uses an original Advanced Emperor/Atreides/Guild
setup, paid shipments and movement to earn two successive Imperial Basin battles.
The prior winner's and loser's surviving discs are checked before the second
explosion. Additional controlled staged histories cover other territories,
already-dead leaders, current-leader deduplication, mutual Traitors and a
concealed captive. Neither fixture establishes full mode or deployed acceptance.
Eight related files pass 162/162, with type checking and lint. One older
controlled Ghola test clears the phase-owned Revival frame before its direct
Mentat jump; it retains its original elite-limit assertions.
The 7 September foundation now feeds a bounded, playable Mirror Weapon battle path (28 September 2026). The historical focused test counts below predate that integration.
## Contract and integration
+20
View File
@@ -1,5 +1,25 @@
# Dune implementation status
## 27 October 2026 — Advanced explosion and prior leaders
Use the user-authorized Advanced rulebook physical11/printed9 to include living
prior-used roster leaders still in an Arrakis territory in an actual
Lasgun–Shield explosion. Current plan discs are killed once, Traitors retain
precedence, no bounty is added, and captured/concealed prior discs remain outside
this location inference. Basic, Homeworld, shared Duke, Kwisatz and special
custody boundaries remain open. [Contract](EFFECTIVE_BATTLE_WEAPONS.md#earlier-leaders-in-an-advanced-explosion--27-october-2026).
The native Advanced Emperor/Atreides/Guild regression reaches two actual
Imperial Basin battles, using paid shipments and ground movement. It selects
unopened starting cards, Spice positions and native scalar lotteries; it does
not install played leaders or armies. Separate controlled staged-history tests
cover noncombatants, other territory, dead leaders, Traitor precedence and a
concealed captive. Complete modes, browser and deployment remain unverified.
Eight selected files pass 162/162 and types/lint pass. A historical controlled
Ghola fixture clears the Revival order at its explicit phase jump; its original
elite-limit assertions remain. Private proof is
`20261027-advanced-explosion-leaders-proof-v2/`.
## 27 October 2026 — Distrans before Richese special bidding
Prototype the existing pre-bid timing interpretation for untouched Once Around
+10
View File
@@ -1,5 +1,15 @@
# Rule decision index
## Earlier leaders in an Advanced Arrakis explosion — 27 October 2026
Apply the supplied Advanced rulebook physical11/printed9: surviving leaders
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).
## Distrans before Richese special bidding — 27 October 2026
Provisionally extend the existing pre-bid interpretation to an untouched Once
+15
View File
@@ -31336,6 +31336,21 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
observeOccupation(g);
dead(al);
dead(dl);
// 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:')) {
let previousLeaders = 0;
for (const owner of g.players)
for (const leader of owner.leaders)
if (!leader.dead && !leader.capturedBy && !leader.concealed &&
leader.usedAt === b.territory) {
dead(leader);
previousLeaders++;
}
if (previousLeaders)
log(g, `${previousLeaders} surviving leader disc${previousLeaders === 1 ? '' : 's'} from earlier battles in this territory ${previousLeaders === 1 ? 'was' : 'were'} also killed by the explosion.`);
}
for (const [p, plan] of [
[a, ap],
[d, dp],
+1
View File
@@ -2815,6 +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.',
'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'],
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { actSalusaGhola, advanceSalusaGhola, salusaGholaClean,
type SalusaGholaStep } from './fixture-salusa-ghola';
import { createAdvisorBattleFlipFixture } from './fixture-advisor-battle-flip';
void test('Advanced explosion does not kill or reveal a captured disc with a stale territory stamp', () => {
const f = createAdvisorBattleFlipFixture({ explosion: true });
let game = structuredClone(f.game);
// Controlled custody supplement: a real BG leader in its native roster has
// already been captured by the actually seated Harkonnen. Preserve the
// capture snapshot that hides its physical custody from the other player;
// the inherited battle, plans, cards, armies and all costs are native.
const owner = game.players.find(p => p.id === 'beneGesserit')!;
const captive = owner.leaders[0]; assert.ok(captive && !captive.dead && !captive.capturedBy);
captive.usedAt = 'arrakeen';
captive.concealed = { captor: 'harkonnen', controller: owner.id, dead: captive.dead,
deaths: captive.deaths, usedAt: captive.usedAt };
captive.capturedBy = 'harkonnen';
const id = captive.id, physicalBefore = structuredClone(captive);
const viewBefore = viewGame(game,'atreides').players.find(p => p.id === owner.id)!.leaders.find(l => l.id === id)!;
assert.equal(viewBefore.capturedBy,undefined);
const steps: SalusaGholaStep[] = [];
game = advanceSalusaGhola(game,g => salusaGholaClean(g) && !!g.battle && !g.battle.revealed &&
!g.battle.preparation && (!g.battle.preLeader || g.battle.preLeader.closed),steps);
for (const plan of f.plans) game = actSalusaGhola(game,plan.actor,plan.action,steps);
game = advanceSalusaGhola(game,g => !g.battle && salusaGholaClean(g),steps);
const after = game.players.find(p => p.id === owner.id)!.leaders.find(l => l.id === id)!;
assert.equal(after.dead,physicalBefore.dead);
assert.equal(after.deaths,physicalBefore.deaths);
assert.equal(after.capturedBy,'harkonnen');
assert.ok(after.concealed);
const viewAfter = viewGame(game,'atreides').players.find(p => p.id === owner.id)!.leaders.find(l => l.id === id)!;
assert.equal(viewAfter.capturedBy,undefined);
assert.equal(viewAfter.dead,viewBefore.dead);
assert.equal(viewAfter.deaths,viewBefore.deaths);
assert.ok(game.log.some(entry => /Lasgun and shield exploded/.test(entry.text)));
});
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createAdvisorBattleFlipFixture as fixture,
commitAdvisorBattleFlip as commit,
advisorBattleFlipPlayer as player,
} from './fixture-advisor-battle-flip';
// The original Advanced game, forces, hands and Lasgun/Shield battle are real
// actions from the shared fixture. Prior leaders' usedAt/dead history and,
// for the mutual-Traitor case, exact physical Traitor custody are staged below:
// this supplement does not claim an earned first battle or Traitor deal.
function staged(mutualTraitors = false) {
const f = fixture({ explosion: true });
const game = structuredClone(f.game);
if (mutualTraitors) {
// Swap original identities across actual held/reserve slots; no Traitor is
// invented or duplicated. The battle driver now calls the two real votes.
const pools = [game.traitorReserve ?? [], ...game.players.flatMap(p => [p.traitors, p.traitorChoices])];
for (const [actor, identity] of [[f.atreides, 'harkonnen-3'], [f.harkonnen, 'atreides-0']] as const) {
const hand = player(game, actor).traitors;
if (hand.includes(identity)) continue;
const source = pools.find(pool => pool.includes(identity));
assert.ok(source && hand.length > 0);
const index = source.indexOf(identity);
[hand[0], source[index]] = [source[index], hand[0]];
}
f.mutualTraitors = true;
}
const bg = player(game, f.bg);
const [prior, elsewhere, unused, alreadyDead] = bg.leaders;
assert.ok(prior && elsewhere && unused && alreadyDead);
assert.ok([prior, elsewhere, unused, alreadyDead].every(leader => !leader.dead && !leader.usedAt));
prior.usedAt = f.territory;
elsewhere.usedAt = 'carthag';
alreadyDead.dead = true;
alreadyDead.deaths = 1;
const current = player(game, f.atreides).leaders.find(leader => leader.id === f.atreidesPlan.leader);
assert.ok(current && !current.dead && !current.usedAt);
current.usedAt = f.territory;
return { f, game, prior: prior.id, elsewhere: elsewhere.id, unused: unused.id,
alreadyDead: alreadyDead.id, current: current.id };
}
void test('Advanced territory-wide explosion kills a prior noncombatant leader exactly once', () => {
const s = staged(), done = commit(s.game, s.f).game;
assert.equal(done.lastBattleContext?.result, 'explosion');
const bg = player(done, s.f.bg);
const byId = (id: string) => { const leader = bg.leaders.find(item => item.id === id); assert.ok(leader); return leader; };
const prior = byId(s.prior);
assert.equal(prior.dead, true);
assert.equal(prior.deaths, 1);
assert.equal(prior.usedAt, undefined);
const current = player(done, s.f.atreides).leaders.find(leader => leader.id === s.current)!;
assert.equal(current.dead, true);
assert.equal(current.deaths, 1, 'The current plan leader is not killed twice');
assert.equal(current.usedAt, undefined);
assert.equal(byId(s.elsewhere).dead, false);
assert.equal(byId(s.elsewhere).usedAt, 'carthag');
assert.equal(byId(s.unused).dead, false);
assert.equal(byId(s.unused).usedAt, undefined);
assert.equal(byId(s.alreadyDead).dead, true);
assert.equal(byId(s.alreadyDead).deaths, 1, 'A dead leader stays in the Tanks without another death');
});
void test('mutual Traitors take precedence over a revealed Lasgun and Shield', () => {
const s = staged(true), done = commit(s.game, s.f).game;
assert.equal(done.lastBattleContext?.result, 'mutualTraitors');
const prior = player(done, s.f.bg).leaders.find(leader => leader.id === s.prior)!;
assert.equal(prior.dead, false);
assert.equal(prior.deaths, 0);
assert.equal(prior.usedAt, s.f.territory);
});
+28
View File
@@ -0,0 +1,28 @@
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('Advanced explosion kills leaders left from a completed earlier battle in the same territory', () => {
const f = createBattleTerritoryExplosionFixture();
const previous = f.afterFirst.players.find(p => p.id === 'atreides')!.leaders.find(l => l.id === f.priorLoser)!;
const winner = f.afterFirst.players.find(p => p.id === f.priorWinnerOwner)!.leaders.find(l => l.id === f.priorWinner)!;
assert.equal(previous.dead,false); assert.equal(previous.usedAt,f.territory);
assert.equal(winner.dead,false); assert.equal(winner.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} is killed by 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);
});
+3
View File
@@ -2221,6 +2221,9 @@ void test('Ghola and normal revival share the elite limit for the entire turn, n
);
g = applyAction(g, 'p0', { type: 'revive', amount: 2, elite: 0 });
conservation(g);
// This controlled fixture jumps directly past Revival to Mentat. Retire the
// phase-owned ordering frame that ordinary phase completion would clear.
delete g.revivalOrder;
g.phase = 8;
g.ready = [];
g = readyAll(g);
+100
View File
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { actSalusaGhola, advanceSalusaGhola, salusaGholaClean,
type SalusaGholaStep } from './fixture-salusa-ghola';
const seat = (g: Game, id: string) => { const p = g.players.find(p => p.id === id); assert.ok(p); return p; };
const readyForPlan = (g: Game) => salusaGholaClean(g) && !!g.battle && !g.battle.revealed &&
!g.battle.preparation && (!g.battle.preLeader || g.battle.preLeader.closed);
function livingLeader(g: Game, id: string, strongest: boolean, excluded?: string) {
const leaders = seat(g, id).leaders.filter(l => !l.dead && !l.capturedBy && l.id !== excluded &&
(!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
* 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);
joinGame(game,newPlayer('atreides','Atreides','atreides'));
joinGame(game,newPlayer('guild','Guild','guild'));
const actions: SalusaGholaStep[] = [];
const act = (actor: string, action: Action) => { game = actSalusaGhola(game,actor,action,actions); };
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});
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');
const ordinary = game.deck.find(c => c.kind === 'projectile');
const lasgun = game.deck.find(c => c.kind === 'lasgun');
assert.ok(shield && ordinary && lasgun);
const selected = [shield,ordinary,lasgun];
const startingSelection = selected.map((card,position) => {
const from = game.deck.findIndex(c => c.id === card.id); assert.ok(from >= position);
[game.deck[position],game.deck[from]] = [game.deck[from],game.deck[position]];
return {position,from,card:card.id};
});
reach(g => g.status === 'playing');
const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(p => p.hand.map(c => c.id)),selected.map(c => c.id));
const spiceSelection = [0,1].map(position => {
const from = game.spiceDeck.findIndex((c,index) => index >= position &&
'territory' in c && !c.discovery && c.territory !== 'imperial_basin');
assert.ok(from >= position); const [c] = game.spiceDeck.splice(from,1);
game.spiceDeck.splice(position,0,c); return {position,from,card:structuredClone(c)};
});
reach(g => g.phase === 5 && salusaGholaClean(g));
const shipments: SalusaGholaStep[] = [];
for (let n = 0; game.phase === 5 && n < 100; n++) {
if (!salusaGholaClean(game)) { reach(salusaGholaClean); continue; }
const owner = game.active!;
if (owner === 'atreides') {
act(owner,{type:'move',from:'arrakeen:10',territory:'imperial_basin',sector:9,amount:1});
} else {
const action: Action = {type:'ship',territory:'imperial_basin',sector:9,
amount:owner === 'emperor' ? 2 : 1,elite:0,allyPayment:0};
shipments.push({actor:owner,action}); act(owner,action);
}
reach(salusaGholaClean);
act(owner,{type:'endMovement'});
}
assert.equal(game.phase,6);
reach(g => salusaGholaClean(g) && !g.battle && !!g.active);
const beforeFirst = structuredClone(game), chooser = game.active!;
const choice = viewGame(game,chooser).battleChoices.find(c => c.territory === 'imperial_basin' &&
(c.attacker === 'atreides' || c.defender === 'atreides'));
assert.ok(choice, 'Choose the actual first contest against Atreides');
const other = choice.attacker === 'atreides' ? choice.defender : choice.attacker;
act(chooser,{type:'chooseBattle',territory:'imperial_basin',target:chooser === 'atreides' ? other : 'atreides'});
reach(readyForPlan);
const priorLoser = livingLeader(game,'atreides',false), priorWinner = livingLeader(game,other,true);
act('atreides',{type:'battlePlan',leader:priorLoser,dial:0,support:0});
act(other,{type:'battlePlan',leader:priorWinner,dial:0,support:0});
reach(g => !g.battle && salusaGholaClean(g) && g.phase === 6 && !!g.active);
const afterFirst = structuredClone(game);
assert.equal(game.lastBattleContext?.winner,other);
assert.ok(seat(game,'atreides').leaders.some(l => l.id === priorLoser && !l.dead && l.usedAt === 'imperial_basin'));
assert.ok(seat(game,other).leaders.some(l => l.id === priorWinner && !l.dead && l.usedAt === 'imperial_basin'));
const secondChooser = game.active!;
const next = viewGame(game,secondChooser).battleChoices.find(c => c.territory === 'imperial_basin' &&
[c.attacker,c.defender].includes('emperor') && [c.attacker,c.defender].includes('guild'));
assert.ok(next, 'The actual two remaining armies must battle');
act(secondChooser,{type:'chooseBattle',territory:'imperial_basin',
target:secondChooser === 'emperor' ? 'guild' : 'emperor'});
reach(readyForPlan);
const beforeExplosion = structuredClone(game);
const emperorLeader = livingLeader(game,'emperor',false,other === 'emperor' ? priorWinner : undefined);
const guildLeader = livingLeader(game,'guild',false,other === 'guild' ? priorWinner : undefined);
const plans = [
{actor:'emperor',action:{type:'battlePlan',leader:emperorLeader,dial:0,support:0,defense:shield.id} as Action},
{actor:'guild',action:{type:'battlePlan',leader:guildLeader,dial:0,support:0,weapon:lasgun.id} as Action},
];
return {game,beforeExplosion,beforeFirst,afterFirst,lobby,setup,afterSetup,startingSelection,spiceSelection,
priorLoser,priorWinner,priorWinnerOwner:other,emperorLeader,guildLeader,plans,actions,shipments,
shield:shield.id,lasgun:lasgun.id,territory:'imperial_basin',
staging:'Only unopened starting cards and Spice positions and scalar lotteries are selected. The board entry, first battle and survivor discs use public native actions.'};
}