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

@@ -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.'};
}