Integrate native Ixian CHOAM and Tleilaxu Occupy aftermath

This commit is contained in:
Quintine committed 2026-10-02 14:47:54 +10:00
1 parent 3333432be8
commit 1057eb2719
17 files changed
+934 -89

No files matched your search

+8
View File
@@ -81,6 +81,7 @@ export type AftermathAuditor = {
export type AftermathFaceDance = {
player: string;
winner: string;
winners?: string[];
leader: string | null;
identity: string | null;
territory: string;
@@ -431,6 +432,12 @@ function calculate(input: BattleAftermathInput): BattleAftermathQuote {
'The Face Dance opportunity needs its Tleilaxu owner and another winning faction.',
);
winner(winning.id);
const winners = value.winners;
requireAftermath(winners === undefined ||
(ids(winners) && winners.length === 2 && winners.includes(winning.id) &&
!winners.includes(owner.id) && winners.every(id => input.players.some(p => p.id === id)) &&
winners.some(id => player(id).faction === 'ecaz')),
'Face Dance must replace the actual Ecaz winning coside, never its own faction.');
const to = territory(value.territory);
requireAftermath(
value.leader === null || id(value.leader),
@@ -454,6 +461,7 @@ function calculate(input: BattleAftermathInput): BattleAftermathQuote {
return {
player: owner.id,
winner: winning.id,
...(winners === undefined ? {} : { winners: [...winners] }),
leader: value.leader,
identity: value.identity as string | null,
territory: to,
+6 -2
View File
@@ -2181,7 +2181,8 @@ function policyActions(g: GameView): Action[] {
if (
d.blocked ||
!me.faceDancers?.some((c) => !c.revealed && c.leader === d.identity) ||
me.ally === d.winner
me.ally === d.winner || d.winners?.includes(me.id) ||
(!!me.ally && d.winners?.includes(me.ally))
)
return [{ type: 'decision', reveal: false }];
const winner = g.players.find((p) => p.id === d.winner)!;
@@ -2191,7 +2192,10 @@ function policyActions(g: GameView): Action[] {
const pool = world?.forces?.[winner.id];
let needed = world
? (pool?.normal ?? 0) + (pool?.elite ?? 0)
: countAt(winner, d.territory);
: d.winners
? g.players.reduce((sum, player) => sum +
(d.winners!.includes(player.id) ? fighterCount(player, d.territory) : 0), 0)
: countAt(winner, d.territory);
const sources: Record<string, number> = world
? {}
: {
+51 -28
View File
@@ -839,6 +839,7 @@ export type Decision =
blocked?: string;
player: string;
winner: string;
winners?: string[];
leader: string | null;
identity: string | null;
territory: string;
@@ -1789,6 +1790,8 @@ export type Game = {
receipt?: { event: string; turn: number; physical: string; signature: string };
homeworld?: { pool: HomeworldForces; cyborgsLost: number; eliteTanks: number; normalTanks: number; battleLosses: number };
player: string;
/** Physical Ix owner remains player; the selected lead owns the cleanup. */
winner?: string;
territory: string;
losses: Record<string, number>;
cards: string[];
@@ -1802,6 +1805,7 @@ export type Game = {
pendingFaceDance?: {
player: string;
winner: string;
winners?: string[];
leader: string | null;
identity: string | null;
territory: string;
@@ -9113,13 +9117,24 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game {
requireFreshFactionInventory(state);
return initializeSetupGameForAudit(state, false, false, false, false, false, false, true);
}
function ecazOccupyCompositionSupported(g: Game): boolean {
return g.advanced === true && 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.players.length >= 2 && g.players.length <= 6 &&
g.players.some(p => p.faction === 'ecaz') &&
g.players.every(p => faction(p.faction).expansion === 'base' ||
(['ecaz', 'moritani', 'ixians', 'tleilaxu', 'choam'].includes(p.faction) &&
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;
}
/** 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.');
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.');
requireFreshBaseRuntime(state);
const g = initializeFactionExpansionsGameForAudit(state);
g.ecazOccupyPreview = true;
@@ -20567,12 +20582,15 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
);
}
const tleilaxu = byFaction(g, 'tleilaxu');
if (winner && tleilaxu && winner.id !== tleilaxu.id &&
const winningCoside = winner && b.ecazOccupy?.lead === winner.id
? [b.ecazOccupy.ecaz, b.ecazOccupy.ally] : winner ? [winner.id] : [];
if (winner && tleilaxu && !winningCoside.includes(tleilaxu.id) &&
(!b.territory.startsWith('homeworld:') || homeworldBattleLocation(g, b.territory)!.native === tleilaxu.id)) {
const winningPlan = winner.id === a.id ? ap : dp;
g.pendingFaceDance = {
player: tleilaxu.id,
winner: winner.id,
...(winningCoside.length > 1 ? { winners: winningCoside } : {}),
leader: winningPlan.leader,
identity:
cardOf(winner, winningPlan.leader)?.kind === 'hero'
@@ -20855,11 +20873,11 @@ function settleWinnerCasualties(
`${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);
stageIxSubstitution(g, physicalOwner, to, cards, losses, p.id);
finishWinner(g, p, to, cards);
}
/** Stage faction substitution after actual Tank losses; skills finish before it opens. */
function stageIxSubstitution(g: Game, p: Player, to: string, cards: string[], losses: Record<string, number>) {
function stageIxSubstitution(g: Game, p: Player, to: string, cards: string[], losses: Record<string, number>, winner = p.id) {
const cyborgsLost = Object.values(losses).reduce((sum, count) => sum + count, 0);
const survivingSuboids = to.startsWith('homeworld:') ? combatArmy(g, p.id, to).normal : Object.entries(p.forces)
.filter(([key]) => splitLocation(key).territory === to)
@@ -20869,6 +20887,7 @@ function stageIxSubstitution(g: Game, p: Player, to: string, cards: string[], lo
);
if (p.faction === 'ixians' && cyborgsLost > 0 && survivingSuboids > 0) {
g.pendingIxSubstitution = { player: p.id, territory: to, losses, cards,
...(winner !== p.id ? { winner } : {}),
...(to.startsWith('homeworld:') ? { homeworld: {
pool: combatArmy(g, p.id, to), cyborgsLost,
eliteTanks: p.elites!.tanks, normalTanks: p.tanks - p.elites!.tanks, battleLosses: p.battleLosses,
@@ -20885,6 +20904,7 @@ function stageIxSubstitution(g: Game, p: Player, to: string, cards: string[], lo
function ixSubstitutionSignature(pending: NonNullable<Game['pendingIxSubstitution']>) {
return JSON.stringify([pending.player, pending.territory, pending.losses, pending.cards,
pending.receipt?.event, pending.receipt?.turn, pending.receipt?.physical,
...(pending.winner === undefined ? [] : [pending.winner]),
...(pending.sources === undefined ? [] : [pending.sources, pending.recover])]);
}
function ixSubstitutionIntegrity(g: Game) {
@@ -20900,11 +20920,11 @@ function ixSubstitutionIntegrity(g: Game) {
const owner = pending && g.players.find(p => p.id === pending.player);
requireRule(pending?.receipt && owner?.faction === 'ixians' && obligation && !obligation.completed &&
context.event === pending.receipt.event && context.turn === pending.receipt.turn && g.turn === context.turn &&
context.winner === pending.player && context.territory === pending.territory &&
context.winner === (pending.winner ?? pending.player) && context.territory === pending.territory &&
g.phase === 6 && !g.battle && g.status === 'playing' &&
pending.receipt.signature === obligation.signature && pending.receipt.signature === ixSubstitutionSignature(pending) &&
pending.receipt.physical === sukPhysicalSignature(owner) &&
pending.cards.every(id => owner.hand.some(card => card.id === id)),
pending.cards.every(id => getPlayer(g, pending.winner ?? pending.player).hand.some(card => card.id === id)),
'The saved Ixian substitution changed its original battle losses, cards or physical counters.');
const decisions = homeworldSavedDecisions(g);
const selecting = decisions.filter(d => d.kind === 'ixSubstitution');
@@ -21154,10 +21174,10 @@ function finishWinner(g: Game, winner: Player, t: string, cards: string[]) {
if (startRihaniVictory(g, winner, t, cards)) return;
if (g.pendingIxSubstitution) {
const pending = g.pendingIxSubstitution;
requireRule(pending.player === winner.id && pending.territory === t &&
requireRule((pending.winner ?? pending.player) === winner.id && pending.territory === t &&
JSON.stringify(pending.cards) === JSON.stringify(cards) && !pending.sources && !pending.recover,
'Resume the original Ixian substitution after Leader Skills.');
g.decision = { kind: 'ixSubstitution', player: winner.id, territory: t, losses: pending.losses };
g.decision = { kind: 'ixSubstitution', player: pending.player, territory: t, losses: pending.losses };
return;
}
if (g.pendingWinnerDiscards) {
@@ -22883,7 +22903,7 @@ function finishResponse(g: Game, canceled: boolean,bureaucratDiversion?:number)
'Karama prevented Ixian substitution. Original cyborg casualties remain in the Tanks; no surviving suboids were exchanged.',
);
}
finishWinner(g, player, pending.territory, pending.cards);
finishWinner(g, getPlayer(g, pending.winner ?? pending.player), pending.territory, pending.cards);
} else if (
[
'choamRevival',
@@ -27534,7 +27554,7 @@ function applyActionInner(
const pending = g.pendingIxSubstitution!;
if (action.decline === true) {
completeIxSubstitution(g);
finishWinner(g, p, pending.territory, pending.cards);
finishWinner(g, getPlayer(g, pending.winner ?? pending.player), pending.territory, pending.cards);
} else {
const parse = (input: unknown, available: Record<string, number>) => {
requireRule(
@@ -27601,8 +27621,10 @@ function applyActionInner(
requireRule(!blocked, blocked ?? 'This Face Dance return is unavailable.');
const home = homeworldBattleLocation(g, decision.territory);
requireRule(!home || home.native === p.id, 'Only the native faction may call a Face Dancer on a Homeworld.');
const winningArmies = (decision.winners ?? [winner.id]).map(owner => getPlayer(g, owner));
const army = combatArmy(g, winner.id, decision.territory);
const maximum = home ? army.normal + army.elite : at(winner, decision.territory);
const maximum = home ? army.normal + army.elite :
winningArmies.reduce((sum, owner) => sum + at(owner, decision.territory), 0);
const sources = action.sources;
requireRule(
sources && typeof sources === 'object' && !Array.isArray(sources),
@@ -27653,16 +27675,17 @@ function applyActionInner(
if (owner.elites) owner.elites.reserves = update.eliteReserves;
}
} else {
for (const key of Object.keys(winner.forces).filter(
(key) => splitLocation(key).territory === decision.territory,
)) {
winner.reserves += winner.forces[key];
if (winner.elites) {
winner.elites.reserves += winner.elites.forces[key] ?? 0;
delete winner.elites.forces[key];
for (const owner of winningArmies)
for (const key of Object.keys(owner.forces).filter(
(key) => splitLocation(key).territory === decision.territory,
)) {
owner.reserves += owner.forces[key];
if (owner.elites) {
owner.elites.reserves += owner.elites.forces[key] ?? 0;
delete owner.elites.forces[key];
}
delete owner.forces[key];
}
delete winner.forces[key];
}
}
const leader = g.players
.flatMap((player) => player.leaders)
@@ -29942,10 +29965,10 @@ function applyActionInner(
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.');
requireRule(ecazOccupyCompositionSupported(g) &&
(g.ecazOccupyPreview === true || g.players.every(player =>
faction(player.faction).expansion === 'base' || ['ecaz', 'moritani'].includes(player.faction))),
'Advanced combined Occupy supports source-selected native factions only in the fresh Occupy profile; Richese/No-Field and 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] };
} else {
+6 -5
View File
@@ -40,9 +40,10 @@ export function quoteIxSubstitutionCancellation(
'This canceled Ixian substitution has no current declaration.',
);
const owner = g.players.find((player) => player.id === p.player);
const winner = g.players.find((player) => player.id === (p.winner ?? p.player));
requireIx(
owner?.faction === 'ixians',
'The canceled substitution needs its Ixian winner.',
owner?.faction === 'ixians' && winner,
'The canceled substitution needs its actual Ixian force owner and selected winner.',
);
const homeworld = g.combatLocations?.find(
(location) => location.id === p.territory && location.kind === 'homeworld',
@@ -69,7 +70,7 @@ export function quoteIxSubstitutionCancellation(
...(homeworld ? [homeworld.id] : []),
],
territory: p.territory,
winner: p.player,
winner: winner.id,
context: g.lastBattleContext,
});
} catch (error) {
@@ -115,7 +116,7 @@ export function quoteIxSubstitutionCancellation(
new Set(p.cards).size === p.cards.length &&
p.cards.every(
(card) =>
owner.hand.filter((c) => c.id === card).length === 1 &&
winner.hand.filter((c) => c.id === card).length === 1 &&
g.physicalCards.filter((c) => c.id === card).length === 1,
),
'The winning battle cards must remain available for cleanup.',
@@ -127,7 +128,7 @@ export function quoteIxSubstitutionCancellation(
decision: p.cards.length
? {
kind: 'battleCards' as const,
player: owner.id,
player: winner.id,
territory: p.territory,
cards: [...p.cards],
}
+5 -3
View File
@@ -1263,14 +1263,16 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'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.',
],
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: '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 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'] },
{ 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'] },
],
related: ['faction-ecaz', 'advanced-ecaz', 'ecaz-ambassadors', 'battle', 'victory', 'implementation-checklist'],
},