Announce leaderless native Basic battles before plans
Verify and publish checkpoint container / container (push) Successful in 10m14s

This commit is contained in:
Quintine committed 2026-10-29 20:33:33 +11:00
1 parent 00e01a7cd2
commit f73a6e0a9c
9 files changed
+481 -21

No files matched your search

+11
View File
@@ -17,6 +17,17 @@ open mode gates.
## Current checkpoint and work
**Basic early leaderless announcement for expansion factions — 29 October.
Prototyped / Partial.** Eligible Basic selected-faction battles announce before
plans, preserving neutral Ghola preparation, Richese's shared preparation and
the selected Ecaz coalition lead. A later Cheap Hero transfer corrects the
notice. Twelve related files pass 98 cases; public-start native fixtures cover
all six expansion factions, and the no-Richese coalition supplement uses an
audit initializer. Leader history and some physical card or board states are
staged. Optional modules, broader capture interactions, full games and deployed
verification remain open.
[Scope](RULE_DECISIONS.md#basic-leaderless-battle-announcement--29-october-2026).
**Selected Nexus with Leader Skills and unused families — 29 October.
Prototyped / Partial.** Basic and Advanced previews support selected decks,
Nexus and Leader Skills even when some families are unseated, without requiring
+8
View File
@@ -320,6 +320,14 @@ Great Maker rides share this destination check; Polar Sink remains clear.
## Basic leaderless battle announcement — 29 October 2026
Fresh Basic selected-faction battles without optional modules now use this
early announcement, including the original Richese preparation and Ecaz's
chosen coalition lead. Public shared-Duke custody and the existing concealed
capture boundary constrain announcements; a later received Cheap Hero also
withdraws the current notice. This extends the provisional online ordering,
not a new publisher timing ruling. Optional-module and full-game acceptance
remain open.
Fresh classic Basic games with Harkonnen also open the neutral dead-leader
preparation using each native leader's public pre-capture snapshot. An
announcement is made only when the public native discs are unavailable, so it
+40 -20
View File
@@ -38383,6 +38383,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
!Object.keys(b.plans).length, 'Resolve Occupy before native powers and plans.');
occupy.canceled = canceled;
ecazBattleProfile(g);
prepareEarlyBasicLeaderless(g);
// The chosen plan owner and final Occupy force profile are now fixed.
// A nonleading ally keeps its waiting receipt for its own later battle.
bindFutureBattleLeaderQuestions(g);
@@ -43791,6 +43792,27 @@ function publicNativeBattleLeaders(player: Player, territory: string) {
unavailable: !leaders.some(leader => !leader.dead && !leader.captured &&
(!leader.usedAt || leader.usedAt === territory)) };
}
function publicSharedDukeAvailable(g: Game, player: string, territory: string): boolean {
const duke = projectedSharedDuke(g, '');
return !!duke && duke.controller === player && !duke.leader.dead &&
!duke.leader.capturedBy && !duke.leader.gholaBy &&
(!duke.leader.usedAt || duke.leader.usedAt === territory);
}
/** Open the neutral Ghola opportunity only for the final battle participants.
* Richese already opens the same shared preparation for Sapho. */
function prepareEarlyBasicLeaderless(g: Game) {
const b = g.battle;
if (!b?.event || b.basicEarlyLeaderlessVersion !== 1) return;
const participants = [b.attacker, b.defender];
const candidates = participants.filter(id => {
const publicState = publicNativeBattleLeaders(getPlayer(g, id), b.territory);
return publicState.hasDead && publicState.unavailable && !publicSharedDukeAvailable(g, id, b.territory);
});
if (!candidates.length) return;
b.basicDeadLeaderPreparation = true;
if (!b.preLeader) b.preLeader = {event: b.event,
ready: participants.filter(id => !candidates.includes(id)), closed: false};
}
/** Publish a clean Basic inability once preparation and battle powers finish. */
function announceEarlyBasicLeaderless(g: Game) {
const b = g.battle;
@@ -43807,16 +43829,18 @@ function announceEarlyBasicLeaderless(g: Game) {
const availableHero = p.hand.some(c => c.kind === 'hero' && !(b.voice?.target === id && !b.voice.must &&
playedVoiceMatch(c, 'leader', b.voice.kind)));
if (b.basicEarlyLeaderless?.includes(id)) {
if (b.basicDeadLeaderPreparation && availableLeader) {
if (availableLeader || availableHero) {
b.basicEarlyLeaderless = b.basicEarlyLeaderless.filter(player => player !== id);
log(g, `${p.name} now has an available leader for this battle.`);
log(g, availableLeader ? `${p.name} now has an available leader for this battle.` :
`${p.name} now has an available Cheap Hero for this battle.`);
}
continue;
}
if (b.basicPublicLeaderlessVersion === 1 &&
(!publicNativeBattleLeaders(p, b.territory).unavailable ||
p.faction === 'harkonnen' && g.players.some(owner => owner.leaders.some(leader =>
leader.concealed?.captor === p.id && leader.capturedBy === p.id)))) continue;
(!publicNativeBattleLeaders(p, b.territory).unavailable || publicSharedDukeAvailable(g, id, b.territory) ||
p.faction === 'harkonnen' && (g.players.some(owner => owner.leaders.some(leader =>
leader.concealed?.captor === p.id && leader.capturedBy === p.id)) ||
g.dukeVidal?.leader.concealed?.captor === p.id))) continue;
if ((!leaders.length && !b.basicDeadLeaderPreparation) || availableLeader || availableHero ||
!b.basicDeadLeaderPreparation && leaders.some(l => l.dead || l.capturedBy)) continue;
(b.basicEarlyLeaderless ??= []).push(id);
@@ -44951,6 +44975,9 @@ function applyActionInner(
// The selected lead supplies normal aggressor priority, not the scheduling ally.
if (g.order.indexOf(b.attacker) > g.order.indexOf(b.defender))
[b.attacker, b.defender] = [b.defender, b.attacker];
// Richese's shared preparation must name the final combatants.
if (b.basicEarlyLeaderlessVersion === 1 && b.preLeader)
b.preLeader.ready = [];
ecazBattleProfile(g);
g.decision = null;
g.response = { kind: 'ecazOccupy', owner: occupy.ecaz, passed: [] };
@@ -48186,27 +48213,17 @@ function applyActionInner(
'Combined Occupy requires source-selected native factions; optional overlays remain gated.');
}
const battleEvent = crypto.randomUUID();
const earlyBasic = !g.advanced && g.expansions.length === 0 && !g.ecazTreachery && !g.techTokens &&
const earlyBasic = !g.advanced && (g.expansions.length === 0
? g.players.every(seat => CLASSIC_FACTIONS[seat.faction] === true)
: selectedPhaseModuleRoster(g)) && !g.ecazTreachery && !g.techTokens &&
!g.strongholdCards && !g.leaderSkills && !g.homeworlds && !g.nexusCards && !g.discoveryEnabled &&
!g.sandtrout && !g.coalitionState && g.players.every(seat =>
['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction));
// Use the same pre-capture snapshot visible to uninvolved players. A
// neutral preparation opportunity must not identify a concealed captive.
const deadLeaderCandidates = earlyBasic
? [choice.attacker, choice.defender].filter(playerId => {
const publicState = publicNativeBattleLeaders(getPlayer(g, playerId), choice.territory);
return publicState.hasDead && publicState.unavailable;
}) : [];
!g.coalitionState && !g.kullPreview && !g.nexusKullPreview;
g.battle = {
...choice,
chooserEvent: battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder?.uses.length ?? 0),
event: battleEvent,
...(earlyBasic ? { basicEarlyLeaderlessVersion: 1 as const } : {}),
...(earlyBasic && byFaction(g, 'harkonnen') ? { basicPublicLeaderlessVersion: 1 as const } : {}),
...(deadLeaderCandidates.length ? { basicDeadLeaderPreparation: true as const,
preLeader: { event: battleEvent,
ready: [choice.attacker, choice.defender].filter(playerId => !deadLeaderCandidates.includes(playerId)),
closed: false } } : {}),
stoneAllocationVersion: 1,
...(g.leaderSkills ? { smugglerCollectionVersion: 1 as const } : {}),
...(g.leaderSkills && mentatQuestionModeSupported(g) ? { mentatQuestionVersion: 1 as const } : {}),
@@ -48223,7 +48240,10 @@ function applyActionInner(
revealed: false,
traitorCalls: {},
};
if (!combined) bindFutureBattleLeaderQuestions(g);
if (!combined) {
prepareEarlyBasicLeaderless(g);
bindFutureBattleLeaderQuestions(g);
}
if (combined) {
g.battle.ecazOccupy = { event: battleEvent, ecaz: ecaz!.id, ally: ally!.id, lead: null, canceled: false };
const opponent = [choice.attacker, choice.defender].find(id => id !== ecaz!.id && id !== ally!.id)!;
+1 -1
View File
@@ -2863,7 +2863,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 supported fresh classic Basic battles, an inability is announced before plans. When dead leaders may be revived, a preparation opportunity lets you play Ghola from your hand before continuing; a later revival updates the public notice. Harkonnen can use a captive leader; its announcement stays at plan submission while it holds a concealed captive. Other Basic and Advanced leaderless plans announce on sealing; 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 supported fresh Basic battles with classic or selected expansion factions, an inability is announced before plans. When dead leaders may be revived, a preparation opportunity lets you play Ghola from your hand before continuing; a later revival or received Cheap Hero updates the public notice. An Ecaz coalition uses its selected lead’s leaders and hand. Harkonnen can use a captive leader; its announcement stays at plan submission while it holds a concealed captive. Other Basic and Advanced leaderless plans announce on sealing; 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,79 @@
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 { advanceNativeLeaderless, basicNativeLeaderlessFixture, nativeLeaderlessClean,
nativeLeaderlessPlayer, type NativeLeaderlessFaction } from './fixture-basic-native-early-leaderless';
const act = (game: Game, player: string, action: Action) => applyAction(game, player, action);
const noLeader: Action = {type:'battlePlan', leader:null, dial:0, support:0, weapon:null, defense:null};
const announcements = (game: Game, player: string) => game.log.filter(entry => entry.text ===
`${nativeLeaderlessPlayer(game, player).name} has no available leader or Cheap Hero for this battle.`);
function atPlanning(faction: NativeLeaderlessFaction, options: {dead?: boolean; hero?: boolean; ghola?: boolean} = {}) {
const fixture = basicNativeLeaderlessFixture(faction, options);
const game = advanceNativeLeaderless(fixture.game, state => !!state.battle && nativeLeaderlessClean(state) &&
!state.battle.preparation && (!state.battle.preLeader || state.battle.preLeader.closed));
assert.ok(game.battle && !game.battle.revealed);
return {...fixture, game};
}
function opponentPlan(game: Game, opponent: string, territory: string): Action {
const leader = nativeLeaderlessPlayer(game, opponent).leaders.find(candidate =>
!candidate.dead && !candidate.capturedBy && (!candidate.usedAt || candidate.usedAt === territory));
assert.ok(leader);
return {type:'battlePlan', leader:leader.id, dial:0, support:0, weapon:null, defense:null};
}
for (const faction of ['ixians','tleilaxu','choam','richese','ecaz','moritani'] as const)
for (const first of ['native','opponent'] as const) void test(
`Basic ${faction} announces no eligible leader before ${first} seals a real public battle`, () => {
const {game: initial, opponent, territory} = atPlanning(faction);
let game = initial;
assert.deepEqual(game.battle!.plans, {});
assert.ok(game.battle!.basicEarlyLeaderless?.includes(faction));
assert.equal(announcements(game, faction).length, 1);
assert.ok(viewGame(game, opponent).battle!.earlyLeaderless?.includes(faction));
const other = opponentPlan(game, opponent, territory);
game = act(game, first === 'native' ? faction : opponent, first === 'native' ? noLeader : other);
assert.equal(game.battle!.revealed, false);
assert.equal(announcements(game, faction).length, 1);
game = act(game, first === 'native' ? opponent : faction, first === 'native' ? other : noLeader);
assert.equal(game.battle!.revealed, true);
assert.ok(viewGame(game, opponent).battle!.leaderless?.includes(faction));
assert.equal(announcements(game, faction).length, 1);
});
void test('Basic native Cheap Hero prevents early leaderless announcement and remains a legal plan', () => {
const {game: initial, faction, opponent} = atPlanning('richese', {hero:true});
let game = initial;
const hero = nativeLeaderlessPlayer(game, faction).hand.find(card => card.kind === 'hero'); assert.ok(hero);
assert.equal(announcements(game, faction).length, 0);
assert.ok(!(viewGame(game, opponent).battle!.earlyLeaderless ?? []).includes(faction));
game = act(game, faction, {...noLeader, leader:hero.id});
assert.equal(game.battle!.plans[faction].leader, hero.id);
});
void test('Basic native dead-leader preparation lets Ghola revive before the sealed plans', () => {
const fixture = basicNativeLeaderlessFixture('moritani', {dead:true, ghola:true});
let game = advanceNativeLeaderless(fixture.game, state => !!state.battle && nativeLeaderlessClean(state) &&
!state.battle.preparation && !!state.battle.preLeader && !state.battle.preLeader.closed);
const faction = fixture.faction, event = game.battle!.preLeader!.event;
assert.equal(announcements(game, faction).length, 0);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, faction); view.players.find(player => player.id === faction)!.bot = difficulty;
const action = botActions(view).find(candidate => candidate.type === 'battlePreparationReady');
assert.ok(action, difficulty);
assert.doesNotThrow(() => act(game, faction, action));
}
game = act(game, faction, {type:'battlePreparationReady', event});
assert.equal(announcements(game, faction).length, 1);
const player = nativeLeaderlessPlayer(game, faction), leader = player.leaders.find(item => item.dead);
const ghola = player.hand.find(card => card.effect === 'ghola'); assert.ok(leader && ghola);
game = act(game, faction, {type:'card', card:ghola.id, leader:leader.id});
assert.equal(nativeLeaderlessPlayer(game, faction).leaders.find(item => item.id === leader.id)!.dead, false);
assert.ok(!(viewGame(game, fixture.opponent).battle!.earlyLeaderless ?? []).includes(faction));
assert.equal(announcements(game, faction).length, 1);
game = act(game, faction, {...noLeader, leader:leader.id});
game = act(game, fixture.opponent, opponentPlan(game, fixture.opponent, fixture.territory));
assert.equal(game.battle!.revealed, true);
});
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { basicNativeLeaderlessOccupy, basicOccupyNoRicheseLeaderless, finishOccupyLeaderlessPreparation } from './fixture-basic-native-leaderless-occupy';
for (const lead of ['ecaz', 'ally'] as const) for (const dead of ['selected', 'opponent'] as const)
void test(`Basic Ecaz Occupy ${lead} lead opens public dead-leader preparation for ${dead} after lead settles`, () => {
const f = basicNativeLeaderlessOccupy({ lead, dead });
const b = f.game.battle!;
assert.equal(b.ecazOccupy?.lead, f.selected);
assert.ok([b.attacker, b.defender].includes(f.selected));
assert.ok(![b.attacker, b.defender].includes(f.unselected));
assert.equal(b.basicEarlyLeaderlessVersion, 1);
assert.equal(b.basicDeadLeaderPreparation, true);
assert.ok(b.preLeader && !b.preLeader.closed);
assert.ok(!b.preLeader.ready.includes(f.dead!));
assert.ok(b.preLeader.ready.every(id => [b.attacker, b.defender].includes(id)));
assert.deepEqual(b.plans, {});
const game = finishOccupyLeaderlessPreparation(f.game);
assert.ok(game.battle);
assert.deepEqual(game.battle.plans, {});
assert.ok(game.battle.basicEarlyLeaderless?.includes(f.dead!));
for (const viewer of game.players)
assert.ok(viewGame(game, viewer.id).battle!.earlyLeaderless?.includes(f.dead!));
});
for (const lead of ['ecaz', 'ally'] as const)
void test(`Basic Ecaz Occupy ${lead} lead does not open dead-leader preparation for a nonleading ally`, () => {
const f = basicNativeLeaderlessOccupy({ lead, dead: 'unselected' });
assert.ok(f.game.battle);
assert.equal(f.game.battle.basicDeadLeaderPreparation, undefined);
assert.ok(f.game.battle.preLeader && !f.game.battle.preLeader.closed,
'The original Richese shared preparation is retained');
const game = finishOccupyLeaderlessPreparation(f.game);
assert.ok(game.battle?.basicEarlyLeaderless?.includes(f.selected));
assert.ok(!game.battle?.basicEarlyLeaderless?.includes(f.unselected));
});
void test('An available shared Duke prevents the Ecaz lead from announcing no leader', () => {
const f = basicNativeLeaderlessOccupy({ lead: 'ecaz', dead: 'none', duke: true });
const game = finishOccupyLeaderlessPreparation(f.game);
assert.equal(game.dukeVidal?.controller, f.fixture.ecaz);
assert.ok(!game.battle?.basicEarlyLeaderless?.includes(f.fixture.ecaz));
assert.ok(game.battle?.basicEarlyLeaderless?.includes(f.fixture.opponent));
});
void test('An available Duke still prevents a notice when every Ecaz native leader is dead', () => {
const f = basicNativeLeaderlessOccupy({ lead: 'ecaz', dead: 'selected', duke: true });
assert.ok(f.game.battle?.preLeader && !f.game.battle.preLeader.closed,
'The original Richese shared preparation remains available');
const game = finishOccupyLeaderlessPreparation(f.game);
assert.ok(!game.battle?.basicEarlyLeaderless?.includes(f.fixture.ecaz));
assert.ok(game.battle?.basicEarlyLeaderless?.includes(f.fixture.opponent));
});
void test('Canceled Ecaz Occupy retains the selected lead and its pre-leader opportunity', () => {
const f = basicNativeLeaderlessOccupy({ lead: 'ally', dead: 'selected', cancel: true });
assert.equal(f.game.battle?.ecazOccupy?.canceled, true);
assert.equal(f.game.battle?.ecazOccupy?.lead, f.selected);
assert.equal(f.game.battle?.basicDeadLeaderPreparation, true);
const game = finishOccupyLeaderlessPreparation(f.game);
assert.ok(game.battle?.basicEarlyLeaderless?.includes(f.selected));
});
void test('Audit-entry Basic Occupy without Richese creates neutral preparation for the final dead-leader combatant', () => {
const f = basicOccupyNoRicheseLeaderless();
const b = f.game.battle!;
assert.equal(b.saphoTimingVersion, undefined);
assert.equal(b.ecazOccupy?.lead, f.fixture.ecaz);
assert.equal(b.basicDeadLeaderPreparation, true);
assert.ok(b.preLeader && !b.preLeader.closed);
assert.ok(!b.preLeader.ready.includes(f.fixture.ecaz));
assert.ok(b.preLeader.ready.includes(f.fixture.opponent));
const game = finishOccupyLeaderlessPreparation(f.game);
assert.ok(game.battle?.basicEarlyLeaderless?.includes(f.fixture.ecaz));
});
void test('Audit-entry Occupy with an available Duke does not add an unnecessary dead-leader preparation', () => {
const f = basicOccupyNoRicheseLeaderless(true);
assert.equal(f.game.battle?.ecazOccupy?.lead, f.fixture.ecaz);
assert.equal(f.game.battle?.basicDeadLeaderPreparation, undefined);
assert.equal(f.game.battle?.preLeader, undefined);
assert.ok(!f.game.battle?.basicEarlyLeaderless?.includes(f.fixture.ecaz));
});
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { advanceNativeLeaderless, basicNativeLeaderlessFixture, nativeLeaderlessClean,
nativeLeaderlessPlayer as player } from './fixture-basic-native-early-leaderless';
import { advanceBasicSemutaAmbassador, basicSemutaAmbassadorInventory } from './fixture-basic-semuta-ambassador';
const announce = (game: Game, id: string) => game.log.filter(entry =>
entry.text === `${player(game, id).name} has no available leader or Cheap Hero for this battle.`);
/** Public setup, real movement and battle choice come from the native fixture.
* The tests stage only prior leader use/death and conserved physical cards. */
void test('Basic Richese keeps its shared preparation when a battle leader is dead', () => {
const fixture = basicNativeLeaderlessFixture('richese', {dead: true, ghola: true});
let game = advanceNativeLeaderless(fixture.game, state => !!state.battle && nativeLeaderlessClean(state) &&
!state.battle.preparation && !!state.battle.preLeader && !state.battle.preLeader.closed);
const battle = game.battle!;
assert.equal(battle.basicEarlyLeaderlessVersion, 1);
assert.equal(battle.basicDeadLeaderPreparation, true);
assert.deepEqual(battle.preLeader!.ready, [], 'Richese Sapho preparation keeps both combatants involved');
assert.equal(announce(game, 'richese').length, 0);
const event = battle.preLeader!.event;
game = applyAction(game, 'richese', {type:'battlePreparationReady', event});
assert.equal(game.battle!.preLeader!.closed, false);
assert.equal(announce(game, 'richese').length, 0);
game = applyAction(game, 'guild', {type:'battlePreparationReady', event});
assert.equal(game.battle!.preLeader!.closed, true);
assert.equal(announce(game, 'richese').length, 1);
const richese = player(game, 'richese'), ghola = richese.hand.find(card => card.effect === 'ghola');
const leader = richese.leaders.find(item => item.dead); assert.ok(ghola && leader);
game = applyAction(game, 'richese', {type:'card', card:ghola.id, leader:leader.id});
game = advanceBasicSemutaAmbassador(game, state => !!state.battle && nativeLeaderlessClean(state));
assert.equal(player(game, 'richese').leaders.find(item => item.id === leader.id)!.dead, false);
assert.ok(!(viewGame(game, 'guild').battle!.earlyLeaderless ?? []).includes('richese'));
});
void test('Basic Distrans receipt of a Cheap Hero corrects an unsealed early announcement', () => {
const fixture = basicNativeLeaderlessFixture('richese');
let game = structuredClone(fixture.beforeBattle);
const recipient = player(game, 'guild'), donor = player(game, 'emperor');
for (const leader of recipient.leaders) {
assert.ok(!leader.dead && !leader.capturedBy);
leader.usedAt = fixture.territory === 'carthag' ? 'arrakeen' : 'carthag';
}
// Keep the recipient below its hand limit with no existing Hero, then move
// the original Distrans and Hero into the donor's hand without creating a card.
for (let i = recipient.hand.length - 1; i >= 0; i--)
if (recipient.hand[i].kind === 'hero' || recipient.hand.length >= 4)
game.deck.push(...recipient.hand.splice(i, 1));
while (donor.hand.length > 2) game.deck.push(donor.hand.pop()!);
const distransIndex = game.richeseCache!.findIndex(card => card.effect === 'distrans');
assert.ok(distransIndex >= 0);
const distrans = game.richeseCache!.splice(distransIndex, 1)[0];
const heroIndex = game.deck.findIndex(card => card.kind === 'hero'); assert.ok(heroIndex >= 0);
const hero = game.deck.splice(heroIndex, 1)[0];
donor.hand.push(distrans, hero);
const inventory = basicSemutaAmbassadorInventory(game);
game = applyAction(game, fixture.chooser, fixture.choose);
game = advanceNativeLeaderless(game, state => !!state.battle && nativeLeaderlessClean(state) &&
!state.battle.preparation && (!state.battle.preLeader || state.battle.preLeader.closed));
assert.ok(viewGame(game, 'richese').battle!.earlyLeaderless?.includes('guild'));
assert.equal(announce(game, 'guild').length, 1);
assert.equal(viewGame(game, 'emperor').distrans?.blocked, null);
game = applyAction(game, 'emperor', {type:'card', card:distrans.id, target:'guild', give:hero.id});
game = advanceBasicSemutaAmbassador(game, state => !!state.battle && nativeLeaderlessClean(state));
assert.ok(player(game, 'guild').hand.some(card => card.id === hero.id));
assert.ok(!(viewGame(game, 'richese').battle!.earlyLeaderless ?? []).includes('guild'));
assert.equal(game.log.filter(entry => entry.text ===
`${player(game, 'guild').name} now has an available Cheap Hero for this battle.`).length, 1);
assert.deepEqual(basicSemutaAmbassadorInventory(game), inventory);
game = applyAction(game, 'guild', {type:'battlePlan', leader:hero.id, dial:0, support:0, weapon:null, defense:null});
assert.equal(game.battle!.plans.guild.leader, hero.id);
});
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { actBasicSemutaAmbassador as act } from './fixture-basic-semuta-ambassador';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export type NativeLeaderlessFaction = Extract<FactionId, 'ixians' | 'tleilaxu' | 'choam' | 'richese' | 'ecaz' | 'moritani'>;
export const nativeLeaderlessPlayer = (game: Game, id: string) => {
const player = game.players.find(candidate => candidate.id === id); assert.ok(player); return player;
};
export const nativeLeaderlessClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision &&
!game.truthtrance && !game.pendingTreacheryDiscard;
export function advanceNativeLeaderless(game: Game, until: (state: Game) => boolean): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextRicheseStrongholdsNativeStep(game);
assert.ok(step, `No native continuation at ${game.status}/${game.phase}/${game.decision?.kind}`);
game = act(game, step.actor, step.action);
}
throw Error('The native public battle did not reach the requested boundary');
}
/** Fresh public selected-deck setup, original phase progression and paid
* shipments create the battle. Only private leader-use/death and conserved
* hand/deck history are staged before the genuine chooseBattle action. */
export function basicNativeLeaderlessFixture(faction: NativeLeaderlessFaction,
options: { dead?: boolean; hero?: boolean; ghola?: boolean } = {}) {
const expansions = faction === 'ixians' || faction === 'tleilaxu' ? ['ix'] :
faction === 'choam' || faction === 'richese' ? ['choam'] : ['ecaz'];
let game = withClassicDiscoveryNexusLottery(() => createGame(`NATIVELEADERLESS-${faction}`,
newPlayer(faction, faction, faction), false, expansions));
for (const other of ['guild', 'emperor'] as const) joinGame(game, newPlayer(other, other, other));
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
game = act(game, faction, {type:'start'});
const setup = structuredClone(game);
game = advanceNativeLeaderless(game, state => state.status === 'playing' && state.phase === 5 && nativeLeaderlessClean(state));
const selected = ['pasty_mesa', 'false_wall_west', 'shield_wall'].map(id => {
const sectors: Record<string, number[]> = { pasty_mesa:[5,6,7,8], false_wall_west:[16,17,18], shield_wall:[8,9] };
return {territory:id, sector:sectors[id].find(value => value !== game.storm)};
}).find(row => row.sector !== undefined && game.players.every(player =>
Object.keys(player.forces).every(key => !key.startsWith(`${row.territory}:`))));
assert.ok(selected && selected.sector !== undefined);
const territory = selected.territory, sector = selected.sector;
while (game.phase === 5) {
if (!nativeLeaderlessClean(game)) {
game = advanceNativeLeaderless(game, state => state.phase !== 5 || nativeLeaderlessClean(state));
continue;
}
const actor = game.active!;
if (actor === faction || actor === 'guild') {
game = act(game, actor, {type:'ship', territory, sector, amount:1, elite:0, allyPayment:0});
game = advanceNativeLeaderless(game, state => state.phase !== 5 || state.active === actor && nativeLeaderlessClean(state));
}
if (game.phase === 5) game = act(game, actor, {type:'endMovement'});
}
game = advanceNativeLeaderless(game, state => state.phase === 6 && nativeLeaderlessClean(state) && !state.battle && !!state.active);
const player = nativeLeaderlessPlayer(game, faction);
for (const leader of player.leaders) {
assert.ok(!leader.dead && !leader.capturedBy);
leader.usedAt = territory === 'carthag' ? 'arrakeen' : 'carthag';
}
if (options.dead) {
player.leaders[0].dead = true; player.leaders[0].deaths = Math.max(1, player.leaders[0].deaths ?? 0);
delete player.leaders[0].usedAt;
}
// Exchange exact original physical cards with the remaining deck; never
// create an extra card or increase the player's hand size.
const swap = (handIndex: number, predicate: (card: Game['deck'][number]) => boolean) => {
const index = game.deck.findIndex(predicate); assert.ok(index >= 0);
[player.hand[handIndex], game.deck[index]] = [game.deck[index], player.hand[handIndex]];
};
for (let i = 0; i < player.hand.length; i++) if (player.hand[i].kind === 'hero')
swap(i, card => card.kind !== 'hero' && (!options.ghola || card.effect !== 'ghola'));
if (options.hero || options.ghola) {
assert.ok(player.hand.length > 0);
swap(0, card => options.hero ? card.kind === 'hero' : card.effect === 'ghola');
}
const chooser = game.active!;
const choice = viewGame(game, chooser).battleChoices.find(row => row.territory === territory &&
[row.attacker, row.defender].includes(faction) && [row.attacker, row.defender].includes('guild'));
assert.ok(choice);
const choose: Action = {type:'chooseBattle', territory, target:choice.attacker === chooser ? choice.defender : choice.attacker};
const beforeBattle = structuredClone(game);
game = act(game, chooser, choose);
return {game, setup, beforeBattle, faction, opponent:'guild', territory, sector, chooser, choose};
}
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import { acquireDuke } from '../game/duke-vidal';
import { basicEcazNativeFixture, nextBasicEcazNativeStep } from './fixture-basic-ecaz-native';
import { basicEcazOccupyBattleAction, basicEcazOccupyFixture } from './fixture-basic-ecaz-occupy';
import { leaderlessSeat, stageLeaderlessCard } from './fixture-basic-leaderless-harkonnen';
export type OccupyLeaderlessOptions = {
lead: 'ecaz' | 'ally';
dead: 'selected' | 'opponent' | 'unselected' | 'none';
duke?: boolean;
cancel?: boolean;
};
/** Public Basic selected-deck setup and original alliance/coalition admission.
* The shared fixture stages conserved board pieces and wallets. This fixture
* stages native public leader history and conserves the physical hands. */
export function basicNativeLeaderlessOccupy(options: OccupyLeaderlessOptions) {
const fixture = basicEcazNativeFixture({ allyFaction: 'choam', opponentFaction: 'guild' });
let game = structuredClone(fixture.beforeBattle);
const selected = options.lead === 'ecaz' ? fixture.ecaz : fixture.ally;
const unselected = options.lead === 'ecaz' ? fixture.ally : fixture.ecaz;
const dead = options.dead === 'selected' ? selected : options.dead === 'opponent' ? fixture.opponent :
options.dead === 'unselected' ? unselected : null;
for (const actor of [fixture.ecaz, fixture.ally, fixture.opponent]) {
stageLeaderlessCard(game, actor, 'none');
for (const leader of leaderlessSeat(game, actor).leaders) {
leader.dead = actor === dead;
leader.deaths = actor === dead ? Math.max(1, leader.deaths) : leader.deaths;
if (actor !== dead) leader.usedAt = 'arrakeen';
else delete leader.usedAt;
}
}
if (options.duke) {
assert.ok(game.dukeVidal);
game.dukeVidal = acquireDuke(game.dukeVidal, fixture.ecaz, game.turn, 'ecaz');
}
const choice = viewGame(game, game.active!).battleChoices.find(row => row.territory === fixture.territory);
assert.ok(choice);
game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: fixture.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
assert.equal(game.decision?.kind, 'ecazBattleLead');
const event = game.decision.event;
game = applyAction(game, game.decision.player, { type: 'decision', event, lead: selected });
if (options.cancel) {
const card = leaderlessSeat(game, fixture.opponent).hand.find(c => c.effect === 'karama');
assert.ok(card);
game = applyAction(game, fixture.opponent, { type: 'card', mode: 'cancel', card: card.id });
}
for (let attempts = 0; attempts < 100 && (game.pendingTreacheryDiscard || game.response || game.phaseOpening || game.decision); attempts++) {
const next = nextBasicEcazNativeStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
assert.ok(game.battle && !game.pendingTreacheryDiscard && !game.response && !game.phaseOpening && !game.decision);
return { fixture, game, selected, unselected, dead, event };
}
/** Resolve existing Richese/shared preparation without submitting battle plans. */
export function finishOccupyLeaderlessPreparation(state: Game): Game {
let game = state;
for (let attempts = 0; attempts < 100; attempts++) {
if (game.battle && !game.battle.preparation && (!game.battle.preLeader || game.battle.preLeader.closed) &&
!game.pendingTreacheryDiscard && !game.response && !game.phaseOpening && !game.decision) return game;
const next = nextBasicEcazNativeStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
throw new Error('Native Occupy leader preparation did not finish');
}
/** Supplemental audit-entry fixture without Richese: original setup, worm
* alliance and shipments, plus labelled conserved rival and leader staging. */
export function basicOccupyNoRicheseLeaderless(duke = false) {
const fixture = basicEcazOccupyFixture({ chooseBattle: false });
let game = structuredClone(fixture.game);
assert.ok(!game.players.some(player => player.faction === 'richese'));
stageLeaderlessCard(game, fixture.ecaz, 'none');
for (const leader of leaderlessSeat(game, fixture.ecaz).leaders) {
leader.dead = true;
leader.deaths = Math.max(1, leader.deaths);
delete leader.usedAt;
}
if (duke) {
assert.ok(game.dukeVidal);
game.dukeVidal = acquireDuke(game.dukeVidal, fixture.ecaz, game.turn, 'ecaz');
}
const chosen = basicEcazOccupyBattleAction({ ...fixture, game });
game = applyAction(game, chosen.actor, chosen.action);
assert.equal(game.decision?.kind, 'ecazBattleLead');
game = applyAction(game, game.decision.player,
{ type: 'decision', event: game.decision.event, lead: fixture.ecaz });
for (let attempts = 0; attempts < 100 && (game.pendingTreacheryDiscard || game.response || game.decision); attempts++) {
const next = nextBasicEcazNativeStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
assert.ok(game.battle && !game.pendingTreacheryDiscard && !game.response && !game.decision);
return { fixture, game };
}