Integrate sourced Treachery cards with Advanced Occupy

This commit is contained in:
Quintine committed 2026-10-02 17:53:24 +10:00
1 parent 85caee6c83
commit cd532222a5
21 files changed
+1226 -99

No files matched your search

+31 -2
View File
@@ -176,6 +176,8 @@ export type BattleResolutionQuote = {
sandmaster?: string;
casualties: {
owner?: string;
/** The returned Harass complement fixes all remaining physical fighters. */
fullyCommitted?: true;
forces: CombatForces;
dial: number;
support: number;
@@ -419,8 +421,23 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
'The Harass & Withdraw plan has an invalid weapon and defense pair.');
requireQuote(side.harassWithdraw && !input.homeworld && !hasStone,
'Harass & Withdraw needs its supported physical context; Homeworld and Stone Burner combinations remain unfinished.');
requireQuote(JSON.stringify(side.harassWithdraw.forces) === JSON.stringify(side.forces),
const profile = input.ecazOccupy?.lead === side.id ? input.ecazOccupy : undefined;
const normalization = side.harassWithdraw.occupy;
if (profile) {
const ownForces = side.id === profile.ecaz ? profile.ecazForces : profile.allyForces;
requireQuote(normalization &&
normalization.cardUser === side.id && normalization.ecaz === profile.ecaz &&
normalization.ally === profile.ally && normalization.canceled === profile.canceled &&
normalization.fixedEcazDial === profile.fixedEcazDial &&
JSON.stringify(normalization.variableForces) === JSON.stringify(profile.forces) &&
JSON.stringify(side.forces) === JSON.stringify(profile.forces) &&
JSON.stringify(side.harassWithdraw.forces) === JSON.stringify(ownForces),
'The Occupy withdrawal context must identify the card user’s own counters and the original mandatory Ecaz commitment.');
} else {
requireQuote(!normalization &&
JSON.stringify(side.harassWithdraw.forces) === JSON.stringify(side.forces),
'The withdrawal context must match the current battle force roles.');
}
withdrawals.set(side.id, quoteHarassWithdraw(side.harassWithdraw, side.plan.dial, side.plan.support, side.harassSelection));
}
}
@@ -724,6 +741,7 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
dial: winner.plan.dial,
support: winner.plan.support,
options,
...(withdrawal ? { fullyCommitted: true as const } : {}),
};
} else {
requireQuote(
@@ -746,7 +764,18 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
...destroyedArmies.filter(owner => owner !== profile.ecaz && owner !== profile.ally),
...occupyOutcome.destroyedArmies,
])];
if (winner?.id === profile.lead) casualties = occupyOutcome.casualties;
if (winner?.id === profile.lead) {
casualties = occupyOutcome.casualties;
// The outcome already holds the original fixed Ecaz commitment. Only a
// withdrawal by the variable army's owner can constrain its typed losses.
const withdrawal = harassWithdraw.find(entry => entry.player === casualties?.owner);
if (casualties && withdrawal) {
const options = casualties.options.filter(option =>
option.normal === withdrawal.remaining.normal && option.elite === withdrawal.remaining.elite);
requireQuote(options.length > 0, 'The Occupy winner lost its selected physical Harass commitment.');
casualties = { ...casualties, forces: withdrawal.remaining, options, fullyCommitted: true };
}
}
}
const strongholdIncome =
result === 'mutualTraitors'
+57 -31
View File
@@ -1425,6 +1425,8 @@ export type Game = {
cards: string[];
casualties?: {
owner?: string;
/** Harass has already returned the complement of this physical pool. */
fullyCommitted?: true;
forces: CombatForces;
dial: number;
support: number;
@@ -6451,13 +6453,18 @@ function treacheryDiscardIntegrity(g: Game) {
'The mandatory battle discard cause is inconsistent.',
);
if (c.casualties) {
const { forces, dial, support, options } = c.casualties;
const { forces, dial, support, options, fullyCommitted } = c.casualties;
const winner = g.players.find((p) => p.id === c.winner);
const pool = winner ? combatArmy(g, winner.id, c.territory) : { normal: 0, elite: 0 };
const forceOwner = g.players.find((p) => p.id === (c.casualties!.owner ?? c.winner));
const pool = forceOwner ? combatArmy(g, forceOwner.id, c.territory) : { normal: 0, elite: 0 };
const total = pool.normal + pool.elite;
const elites = pool.elite;
requireRule(
winner &&
winner && forceOwner &&
(forceOwner.id === winner.id || (forceOwner.ally === winner.id &&
winner.ally === forceOwner.id &&
(winner.faction === 'ecaz' || forceOwner.faction === 'ecaz'))) &&
(fullyCommitted === undefined || fullyCommitted === true) &&
forces &&
validCombatForces(forces) &&
Number.isSafeInteger(forces.normal) &&
@@ -6475,7 +6482,10 @@ function treacheryDiscardIntegrity(g: Game) {
Array.isArray(options) &&
options.length > 0 &&
JSON.stringify(options) ===
JSON.stringify(casualtyOptions(forces, dial, support)),
JSON.stringify(fullyCommitted
? casualtyOptions(forces, dial, support).filter(option =>
option.normal === forces.normal && option.elite === forces.elite)
: casualtyOptions(forces, dial, support)),
'The saved battle casualties do not match the committed dial, support and effective forces.',
);
}
@@ -9129,14 +9139,16 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
g.expansions.includes(faction(p.faction).expansion))) &&
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.strongholdCards &&
!g.techTokens && !g.discoveryEnabled && !g.discoveries &&
!g.discoveryStash && !g.greatMaker && !g.ecazTreachery;
!g.discoveryStash && !g.greatMaker &&
(g.ecazTreachery === undefined || g.ecazTreachery === true);
}
/** Fresh native Advanced Occupy profile; public starts and Basic are unchanged. */
export function initializeEcazOccupyGameForAudit(state: Game): Game {
requireRule(ecazOccupyCompositionSupported(state),
'Advanced Occupy requires native Ecaz, classic/Moritani/Ixian/Tleilaxu/CHOAM factions and their distinct selected family decks, without Richese, optional modules or the independent Ecaz Treachery variant.');
'Advanced Occupy requires native Ecaz, classic/Moritani/Ixian/Tleilaxu/CHOAM factions and their distinct selected family decks, without Richese or optional overlays.');
requireFreshBaseRuntime(state);
const g = initializeFactionExpansionsGameForAudit(state);
requireFreshFactionInventory(state);
const g = initializeSetupGameForAudit(state, false, false, false, false, false, false, true, state.ecazTreachery === true);
g.ecazOccupyPreview = true;
return g;
}
@@ -11929,30 +11941,33 @@ function recruitsModeSupported(g: Game) {
!g.discoveryEnabled
);
}
function ecazAllyAtBattle(g: Game, side: Player, territoryId: string) {
const ally = g.players.find(player => player.id === side.ally && player.ally === side.id);
return !!ally && (side.faction === 'ecaz' || ally.faction === 'ecaz') &&
Object.entries(ally.forces).some(([key, amount]) => amount > 0 && splitLocation(key).territory === territoryId);
}
function combinedCardBattleBlock(g: Game): string | null {
const battle = g.battle;
if (!battle || battle.ecazOccupy) return null;
const ecaz = byFaction(g, 'ecaz');
const ally = ecaz && g.players.find(player => player.id === ecaz.ally && player.ally === ecaz.id);
if (ecaz && ally &&
(battle.attacker === ecaz.id || battle.defender === ecaz.id ||
battle.attacker === ally.id || battle.defender === ally.id) &&
fighterCount(ecaz, battle.territory) > 0 && fighterCount(ally, battle.territory) > 0)
return 'Combined card armies require the supported Advanced Occupy battle profile.';
return null;
}
function reinforcementsModeSupported(g: Game) {
const pairedEcaz = g.expansions.length === 1 && g.expansions[0] === 'ecaz';
return !!g.ecazTreachery && recruitsModeSupported(g) && !g.sandtrout &&
(g.expansions.length === 0 || pairedEcaz) &&
g.players.every(player => {
const expansion = faction(player.faction).expansion;
return expansion === 'base' || (pairedEcaz && expansion === 'ecaz');
});
((g.ecazOccupyPreview === true && ecazOccupyCompositionSupported(g)) ||
((g.expansions.length === 0 || pairedEcaz) && g.players.every(player => {
const expansion = faction(player.faction).expansion;
return expansion === 'base' || (pairedEcaz && expansion === 'ecaz');
})));
}
function currentReinforcementsCost(g: Game, p: Player) {
requireRule(reinforcementsModeSupported(g),
'Reinforcements requires the standalone Ecaz card variant with classic or Ecaz/Moritani factions.');
if (g.battle) {
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 combined Occupy armies needs its additional-card source ruling.');
}
'Reinforcements requires the independent Ecaz card variant in a supported native faction composition.');
const combinedBlock = combinedCardBattleBlock(g);
requireRule(!combinedBlock, combinedBlock ?? 'This combined card battle is unavailable.');
try {
return quoteReinforcements(p.reserves - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0);
} catch (error) {
@@ -11966,14 +11981,14 @@ function reinforcementsPreview(g: Game, p: Player) {
!p.hand.some(isReinforcements)) return null;
const normal = Math.min(3, Math.max(0, p.reserves - (p.elites?.reserves ?? 0)));
const elite = Math.min(3 - normal, Math.max(0, p.elites?.reserves ?? 0));
const combinedBlock = combinedCardBattleBlock(g);
let blocked: string | null = null;
if (!reinforcementsModeSupported(g))
blocked = 'Reinforcements requires the standalone Ecaz card variant with classic or Ecaz/Moritani factions.';
blocked = 'Reinforcements requires the independent Ecaz card variant in a supported native faction composition.';
else if (g.status !== 'playing' || g.phase !== 6 || battle.revealed || battle.plans[p.id])
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 combined Occupy armies needs its additional-card source ruling.';
else if (combinedBlock)
blocked = combinedBlock;
else if (normal + elite < 3)
blocked = 'Reinforcements needs three physical forces in your reserves.';
return { blocked, normal, elite };
@@ -18085,6 +18100,7 @@ function inspectedPlanMatches(p: Player, field: PlanField, fixed: unknown, candi
function harassWithdrawContext(g: Game, p: Player): HarassWithdrawContext {
const b = g.battle!;
const other = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
const combinedBlock = combinedCardBattleBlock(g);
let blocked: string | null = null;
if (!g.ecazTreachery) blocked = 'Enable the independent Ecaz Treachery Cards variant before using Harass & Withdraw.';
else if (g.status !== 'playing' || g.phase !== 6 || ![b.attacker, b.defender].includes(p.id))
@@ -18093,14 +18109,22 @@ function harassWithdrawContext(g: Game, p: Player): HarassWithdrawContext {
blocked = 'Harass & Withdraw with other optional modules, including Homeworlds, is still being implemented.';
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 combined Occupy armies needs its additional-card source ruling.';
else if (combinedBlock)
blocked = combinedBlock;
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)
.filter(([key, amount]) => amount > 0 && splitLocation(key).territory === b.territory)
.map(([key, amount]) => [key, { normal: amount - (p.elites?.forces[key] ?? 0), elite: p.elites?.forces[key] ?? 0 }]));
return { blocked, forces: combatForces(g, p, b.territory, other), locations };
const profile = ecazBattleProfile(g);
const forces = profile?.lead === p.id
? p.id === profile.ecaz ? profile.ecazForces : profile.allyForces
: combatForces(g, p, b.territory, profile?.lead === other.id ? getPlayer(g, profile.forceOwner) : other);
return { blocked, forces, locations,
...(profile?.lead === p.id ? { occupy: {
cardUser: p.id, ecaz: profile.ecaz, ally: profile.ally, canceled: profile.canceled,
fixedEcazDial: profile.fixedEcazDial, variableForces: profile.forces,
} } : {}) };
}
function currentHarassWithdrawQuote(g: Game, p: Player, plan: Pick<Plan, 'dial' | 'support'>) {
try {
@@ -20320,6 +20344,8 @@ function advanceBattleOutcome(g: Game) {
function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
const b = g.battle!;
requireRule(!!b.diplomatRetreat === !!retreat, 'Resolve the pending Diplomat retreat before battle losses.');
// Quote before any withdrawal: fixedLosses retains the original ceil(E/2)
// physical commitment, never one recalculated from Ecaz's diminished board.
const quote = currentBattleResolutionQuote(g);
const a = getPlayer(g, b.attacker),
d = getPlayer(g, b.defender),
+97 -3
View File
@@ -11,10 +11,22 @@ export type HarassWithdrawForces = Readonly<{ normal: number; elite: number }>;
export type HarassWithdrawSelection = Readonly<
Record<string, HarassWithdrawForces>
>;
/** Owner-labelled facts from the selected Occupy plan, not a merged army.
* fixedEcazDial is the original mandatory commitment, captured before return. */
export type HarassWithdrawOccupyContext = Readonly<{
cardUser: string;
ecaz: string;
ally: string;
canceled: boolean;
fixedEcazDial: number;
variableForces: CombatForces;
}>;
export type HarassWithdrawContext = Readonly<{
blocked: string | null;
forces: CombatForces;
locations: Readonly<Record<string, HarassWithdrawForces>>;
/** Absent for ordinary battles and for the Occupy opponent. */
occupy?: HarassWithdrawOccupyContext;
}>;
export type HarassWithdrawPreview = HarassWithdrawContext &
Readonly<{
@@ -90,6 +102,88 @@ function validateContext(context: HarassWithdrawContext) {
);
return locations;
}
function sameForcePool(a: CombatForces, b: CombatForces): boolean {
return (
a.normal === b.normal &&
a.elite === b.elite &&
a.eliteStrength === b.eliteStrength &&
a.freeSupport === b.freeSupport &&
!!a.normalFixedHalf === !!b.normalFixedHalf &&
!!a.normalFreeSupport === !!b.normalFreeSupport &&
!!a.eliteFreeSupport === !!b.eliteFreeSupport &&
(a.temporaryElite ?? 0) === (b.temporaryElite ?? 0)
);
}
function ownCommitments(
context: HarassWithdrawContext,
dial: number,
support: number,
): HarassWithdrawForces[] {
const occupy = context.occupy;
if (occupy === undefined) return casualtyOptions(context.forces, dial, support);
requireHarass(
record(occupy) &&
[occupy.cardUser, occupy.ecaz, occupy.ally].every(
(id) => typeof id === 'string' && id.trim().length > 0,
) &&
occupy.ecaz !== occupy.ally &&
[occupy.ecaz, occupy.ally].includes(occupy.cardUser) &&
typeof occupy.canceled === 'boolean' &&
Number.isSafeInteger(occupy.fixedEcazDial) &&
validCombatForces(occupy.variableForces),
'Harass & Withdraw needs the selected Occupy card user and native force owners.',
);
if (occupy.canceled) {
requireHarass(
occupy.fixedEcazDial === 0 &&
sameForcePool(context.forces, occupy.variableForces),
'Canceled Occupy uses only the card user’s normal battle pool, with no fixed contribution.',
);
return casualtyOptions(context.forces, dial, support);
}
requireHarass(
occupy.fixedEcazDial > 0 && occupy.fixedEcazDial <= 10,
'Active Occupy needs its original mandatory Ecaz commitment.',
);
const variable = casualtyOptions(
occupy.variableForces,
dial - occupy.fixedEcazDial,
support,
);
requireHarass(
variable.length > 0,
'The total Occupy dial and spice must match the ally’s native variable commitment.',
);
if (occupy.cardUser === occupy.ally) {
requireHarass(
sameForcePool(context.forces, occupy.variableForces),
'Harass & Withdraw returns only the ally card user’s own variable army.',
);
return variable;
}
requireHarass(
context.forces.elite === 0 &&
!context.forces.normalFixedHalf &&
context.forces.normal >= occupy.fixedEcazDial,
'Harass & Withdraw needs the Ecaz card user’s own ordinary mandatory fighters.',
);
// The selected lead's spice supports the ally, never these fully supported
// free Ecaz counters. Do not recompute ceil(E/2) after a physical return.
return [{ normal: occupy.fixedEcazDial, elite: 0 }];
}
function committedForces(
context: HarassWithdrawContext,
committed: HarassWithdrawForces,
): CombatForces {
const occupy = context.occupy;
return {
...context.forces,
...committed,
...(occupy && !occupy.canceled && occupy.cardUser === occupy.ecaz
? { freeSupport: true }
: {}),
};
}
/** Distinct physical dial commitments; equivalent support allocations are not extra choices. */
export function harassWithdrawCommitments(
context: HarassWithdrawContext,
@@ -97,7 +191,7 @@ export function harassWithdrawCommitments(
support: number,
): { normal: number; elite: number }[] {
validateContext(context);
const choices = casualtyOptions(context.forces, dial, support);
const choices = ownCommitments(context, dial, support);
requireHarass(
choices.length > 0,
'Dial and spice must match a legal physical force commitment.',
@@ -217,7 +311,7 @@ export function quoteHarassWithdraw(
);
return {
returned,
remaining: { ...forces, ...committed },
remaining: committedForces(context, committed),
locations: Object.fromEntries(
entries
.filter(([, group]) => group.normal || group.elite)
@@ -263,7 +357,7 @@ export function quoteHarassWithdraw(
}
return {
returned,
remaining: { ...forces, normal: committed.normal, elite: committed.elite },
remaining: committedForces(context, committed),
locations: Object.fromEntries(allocation),
};
}
+11 -9
View File
@@ -1265,14 +1265,16 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'An Ixian variable army takes its own typed losses and chooses native surviving-Suboid substitution, including when Ecaz formed the plan. Actual Ixians own the exchange/counter; the selected lead still owns winner cards and continuation. CHOAM support income uses original eligible payer/donor shares, not the free fixed contribution.',
'A Tleilaxu winning co-side cannot Face Dance itself. External Face Dance returns both actual winning allied armies with a combined survivor maximum, while the selected winner keeps original rewards and card cleanup. Reserve/board zero/partial/full replacements remain native choices, not a copied player.',
'These numbers come from the user-authorized revised Advanced PDF, physical pages 32 and 19, not a newly located publisher erratum. Basic and the specific Ecaz-only Duke/Ghola decision remain. Fresh ecaz-occupy admits Ecaz/classic/optional Moritani/Ixians/Tleilaxu/CHOAM two-to-six-seat Advanced with distinct required family decks:33 Ecaz alone,47 Ix,35 CHOAM,47 both. Richese mixed planning, independent E3 variant and other overlays remain open; full Ecaz and public starts remain gated.',
'With the independent variant, Harass returns only the actual card user’s undialed forces. An Ecaz user keeps the original mandatory ceil contribution committed at free full strength while its floor count returns; ally support is separate. An allied user subtracts fixed strength before choosing its native typed commitment. Canceled Occupy uses its selected lead’s own pool. Returned reserves are outside later casualties/explosion/Face Dance.',
'Reinforcements consumes three card-user reserves normal-first and adds +2 score without extra support or on-board arrivals. Its existing provisional cost/disposal on every revealed outcome is unchanged, not a new publisher ruling. Own same-plan Harass/Reinforcements and Stone remain guarded; Basic/unprofiled co-side cards remain unsupported.',
'These numbers come from the user-authorized revised Advanced PDF, physical pages 32 and 19, not a newly located publisher erratum. Basic and the specific Ecaz-only Duke/Ghola decision remain. Fresh ecaz-occupy admits Ecaz/classic/optional Moritani/Ixians/Tleilaxu/CHOAM two-to-six-seat Advanced with required family decks:33 Ecaz alone,47 Ix,35 CHOAM,47 both. Explicit --ecaz-treachery independently adds all three cards for36/50/38; Richese mixed planning and other overlays remain open. Full factions/public starts stay 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: 'Original human390px rooms exercise mandatory/free total dials, native Karama own-army redial, physical Ix two-Suboid/two-Cyborg exchange after an Ecaz plan, and full three-force FaceDance replacement across both winning armies. Selected lead retains its cards and native Collection continuation.', evidence: ['components/ecaz-occupy.tsx', 'components/face-dance-decision.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: first source arithmetic/native program, expanded native34 cases and19 actual programs, relevant191-case batch, four policies and original390px human aftermath. Twenty-three unstaged2..6 classic/paired/mixed games complete13004 accepted actions/no rejection; none reaches combined lead choice, so native cases/human rooms prove that path. Full factions/other combinations and comprehensive assurance remain after all rules.', evidence: ['tests/ecaz-occupy-battle.test.ts', 'tests/ecaz-occupy-options.test.ts', 'tests/ecaz-occupy-runtime.test.ts', 'tests/ecaz-occupy-ix-choam-runtime.test.ts', 'tests/ecaz-occupy-tleilaxu-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Development Verified: original arithmetic/native force integration, independent-card native/pure67 cases, relevant284-case batch,27 actual programs/four policies and original390px human Harass/Reinforcements. Current46 ordinary/variant2..6 games complete26619 accepted actions/no rejection with nine actual combined lead choices in full results. Prior five/23-game full counters record one/three, correcting compact-summary misreading. Full factions/other combinations and comprehensive assurance remain after all rules.', evidence: ['tests/ecaz-occupy-cards-runtime.test.ts', 'tests/ecaz-occupy-harass-quote.test.ts', 'tests/ecaz-occupy-ix-choam-runtime.test.ts', 'tests/ecaz-occupy-tleilaxu-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
],
related: ['faction-ecaz', 'advanced-ecaz', 'ecaz-ambassadors', 'battle', 'victory', 'implementation-checklist'],
},
@@ -4587,21 +4589,21 @@ export const RULE_TOPICS: RuleTopic[] = [
title: 'Reinforcements',
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
developmentStage: 'Verified',
summary: 'Add two to battle strength while paying three own reserve counters into the Tanks when the physical card is revealed.',
steps: [
'In the independent Ecaz Treachery Cards variant, place your held Reinforcements card in either battle-card slot; it is neither a weapon nor a defense. A category Prescience answer is None, not the name of this card. One physical card cannot fill both slots.',
'Have three own forces in reserves when sealing. The current Basic/Advanced classic-faction and paired Ecaz/Moritani prototype spends ordinary reserves first and then elite reserves, moving exactly three to their corresponding Tanks subpools when the battle resolves. No extra forces arrive in the battle territory.',
'Have three own forces in reserves when sealing. Classic/paired Ecaz/Moritani Basic/Advanced and explicitly selected native Advanced Occupy prototypes spend ordinary reserves first and then elite reserves, moving exactly three to corresponding Tanks subpools at resolution. These are the actual card holder’s reserves, not the variable ally’s pool; no extra forces arrive.',
'The normal-outcome score gains 2 without adding to the physical dial, spice support or on-board casualties. The card and its reserve cost are consumed even on a Traitor victory, a mutual Traitor result or a Lasgun/Shield explosion; successful Traitor calls still decide the outcome directly.',
'Payment on every outcome, deterministic normal-first selection and counting the reserve transfer as battle losses are documented provisional interpretations, not publisher rulings. Harass/Stone Burner, co-present Ecaz allied armies, other optional modules and other expansion pairings remain unavailable in this bounded prototype.',
'All-outcome payment, normal-first selection and reserve transfer counting as battle losses remain provisional interpretations, not publisher rulings. The explicit Advanced Occupy/independent-card profile is verified through native and human play; Basic/unprofiled co-side, own same-plan Harass/Reinforcements, Stone Burner and other optional modules remain guarded.',
],
related: ['battle-cards', 'battle', 'card-recruits', 'card-harass-withdraw', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Physical card custody, either battle slot, three typed reserve counters, normal score modifier and mandatory discard connect in classic or paired Ecaz/Moritani Basic/Advanced games.', evidence: ['game/reinforcements.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Physical card, both slots, own typed reserve cost, score-only bonus and single mandatory discard connect in classic/paired modes and explicit native Advanced Occupy. Selected lead cards/payment and variable ally casualties stay separate.', evidence: ['game/reinforcements.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Owner-only availability, exact cost guidance and both battle selectors support the card; private category answers remain None.', evidence: ['components/battle-preparation.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four profiles consider legal physical slot candidates and the normal score bonus; strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'The printed effect and unresolved Traitor, explosion, type-selection and accounting interpretations are distinguished from this temporary prototype.', evidence: ['docs/ECAZ_TREACHERY_RULES.md', 'docs/REINFORCEMENTS_RUNTIME.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Rule, paired-faction engine/controls and saved concurrent recovery cases cover bounded behavior; co-present Ecaz allied armies, the full card variant and combined optional modules remain uncertified.', evidence: ['tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/reinforcements-controls.test.tsx', 'tests/reinforcements-recovery.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Existing rule/control proofs plus combined-card67 cases, actual27-case program, four policies, natural combined games and390px decisive equal-leader Reinforcements cover the bounded profile. Full faction/optional-module acceptance remains open; comprehensive assurance follows all rules.', evidence: ['tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
],
},
{
@@ -4617,7 +4619,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Return your undialed ordinary and elite forces to reserves. Your leader can still be killed. An opponent’s successful Traitor call cancels withdrawal, including mutual calls. A Face Dancer cannot replace the forces already returned.',
'Discard Harass after use, including a win by your own Traitor call. Reveal-before-explosion return and card-specific discard precedence are explicit implementation inferences; neither is a dedicated combined FAQ ruling.',
'For multiple legal physical allocations, choose all undialed ordinary and elite counters by sector after both plans reveal and before traitor declarations. Unique returns are automatic. The selected return is public; it is not an extra sealed plan element. Richese card combinations and additional optional modules remain guarded. The printed card forbids use on your own Homeworld.',
'With Ecaz and an ally together, only the card user’s undialed forces return. Combined army attribution remains unfinished. Public variant starts and complete rules acceptance remain gated.',
'In explicitly selected Advanced Occupy, only the actual card user’s undialed forces return. An Ecaz user preserves its original mandatory fixed count at free full strength; an allied user uses its native variable commitment, while canceled Occupy uses its selected lead’s ordinary own pool. Returned reserves remain outside losses and Face Dance. Basic/unprofiled co-side, public starts and full acceptance remain gated.',
],
related: ['battle-cards', 'battle', 'card-recruits', 'ecaz-modules', 'implementation-checklist'],
checklist: [
@@ -4639,7 +4641,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Development setup supports two through six distinct seats from the base factions and selected expansion sets. Expansion selection and optional modules are separate; public expansion starts remain disabled.',
'Ecaz places six starting forces in Imperial Basin, allocated among sectors 9, 10 and 11, and keeps fourteen in reserve. Finish Fremen placement first; Bene Gesserit then places its Advanced starting advisor. Moritani places its six forces in an unoccupied territory after all other setup is complete.',
'Ixians keep their private starting-card selection and physical cyborg/suboid forces. Tleilaxu receives three private Face Dancers. Advanced CHOAM receives its Auditor. Richese has a separate ten-card cache and three private No-Fields. Ecaz and Moritani receive their Ambassador and Terror inventories and share one Duke Vidal disc.',
'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: the separate three-card variant has Recruits, bounded Harass & Withdraw and bounded Reinforcements development controls for classic or Ecaz/Moritani games.',
'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck; Ix or combined decks contain47. Ecaz faction selection adds no cards. Explicit independent variant adds Recruits/Harass/Reinforcements for36/50/38, with classic/paired controls and a separately verified native Advanced Occupy combined-army path.',
'Advanced Ixians may explicitly decline Technology for one Richese special lot. Their once-per-round use remains available later. The special-lot exchange itself is unfinished pending replacement-card custody interpretation.',
'Fresh unsupported movement arrivals still reject before opening prevention choices or reserving a new movement card. A saved CHOAM allow choice can return an uncommitted unsupported move for another choice, preserving its existing reserved Ornithopter. The bounded Ecaz/Moritani same-entry Ambassador/Terror overlap instead snapshots both eligible owners in storm order without replaying the original move.',
'Ordinary shipment checks unfinished arrival combinations before saving the Guild interception choice. An already saved ordinary shipment may return unspent when its still-gated arrival cannot commit; older No-Field, Smuggler, Nexus and Ambassador shipments retain their separate recovery boundaries. Classic plus Ecaz/Moritani Ambassador/Terror overlap has a provisional saved storm-order sequence, while other simultaneous priorities remain unfinished.',