Integrate authorized Advanced Ecaz Occupy combined battles

This commit is contained in:
Quintine committed 2026-10-02 13:06:00 +10:00
1 parent bf4122029e
commit e112df858a
29 files changed
+1897 -123

No files matched your search

+2
View File
@@ -23,6 +23,7 @@ const optionalCount = (value: unknown) => value === undefined || count(value);
const nullableCount = (value: unknown) => value === null || count(value);
const decisions = {
ecazBattleLead: true,
harassWithdraw: true,
diplomatDefense: true,
diplomatRetreat: true,
@@ -108,6 +109,7 @@ const responses = {
moritaniPlacement: true,
ecazPlacement: true,
ecazCollection: true,
ecazOccupy: true,
moritaniAlliance: true,
moritaniDuke: true,
moritaniRetention: true,
+20 -2
View File
@@ -52,6 +52,7 @@ import {
type BattleLeaderSkill,
type LeaderSkillBattleBonus,
} from './leader-skill-combat';
import { quoteEcazOccupyOutcome, type EcazOccupyBattleProfile } from './ecaz-occupy-battle';
export class BattleResolutionQuoteError extends Error {
constructor(message: string) {
@@ -101,6 +102,7 @@ export type ResolutionCombatant = ResolutionParticipant & {
aid?: { donor: string; amount: number };
};
export type BattleResolutionInput = {
ecazOccupy?: EcazOccupyBattleProfile;
/** Stable physical slots remain unchanged when Sapho changes aggressor. */
aggressor?: string;
advanced: boolean;
@@ -137,6 +139,7 @@ export type BattleSupportPayment = {
freeByTraitor: boolean;
};
export type BattleResolutionQuote = {
fixedLosses?: { owner: string; normal: number; elite: number }[];
/** Reserve-to-Tanks costs apply to every revealed outcome, including traitors and explosions. */
reinforcements?: { player: string; card: typeof REINFORCEMENTS_CARD; normal: number; elite: number }[];
harassWithdraw?: (HarassWithdrawQuote & { player: string; card: typeof HARASS_WITHDRAW_CARD })[];
@@ -172,6 +175,7 @@ export type BattleResolutionQuote = {
rihani?: RihaniSkill;
sandmaster?: string;
casualties: {
owner?: string;
forces: CombatForces;
dial: number;
support: number;
@@ -672,7 +676,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
(deaths.defender ? strengths.defender : 0),
};
}
const destroyedArmies =
let destroyedArmies =
result === 'explosion'
? input.participants.filter((p) => p.id !== homeworld?.native).map((p) => p.id)
: winner
@@ -694,7 +698,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
!(winner === a ? deaths.attacker : deaths.defender)) : null;
const sandmaster = winner && usesSurvivingSkilledLeader(winner.leaderSkills ?? [], 'sandmaster', winner.leader?.id,
!(winner === a ? deaths.attacker : deaths.defender)) ? winner.leader!.id : null;
if (winner && result === 'normal') {
if (winner && result === 'normal' && winner.id !== input.ecazOccupy?.lead) {
if (
input.advanced ||
input.typedCasualties ||
@@ -731,6 +735,19 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
basicWinnerLosses = winner.plan.dial;
}
}
const occupyOutcome = input.ecazOccupy ? quoteEcazOccupyOutcome(input.ecazOccupy, {
result, won: winner?.id === input.ecazOccupy.lead,
dial: (a.id === input.ecazOccupy.lead ? a : d).plan.dial,
support: (a.id === input.ecazOccupy.lead ? a : d).plan.support,
}) : null;
if (occupyOutcome) {
const profile = input.ecazOccupy!;
destroyedArmies = [...new Set([
...destroyedArmies.filter(owner => owner !== profile.ecaz && owner !== profile.ally),
...occupyOutcome.destroyedArmies,
])];
if (winner?.id === profile.lead) casualties = occupyOutcome.casualties;
}
const strongholdIncome =
result === 'mutualTraitors'
? []
@@ -872,6 +889,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
return {
...(harassWithdraw.length ? { harassWithdraw } : {}),
...(reinforcements.length ? { reinforcements } : {}),
...(occupyOutcome ? { fixedLosses: occupyOutcome.fixedLosses } : {}),
result,
winner: winner?.id ?? null,
attackerTraitor: ac,
+35 -27
View File
@@ -98,10 +98,33 @@ export function settledBoard(g: BoardContext) {
});
return { players, released };
}
export type BattleBoardBattle = {
territory: string;
attacker: string;
defender: string;
/** Actual co-side members, retained when one seat represents the joint army. */
coalition?: readonly string[];
};
export function quoteBattleBoard(g: BoardContext) {
const { players, released } = settledBoard(g);
const battles: { territory: string; attacker: string; defender: string }[] =
[];
const battles: BattleBoardBattle[] = [];
const connected = (a: BoardSeat, b: BoardSeat, site: string) => {
const left = Object.entries(presenceByLocation(a))
.filter(([key, n]) => n > 0 && splitLocation(key).territory === site);
const right = Object.entries(presenceByLocation(b))
.filter(([key, n]) => n > 0 && splitLocation(key).territory === site);
const clear = (x: string, y: string) => gameDistance(g, x, y,
key => splitLocation(key === MOBILE_LOCATION ? (g.mobileStronghold?.location ?? key) : key).sector === g.storm) === 0;
return g.advanced
? left.length > 0 && right.length > 0 && left.every(([x]) => right.every(([y]) => clear(x, y)))
: left.some(([x]) => right.some(([y]) => clear(x, y)));
};
const members = (seat: BoardSeat, site: string) => {
const ally = players.find(other => other.id === seat.ally && other.ally === seat.id);
return g.advanced && ally && (seat.faction === 'ecaz' || ally.faction === 'ecaz') &&
fighterCount(ally, site) > 0 ? [seat, ally] : [seat];
};
const emitted = new Set<string>();
for (const id of g.order) {
const p = players.find((p) => p.id === id)!;
for (const t of gameTerritories(g)) {
@@ -114,31 +137,16 @@ export function quoteBattleBoard(g: BoardContext) {
g.order.indexOf(other.id) < g.order.indexOf(id)
)
continue;
const keys = (s: BoardSeat) =>
Object.entries(presenceByLocation(s))
.filter(
([key, n]) => n > 0 && splitLocation(key).territory === t.id,
)
.map(([key]) => key);
if (
keys(p).some((x) =>
keys(other).some(
(y) =>
gameDistance(
g,
x,
y,
(key) =>
splitLocation(
key === MOBILE_LOCATION
? (g.mobileStronghold?.location ?? key)
: key,
).sector === g.storm,
) === 0,
),
)
)
battles.push({ territory: t.id, attacker: id, defender: other.id });
const left = members(p, t.id), right = members(other, t.id);
if (!left.every(a => right.every(b => connected(a, b, t.id)))) continue;
const sides = [left, right].map(side => side.sort((a, b) => g.order.indexOf(a.id) - g.order.indexOf(b.id)))
.sort((a, b) => g.order.indexOf(a[0].id) - g.order.indexOf(b[0].id));
const key = JSON.stringify([t.id, sides.map(side => side.map(seat => seat.id).sort())]);
if (emitted.has(key)) continue;
emitted.add(key);
const coalition = sides.find(side => side.length > 1);
battles.push({ territory: t.id, attacker: sides[0][0].id, defender: sides[1][0].id,
...(coalition ? { coalition: coalition.map(seat => seat.id) } : {}) });
}
}
}
+14 -4
View File
@@ -1,4 +1,6 @@
import { discoveryBotActions } from './discovery-options';
import { ecazOccupyDialChoices, ecazOccupyOwnProfile, ecazOccupyPolicyActions } from './ecaz-occupy-options';
import { quoteEcazOccupyDial } from './ecaz-occupy-battle';
import { recruitsPlayAction } from './recruits';
import type { Card } from './cards';
import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, usesSurvivingSkilledLeader } from './leader-skill-combat';
@@ -616,10 +618,12 @@ function plans(g: GameView): Action[] {
!g.advanced || (me.faction === 'fremen' && !b.fremenSupportBlocked),
};
const own = forces.normal + forces.elite;
const occupyProfile = ecazOccupyOwnProfile(g);
const occupyDials = ecazOccupyDialChoices(g);
const typedForces = g.advanced || me.faction === 'ixians';
const ownStrength = typedForces
const ownStrength = occupyProfile?.maxDial ?? (typedForces
? maxCombatDial(forces)
: own;
: own);
const supportCache = new Map<
number,
{ support: number; cost: number } | null
@@ -639,7 +643,10 @@ function plans(g: GameView): Action[] {
: 0) + 1,
},
(_, support) =>
casualtyOptions(forces, dial, support).map((loss) => ({
(occupyProfile
? occupyDials.some((option) => option.dial === dial && option.support === support)
? quoteEcazOccupyDial(occupyProfile, dial, support).options : []
: casualtyOptions(forces, dial, support)).map((loss) => ({
support,
cost:
Math.max(
@@ -889,6 +896,7 @@ function plans(g: GameView): Action[] {
ideal,
...bankSupportedDials,
...freeEliteDials,
...occupyDials.map((option) => option.dial),
...[1, 2, 3].map((bonus) => Math.max(0, ideal - bonus)),
0,
...(typedForces ? [0.5] : []),
@@ -1065,7 +1073,7 @@ function plans(g: GameView): Action[] {
if (b.compliantPlan && commitments.every(
(element) => fixed(element.field, b.compliantPlan![element.field]),
)) ranked.push({ type: 'battlePlan', ...b.compliantPlan });
return ranked;
return ecazOccupyPolicyActions(g, ranked);
}
// This policy receives a personalized view, never the authoritative decks or rival hands.
@@ -1459,6 +1467,7 @@ function policyActions(g: GameView): Action[] {
(g.response.kind === 'nexusPrescience' &&
g.battle!.nexusInspection?.target === me.id) ||
g.response.kind === 'harkonnenTraitor' ||
g.response.kind === 'ecazOccupy' ||
g.response.kind === 'eliteStrength' ||
g.response.kind === 'fremenSupport' ||
g.response.kind === 'nexusSardaukar' ||
@@ -1545,6 +1554,7 @@ function policyActions(g: GameView): Action[] {
{ type: 'decision', event: d.event, pay, ...(pay ? { count } : {}) },
];
}
if (d.kind === 'ecazBattleLead') return ecazOccupyPolicyActions(g);
if (d.kind === 'strongholdCopy') {
const score = (id: string) =>
id === 'arrakeen'
+28 -11
View File
@@ -1,5 +1,6 @@
import { FACTIONS, type FactionId } from './catalog';
import { VOICE_KINDS } from './battle-cards';
import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from './ecaz-occupy-battle';
export type CombatCheckKind =
| 'kwisatz'
@@ -9,6 +10,7 @@ export type CombatCheckKind =
export type CombatResponseKind = CombatCheckKind | 'voice' | 'prescience';
export type CombatCheck = { kind: CombatCheckKind; owner: string };
export type CombatResponseInput = {
ecazOccupy?: EcazOccupyBattleProfile;
status: string;
phase: number;
advanced: boolean;
@@ -132,6 +134,20 @@ function calculate(
input.territoryIds.includes(b.territory),
'Combat responses need two distinct seated combatants and their territory.',
);
const occupy = input.ecazOccupy;
if (occupy) {
const ecaz = input.players.find(player => player.id === occupy.ecaz);
const ally = input.players.find(player => player.id === occupy.ally);
requireCombat(input.advanced && ecaz?.faction === 'ecaz' && ally && combatants.includes(occupy.lead),
'Occupy force powers need the actual Ecaz coalition and selected lead.');
const profile = quoteEcazOccupyBattle({
battleOrderActor: occupy.battleOrderActor,
ecaz: { id: ecaz.id, faction: 'ecaz', ally: ecaz.ally, forces: occupy.ecazForces },
ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: occupy.allyForces },
lead: occupy.lead, canceled: occupy.canceled,
});
requireCombat(profile.forceOwner === occupy.forceOwner, 'Occupy lost its actual physical force owner.');
}
requireCombat(
record(b.plans) &&
Object.keys(b.plans).every(
@@ -154,27 +170,28 @@ function calculate(
requireCombat(
b.eliteBlocked === undefined ||
(ids(b.eliteBlocked) &&
b.eliteBlocked.every((id) => combatants.includes(id))),
b.eliteBlocked.every((id) => combatants.some(seat => (seat === occupy?.lead ? occupy.forceOwner : seat) === id))),
'Blocked elite owners are malformed.',
);
// This is only the possible ordering of already declared checks. Current
// elite counts or KH availability are deliberately not benefit prerequisites.
const possible: CombatCheck[] = [];
for (const id of combatants) {
const p = input.players.find((p) => p.id === id)!,
other = input.players.find(
(p) => p.id === combatants.find((x) => x !== id),
)!;
const p = input.players.find(player => player.id === id)!;
const opponent = combatants.find(seat => seat !== id)!;
const other = input.players.find(player => player.id === (opponent === occupy?.lead ? occupy.forceOwner : opponent))!;
if (input.advanced && p.faction === 'atreides')
possible.push({ kind: 'kwisatz', owner: id });
const forceOwner = id === occupy?.lead ? occupy.forceOwner : id;
const forcePlayer = input.players.find(player => player.id === forceOwner)!;
if (
((input.advanced && ['emperor', 'fremen'].includes(p.faction)) ||
(!input.advanced && p.faction === 'ixians')) &&
!(p.faction === 'emperor' && other.faction === 'fremen')
((input.advanced && ['emperor', 'fremen'].includes(forcePlayer.faction)) ||
(!input.advanced && forcePlayer.faction === 'ixians')) &&
!(forcePlayer.faction === 'emperor' && other.faction === 'fremen')
)
possible.push({ kind: 'eliteStrength', owner: id });
if (input.advanced && p.faction === 'fremen')
possible.push({ kind: 'fremenSupport', owner: id });
possible.push({ kind: 'eliteStrength', owner: forceOwner });
if (input.advanced && forcePlayer.faction === 'fremen')
possible.push({ kind: 'fremenSupport', owner: forceOwner });
}
const choam = input.players.find((p) => p.faction === 'choam');
if (
+243
View File
@@ -0,0 +1,243 @@
import type { FactionId } from './catalog';
import {
casualtyOptions,
maxCombatDial,
maxCombatSupport,
validCombatForces,
type Casualties,
type CombatForces,
} from './combat';
export class EcazOccupyBattleError extends Error {
constructor(message: string) {
super(message);
this.name = 'EcazOccupyBattleError';
}
}
/** Native fighter pools for an eligible whole-territory battle. The canonical
* board quote excludes storm-obstructed armies and advisors before planning. */
export type EcazOccupyBattleSeat = Readonly<{
id: string;
faction: FactionId;
ally?: string | null;
forces: CombatForces;
}>;
export type EcazOccupyBattleInput = Readonly<{
/** Original battle chooser, before Ecaz selects the coalition's plan actor. */
battleOrderActor: string;
ecaz: EcazOccupyBattleSeat;
ally: EcazOccupyBattleSeat;
lead: string;
canceled: boolean;
}>;
export type EcazOccupyBattleProfile = Readonly<{
battleOrderActor: string;
ecaz: string;
ally: string;
lead: string;
planOwner: string;
payer: string;
forceOwner: string;
canceled: boolean;
ecazForces: CombatForces;
allyForces: CombatForces;
/** Only the variable dial's physical counters, never a merged army. */
forces: CombatForces;
fixedEcazDial: number;
maxDial: number;
maxSupport: number;
}>;
export type EcazOccupyDialQuote = {
forceOwner: string;
payer: string;
fixedEcazDial: number;
variableDial: number;
support: number;
options: Casualties[];
};
export type EcazOccupyDialOption = {
dial: number;
support: number;
variableDial: number;
};
export type EcazOccupyOutcomeInput = Readonly<{
result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion';
/** Whether this coalition, not necessarily its original slot actor, won. */
won: boolean;
dial: number;
support: number;
}>;
export type EcazOccupyOutcomeQuote = {
destroyedArmies: string[];
fixedLosses: { owner: string; normal: number; elite: number }[];
casualties: {
owner: string;
forces: CombatForces;
dial: number;
support: number;
options: Casualties[];
} | null;
};
function requireBattle(condition: unknown, message: string): asserts condition {
if (!condition) throw new EcazOccupyBattleError(message);
}
const text = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;
/** Authorized Advanced source: ceil(E/2) contributes at full strength and
* costs no support. The selected lead supplies the plan and pays for the
* actual ally's dial; selecting Ecaz does not transfer the ally's counters. */
export function quoteEcazOccupyBattle(
input: EcazOccupyBattleInput,
): EcazOccupyBattleProfile {
const { ecaz, ally, lead, canceled, battleOrderActor } = input;
requireBattle(
ecaz && ally && text(ecaz.id) && text(ally.id) && ecaz.id !== ally.id &&
ecaz.faction === 'ecaz' && ally.faction !== 'ecaz' &&
ecaz.ally === ally.id && ally.ally === ecaz.id,
'An Occupy battle needs Ecaz and its distinct reciprocal ally.',
);
requireBattle(
validCombatForces(ecaz.forces) && validCombatForces(ally.forces),
'An Occupy battle needs valid native physical fighter pools.',
);
requireBattle(
ecaz.forces.elite === 0 && !ecaz.forces.temporaryElite &&
!ecaz.forces.normalFixedHalf && ecaz.forces.normal > 0 &&
ally.forces.normal + ally.forces.elite > 0,
'Both Occupy members need storm-connected fighters; Ecaz uses ordinary counters, not advisors or an elite pool.',
);
requireBattle(
lead === ecaz.id || lead === ally.id,
'The Occupy lead must be Ecaz or its participating ally.',
);
requireBattle(
text(battleOrderActor),
'The original Occupy battle-order actor must be identified.',
);
requireBattle(
typeof canceled === 'boolean',
'The Occupy profile needs its pre-plan cancellation state.',
);
const forceOwner = canceled ? lead : ally.id;
const forces = forceOwner === ecaz.id ? ecaz.forces : ally.forces;
const fixedEcazDial = canceled ? 0 : Math.ceil(ecaz.forces.normal / 2);
return {
battleOrderActor,
ecaz: ecaz.id,
ally: ally.id,
lead,
planOwner: lead,
payer: lead,
forceOwner,
canceled,
ecazForces: ecaz.forces,
allyForces: ally.forces,
forces,
fixedEcazDial,
maxDial: fixedEcazDial + maxCombatDial(forces),
maxSupport: maxCombatSupport(forces),
};
}
/** The public dial includes Ecaz's mandatory increment. Support and typed
* casualties belong only to the native variable pool; fixed strength neither
* spends spice nor creates a CHOAM support payment. Funding is checked by the
* caller against the selected payer's existing native funding permissions. */
export function quoteEcazOccupyDial(
profile: EcazOccupyBattleProfile,
dial: number,
support: number,
): EcazOccupyDialQuote {
requireBattle(
Number.isFinite(dial) && Number.isSafeInteger(dial * 2) &&
dial >= profile.fixedEcazDial && dial <= profile.maxDial,
`The Occupy dial must include ${profile.fixedEcazDial} fixed Ecaz strength and fit the ${profile.forceOwner} fighter pool.`,
);
requireBattle(
Number.isSafeInteger(support) && support >= 0 && support <= profile.maxSupport,
`The Occupy support must fit the native ${profile.forceOwner} support rules; the fixed Ecaz contribution is free.`,
);
const variableDial = dial - profile.fixedEcazDial;
const options = casualtyOptions(profile.forces, variableDial, support);
requireBattle(
options.length > 0,
`The Occupy dial and support do not match a legal physical commitment by ${profile.forceOwner}.`,
);
return {
forceOwner: profile.forceOwner,
payer: profile.payer,
fixedEcazDial: profile.fixedEcazDial,
variableDial,
support,
options,
};
}
/** Legal physical commitments, limited by the selected lead's available
* support funding. Native payment permissions remain the caller's concern. */
export function ecazOccupyDialOptions(
profile: EcazOccupyBattleProfile,
availableSupport: number,
): EcazOccupyDialOption[] {
requireBattle(
Number.isSafeInteger(availableSupport) && availableSupport >= 0,
'Available Occupy support must be a nonnegative whole spice amount.',
);
const options: EcazOccupyDialOption[] = [];
const maximumSupport = Math.min(availableSupport, profile.maxSupport);
for (let halfDial = profile.fixedEcazDial * 2;
halfDial <= profile.maxDial * 2; halfDial++) {
const dial = halfDial / 2;
const variableDial = dial - profile.fixedEcazDial;
for (let support = 0; support <= maximumSupport; support++) {
if (casualtyOptions(profile.forces, variableDial, support).length > 0)
options.push({ dial, support, variableDial });
}
}
return options;
}
/** Force settlement only: leader deaths, cards, payments and bounty remain
* native battle outcomes. Karama's canceled winner keeps the other member's
* undialed army; canceled losing-side destruction follows the general rule. */
export function quoteEcazOccupyOutcome(
profile: EcazOccupyBattleProfile,
input: EcazOccupyOutcomeInput,
): EcazOccupyOutcomeQuote {
requireBattle(
['normal', 'traitor', 'mutualTraitors', 'explosion'].includes(input.result) &&
typeof input.won === 'boolean',
'The Occupy aftermath needs an actual native battle result and winning side.',
);
requireBattle(
!input.won || input.result === 'normal' || input.result === 'traitor',
'Mutual traitors and explosions have no Occupy winner.',
);
const dial = quoteEcazOccupyDial(profile, input.dial, input.support);
if (!input.won)
return {
destroyedArmies: [profile.ecaz, profile.ally],
fixedLosses: [],
casualties: null,
};
if (input.result === 'traitor')
return { destroyedArmies: [], fixedLosses: [], casualties: null };
return {
destroyedArmies: [],
fixedLosses: profile.canceled ? [] : [{
owner: profile.ecaz,
normal: profile.fixedEcazDial,
elite: 0,
}],
casualties: {
owner: profile.forceOwner,
forces: profile.forces,
dial: dial.variableDial,
support: dial.support,
options: dial.options,
},
};
}
+87
View File
@@ -0,0 +1,87 @@
import type { Action, GameView } from './engine';
import {
ecazOccupyDialOptions,
quoteEcazOccupyDial,
type EcazOccupyBattleProfile,
} from './ecaz-occupy-battle';
/** The selected lead plans and pays; the profile's force owner need not be that lead. */
export function ecazOccupyOwnProfile(game: GameView): EcazOccupyBattleProfile | null {
const battle = game.battle;
const occupy = battle?.ecazOccupy;
const profile = occupy?.profile;
return battle && occupy?.event === battle.event && profile?.planOwner === game.me
? profile : null;
}
export function ecazOccupyLeadOptions(game: GameView) {
const decision = game.decision;
const battle = game.battle;
if (decision?.kind !== 'ecazBattleLead' || !battle?.ecazOccupy ||
decision.event !== battle.event || decision.event !== battle.ecazOccupy.event) return [];
const blocked = game.status !== 'playing' || game.phase !== 6
? 'This battle is not awaiting a lead choice.'
: decision.player !== game.me ? 'Only Ecaz chooses the lead.' : null;
return decision.choices.map((lead) => ({
lead,
label: game.players.find((player) => player.id === lead)?.name ?? lead,
blocked,
action: { type: 'decision', event: decision.event, lead },
}));
}
function supportBudget(game: GameView, profile: EcazOccupyBattleProfile) {
const personalSpice = game.players.find((player) => player.id === game.me)?.spice ?? 0;
const bankSupport = game.battle?.strongholdEffects[profile.payer] === 'arrakeen' ? 2 : 0;
return {
personalSpice,
maximum: Math.min(profile.maxSupport, personalSpice + game.aid.available + bankSupport),
};
}
/** Only the lead's own projected funds enter affordability; never the other ally's spice. */
export function ecazOccupyDialChoices(game: GameView) {
const profile = ecazOccupyOwnProfile(game);
return profile ? ecazOccupyDialOptions(profile, supportBudget(game, profile).maximum) : [];
}
/** Numeric editor constraints and an explanation for an unsealable physical dial. */
export function ecazOccupyPlanControl(game: GameView, dial: number, support: number) {
const profile = ecazOccupyOwnProfile(game);
if (!profile) return null;
const budget = supportBudget(game, profile);
let blocked: string | null = null;
let variableDial: number | null = null;
try {
const quote = quoteEcazOccupyDial(profile, dial, support);
variableDial = quote.variableDial;
if (support > budget.maximum) blocked = 'The selected lead cannot afford this spice support.';
} catch (error) {
blocked = error instanceof Error ? error.message : 'Choose a legal total dial and spice support.';
}
return {
profile,
minimum: profile.fixedEcazDial,
maximum: profile.maxDial,
step: 0.5,
supportMaximum: budget.maximum,
personalSpice: budget.personalSpice,
fixedEcazDial: profile.fixedEcazDial,
variableDial,
blocked,
};
}
/**
* All four native policies share this legality adapter. Native candidate plans already
* use the selected lead's leaders/cards and TOTAL dials; no new strategy is introduced.
* It also serves prescience candidate generation before the ordinary plan editor opens.
*/
export function ecazOccupyPolicyActions(game: GameView, nativeCandidates: Action[] = []): Action[] {
if (game.decision?.kind === 'ecazBattleLead') {
return ecazOccupyLeadOptions(game).filter((option) => !option.blocked).map((option) => option.action);
}
if (!ecazOccupyOwnProfile(game)) return nativeCandidates;
return nativeCandidates.filter((action) => action.type !== 'battlePlan' ||
!ecazOccupyPlanControl(game, Number(action.dial), Number(action.support ?? 0))?.blocked);
}
+152 -38
View File
@@ -159,6 +159,7 @@ import {
} from './homeworld-native-reserves';
import { validAmbassadorResume } from './ambassador-resume';
import { ecazOccupancyRelation } from './ecaz-occupy';
import { quoteEcazOccupyBattle, quoteEcazOccupyDial, type EcazOccupyBattleProfile } from './ecaz-occupy-battle';
import { territoryEntryBlock, strongholdPathBlocked } from './occupancy';
import {
validateGuildAmbassadorArrivalContext,
@@ -664,6 +665,7 @@ export type HarassAllocationReceipt = {
stage: 'offered' | 'selected'; selection: HarassWithdrawSelection | null; signature: string;
};
export type Battle = {
ecazOccupy?: { event: string; ecaz: string; ally: string; lead: string | null; canceled: boolean };
/** New battles bind automatic collection to the original public plan reveal. */
smugglerCollectionVersion?: 1;
smugglerCollection?: SmugglerBattleReceipt | null;
@@ -757,6 +759,7 @@ export type Auction = {
allyPayment?: number;
};
export type Decision =
| { kind: 'ecazBattleLead'; player: string; event: string; choices: string[] }
| { kind: 'harassWithdraw'; player: string; event: string }
| { kind: 'diplomatDefense'; player: string; event: string; cards: string[]; source: string }
| { kind: 'diplomatRetreat'; player: string; event: string; destinations: DiplomatRetreatDestination[] }
@@ -868,6 +871,7 @@ export type Decision =
player: string;
territory: string;
options: Casualties[];
forceOwner?: string;
cards: string[];
}
| { kind: 'homeworldDefense'; player: string; event: string }
@@ -969,6 +973,7 @@ export type ResponseWindow = {
| 'moritaniPlacement'
| 'ecazPlacement'
| 'ecazCollection'
| 'ecazOccupy'
| 'moritaniAlliance'
| 'moritaniDuke'
| 'moritaniRetention'
@@ -1119,6 +1124,7 @@ type RicheseAllyOffer = {
payer: string;
};
export type Game = {
ecazOccupyPreview?: boolean;
/** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */
guildBetrayalPreview?: boolean;
guildBetrayal?: GuildBetrayalCursor;
@@ -1417,6 +1423,7 @@ export type Game = {
winner: string | null;
cards: string[];
casualties?: {
owner?: string;
forces: CombatForces;
dial: number;
support: number;
@@ -3015,6 +3022,7 @@ function nexusTraitorParentSignature(g: Game, event: string) {
function traitorDeclarationContext(g: Game): TraitorDeclarationContext {
const b = g.battle!;
return { event: b.event!, attacker: b.attacker, defender: b.defender, plans: b.plans,
...(b.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}),
players: g.players.map(({id,faction,ally,traitors}) => ({id,faction,ally,traitors})),
heroLeaderIds: physicalTreacheryCards(g).filter(c => c.kind === 'hero').map(c => c.id),
};
@@ -7280,6 +7288,31 @@ function combatForces(
(p.faction === 'fremen' && !g.battle?.fremenSupportBlocked),
};
}
function ecazBattleProfile(g: Game): EcazOccupyBattleProfile | null {
const b = g.battle, occupy = b?.ecazOccupy;
if (!b || !occupy?.lead) return null;
const lead = occupy.lead;
const ecaz = getPlayer(g, occupy.ecaz), ally = getPlayer(g, occupy.ally);
requireRule(g.advanced && occupy.event === b.event && ecaz.faction === 'ecaz' &&
ecaz.ally === ally.id && ally.ally === ecaz.id && [b.attacker, b.defender].includes(occupy.lead) &&
!isAdvisor(ecaz, b.territory) && !isAdvisor(ally, b.territory) &&
Object.entries(ecaz.forces).some(([x, n]) => n > 0 && splitLocation(x).territory === b.territory &&
Object.entries(ally.forces).some(([y, m]) => m > 0 && splitLocation(y).territory === b.territory &&
gameDistance(g, x, y, key => splitLocation(key).sector === g.storm) === 0)),
'Occupy needs the original battle and physically connected allied fighters.');
const opponent = getPlayer(g, b.attacker === occupy.lead ? b.defender : b.attacker);
return nexusRule(() => quoteEcazOccupyBattle({
battleOrderActor: b.chooser ?? b.attacker,
ecaz: { id: ecaz.id, faction: 'ecaz', ally: ecaz.ally, forces: combatForces(g, ecaz, b.territory, opponent) },
ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: combatForces(g, ally, b.territory, opponent) },
lead, canceled: occupy.canceled,
}));
}
function planCombatForces(g: Game, p: Player, opponent: Player): CombatForces {
const profile = ecazBattleProfile(g);
return profile?.lead === p.id ? profile.forces :
combatForces(g, p, g.battle!.territory, profile?.lead === opponent.id ? getPlayer(g, profile.forceOwner) : opponent);
}
function takeBattleLosses(g: Game, p: Player, t: string, losses: Casualties) {
if (t.startsWith('homeworld:')) {
commitHomeworldLoss(g, p.id, t, { normal: losses.normal, elite: losses.elite });
@@ -9080,6 +9113,18 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game {
requireFreshFactionInventory(state);
return initializeSetupGameForAudit(state, false, false, false, false, false, false, true);
}
/** Fresh native Advanced Occupy profile; public starts and Basic are unchanged. */
export function initializeEcazOccupyGameForAudit(state: Game): Game {
requireRule(state.advanced === true && state.expansions.length === 1 && state.expansions[0] === 'ecaz' &&
state.players.length >= 2 && state.players.length <= 6 && state.players.some(p => p.faction === 'ecaz') &&
state.players.every(p => faction(p.faction).expansion === 'base' || p.faction === 'ecaz' || p.faction === 'moritani') &&
!state.ecazTreachery,
'Advanced Occupy requires fresh Ecaz and classic opponents, optional native Moritani, the Ecaz faction expansion and base Treachery.');
requireFreshBaseRuntime(state);
const g = initializeFactionExpansionsGameForAudit(state);
g.ecazOccupyPreview = true;
return g;
}
/** Fresh Advanced E1/E2 faction composition; normal starts and other modules stay gated. */
export function initializeStrongholdFactionsGameForAudit(state: Game): Game {
requireRule(state.advanced === true && Array.isArray(state.expansions) &&
@@ -11891,7 +11936,7 @@ function currentReinforcementsCost(g: Game, p: Player) {
const other = getPlayer(g, g.battle.attacker === p.id ? g.battle.defender : g.battle.attacker);
requireRule(!ecazAllyAtBattle(g, p, g.battle.territory) &&
!ecazAllyAtBattle(g, other, g.battle.territory),
'Reinforcements with co-present Ecaz allies awaits combined-army battle integration.');
'Reinforcements with combined Occupy armies needs its additional-card source ruling.');
}
try {
return quoteReinforcements(p.reserves - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0);
@@ -11913,7 +11958,7 @@ function reinforcementsPreview(g: Game, p: Player) {
blocked = 'Choose Reinforcements before sealing your Battle Plan.';
else if (ecazAllyAtBattle(g, p, battle.territory) ||
ecazAllyAtBattle(g, getPlayer(g, battle.attacker === p.id ? battle.defender : battle.attacker), battle.territory))
blocked = 'Reinforcements with co-present Ecaz allies awaits combined-army battle integration.';
blocked = 'Reinforcements with combined Occupy armies needs its additional-card source ruling.';
else if (normal + elite < 3)
blocked = 'Reinforcements needs three physical forces in your reserves.';
return { blocked, normal, elite };
@@ -18034,7 +18079,7 @@ function harassWithdrawContext(g: Game, p: Player): HarassWithdrawContext {
else if (g.players.some(player => player.faction === 'richese') || g.richeseCache !== undefined || g.richeseRemoved !== undefined)
blocked = 'Harass & Withdraw with the Richese card family awaits the Stone Burner timing ruling.';
else if (ecazAllyAtBattle(g, p, b.territory) || ecazAllyAtBattle(g, other, b.territory))
blocked = 'Harass & Withdraw with co-present Ecaz allies awaits combined-army battle integration.';
blocked = 'Harass & Withdraw with combined Occupy armies needs its additional-card source ruling.';
else if (p.noField?.deployed?.location.territory === b.territory)
blocked = 'Reveal your No-Field before using Harass & Withdraw.';
const locations = Object.fromEntries(Object.entries(p.forces)
@@ -18410,7 +18455,9 @@ function validatePlan(
): Plan {
const b = g.battle!;
const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
const forces = combatForces(g, p, b.territory, opponent);
const forces = planCombatForces(g, p, opponent);
const profile = ecazBattleProfile(g);
const occupy = profile?.lead === p.id ? profile : null;
const typedForces = g.advanced || p.faction === 'ixians';
const bankerSpice = input.bankerSpice === undefined ? 0 : input.bankerSpice;
requireRule(typeof bankerSpice === 'number' && Number.isSafeInteger(bankerSpice) && bankerSpice >= 0 && bankerSpice <= 3,
@@ -18424,7 +18471,7 @@ function validatePlan(
requireRule(
!typedForces ||
(typeof input.dial === 'number' &&
casualtyOptions(forces, dial, support).length > 0),
(occupy ? nexusRule(() => quoteEcazOccupyDial(occupy, dial, support)).options : casualtyOptions(forces, dial, support)).length > 0),
'Dial and spice must match a legal force commitment.',
);
const allyPayment = integer(
@@ -18582,6 +18629,7 @@ function currentCombatResponseQuote(
status: g.status,
phase: g.phase,
advanced: g.advanced,
...(g.battle?.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}),
territoryIds: combatLocations(g).map((t) => t.id),
players: g.players.map((p) => ({
id: p.id,
@@ -18623,10 +18671,14 @@ function nextCombatResponse(g: Game) {
function combatResponses(g: Game) {
const b = g.battle!;
b.powerChecks = [];
const profile = ecazBattleProfile(g);
for (const id of [b.attacker, b.defender]) {
const p = getPlayer(g, id),
other = getPlayer(g, id === b.attacker ? b.defender : b.attacker);
if (g.advanced && p.faction === 'atreides')
const planOwner = getPlayer(g, id);
const forceOwner = profile?.lead === id ? profile.forceOwner : id;
const p = getPlayer(g, forceOwner);
const opponentId = id === b.attacker ? b.defender : b.attacker;
const other = getPlayer(g, profile?.lead === opponentId ? profile.forceOwner : opponentId);
if (g.advanced && planOwner.faction === 'atreides')
b.powerChecks.push({ kind: 'kwisatz', owner: id });
if (
((g.advanced && ['emperor', 'fremen'].includes(p.faction)) ||
@@ -18634,9 +18686,9 @@ function combatResponses(g: Game) {
!(p.faction === 'emperor' && other.faction === 'fremen') &&
combatForces(g, p, b.territory, other).elite > 0
)
b.powerChecks.push({ kind: 'eliteStrength', owner: id });
b.powerChecks.push({ kind: 'eliteStrength', owner: forceOwner });
if (g.advanced && p.faction === 'fremen')
b.powerChecks.push({ kind: 'fremenSupport', owner: id });
b.powerChecks.push({ kind: 'fremenSupport', owner: forceOwner });
}
const choam = byFaction(g, 'choam');
if (
@@ -18957,11 +19009,13 @@ function findLegalBattlePlan(
return accepts(sealed) ? sealed : null;
}
const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
const forces = combatForces(g, p, b.territory, opponent);
const forces = planCombatForces(g, p, opponent);
const profile = ecazBattleProfile(g);
const fixedDial = profile?.lead === p.id ? profile.fixedEcazDial : 0;
const typed = g.advanced || p.faction === 'ixians';
const maxDial = typed ? maxCombatDial(forces) : forces.normal + forces.elite;
const maxDial = fixedDial + (typed ? maxCombatDial(forces) : forces.normal + forces.elite);
const numerical: { dial: number; support: number }[] = [];
for (let dial = 0; dial <= maxDial; dial += typed ? 0.5 : 1)
for (let dial = fixedDial; dial <= maxDial; dial += typed ? 0.5 : 1)
for (
let support = 0;
support <=
@@ -18973,7 +19027,7 @@ function findLegalBattlePlan(
const n = { dial, support };
if (
accepts(n) &&
(!typed || casualtyOptions(forces, dial, support).length)
(!typed || casualtyOptions(forces, dial - fixedDial, support).length)
)
numerical.push(n);
}
@@ -19552,6 +19606,8 @@ function feasiblePrescience(
// Validate the revealed element without committing any other plan components.
// A legal completion must exist, including compliance with the preceding Voice.
const b = g.battle!;
const profile = ecazBattleProfile(g);
const forces = planCombatForces(g, p, getPlayer(g, b.attacker === p.id ? b.defender : b.attacker));
const leaderIds: (string | null)[] = [
null,
...controlledLeaders(g, p)
@@ -19577,7 +19633,7 @@ function feasiblePrescience(
for (const defense of field === 'defense' ? defenses.filter(candidate => inspectedPlanMatches(p, field, value, candidate)) : defenses) {
const maxSupport =
g.advanced && field === 'dial'
? Math.min(battleSupportBudget(g, p), (combatArmy(g, p.id, b.territory).normal + combatArmy(g, p.id, b.territory).elite))
? Math.min(battleSupportBudget(g, p), maxCombatSupport(forces))
: 0;
for (let support = 0; support <= maxSupport; support++) {
try {
@@ -19586,7 +19642,7 @@ function feasiblePrescience(
weapon,
defense,
support,
dial: field === 'dial' ? value : 0,
dial: field === 'dial' ? value : profile?.lead === p.id ? profile.fixedEcazDial : 0,
});
if (inspectedPlanMatches(p, field, value, plan[field])) return true;
} catch (error) {
@@ -20001,6 +20057,9 @@ function traitorVoters(g: Game, b: Battle) {
const home = homeworldBattleLocation(g, b.territory);
if (home) return [b.attacker, b.defender].filter((id) => id === home.native);
const voters = [b.attacker, b.defender];
if (b.ecazOccupy?.lead)
for (const owner of [b.ecazOccupy.ecaz, b.ecazOccupy.ally])
if (!voters.includes(owner)) voters.push(owner);
const harkonnen = byFaction(g, 'harkonnen');
if (
harkonnen?.ally &&
@@ -20011,6 +20070,7 @@ function traitorVoters(g: Game, b: Battle) {
return voters;
}
function traitorBeneficiary(g: Game, b: Battle, id: string) {
if (b.ecazOccupy?.lead && [b.ecazOccupy.ecaz, b.ecazOccupy.ally].includes(id)) return b.ecazOccupy.lead;
return [b.attacker, b.defender].includes(id) ? id : getPlayer(g, id).ally;
}
function pendingSmugglerBattle(g: Game): SmugglerBattleReceipt | null {
@@ -20064,7 +20124,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
leader: controlledLeaders(g, p).find((l) => l.id === plan.leader),
leaderSkills: battleLeaderSkills(g, p),
occupiedStrongholds: leaderSkillStrongholdCount(g, p),
forces: combatForces(g, p, b.territory, opponent),
forces: planCombatForces(g, p, opponent),
...([cardOf(p, plan.weapon), cardOf(p, plan.defense)].some(isReinforcements)
? { reinforcementsReserves: {
normal: p.reserves - (p.elites?.reserves ?? 0), elite: p.elites?.reserves ?? 0,
@@ -20086,6 +20146,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
try {
const quote = quoteBattleResolution({
advanced: g.advanced,
...(b.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}),
aggressor: battleAggressor(g),
typedCasualties: !!g.homeworlds,
...(homeworldBattleLocation(g, b.territory) ? { homeworld: currentHomeworldBattleRules(g, b.territory)! } : {}),
@@ -20142,11 +20203,13 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
for (const losses of quote.casualties!.options) quoteHomeworldLoss(g, quote.winner, b.territory, { normal: losses.normal, elite: losses.elite });
else if (quote.winner && quote.result === 'normal')
validateBattleForceLoss(
getPlayer(g, quote.winner),
getPlayer(g, quote.casualties?.owner ?? quote.winner),
b.territory,
quote.basicWinnerLosses ??
Math.max(...quote.casualties!.options.map((c) => c.normal + c.elite)),
);
for (const loss of quote.fixedLosses ?? [])
validateBattleForceLoss(getPlayer(g, loss.owner), b.territory, loss.normal + loss.elite);
if (quote.homeworldExplosion)
for (const losses of quote.homeworldExplosion.options) quoteHomeworldLoss(g, quote.homeworldExplosion.player, b.territory, losses);
return { ...quote, smuggler };
@@ -20370,16 +20433,14 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
if (ac && dc) {
dead(al);
dead(dl);
killTerritory(g, a, b.territory, Infinity, true);
killTerritory(g, d, b.territory, Infinity, true);
for (const owner of quote.destroyedArmies) killTerritory(g, getPlayer(g, owner), b.territory, Infinity, true);
observeOccupation(g);
log(g, 'Both leaders were traitors. Both armies were destroyed.');
} else if (ac || dc) {
const loser = ac ? d : a;
const l = ac ? dl : al;
dead(l);
winner!.spice += quote.bounty!.amount;
killTerritory(g, loser, b.territory, Infinity, true);
for (const owner of quote.destroyedArmies) killTerritory(g, getPlayer(g, owner), b.territory, Infinity, true);
observeOccupation(g);
log(
g,
@@ -20452,7 +20513,7 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
{ faction: loser.faction, name: 'Diplomat retreat' });
observeOccupation(g);
}
killTerritory(g, loser, b.territory, Infinity, true);
for (const owner of quote.destroyedArmies) killTerritory(g, getPlayer(g, owner), b.territory, Infinity, true);
observeOccupation(g);
if (quote.basicWinnerLosses !== null)
killTerritory(g, winner!, b.territory, quote.basicWinnerLosses, true);
@@ -20466,6 +20527,12 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
? { faction: (isStoneBurner(aw) ? a : d).faction, name: 'Stone Burner' }
: { faction: winner!.faction, name: 'Battle' },
);
for (const loss of quote.fixedLosses ?? []) {
const owner = getPlayer(g, loss.owner);
takeBattleLosses(g, owner, b.territory, { normal: loss.normal, elite: loss.elite, paidNormal: 0, paidElite: 0 });
log(g, `${owner.name} sent ${loss.normal + loss.elite} mandatory Occupy forces to the Tanks.`, { faction: 'ecaz', name: 'Occupy casualties' });
}
if (quote.fixedLosses?.length) observeOccupation(g);
}
if (g.spiceBankerIncomePreview) for (const payment of quote.payments) {
const player = getPlayer(g, payment.player);
@@ -20734,13 +20801,14 @@ function continueResolvedBattle(
return;
}
if (winner && losses?.length === 1) {
settleWinnerCasualties(g, winner, to, cards, losses[0], true);
settleWinnerCasualties(g, winner, to, cards, losses[0], true, continuation.casualties?.owner);
} else if (winner && losses)
g.decision = {
kind: 'battleLosses',
player: winner.id,
territory: to,
options: losses,
...(continuation.casualties?.owner ? { forceOwner: continuation.casualties.owner } : {}),
cards,
};
else if (winner) finishWinner(g, winner, to, cards);
@@ -20753,6 +20821,7 @@ function settleWinnerCasualties(
cards: string[],
choice: Casualties,
automatic = false,
forceOwner?: string,
) {
if (g.pendingSukRescue) {
const pending = g.pendingSukRescue;
@@ -20773,7 +20842,8 @@ function settleWinnerCasualties(
if (sardaukar?.casualties) requireRule(sardaukar.casualties.outcome === 'pending' &&
sardaukar.casualties.options.some(option => JSON.stringify(option) === JSON.stringify(choice)),
'Choose an original Nexus Sardaukar casualty allocation.');
const losses = takeBattleLosses(g, p, to, choice);
const physicalOwner = forceOwner ? getPlayer(g, forceOwner) : p;
const losses = takeBattleLosses(g, physicalOwner, to, choice);
if (sardaukar?.casualties) {
sardaukar.casualties.outcome = 'complete';
sardaukar.signature = nexusSardaukarSignature(sardaukar);
@@ -20782,7 +20852,7 @@ function settleWinnerCasualties(
if (to.startsWith('homeworld:')) g.homeworldBattleLoss = null;
log(
g,
`${p.name} sent ${choice.normal} normal and ${choice.elite} elite forces from ${combatLocationName(g, to)} to the Tanks. ${automatic ? 'This was the only legal casualty allocation for the revealed battle plan, so it was applied automatically.' : 'This applies the selected casualty allocation for the revealed battle plan.'}`,
`${physicalOwner.name} sent ${choice.normal} normal and ${choice.elite} elite forces from ${combatLocationName(g, to)} to the Tanks. ${automatic ? 'This was the only legal casualty allocation for the revealed battle plan, so it was applied automatically.' : 'This applies the selected casualty allocation for the revealed battle plan.'}`,
automatic ? { faction: p.faction, name: 'Battle casualties' } : undefined,
);
stageIxSubstitution(g, p, to, cards, losses);
@@ -22235,6 +22305,17 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
: null;
if (!canceled && bureaucratDiversion === undefined && offerBureaucratPayment(g,response.bureaucratPayment,{kind:'response'})) return;
g.response = null;
if (response.kind === 'ecazOccupy') {
const b = g.battle, occupy = b?.ecazOccupy;
requireRule(b && occupy?.lead && occupy.ecaz === response.owner && !b.revealed &&
!Object.keys(b.plans).length, 'Resolve Occupy before native powers and plans.');
occupy.canceled = canceled;
ecazBattleProfile(g);
createMentatQuestion(g);
beginStrongholdBattle(g);
log(g, canceled ? 'Karama canceled the combined dial: the selected lead now dials only their own forces; the other allied army contributes zero.' : 'Ecaz Occupy proceeds with its mandatory free contribution and the ally’s physical force pool.', { faction: 'ecaz', name: 'Occupy' });
return;
}
if (response.kind === 'guildRate') {
if (g.pendingGuildTransport) finishGuildTransportRateResponse(g, response, canceled);
else finishGuildRateResponse(g, response, canceled);
@@ -26802,6 +26883,25 @@ function applyActionInner(
}
return g;
}
if (g.decision?.kind === 'ecazBattleLead') {
const decision = g.decision, b = g.battle!, occupy = b.ecazOccupy!;
requireRule(t === 'decision' && id === decision.player && action.event === decision.event &&
typeof action.lead === 'string' && decision.choices.includes(action.lead) &&
Object.keys(action).every(key => ['type', 'event', 'lead'].includes(key)),
'Choose Ecaz or its current ally to lead this combined battle.');
const previous = b.attacker === occupy.ecaz || b.attacker === occupy.ally ? b.attacker : b.defender;
if (b.attacker === previous) b.attacker = action.lead;
else b.defender = action.lead;
occupy.lead = action.lead;
// 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];
ecazBattleProfile(g);
g.decision = null;
g.response = { kind: 'ecazOccupy', owner: occupy.ecaz, passed: [] };
log(g, `${getPlayer(g, action.lead).name} leads the combined Ecaz battle, using their own leaders, cards and spice.`, { faction: 'ecaz', name: 'Occupy' });
return g;
}
if (
t === 'offerRicheseNoField' &&
g.decision?.kind === 'richeseAllyOpportunity' &&
@@ -27834,7 +27934,7 @@ function applyActionInner(
'Casualty choice',
)
];
settleWinnerCasualties(g, p, decision.territory, decision.cards, choice);
settleWinnerCasualties(g, p, decision.territory, decision.cards, choice, false, decision.forceOwner);
} else if (decision.kind === 'auctionPayment') {
normalKaramaAuction(g, p, true, action.karama === true);
requireRule(
@@ -29834,8 +29934,24 @@ function applyActionInner(
revealed: false,
traitorCalls: {},
};
createMentatQuestion(g);
beginStrongholdBattle(g);
const ecaz = byFaction(g, 'ecaz');
const ally = ecaz?.ally ? getPlayer(g, ecaz.ally) : null;
const connected = ecaz && ally && Object.entries(ecaz.forces).some(([x, n]) =>
n > 0 && splitLocation(x).territory === choice.territory && Object.entries(ally.forces).some(([y, m]) =>
m > 0 && splitLocation(y).territory === choice.territory &&
gameDistance(g, x, y, key => splitLocation(key).sector === g.storm) === 0));
if (g.advanced && ecaz && ally?.ally === ecaz.id && connected &&
!isAdvisor(ally, choice.territory) && [choice.attacker, choice.defender].some(seat => seat === ecaz.id || seat === ally.id)) {
requireRule(!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.strongholdCards &&
!g.techTokens && !g.discoveryEnabled && !g.ecazTreachery &&
g.players.every(player => faction(player.faction).expansion === 'base' || ['ecaz', 'moritani'].includes(player.faction)),
'Advanced combined Occupy battles support native Ecaz/classic/Moritani with base cards; optional modules and exotic faction battle composition require their source ruling.');
g.battle.ecazOccupy = { event: battleEvent, ecaz: ecaz.id, ally: ally.id, lead: null, canceled: false };
g.decision = { kind: 'ecazBattleLead', player: ecaz.id, event: battleEvent, choices: [ecaz.id, ally.id] };
} else {
createMentatQuestion(g);
beginStrongholdBattle(g);
}
log(
g,
`${p.name} chose the battle between ${getPlayer(g, choice.attacker).name} and ${getPlayer(g, choice.defender).name} in ${combatLocationName(g, choice.territory)}. ${getPlayer(g, choice.attacker).name} remains the aggressor.`,
@@ -30026,7 +30142,7 @@ function applyActionInner(
'Traitor decision is not available.',
);
requireRule(typeof action.call === 'boolean', 'Choose reveal or decline.');
if (g.nexusCards?.cards) ensureTraitorDeclarations(g);
if (g.nexusCards?.cards || b.ecazOccupy) ensureTraitorDeclarations(g);
if (action.call) {
const other =
b.attacker === traitorBeneficiary(g, b, id) ? b.defender : b.attacker;
@@ -30054,7 +30170,8 @@ function applyActionInner(
if (
action.call &&
p.faction === 'harkonnen' &&
![b.attacker, b.defender].includes(id)
![b.attacker, b.defender].includes(id) &&
!(b.ecazOccupy && [b.ecazOccupy.ecaz, b.ecazOccupy.ally].includes(id))
) {
g.response = { kind: 'harkonnenTraitor', owner: id, passed: [] };
return g;
@@ -30597,6 +30714,7 @@ export function viewGame(state: Game, id: string) {
: null,
techTokens: g.techTokens ?? null,
ecazLoyalty: g.ecazLoyalty?.card ? { player: g.ecazLoyalty.player, card: g.ecazLoyalty.card } : null,
ecazOccupyPreview: !!g.ecazOccupyPreview,
strongholdCards: g.strongholdCards ?? null,
discoveries: discoveryChoices(g,id,g.phase === 7 && grummanCollectionAutomatic(g)),
ecologicalStorm: g.decision?.kind === 'ecologicalStorm' && g.decision.player === id && g.ecologicalStorm?.stage === 'choose'
@@ -31252,11 +31370,12 @@ export function viewGame(state: Game, id: string) {
battle: b
? {
locationName: combatLocationName(g, b.territory),
ecazOccupy: b.ecazOccupy ? { ...b.ecazOccupy, profile: ecazBattleProfile(g) } : null,
native: homeworldBattleLocation(g, b.territory)?.native ?? null,
nativeBattleStrength: homeworldBattleLocation(g, b.territory)?.nativeBattleStrength ?? 0,
opponentForces: [b.attacker, b.defender].includes(id) &&
!getPlayer(g, b.attacker === id ? b.defender : b.attacker).noField?.deployed
? combatForces(g, getPlayer(g, b.attacker === id ? b.defender : b.attacker), b.territory, me)
? planCombatForces(g, getPlayer(g, b.attacker === id ? b.defender : b.attacker), me)
: null,
strongholdCopy: b.strongholdCopy ?? null,
strongholdEffects: Object.fromEntries(
@@ -31268,12 +31387,7 @@ export function viewGame(state: Game, id: string) {
tieWinner: battleTieWinner(g),
noFieldPlayers: b.noFieldPlayers ?? [],
ownForces: [b.attacker, b.defender].includes(id)
? combatForces(
g,
me,
b.territory,
getPlayer(g, b.attacker === id ? b.defender : b.attacker),
)
? planCombatForces(g, me, getPlayer(g, b.attacker === id ? b.defender : b.attacker))
: null,
truthPromises: b.truthPromises ?? [],
compliantPlan:
+4 -2
View File
@@ -169,12 +169,14 @@ export const FACTION_RULES: Record<
'Your Ecaz Ambassador can acquire an available Duke Vidal or offer an alliance when both participants are unallied. Duke availability, loans and custody use his separate rules.',
'You and your ally may share territories. Divide shared desert spice by agreement; without agreement, divide as equally as possible and give the odd spice to your ally. Both factions occupying each of three qualifying strongholds provides an allied victory route.',
'When you are allied with Fremen, sharing Sietch Tabr does not itself prevent the Fremen special victory. Its other conditions still apply.',
'Combined Occupy combat, Tleilaxu Ambassador revival, Duke loans and revival, and competing arrival reactions remain unfinished. The connected Sietch Tabr exception does not establish every special-victory combination.',
'Basic combined Occupy combat, Tleilaxu Ambassador revival, Duke loans and exceptional custody, and remaining competing arrival reactions are unfinished. The connected Sietch Tabr exception does not establish every special-victory combination.',
],
advanced: [
'At Spice Collection, both allies receive the normal bank income from a jointly occupied Arrakeen, Carthag or Tuek’s Sietch. Canceling Ecaz’s Collection benefit leaves the ally’s income intact.',
'Loyalty: before initial Traitor dealing, randomly set aside one of your five native Traitor Cards face up for everyone. It stays outside the deck for the whole game, including later Traitor and Face Dancer draws. Karama cannot prevent this automatic power; the corresponding leader disc is unchanged.',
'Loyalty and Collection have connected development implementations. Remaining Advanced powers and combined interactions are still being completed; this sheet is not a complete faction certification.',
'In the bounded Advanced Occupy preview, choose Ecaz or your ally as lead. The lead uses its own leaders, cards and spice; total dial includes the ceiling of half your fighters at free full strength plus chosen allied army strength. An ordinary win leaves the floor of half your fighters and settles allied dial losses separately. Every participating whole army must be storm-clear and connected.',
'Karama prevention keeps the selected lead but switches to that lead’s own forces; the other ally contributes zero. These numbers use the authorized revised Advanced sheet, not a Basic ruling; only Ecaz may revive Duke, including by Ghola.',
'Loyalty, Collection and bounded Advanced Occupy have connected development implementations. Remaining powers and combined interactions are still being completed; this sheet is not a complete faction certification.',
],
},
moritani: {
+1
View File
@@ -36,6 +36,7 @@ type SourceSelector = (g: Game, response: ResponseWindow) => unknown;
* this with all pending records: independent interruptions may create those. */
const cancellationSources = {
ecazCollection: (g) => ({ pending: g.ecazCollection ?? null }),
ecazOccupy: (g) => ({ battle: battleSource(g), occupy: g.battle?.ecazOccupy ?? null }),
moritaniPlacement: (g) => ({
pending: g.pendingMoritaniPlacement ?? null,
terror: g.moritaniTerror ?? null,
+1 -1
View File
@@ -92,7 +92,7 @@ export function quoteAdvisorBattleOffer(g: AdvisorContext) {
});
return candidate.battles.some(
(b) =>
b.territory === t && (b.attacker === bg.id || b.defender === bg.id),
b.territory === t && (b.attacker === bg.id || b.defender === bg.id || b.coalition?.includes(bg.id)),
);
});
return { released: board.released, owner: bg.id, territories };
+24 -1
View File
@@ -1251,6 +1251,29 @@ export const RULE_TOPICS: RuleTopic[] = [
],
}),
),
{
id: 'ecaz-occupy',
title: 'Advanced Ecaz Occupy: combined-army battles',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Verified',
summary: 'Ecaz chooses who leads its combined army. The authorized Advanced revision supplies mandatory Ecaz strength and lead-own forces after Karama; the bounded fresh local rule and human controls are Development Verified.',
steps: [
'When reciprocal Ecaz allies coexist with fighting forces, Ecaz chooses Ecaz or its ally to lead. Every participating whole-territory army must be clear of storm and connected; no component-only battle or casualties are created. The selected lead uses its own leaders, Treachery cards and spice; the original battle-order chooser is not replaced. Advisors are not fighters but retain their native voluntary fighter-conversion opportunity.',
'With Occupy active, the total dial includes the ceiling of half the Ecaz fighters as mandatory full-strength contribution without spice cost, plus the chosen allied army strength. The variable army retains its native Fremen/Fedaykin or Emperor/Sardaukar properties even when Ecaz forms the plan.',
'An ordinary victory leaves the floor of half the Ecaz fighters alive and separately applies the variable army’s dial losses. A sole successful Traitor-call winner preserves both armies; an ordinary defeat, mutual Traitor result or resolved explosion removes the actual combined side. Ecaz and its ally share the printed same-faction Traitor/Face Dancer relation.',
'Eligible Karama can prevent Occupy after the lead choice and before powers/plans. The chosen lead remains lead and dials its own army; the other ally contributes zero. The plan editor then uses the new force pool and no fixed Ecaz strength.',
'These numbers come from the user-authorized revised Advanced PDF, physical pages 32 and 19, not a newly located publisher erratum. Basic is unchanged. The specific Ecaz-only Duke revival/Ghola decision remains. Fresh local ecaz-occupy uses Ecaz/classic/optional Moritani two-to-six-seat Advanced, base33 ordinary cards and no other overlays; full Ecaz and public starts remain gated.',
],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Combined-side enumeration, explicit lead choice, native Occupy response and owner-labelled physical dial/outcome integration are implemented. Optional/exotic compositions remain open.', evidence: ['game/ecaz-occupy-battle.ts', 'game/board-resolution-quote.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Human390px original CLI rooms exercised Ecaz lead, free Fremen/Fedaykin total9 with minimum3, real printed Karama prevention, and canceled lead-own dial1/support1 with minimum0. Both battles reached native Collection with the expected physical survivors.', evidence: ['components/ecaz-occupy.tsx', 'components/game-table.tsx', 'game/ecaz-occupy-options.ts'] },
{ area: 'AI', status: 'Partial', detail: 'All four native policies actually seal legal total plans for either lead, active or canceled, using native leaders/cards and physical support. Strategy remains deferred.', evidence: ['game/ecaz-occupy-options.ts', 'game/bots.ts', 'tests/ecaz-occupy-options.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Authorized Advanced arithmetic/cancellation, unchanged Basic and specific Duke override are separated from historical source gaps and optional-module gates.', evidence: ['docs/ECAZ_OCCUPY_RULES.md', 'docs/RULE_DECISIONS.md', 'game/reference.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Development Verified for the bounded source: actual seven-case native program, before/after whole-army storm proof, typed/odd/even/traitor/outcome and all-policy cases, and two human390px native battles. Five unstaged2..6 games complete with2123 accepted actions/no rejection; those samples contain no combined lead choice, so they prove profile continuation, not that rare rule path. Full Ecaz and optional compositions remain open; comprehensive assurance follows all rules.', evidence: ['tests/ecaz-occupy-battle.test.ts', 'tests/ecaz-occupy-options.test.ts', 'tests/ecaz-occupy-runtime.test.ts', 'tests/board-resolution-quote.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
],
related: ['faction-ecaz', 'advanced-ecaz', 'ecaz-ambassadors', 'battle', 'victory', 'implementation-checklist'],
},
{
id: 'ecaz-loyalty',
title: 'Ecaz Loyalty: public Traitor Card',
@@ -1298,7 +1321,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The Guild Ambassador grants an immediate free shipment of zero through four physical reserve forces for Ecaz or its ally, including typed elites. Choose a clear territory and sector; this does not spend ordinary shipment or movement, ally aid or a retained rate card. Fremen may use destinations outside their ordinary reinforcement radius, but their southern reserves stay on-planet and this effect never permits storm entry. Only Ixians may ship directly into their placed mobile stronghold.',
'During Shipment and Movement, Guild can use its special Karama to stop another eligible off-planet Ambassador shipment before forces arrive; it does not stop this grant during a phase-one worm sequence. A successful off-planet shipment may invite Bene Gesserit accompaniment and accrues applicable phase-five Heighliner income once. BG can accompany Ixians into the mobile stronghold. Projected accompaniment choices show blocked sectors and keep Polar Sink and decline available when legal. Intrusion, separate Terror and accompanying fighter-triggered Ambassadors finish before the original entrant or worm rider resumes.',
'No-Field substitution into the free four-force grant remains unavailable. In bounded classic plus Ecaz/Moritani games, a shared Ambassador/Terror entry gives both owners saved optional prompts in current storm order without repeating the committed entry; this is a provisional table order, not a printed publisher priority. Competing Intrusion and Ambassador-created Fremen/Guild child arrivals remain gated. Atomics Aftermath, Homeworlds, Nexus and Discovery combinations do not gain coverage from this first overlap path.',
'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition or consensual alliance through the reusable Ecaz token. The remaining effects, combined Occupy combat, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Full Ecaz starts remain disabled.',
'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition or consensual alliance through the reusable Ecaz token. Advanced combined Occupy has a separate bounded Development Verified guide; Basic Occupy, remaining effects, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Full Ecaz starts remain disabled.',
],
checklist: [
{
+2
View File
@@ -4,6 +4,7 @@ import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
// Exhaustive typed discriminants for the existing engine control unions. These
// are envelope checks, not a duplicate implementation of each future choice.
const DECISIONS = {
ecazBattleLead: true,
leaderSkillVisibility: true,
leaderSkillRevival: true,
moritaniAssassinate: true,
@@ -89,6 +90,7 @@ const RESPONSES = {
moritaniPlacement: true,
ecazPlacement: true,
ecazCollection: true,
ecazOccupy: true,
moritaniAlliance: true,
moritaniDuke: true,
moritaniRetention: true,
+19 -2
View File
@@ -1,7 +1,9 @@
import { FACTIONS, type FactionId } from './catalog';
import { CHEAP_HERO_TRAITOR } from './traitors';
import { quoteEcazOccupyBattle, type EcazOccupyBattleProfile } from './ecaz-occupy-battle';
export type TraitorDeclarationContext = {
ecazOccupy?: EcazOccupyBattleProfile;
event: string;
attacker: string;
defender: string;
@@ -73,17 +75,32 @@ function validateContext(context: TraitorDeclarationContext): void {
'A traitor declaration requires both sealed battle plans.',
);
}
if (context.ecazOccupy) {
const occupy = context.ecazOccupy;
const ecaz = context.players.find(player => player.id === occupy.ecaz);
const ally = context.players.find(player => player.id === occupy.ally);
requireDeclaration(ecaz?.faction === 'ecaz' && ally && [context.attacker, context.defender].includes(occupy.lead),
'Traitor co-side voters need the actual Ecaz coalition and selected combatant.');
quoteEcazOccupyBattle({
battleOrderActor: occupy.battleOrderActor,
ecaz: { id: ecaz.id, faction: 'ecaz', ally: ecaz.ally, forces: occupy.ecazForces },
ally: { id: ally.id, faction: ally.faction, ally: ally.ally, forces: occupy.allyForces },
lead: occupy.lead, canceled: occupy.canceled,
});
}
}
function declaredElements(context: TraitorDeclarationContext, voter: string) {
const owner = context.players.find((player) => player.id === voter);
requireDeclaration(owner, 'The traitor declaration voter is not seated.');
const combatants = [context.attacker, context.defender];
const beneficiary = combatants.includes(voter) ? voter : owner.ally;
const occupy = context.ecazOccupy;
const coSide = !!occupy && [occupy.ecaz, occupy.ally].includes(voter);
const beneficiary = coSide ? occupy.lead : combatants.includes(voter) ? voter : owner.ally;
requireDeclaration(
beneficiary &&
combatants.includes(beneficiary) &&
(beneficiary === voter || owner.faction === 'harkonnen'),
(beneficiary === voter || owner.faction === 'harkonnen' || coSide),
'Only a combatant or its Harkonnen ally can declare this traitor.',
);
const target =