Compose source-clear Basic even-force Ecaz Occupy

This commit is contained in:
Quintine committed 2026-10-03 20:34:22 +10:00
1 parent 63e57bd894
commit 5f082696a6
18 files changed
+861 -52

No files matched your search

+3 -1
View File
@@ -25,6 +25,8 @@ export type BoardSeat = Pick<Player, 'id' | 'faction' | 'ally' | 'advisors'> &
ForcePresence;
export type BoardContext = MobileBoard & {
advanced: boolean;
/** Fresh Basic coalition protocol; existing played Basic tables are not retrofitted. */
ecazOccupyPreview?: boolean;
storm: number;
order: readonly string[];
players: readonly BoardSeat[];
@@ -121,7 +123,7 @@ export function quoteBattleBoard(g: BoardContext) {
};
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') &&
return (g.advanced || g.ecazOccupyPreview === true) && ally && (seat.faction === 'ecaz' || ally.faction === 'ecaz') &&
fighterCount(ally, site) > 0 ? [seat, ally] : [seat];
};
const emitted = new Set<string>();
+2 -1
View File
@@ -138,9 +138,10 @@ function calculate(
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),
requireCombat(input.advanced === occupy.advanced && ecaz?.faction === 'ecaz' && ally && combatants.includes(occupy.lead),
'Occupy force powers need the actual Ecaz coalition and selected lead.');
const profile = quoteEcazOccupyBattle({
advanced: input.advanced,
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,
+17 -5
View File
@@ -27,6 +27,7 @@ export type EcazOccupyBattleSeat = Readonly<{
noFieldPresence?: true;
}>;
export type EcazOccupyBattleInput = Readonly<{
advanced: boolean;
/** Original battle chooser, before Ecaz selects the coalition's plan actor. */
battleOrderActor: string;
ecaz: EcazOccupyBattleSeat;
@@ -35,6 +36,7 @@ export type EcazOccupyBattleInput = Readonly<{
canceled: boolean;
}>;
export type EcazOccupyBattleProfile = Readonly<{
advanced: boolean;
battleOrderActor: string;
ecaz: string;
ally: string;
@@ -90,13 +92,17 @@ function requireBattle(condition: unknown, message: string): asserts condition {
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. */
/** Advanced retains ceil(E/2); source-clear Basic admits only even E and uses
* E/2. The increment is free. 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;
const { advanced, ecaz, ally, lead, canceled, battleOrderActor } = input;
requireBattle(
typeof advanced === 'boolean',
'The Occupy profile needs an explicit Basic or Advanced rules band.',
);
requireBattle(
ecaz && ally && text(ecaz.id) && text(ally.id) && ecaz.id !== ally.id &&
ecaz.faction === 'ecaz' && ally.faction !== 'ecaz' &&
@@ -131,10 +137,16 @@ export function quoteEcazOccupyBattle(
typeof canceled === 'boolean',
'The Occupy profile needs its pre-plan cancellation state.',
);
requireBattle(
advanced || canceled || ecaz.forces.normal % 2 === 0,
'Uncanceled Basic Occupy needs an even Ecaz force count; odd-force publisher casualty wording remains unresolved.',
);
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);
const fixedEcazDial = canceled ? 0 :
advanced ? Math.ceil(ecaz.forces.normal / 2) : ecaz.forces.normal / 2;
return {
advanced,
battleOrderActor,
ecaz: ecaz.id,
ally: ally.id,
+22 -12
View File
@@ -7338,7 +7338,7 @@ function ecazBattleProfile(g: Game): EcazOccupyBattleProfile | null {
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' &&
requireRule(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) &&
(occupyArmiesConnected(g, ecaz, ally, b.territory) ||
@@ -7347,6 +7347,7 @@ function ecazBattleProfile(g: Game): EcazOccupyBattleProfile | null {
'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({
advanced: g.advanced,
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,
@@ -9230,10 +9231,13 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game {
return initializeSetupGameForAudit(state, false, false, false, false, false, false, true);
}
function ecazOccupyCompositionSupported(g: Game): boolean {
return g.advanced === true && Array.isArray(g.expansions) &&
return typeof g.advanced === 'boolean' && Array.isArray(g.expansions) &&
g.expansions.includes('ecaz') &&
g.expansions.every(id => ['ecaz', 'ix', 'choam'].includes(id)) &&
new Set(g.expansions).size === g.expansions.length &&
(g.advanced || (g.expansions.length === 1 && g.expansions[0] === 'ecaz' &&
!g.ecazTreachery && g.players.every(p => p.faction === 'ecaz' ||
p.faction === 'moritani' || CLASSIC_FACTIONS[p.faction]))) &&
g.players.length >= 2 && g.players.length <= 6 &&
g.players.some(p => p.faction === 'ecaz') &&
g.players.every(p => faction(p.faction).expansion === 'base' ||
@@ -9244,10 +9248,10 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
!g.discoveryStash && !g.greatMaker &&
(g.ecazTreachery === undefined || g.ecazTreachery === true);
}
/** Fresh native Advanced Occupy profile; public starts and Basic are unchanged. */
/** Fresh native Occupy profile; Basic's disputed odd-force arithmetic stays guarded. */
export function initializeEcazOccupyGameForAudit(state: Game): Game {
requireRule(ecazOccupyCompositionSupported(state),
'Advanced Occupy requires native Ecaz, classic/Moritani/Ixian/Tleilaxu/CHOAM/Richese factions and their distinct selected family decks, without optional overlays.');
'Occupy requires supported native factions and their distinct selected decks; Basic uses Ecaz/classic/optional Moritani without optional modules.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
const g = initializeSetupGameForAudit(state, false, false, false, false, false, false, true, state.ecazTreachery === true);
@@ -18628,7 +18632,7 @@ function validatePlan(
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 typedForces = g.advanced || p.faction === 'ixians' || !!occupy;
const bankerSpice = input.bankerSpice === undefined ? 0 : input.bankerSpice;
requireRule(typeof bankerSpice === 'number' && Number.isSafeInteger(bankerSpice) && bankerSpice >= 0 && bankerSpice <= 3,
'Choose zero to decline, or one through three spice for Spice Banker.');
@@ -30124,6 +30128,9 @@ function applyActionInner(
const ecaz = byFaction(g, 'ecaz');
const ally = ecaz?.ally ? getPlayer(g, ecaz.ally) : null;
const connected = ecaz && ally && occupyArmiesConnected(g, ecaz, ally, choice.territory);
const combined = (g.advanced || g.ecazOccupyPreview === true) && ecaz &&
ally?.ally === ecaz.id && connected && !isAdvisor(ally, choice.territory) &&
[choice.attacker, choice.defender].some(seat => seat === ecaz.id || seat === ally.id);
const noFieldPlayers = g.players.filter(player =>
player.noField?.deployed?.location.territory === choice.territory &&
(player.id === choice.attacker || player.id === choice.defender ||
@@ -30136,6 +30143,12 @@ function applyActionInner(
),
'Mixed ordinary-force and No-Field battle dialing awaits a ruling. Reveal voluntarily before Battle while that option is available.',
);
if (combined) {
requireRule(ecazOccupyCompositionSupported(g),
'Combined Occupy requires source-selected native factions; optional overlays remain gated.');
requireRule(g.advanced || fighterCount(ecaz!, choice.territory) % 2 === 0,
'Odd-force Basic Occupy awaits the preserved publisher casualty-rounding ruling.');
}
const battleEvent = crypto.randomUUID();
g.battle = {
...choice,
@@ -30155,12 +30168,9 @@ function applyActionInner(
revealed: false,
traitorCalls: {},
};
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(ecazOccupyCompositionSupported(g),
'Advanced combined Occupy requires source-selected native factions; optional overlays remain gated.');
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] };
if (combined) {
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);
@@ -30244,7 +30254,7 @@ function applyActionInner(
);
const value =
field === 'dial'
? g.advanced || p.faction === 'ixians'
? g.advanced || p.faction === 'ixians' || b.ecazOccupy?.lead === p.id
? typeof action.value === 'number'
? action.value
: NaN
+7 -6
View File
@@ -1257,10 +1257,11 @@ export const RULE_TOPICS: RuleTopic[] = [
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.',
summary: 'Ecaz chooses who leads its combined army. Basic uses the source-clear even-force half contribution and losses; the authorized Advanced revision supplies ceiling strength and lead-own forces after Karama. Bounded fresh local rules 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.',
'Basic combined battles are admitted for the exact Ecaz deck with classic or optional Moritani opponents and no optional modules. An even Ecaz force count contributes half at full strength and loses half on an ordinary win; the printed page-8 and page-15 odd-count examples conflict, so an uncanceled odd Basic count is rejected before any lead choice rather than choosing a rounding. Basic has no Emperor/Fremen elite inventory and its supplied support normalizes to zero.',
'With Advanced 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.',
'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.',
@@ -1269,14 +1270,14 @@ export const RULE_TOPICS: RuleTopic[] = [
'Stone Burner compares physical undialed counters, not total dial or paid strength: active Occupy contributes floor(Ecaz forces / 2) plus the ally’s native undialed tokens. Canceled Occupy compares only the selected lead’s own pool. Physical Mirror copies use the same comparison and retain their original cleanup. Hidden opposing No-Field preflight uses public possible pools, not its actual denomination; genuinely winner-changing typed allocations remain blocked.',
'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/Richese two-to-six-seat Advanced with required family decks:33 Ecaz alone,47 Ix,35 CHOAM,47 both; Richese retains its separate ten-card cache. Explicit --ecaz-treachery independently adds all three cards for36/50/38. Own mixed No-Field dialing, whole-plan disclosure, Ix Stone/Tooth timing, Harass/Stone and optional overlays remain open. Full factions/public starts stay gated.',
'These numbers come from the user-authorized revised Advanced PDF, physical pages 32 and 19, not a newly located publisher erratum. Basic keeps the preserved even-only composition above. Fresh ecaz-occupy admits Ecaz/classic/optional Moritani/Ixians/Tleilaxu/CHOAM/Richese two-to-six-seat Advanced with required family decks:33 Ecaz alone,47 Ix,35 CHOAM,47 both; Richese retains its separate ten-card cache. Basic admits Ecaz/classic/optional Moritani and the exact ecaz deck. Explicit --ecaz-treachery independently adds all three cards for36/50/38. Own mixed No-Field dialing, whole-plan disclosure, Ix Stone/Tooth timing, Harass/Stone and optional 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 390px controls cover mandatory/free dials, native Karama redial, Ix substitutions and whole-side Face Dance. Richese human T78EFNZK chose Ecaz lead, sealed total5/support2 with its real leader, revealed the ally’s original No-Field5 into only two available reserves, and reached Collection with Ecaz2 and two Richese losses. Other combinations remain open.', evidence: ['components/ecaz-occupy.tsx', 'components/face-dance-decision.tsx', 'components/game-table.tsx', 'game/ecaz-occupy-options.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Combined-side enumeration, explicit lead choice, native Occupy response, source-band even Basic or Advanced arithmetic 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 390px controls cover mandatory/free dials, native Karama redial, Ix substitutions and whole-side Face Dance. Richese human T78EFNZK chose Ecaz lead, sealed total5/support2 with its real leader, revealed the ally’s original No-Field5 into only two available reserves, and reached Collection with Ecaz2 and two Richese losses. Basic human FEJ6SBFB shows the correct even-force labels, a total-dial slider bounded2..6 with no support control, and real Ecaz/guild casualties after an8–6 win. Other combinations remain open.', 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: affected363 rules cases,26 actual native programs including all four legal policies, and original390px Richese/No-Field controls. The previously stalled six-native Advanced game completes2424 accepted actions, no rejections and65JSON continuations. Earlier46 ordinary/variant games and source-specific card evidence remain historical; complete factions/optional combinations and comprehensive assurance follow all rules.', evidence: ['tests/ecaz-occupy-richese.test.ts', 'tests/ecaz-occupy-stone.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Development Verified: affected363 rules cases,26 actual native programs including all four legal policies, and original390px Richese/No-Field controls. Source-band Basic adds focused even/odd/canceled quote cases and native runtime cases with all four policies and original Collection/victory. Five genuine2..6-seat Basic games: four complete2090 accepted actions/54 JSON continuations/no rejection; four-seat seed20261299 is captured at the preserved odd-count conflict. Earlier46 ordinary/variant games and source-specific card evidence remain historical; complete factions/optional combinations and comprehensive assurance follow all rules.', evidence: ['tests/ecaz-occupy-battle.test.ts', 'tests/basic-ecaz-occupy-runtime.test.ts', 'tests/ecaz-occupy-richese.test.ts', 'tests/ecaz-occupy-stone.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
],
related: ['faction-ecaz', 'advanced-ecaz', 'ecaz-ambassadors', 'battle', 'victory', 'implementation-checklist'],
},
+1
View File
@@ -82,6 +82,7 @@ function validateContext(context: TraitorDeclarationContext): void {
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({
advanced: occupy.advanced,
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,