From 1057eb271938a92a651feebeeef13738659ec8c0 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:47:54 +1000 Subject: [PATCH] Integrate native Ixian CHOAM and Tleilaxu Occupy aftermath --- README.md | 8 +- components/face-dance-decision.tsx | 14 +- docs/CURRENT_STATUS.md | 17 ++ docs/ECAZ_OCCUPY_RULES.md | 39 +++- docs/IMPLEMENTATION_STATUS.md | 46 ++++ docs/RULE_DECISIONS.md | 7 +- game/battle-aftermath-quote.ts | 8 + game/bots.ts | 8 +- game/engine.ts | 79 ++++--- game/ix-substitution-cancellation.ts | 11 +- game/reference.ts | 8 +- tests/ecaz-occupy-ix-choam-runtime.test.ts | 237 +++++++++++++++++++++ tests/ecaz-occupy-tleilaxu-runtime.test.ts | 154 +++++++++++++ tests/fixture-ecaz-ix-choam.ts | 211 ++++++++++++++++++ tests/fixture-ecaz-occupy.ts | 134 +++++++++--- tools/faction-games.ts | 40 +++- tools/start-prototype.ts | 2 +- 17 files changed, 934 insertions(+), 89 deletions(-) create mode 100644 tests/ecaz-occupy-ix-choam-runtime.test.ts create mode 100644 tests/ecaz-occupy-tleilaxu-runtime.test.ts create mode 100644 tests/fixture-ecaz-ix-choam.ts diff --git a/README.md b/README.md index ecf63d74..7e509d11 100644 --- a/README.md +++ b/README.md @@ -242,9 +242,11 @@ save/privacy/custody assurance follows all rules. See the For local **Advanced Ecaz combined-army Occupy**, use `node --import tsx tools/start-prototype.ts --profile ecaz-occupy` with the existing `--db`, `--room`, `--version` and private `--out` arguments -on a fresh ready2..6-seat Advanced lobby: native Ecaz, classic opponents, -optional native Moritani, expansion `ecaz` alone and ordinary33 Treachery Cards. -No three-card variant or other overlays. The selected lead owns leaders/cards/ +on a fresh ready2..6-seat Advanced lobby: native Ecaz with classic, Ixian, +Tleilaxu and CHOAM opponents, optional native Moritani. Select distinct `ecaz` +and each native `ix`/`choam` family: ordinary33/47/35 cards retain original +offers/HMS/Auditor/Face Dancers. Richese mixed planning, three-card variant +and other overlays remain unfinished. The selected lead owns leaders/cards/ payment; total dial includes mandatory free `ceil(E/2)` plus native allied strength. Karama switches to the selected lead's own army, other ally zero. Whole participating armies must be clear and connected; BG's voluntary fighter diff --git a/components/face-dance-decision.tsx b/components/face-dance-decision.tsx index 9c672a26..c8577a39 100644 --- a/components/face-dance-decision.tsx +++ b/components/face-dance-decision.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { territory, splitLocation } from '@/game/board'; import type { Action, GameView } from '@/game/engine'; +import { fighterCount } from '@/game/advisors'; import { Button } from './ui/button'; import { Input } from './ui/input'; export function FaceDanceDecision({ @@ -27,9 +28,10 @@ export function FaceDanceDecision({ d?.kind === 'faceDance' ? world ? (foreign?.normal ?? 0) + (foreign?.elite ?? 0) - : Object.entries(g.players.find((p) => p.id === d.winner)!.forces) - .filter(([key]) => splitLocation(key).territory === d.territory) - .reduce((sum, [, count]) => sum + count, 0) + : d.winners + ? g.players.reduce((sum, player) => sum + + (d.winners!.includes(player.id) ? fighterCount(player, d.territory) : 0), 0) + : fighterCount(g.players.find((player) => player.id === d.winner)!, d.territory) : 0; const [sources, setSources] = useState>( world @@ -65,9 +67,9 @@ export function FaceDanceDecision({ {held ? ( <>

- Return the winner’s {maximum} remaining forces to reserves. Replace - up to {maximum} with your forces. Their leader goes to the tanks - without a spice bounty. + Return the {d.winners ? 'winning allied side’s' : 'winner’s'} {maximum} remaining + forces to reserves. Replace up to {maximum} with your forces. The + selected winning leader goes to the tanks without a spice bounty.

{world && (

diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 22bdd0da..5ca5d0a2 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -25,6 +25,23 @@ authorization and deployment/publication safeguards remain intact. Use the [bounded context index](DEVELOPMENT.md#context-index) and [feedback/proof schedule](DEVELOPMENT.md#feedback-and-proof-schedule). +**Native E1/E2 Ecaz Occupy — bounded Development Verified:** existing profile +now composes Ixians/Tleilaxu/CHOAM with Ecaz/classic/optional Moritani2..6 and +distinct selected E3/E1/E2 ordinary33/47/35 decks. Physical Ix owns native +Suboid substitution, selected lead keeps card aftermath; CHOAM original paid +support shares exclude free fixed Ecaz strength. External FaceDance returns +both winning armies with combined maximum; own-winning Tleilaxu is excluded. +Native34, affected191/191, types/lint and19 actual program cases pass;23 +unstaged games complete13,004 accepted/no rejects/339JSON continuations, +source unchanged. They contain no combined lead choice; native cases and +human390px **5B4FW8D3 v12** and **UVGT4DLN v10** prove that rule path. +Two scoped rule reviews find no defect. Richese mixed planning, optional +overlays, full factions/public starts remain gated; assurance follows all rules. +See [native force-owner integration](ECAZ_OCCUPY_RULES.md#native-e1e2-force-owner-integration--2-october-2026). +Final types/lint, canonical guide10/10, build and390px expanded guide pass; +six changed-region local links/anchors are valid. Owned drivers/tabs/workers +are removed/closed without a saved-game reset. + **Advanced Ecaz Occupy — bounded Development Verified:** the authorized supplied PDF physical32/19 resolves ceiling-half free Ecaz contribution, floor-half ordinary survivors and selected-lead-own forces after Karama. diff --git a/docs/ECAZ_OCCUPY_RULES.md b/docs/ECAZ_OCCUPY_RULES.md index 35349f87..acb5c1dc 100644 --- a/docs/ECAZ_OCCUPY_RULES.md +++ b/docs/ECAZ_OCCUPY_RULES.md @@ -48,8 +48,8 @@ save/privacy/custody assurance follows all rules under the2 October priority. ### Bounded native rule and control proof -The fresh local `ecaz-occupy` profile admits Ecaz/classic/optional Moritani, -2..6-seat Advanced, ordinary33 Treachery Cards and the E3 faction setup alone. +The first checkpoint admitted Ecaz/classic/optional Moritani,2..6-seat +Advanced, ordinary33 Treachery Cards and the E3 faction setup alone. It does not select the independent three-card variant, other overlays, public expansion starts or an existing-game retrofit. @@ -77,6 +77,41 @@ a combined lead choice: profile continuation is observed there, while the native cases and human rooms establish the actual combined rule path. +### Native E1/E2 force-owner integration — 2 October 2026 + +The existing profile now admits native Ixians, Tleilaxu and CHOAM with Ecaz, +classic opponents and optional Moritani. Distinct selected decks include `ecaz` +and each native `ix`/`choam` family:33 ordinary with Ecaz alone,47 with Ix, +35 with CHOAM alone,47 with both. Genuine setup preserves original Ix offers/ +HMS, Tleilaxu Face Dancers and CHOAM Auditor. Richese mixed No-Field planning, +independent E3 Treachery and optional overlays remain explicitly unfinished. + +Physical Ix variable losses lead to the actual Ixian's Suboid substitution +decision/counter, including when Ecaz formed the plan. Accept, decline and +Karama prevention retain the original selected lead's cards and winner suffix. +CHOAM receives only its original eligible paid support shares; mandatory free +Ecaz strength mints no support payment, donor share or traitor income. +An actual Tleilaxu winning co-side cannot Face Dance itself. An external +Face Dancer returns both surviving Ecaz/allied armies, with replacement maximum +equal to their combined survivors, while original selected winner rewards/ +cards remain. Sources are real reserves/board choices; no fake merged player. + +Native34 rule cases, coherent affected191/191, types/lint and19 actual program +cases pass, including Ix substitution decline/prevention and zero/full whole +co-side replacement. Twenty-three unstaged classic/paired/mixed2..6 games +complete with13,004 accepted actions/no rejection and339JSON continuations, +`sourceUnchanged:true`. They contain no combined lead choice; those samples +prove profile continuation, not the rare rule transitions covered by native +cases and human controls. Two scoped rule reviews find no defect. + +Original CLI/human390px **5B4FW8D3 v12** let physical Ixians sacrifice two +Suboids to recover two Cyborgs after an Ecaz-led plan; Ecaz retained its Shield +and the real Collection opened with Ecaz2/Ix4 including two Cyborgs. +**UVGT4DLN v10** exposed and used the full three-force replacement maximum, +returned Ecaz1/Guild2 to their own reserves, placed three Tleilaxu forces and +retained the original Ecaz Slip Tip and bounty. Both continue the original +phase rather than manufacturing another battle or card owner. + ## Provenance and attribution correction | Source | Observed wording or correction | Authority and conclusion | diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 95dea119..7de52a4f 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,51 @@ # Dune implementation status +## 2 October 2026 — native Ixian, Tleilaxu and CHOAM Ecaz Occupy integration + +The existing Advanced `ecaz-occupy` profile now admits source-selected native +Ixians/Tleilaxu/CHOAM with Ecaz/classic/optional Moritani2..6, distinct E3/E1/E2 +decks and ordinary33/47/35 cards. Genuine original setup, offers/HMS/Auditor/ +Face Dancers remain. No Richese mixed No-Field, independent E3 Treachery or +optional overlays, public start or existing-game retrofit is activated. + +Three bounded owners integrated actual physical Ix casualties/Suboid exchange +with selected lead card cleanup, native CHOAM original support shares, and +external Face Dance returning both actual winning co-side armies while +retaining selected winner rewards. Same-side Tleilaxu cannot Face Dance itself. +Parent human/four-policy maximum now sums actual co-side fighters without a +fake merged player. Two scoped rule reviews find no defect. + +First Ecaz-led funded Ix case passed. Exact FaceDance return/replacement passed +but an incidental same-phase pin rejected valid automatic Collection; deleted, +not re-pinned. Narrow feedback also retained real CHOAM movement decisions/ +opening passes and generic RNG original-input forwarding. Native34 cases, +coherent affected191/191, types/lint and19 actual native-program cases pass. +The program includes accept/decline/Karama-denied Ix exchange, own/other/donor/ +traitor CHOAM payments, and zero/full whole-co-side replacement. + +Twenty-three unstaged classic/paired/mixed2..6 games complete with13,004 +accepted actions/no rejection and339JSON continuations, `sourceUnchanged:true`. +Exact family metadata33/47/35 matches selected decks. No sample reaches a +combined lead choice; native programs/meaningful cases and human rooms establish +that rare rule path, while game samples prove profile continuation. + +Original CLI/human390px **5B4FW8D3 v12** let actual Ixians exchange two +surviving Suboids for two lost Cyborgs after Ecaz formed the plan. Ecaz retained +its Shield; Collection opened with Ecaz2/Ix4 including two Cyborgs. +**UVGT4DLN v10** displayed/used combined replacement maximum3, returned +Ecaz1/Guild2 to their own reserves, placed three Tleilaxu forces and retained +Ecaz’s original Slip Tip and bounty. Both continue original Collection. + +Final types/lint (`check:quick`), canonical guide10/10 and build pass. The +final390px guide retains Partial / Development: Verified and the exact expanded +rule boundaries; six changed-region local links/anchors are valid. Owned +native drivers are removed, three managed human/guide tabs and both workers +are closed. Private original setups and immutable game reports remain outside Git. + +No new recovery/privacy/custody/seat/getter/HTTP-suite/full6,500-suite assurance +campaign. Full rules, combined modes, later comprehensive assurance, +administration and deployment remain unfinished. + ## 2 October 2026 — authorized Advanced Ecaz Occupy combined-army rules The user-authorized supplied revision, physical32/19, resolves the Advanced diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index e77636c0..1e40fcc0 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -105,8 +105,11 @@ above the supplied sheet's contrary exception. The fresh, bounded `ecaz-occupy` rule/control implementation is **Partial / Development Verified**. Whole participating armies must be clear and connected under physical pages6/11; canonical co-side membership preserves voluntary BG -advisor conversion. Basic is unchanged. Optional/exotic compositions and full -Ecaz acceptance remain open. See the +advisor conversion. Basic is unchanged. The existing profile now additionally +composes native Ixian typed casualties/Suboid substitution, actual CHOAM payer/ +donor income and whole Ecaz co-side FaceDance return with original lead rewards. +Exact selected E3/E1/E2 decks retain33/47/35 cards; Richese mixed planning, +other overlays and full Ecaz acceptance remain open. See the [source comparison and exact cutover](ECAZ_OCCUPY_RULES.md#authorized-advanced-cutover--2-october-2026). Comprehensive recovery/privacy/custody review follows all rules rather than becoming another prototype assurance wave. diff --git a/game/battle-aftermath-quote.ts b/game/battle-aftermath-quote.ts index e5b799c0..e479b4f3 100644 --- a/game/battle-aftermath-quote.ts +++ b/game/battle-aftermath-quote.ts @@ -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, diff --git a/game/bots.ts b/game/bots.ts index 1da2086f..7d0c0b32 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -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 = world ? {} : { diff --git a/game/engine.ts b/game/engine.ts index fe40ad34..be3cdeaf 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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; 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) { +function stageIxSubstitution(g: Game, p: Player, to: string, cards: string[], losses: Record, 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) { 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) => { 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 { diff --git a/game/ix-substitution-cancellation.ts b/game/ix-substitution-cancellation.ts index 8f2bb860..0f86eba8 100644 --- a/game/ix-substitution-cancellation.ts +++ b/game/ix-substitution-cancellation.ts @@ -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], } diff --git a/game/reference.ts b/game/reference.ts index 503d5ce9..93787e67 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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'], }, diff --git a/tests/ecaz-occupy-ix-choam-runtime.test.ts b/tests/ecaz-occupy-ix-choam-runtime.test.ts new file mode 100644 index 00000000..df564002 --- /dev/null +++ b/tests/ecaz-occupy-ix-choam-runtime.test.ts @@ -0,0 +1,237 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { ecazOccupyTerritoryForces } from './fixture-ecaz-occupy'; +import { + nativeIxOccupyCase, nativeChoamOccupyCase, nativeOccupyPlayer, + type NativeIxOccupyCase, type NativeChoamOccupyCase, +} from './fixture-ecaz-ix-choam'; + +function forces(game: Game, result: NativeIxOccupyCase | NativeChoamOccupyCase, owner: string): number { + return ecazOccupyTerritoryForces(game, owner, result.fixture.territory); +} +function cyborgs(game: Game, result: NativeIxOccupyCase): number { + return nativeOccupyPlayer(game, result.fixture.ally).elites?.forces[result.fixture.location] ?? 0; +} +function conservedIx(game: Game, result: NativeIxOccupyCase): void { + const ix = nativeOccupyPlayer(game, result.fixture.ally); + assert.equal(ix.reserves + ix.tanks + Object.values(ix.forces).reduce((sum, count) => sum + count, 0), 20); + assert.ok(ix.elites); + assert.equal(ix.elites.reserves + ix.elites.tanks + + Object.values(ix.elites.forces).reduce((sum, count) => sum + count, 0), 7); +} +function sameEvent(result: NativeIxOccupyCase): void { + const event = result.before.battle?.ecazOccupy?.event; + assert.ok(event); + assert.equal(result.afterLosses.lastBattleContext?.event, event, + 'Typed physical casualties belong to the original Occupy battle.'); + assert.equal(result.game.lastBattleContext?.event, event, + 'Ix substitution and selected-lead cleanup must not create a second battle.'); +} + +for (const lead of ['ecaz', 'ally'] as const) + for (const commitment of ['funded', 'half'] as const) + void test(`Native mixed Ix ${commitment} commitment with ${lead} lead settles typed losses, Suboids and lead cards`, () => { + const result = nativeIxOccupyCase({ lead, commitment }); + const { fixture, actor, before, afterReveal, afterLosses, afterSubstitution, game } = result; + const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz; + const expectedLost = commitment === 'funded' ? 4 : 5; + const expectedCyborgsLost = commitment === 'funded' ? 2 : 1; + const profile = viewGame(before, actor).battle?.ecazOccupy?.profile; + assert.ok(profile); + assert.equal(profile.forceOwner, fixture.ally); + assert.equal(profile.payer, actor); + assert.equal(profile.forces.normalFixedHalf, true, 'Actual Suboids stay half-strength regardless of selected lead.'); + assert.equal(profile.maxSupport, 2, 'Only two actual Cyborgs can receive paid support.'); + assert.equal(profile.fixedEcazDial, 3); + assert.ok(afterReveal.decision?.kind === 'battleLosses'); + assert.equal(afterReveal.decision.player, actor); + assert.equal(afterReveal.decision.forceOwner, fixture.ally); + assert.ok(afterReveal.decision.options.some(loss => loss.normal === (commitment === 'funded' ? 2 : 4) && + loss.elite === expectedCyborgsLost), 'The selected dial has a real physical typed winner allocation.'); + assert.equal(forces(afterLosses, result, fixture.ally), 8 - expectedLost); + assert.equal(cyborgs(afterLosses, result), 2 - expectedCyborgsLost); + assert.equal(nativeOccupyPlayer(afterLosses, fixture.ally).elites!.tanks - + nativeOccupyPlayer(before, fixture.ally).elites!.tanks, expectedCyborgsLost); + assert.ok(afterLosses.decision?.kind === 'ixSubstitution'); + assert.equal(afterLosses.decision.player, fixture.ally); + assert.equal(afterLosses.decision.losses[fixture.location], expectedCyborgsLost); + assert.equal(forces(afterSubstitution, result, fixture.ally), 8 - expectedLost, + 'A surviving Suboid is exchanged, not an additional Cyborg created.'); + assert.equal(cyborgs(afterSubstitution, result), 2); + assert.equal(nativeOccupyPlayer(afterSubstitution, fixture.ally).elites!.tanks, + nativeOccupyPlayer(before, fixture.ally).elites!.tanks); + assert.equal(nativeOccupyPlayer(afterSubstitution, fixture.ally).tanks - + nativeOccupyPlayer(before, fixture.ally).tanks, expectedLost); + assert.ok(afterSubstitution.decision?.kind === 'battleCards'); + assert.equal(afterSubstitution.decision.player, actor, + 'Native Ix substitution resumes the selected leader/card owner, not the force owner.'); + assert.equal(game.lastBattleContext?.winner, actor); + assert.equal(forces(game, result, fixture.ecaz), 2); + assert.equal(forces(game, result, fixture.opponent), 0); + assert.equal(nativeOccupyPlayer(before, actor).spice - nativeOccupyPlayer(game, actor).spice, + commitment === 'funded' ? 1 : 0, 'Suboids and fixed Ecaz contribute no paid support.'); + assert.equal(nativeOccupyPlayer(before, nonLead).spice, nativeOccupyPlayer(game, nonLead).spice); + assert.deepEqual(nativeOccupyPlayer(game, nonLead).hand, nativeOccupyPlayer(before, nonLead).hand); + assert.ok(game.discard.some(card => card.id === result.shield)); + assert.ok(!nativeOccupyPlayer(game, actor).hand.some(card => card.id === result.shield)); + sameEvent(result); + for (const checkpoint of [before, afterLosses, afterSubstitution, game]) conservedIx(checkpoint, result); + assert.ok(game.phase >= 7); + }); + +for (const lead of ['ecaz', 'ally'] as const) + void test(`Native Ix zero variable commitment with ${lead} lead still pays the mandatory free Ecaz losses`, () => { + const result = nativeIxOccupyCase({ lead, commitment: 'zero' }); + const { fixture, actor, before, game } = result; + assert.equal(game.lastBattleContext?.winner, actor); + assert.equal(forces(game, result, fixture.ecaz), 2); + assert.equal(forces(game, result, fixture.ally), 8); + assert.equal(cyborgs(game, result), 2); + assert.equal(nativeOccupyPlayer(game, fixture.ally).tanks, nativeOccupyPlayer(before, fixture.ally).tanks); + assert.equal(nativeOccupyPlayer(game, actor).spice, nativeOccupyPlayer(before, actor).spice); + assert.notEqual(result.afterLosses.decision?.kind, 'ixSubstitution'); + conservedIx(game, result); + }); + +void test('Native Karama cancellation with Ecaz lead uses only Ecaz own army and leaves mixed Ix untouched on a win', () => { + const result = nativeIxOccupyCase({ lead: 'ecaz', canceled: true }); + const { fixture, before, game } = result; + const profile = viewGame(before, fixture.ecaz).battle?.ecazOccupy?.profile; + assert.ok(profile?.canceled); + assert.equal(profile.forceOwner, fixture.ecaz); + assert.equal(profile.fixedEcazDial, 0); + assert.equal(profile.forces.normalFixedHalf, false); + assert.equal(game.lastBattleContext?.winner, fixture.ecaz); + assert.equal(forces(game, result, fixture.ecaz), 3); + assert.equal(forces(game, result, fixture.ally), 8); + assert.equal(cyborgs(game, result), 2); + assert.equal(nativeOccupyPlayer(before, fixture.ecaz).spice - nativeOccupyPlayer(game, fixture.ecaz).spice, 2); + assert.equal(nativeOccupyPlayer(game, fixture.ally).tanks, nativeOccupyPlayer(before, fixture.ally).tanks); + assert.equal(nativeOccupyPlayer(game, fixture.ally).elites!.tanks, nativeOccupyPlayer(before, fixture.ally).elites!.tanks); + assert.deepEqual(nativeOccupyPlayer(game, fixture.ally).hand, nativeOccupyPlayer(before, fixture.ally).hand); + assert.notEqual(result.afterLosses.decision?.kind, 'ixSubstitution'); + sameEvent(result); + conservedIx(game, result); +}); + +void test('Native Karama cancellation with Ix lead uses Ix own typed army then returns to Ix cards without consuming Ecaz', () => { + const result = nativeIxOccupyCase({ lead: 'ally', canceled: true }); + const { fixture, before, afterLosses, afterSubstitution, game } = result; + const profile = viewGame(before, fixture.ally).battle?.ecazOccupy?.profile; + assert.ok(profile?.canceled); + assert.equal(profile.forceOwner, fixture.ally); + assert.equal(profile.fixedEcazDial, 0); + assert.equal(game.lastBattleContext?.winner, fixture.ally); + assert.equal(forces(game, result, fixture.ecaz), 5); + assert.equal(forces(game, result, fixture.ally), 4); + assert.ok(afterLosses.decision?.kind === 'ixSubstitution'); + assert.equal(afterLosses.decision.player, fixture.ally); + assert.equal(cyborgs(afterLosses, result), 0); + assert.equal(cyborgs(afterSubstitution, result), 2); + assert.ok(afterSubstitution.decision?.kind === 'battleCards'); + assert.equal(afterSubstitution.decision.player, fixture.ally); + assert.equal(nativeOccupyPlayer(before, fixture.ally).spice - nativeOccupyPlayer(game, fixture.ally).spice, 1); + assert.deepEqual(nativeOccupyPlayer(game, fixture.ecaz).hand, nativeOccupyPlayer(before, fixture.ecaz).hand); + assert.equal(nativeOccupyPlayer(game, fixture.ecaz).tanks, nativeOccupyPlayer(before, fixture.ecaz).tanks); + sameEvent(result); + conservedIx(game, result); +}); + +void test('All four native minimal policies can continue after Ecaz-led Ix substitution and selected-card cleanup', () => { + const { actor, game } = nativeIxOccupyCase({ lead: 'ecaz' }); + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const view = viewGame(game, actor); + const player = view.players.find(p => p.id === actor); + assert.ok(player); + player.bot = difficulty; + const action = botActions(view).find(candidate => candidate.type === 'ready'); + assert.ok(action, `${difficulty} must be able to continue the actual post-Ix phase.`); + let next = applyAction(game, actor, action); + while (next.phaseOpening) { + const pending = next.players.find(p => !next.phaseOpening!.passed.includes(p.id)); + assert.ok(pending); + next = applyAction(next, pending.id, { type: 'ready' }); + } + assert.equal(next.phase, 7, 'The policy and native opening acknowledgments reach actual Collection.'); + assert.equal(next.phaseOpening, null); + } +}); + +for (const relationship of ['coside', 'opposing'] as const) + for (const lead of ['ecaz', 'ally'] as const) + void test(`Native ${relationship} CHOAM with ${lead} lead receives only floor halves of actual other-payer support`, () => { + const result = nativeChoamOccupyCase({ relationship, lead }); + const { fixture, actor, choam, before, revealed, game } = result; + const plan = revealed.battle?.plans[actor]; + assert.ok(plan); + assert.equal(plan.support, 3); + assert.equal(plan.allyPayment ?? 0, 0); + assert.equal(plan.dial, 6, 'Three mandatory free Ecaz strength is not three additional support payments.'); + assert.equal(game.lastBattleContext?.winner, actor); + assert.equal(game.lastBattleContext?.result, 'normal'); + const actorIsChoam = actor === choam; + const opponentIsChoam = fixture.opponent === choam; + const income = actorIsChoam || opponentIsChoam ? 1 : 2; + assert.equal(nativeOccupyPlayer(game, choam).spice - nativeOccupyPlayer(before, choam).spice, + income - (actorIsChoam || opponentIsChoam ? 3 : 0), + 'Each actual other payer contributes floor(3/2); CHOAM own support goes wholly to the Bank.'); + if (!actorIsChoam) + assert.equal(nativeOccupyPlayer(before, actor).spice - nativeOccupyPlayer(game, actor).spice, 3); + const nonLead = actor === fixture.ecaz ? fixture.ally : fixture.ecaz; + if (nonLead !== choam) + assert.equal(nativeOccupyPlayer(before, nonLead).spice, nativeOccupyPlayer(game, nonLead).spice); + assert.equal(forces(game, result, fixture.ecaz), 2); + assert.equal(forces(game, result, fixture.ally), 5); + assert.equal(forces(game, result, fixture.opponent), 0); + assert.ok(game.phase >= 7); + }); + +for (const lead of ['ecaz', 'ally'] as const) + void test(`Native coside CHOAM fixed-only ${lead} lead has no bank payment or minted battle income`, () => { + const result = nativeChoamOccupyCase({ lead, support: 0, opponentSupport: 0 }); + const { fixture, actor, choam, before, game } = result; + assert.equal(game.lastBattleContext?.winner, actor); + assert.equal(nativeOccupyPlayer(game, choam).spice, nativeOccupyPlayer(before, choam).spice); + assert.equal(nativeOccupyPlayer(game, fixture.ecaz).spice, nativeOccupyPlayer(before, fixture.ecaz).spice); + assert.equal(game.pendingChoamBattleIncome ?? null, null); + assert.equal(forces(game, result, fixture.ecaz), 2); + assert.equal(forces(game, result, fixture.ally), 8); + }); + +void test('Native CHOAM funding separates Ecaz own spice from actual prepaid donor spice and excludes donor share from income', () => { + const result = nativeChoamOccupyCase({ lead: 'ecaz', donorPayment: 2 }); + const { fixture, actor, choam, beforeFunding, before, revealed, game } = result; + assert.equal(before.aid[choam]?.amount, 2, 'The native CHOAM offer must actually escrow the donor share.'); + assert.equal(nativeOccupyPlayer(beforeFunding, choam).spice - nativeOccupyPlayer(before, choam).spice, 2); + assert.equal(revealed.battle?.plans[actor]?.allyPayment, 2); + assert.equal(nativeOccupyPlayer(before, actor).spice - nativeOccupyPlayer(game, actor).spice, 1); + assert.equal(nativeOccupyPlayer(game, choam).spice - nativeOccupyPlayer(beforeFunding, choam).spice, -1, + 'Two prepaid donor spice yield no CHOAM share; only floor(3/2) enemy income returns.'); + assert.equal(game.lastBattleContext?.winner, fixture.ecaz); + assert.equal(forces(game, result, fixture.ecaz), 2); + assert.equal(forces(game, result, fixture.ally), 5); +}); + +for (const relationship of ['coside', 'opposing'] as const) + for (const lead of ['ecaz', 'ally'] as const) + void test(`Native ${relationship} CHOAM ${lead}-led traitor victory excludes all battle income and waives winner support`, () => { + const result = nativeChoamOccupyCase({ relationship, lead, traitor: true }); + const { fixture, actor, choam, before, revealed, game } = result; + const opposingLeader = nativeOccupyPlayer(before, fixture.opponent).leaders.find(leader => + leader.id === revealed.battle?.plans[fixture.opponent]?.leader); + assert.ok(opposingLeader); + const bounty = opposingLeader.strength; + assert.equal(game.lastBattleContext?.result, 'traitor'); + assert.equal(game.lastBattleContext?.winner, actor); + assert.equal(nativeOccupyPlayer(game, actor).spice - nativeOccupyPlayer(before, actor).spice, bounty, + 'The successful caller pays no winning support and retains the original leader bounty.'); + assert.equal(nativeOccupyPlayer(game, choam).spice - nativeOccupyPlayer(before, choam).spice, + choam === fixture.opponent ? -3 : choam === actor ? bounty : 0, + 'No actual support payment generates CHOAM income during traitor combat; original winner bounty is separate.'); + assert.equal(game.pendingChoamBattleIncome ?? null, null); + assert.equal(forces(game, result, fixture.ecaz), 5); + assert.equal(forces(game, result, fixture.ally), 8); + assert.equal(forces(game, result, fixture.opponent), 0); + }); diff --git a/tests/ecaz-occupy-tleilaxu-runtime.test.ts b/tests/ecaz-occupy-tleilaxu-runtime.test.ts new file mode 100644 index 00000000..e40c25b9 --- /dev/null +++ b/tests/ecaz-occupy-tleilaxu-runtime.test.ts @@ -0,0 +1,154 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Game, type Player } from '../game/engine'; +import { botActions } from '../game/bots'; +import { splitLocation } from '../game/board'; +import { ecazOccupyFixture, createEcazOccupySetup, chooseEcazOccupyLead, + openEcazOccupyPlans, stageEcazOccupyCard, ecazOccupyTerritoryForces, + type EcazOccupyFixture } from './fixture-ecaz-occupy'; + +const seat = (game: Game, id: string): Player => { + const player = game.players.find(p => p.id === id); assert.ok(player); return player; +}; +const army = (game: Game, fixture: EcazOccupyFixture, id: string) => + ecazOccupyTerritoryForces(game, id, fixture.territory); + +function aftermath(state: Game): Game { + let game = state; + for (let n = 0; n < 100; n++) { + if (game.response) game = applyAction(game, + game.players.find(p => !game.response!.passed.includes(p.id))!.id, { type: 'passResponse' }); + else if (game.decision?.kind === 'battleLosses') + game = applyAction(game, game.decision.player, { type: 'decision', choice: 0 }); + else if (game.pendingTreacheryDiscard) + game = applyAction(game, game.players[0].id, { type: 'advanceBots' }); + else return game; + } + throw Error('Native Occupy aftermath did not finish.'); +} + +function originalWin(fixture: EcazOccupyFixture, lead: 'ecaz' | 'ally') { + const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally; + const tleilaxu = fixture.game.players.find(p => p.faction === 'tleilaxu')!.id; + const held = seat(fixture.game, tleilaxu).faceDancers!; + const candidates = seat(fixture.game, actor).leaders.filter(l => !l.dead && !l.usedAt); + const winner = candidates.find(l => held.some(c => !c.revealed && c.leader === l.id)); + assert.ok(winner, 'Use an actual first-draw Face Dancer and the selected lead\'s real leader.'); + const loser = seat(fixture.game, fixture.opponent).leaders.filter(l => !l.dead && !l.usedAt) + .sort((a, b) => a.strength - b.strength)[0]; + const weapon = stageEcazOccupyCard(fixture.game, actor, card => card.kind === 'projectile'); + const shield = stageEcazOccupyCard(fixture.game, actor, card => card.kind === 'shield'); + let game = openEcazOccupyPlans(chooseEcazOccupyLead(fixture, lead)); + const before = structuredClone(game); + const fixed = viewGame(game, actor).battle!.ecazOccupy!.profile!.fixedEcazDial; + game = applyAction(game, actor, { type: 'battlePlan', dial: fixed + 2, + support: 2, leader: winner.id, weapon, defense: shield }); + game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, + support: 0, leader: loser.id }); + for (let n = 0; n < 10 && game.battle; n++) { + const view = viewGame(game, actor).battle!; + const voter = view.traitorVoters.find(id => !view.traitorSubmitted.includes(id)); + if (!voter) break; + game = applyAction(game, voter, { type: 'traitorCall', call: false }); + } + game = aftermath(game); + assert.equal(game.decision?.kind, 'battleCards'); + return { game, before, actor, tleilaxu, winner: winner.id, loser: loser.id, + bounty: loser.strength, weapon, shield }; +} + +function continueNativeBattlePhase(state: Game): Game { + let game = state; + for (let n = 0; n < 30 && game.phase === 6; n++) { + const actor = game.players.find(p => !game.ready.includes(p.id)); assert.ok(actor); + const view = viewGame(game, actor.id); + view.players.find(p => p.id === actor.id)!.bot = 'Easy'; + const action = botActions(view).find(candidate => candidate.type === 'ready'); assert.ok(action); + game = applyAction(game, actor.id, action); + } + assert.equal(game.phase, 7, 'The original battle must continue naturally to Spice Collection.'); + return game; +} + +for (const lead of ['ecaz', 'ally'] as const) { + void test(`Tleilaxu winning Occupy coside cannot Face Dance its selected ${lead} leader`, () => { + const fixture = ecazOccupyFixture({ allyFaction: 'tleilaxu', + faceDancerFaction: lead === 'ecaz' ? 'ecaz' : 'tleilaxu' }); + const win = originalWin(fixture, lead); + const voters = viewGame(win.before, win.actor).battle!.traitorVoters; + assert.ok(voters.includes(fixture.ecaz) && voters.includes(fixture.ally), + 'Both actual same-faction participants receive their native traitor declaration.'); + let game = applyAction(win.game, win.actor, { type: 'decision', discard: [win.shield] }); + game = aftermath(game); + assert.equal(game.battle, null); + assert.equal(game.decision, null, 'No winning-coside Face Dance opportunity may be offered.'); + assert.equal(army(game, fixture, fixture.ecaz), 1); + assert.equal(army(game, fixture, fixture.ally), 2); + assert.equal(seat(game, win.actor).leaders.find(l => l.id === win.winner)!.dead, false); + assert.ok(seat(game, win.tleilaxu).faceDancers!.some(c => c.leader === win.winner && !c.revealed)); + assert.equal(seat(game, win.actor).spice, seat(win.before, win.actor).spice - 2 + win.bounty); + assert.ok(seat(game, win.actor).hand.some(c => c.id === win.weapon)); + assert.ok(game.discard.some(c => c.id === win.shield)); + continueNativeBattlePhase(game); + }); +} + +for (const lead of ['ecaz', 'ally'] as const) + for (const origin of ['reserves', 'board'] as const) + for (const count of [0, 1, 3]) { + void test(`External Tleilaxu replaces both ${lead}-led Occupy armies: ${origin} ${count}/3`, () => { + const options = { allyFaction: 'guild', opponentFaction: 'emperor', + roster: ['ecaz', 'guild', 'emperor', 'tleilaxu'], expansions: ['ecaz', 'ix'], + faceDancerFaction: lead === 'ecaz' ? 'ecaz' : 'guild' } as const; + const initial = createEcazOccupySetup({ ...options, roster: [...options.roster], expansions: [...options.expansions] }); + const fixture = ecazOccupyFixture({ ...options, initial, + roster: [...options.roster], expansions: [...options.expansions] }); + const tleilaxu = fixture.game.players.find(p => p.faction === 'tleilaxu')!.id; + // Labelled conserved physical source placement outside the battle: + // five original Tleilaxu reserve counters become Arrakeen board sources. + const source = 'arrakeen:10'; + seat(fixture.game, tleilaxu).reserves -= 5; + seat(fixture.game, tleilaxu).forces[source] = 5; + const win = originalWin(fixture, lead); + let game = applyAction(win.game, win.actor, { type: 'decision', discard: [win.shield] }); + game = aftermath(game); + const decision = game.decision; + assert.ok(decision?.kind === 'faceDance'); + assert.equal(decision.winner, win.actor); + assert.equal(decision.leader, win.winner); + assert.equal(army(game, fixture, fixture.ecaz), 1); + assert.equal(army(game, fixture, fixture.ally), 2); + assert.equal(seat(game, win.actor).spice, seat(win.before, win.actor).spice - 2 + win.bounty); + const beforeDance = structuredClone(game); + const nonLead = win.actor === fixture.ecaz ? fixture.ally : fixture.ecaz; + const nonLeadHand = structuredClone(seat(game, nonLead).hand); + const sector = splitLocation(fixture.location).sector; + assert.throws(() => applyAction(game, tleilaxu, + { type: 'decision', reveal: true, sources: { reserves: 4 }, sector }), /more forces/, + 'Replacement cannot exceed the whole surviving coalition.'); + const sources = origin === 'reserves' ? { reserves: count } : { [source]: count }; + game = applyAction(game, tleilaxu, { type: 'decision', reveal: true, sources, sector }); + game = aftermath(game); + assert.equal(army(game, fixture, fixture.ecaz), 0); + assert.equal(army(game, fixture, fixture.ally), 0); + assert.equal(army(game, fixture, tleilaxu), count); + assert.equal(seat(game, fixture.ecaz).reserves, seat(beforeDance, fixture.ecaz).reserves + 1); + assert.equal(seat(game, fixture.ally).reserves, seat(beforeDance, fixture.ally).reserves + 2); + assert.equal(seat(game, tleilaxu).reserves, + seat(beforeDance, tleilaxu).reserves - (origin === 'reserves' ? count : 0)); + assert.equal(seat(game, tleilaxu).forces[source], 5 - (origin === 'board' ? count : 0)); + assert.equal(seat(game, win.actor).leaders.find(l => l.id === win.winner)!.dead, true); + assert.equal(seat(game, fixture.opponent).leaders.find(l => l.id === win.loser)!.dead, true); + assert.equal(seat(game, tleilaxu).spice, seat(beforeDance, tleilaxu).spice, + 'Face Dance leader death is not a second bounty.'); + assert.equal(seat(game, win.actor).spice, seat(beforeDance, win.actor).spice, + 'Original selected winner retains its bounty after replacement.'); + assert.ok(seat(game, win.actor).hand.some(c => c.id === win.weapon)); + assert.ok(game.discard.some(c => c.id === win.shield)); + assert.deepEqual(seat(game, nonLead).hand, nonLeadHand); + assert.ok(seat(game, tleilaxu).faceDancers!.some(c => c.leader === win.winner && c.revealed)); + assert.equal(game.battle, null); + assert.equal(game.decision, null); + continueNativeBattlePhase(game); + }); + } diff --git a/tests/fixture-ecaz-ix-choam.ts b/tests/fixture-ecaz-ix-choam.ts new file mode 100644 index 00000000..e32daa9b --- /dev/null +++ b/tests/fixture-ecaz-ix-choam.ts @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Game, type Player } from '../game/engine'; +import { + ecazOccupyFixture, chooseEcazOccupyLead, cancelEcazOccupy, openEcazOccupyPlans, + allowEcazOccupyResponses, stageEcazOccupyCard, type EcazOccupyFixture, +} from './fixture-ecaz-occupy'; + +export type NativeOccupyLead = 'ecaz' | 'ally'; +export const nativeOccupyPlayer = (game: Game, id: string): Player => { + const player = game.players.find(p => p.id === id); + assert.ok(player, 'The original native roster must contain this player.'); + return player; +}; + +function availableLeader(game: Game, id: string, strongest: boolean): string { + const available = nativeOccupyPlayer(game, id).leaders.filter(l => !l.dead && !l.usedAt); + available.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength); + assert.ok(available[0], 'The native roster must supply an available physical leader.'); + return available[0].id; +} + +function prepare(fixture: EcazOccupyFixture, lead: NativeOccupyLead, + canceled: boolean, funding = 0): Game { + let game = canceled ? cancelEcazOccupy(fixture, lead) : chooseEcazOccupyLead(fixture, lead); + game = allowEcazOccupyResponses(game); + if (game.decision?.kind === 'choamBattleFunding') + game = applyAction(game, game.decision.player, { type: 'decision', amount: funding }); + else assert.equal(funding, 0, 'Donor spice requires the original native CHOAM funding offer.'); + return openEcazOccupyPlans(game); +} + +function reveal(state: Game, actor: string, caller?: string): Game { + let game = state; + for (let attempt = 0; attempt < 10 && game.battle; attempt++) { + const battle = viewGame(game, actor).battle; + assert.ok(battle); + const voter = battle.traitorVoters.find(id => !battle.traitorSubmitted.includes(id)); + if (!voter) break; + game = applyAction(game, voter, { type: 'traitorCall', call: voter === caller }); + } + return game; +} + +/** Only consume real aftermath windows. Never install a decision, plan or phase. */ +export function finishNativeOccupyAftermath(state: Game, discardCard?: string): Game { + let game = state; + for (let attempt = 0; attempt < 100; attempt++) { + if (game.pendingTreacheryDiscard) { + game = applyAction(game, game.players[0].id, { type: 'advanceBots' }); + } else if (game.response) { + game = allowEcazOccupyResponses(game); + } else if (game.decision?.kind === 'battleCards') { + const discard = discardCard && game.decision.cards.includes(discardCard) ? [discardCard] : []; + game = applyAction(game, game.decision.player, { type: 'decision', discard }); + } else if (game.decision?.kind === 'choamMarket') { + game = applyAction(game, game.decision.player, { type: 'decision', done: true }); + } else { + assert.equal(game.battle, null, 'Native plans and traitor voting must have resolved.'); + assert.equal(game.decision, null, 'Native typed losses/substitution must precede continuation.'); + assert.ok(game.phase >= 7, 'The actual coalition battle must reach native Collection.'); + return game; + } + } + throw new Error('The native Occupy battle aftermath did not finish.'); +} + +export type NativeIxOccupyOptions = { + initial?: Game; + lead?: NativeOccupyLead; + canceled?: boolean; + commitment?: 'funded' | 'half' | 'zero'; +}; +export type NativeIxOccupyCase = { + fixture: EcazOccupyFixture; + actor: string; + shield: string; + variableDial: number; + support: number; + before: Game; + afterReveal: Game; + afterLosses: Game; + afterSubstitution: Game; + game: Game; +}; + +/** Original Ix setup, real Nexus and mixed shipment (six Suboids, two Cyborgs). + * Only conserved original hand-card staging is added to the common fixture. */ +export function nativeIxOccupyCase(options: NativeIxOccupyOptions = {}): NativeIxOccupyCase { + const lead = options.lead ?? 'ecaz'; + const canceled = options.canceled ?? false; + const commitment = options.commitment ?? 'funded'; + const fixture = ecazOccupyFixture({ initial: options.initial, allyFaction: 'ixians', + expansions: options.initial?.expansions ?? ['ecaz', 'ix'], ecazForces: 5, allyForces: 8, allyElite: 2 }); + const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally; + const shield = stageEcazOccupyCard(fixture.game, actor, card => card.kind === 'shield'); + let game = prepare(fixture, lead, canceled); + const before = structuredClone(game); + const profile = viewGame(game, actor).battle?.ecazOccupy?.profile; + assert.ok(profile); + const ecazOwn = canceled && lead === 'ecaz'; + const variableDial = ecazOwn ? 2 : commitment === 'funded' ? 4 : commitment === 'half' ? 3 : 0; + const support = ecazOwn ? 2 : commitment === 'funded' ? 1 : 0; + game = applyAction(game, actor, { type: 'battlePlan', dial: profile.fixedEcazDial + variableDial, + support, leader: availableLeader(game, actor, true), defense: shield }); + game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, support: 0, + leader: availableLeader(game, fixture.opponent, false) }); + game = reveal(game, actor); + const afterReveal = structuredClone(game); + const normal = ecazOwn ? 2 : commitment === 'funded' ? 2 : commitment === 'half' ? 4 : 0; + const elite = ecazOwn || commitment === 'zero' ? 0 : commitment === 'funded' ? 2 : 1; + if (game.decision?.kind === 'battleLosses') { + const choice = game.decision.options.findIndex(loss => loss.normal === normal && loss.elite === elite); + assert.ok(choice >= 0, 'The actual Ix dial must offer the requested native typed allocation.'); + game = applyAction(game, game.decision.player, { type: 'decision', choice }); + } + const afterLosses = structuredClone(game); + if (elite > 0) { + assert.ok(game.decision?.kind === 'ixSubstitution'); + assert.equal(game.decision.player, fixture.ally, 'Only the actual Ixian chooses surviving Suboids.'); + game = applyAction(game, fixture.ally, { type: 'decision', + sources: { [fixture.location]: elite }, recover: { [fixture.location]: elite } }); + game = allowEcazOccupyResponses(game); + } else assert.notEqual(game.decision?.kind, 'ixSubstitution'); + const afterSubstitution = structuredClone(game); + game = finishNativeOccupyAftermath(game, shield); + return { fixture, actor, shield, variableDial, support, before, afterReveal, + afterLosses, afterSubstitution, game }; +} + +export type NativeChoamOccupyOptions = { + initial?: Game; + lead?: NativeOccupyLead; + relationship?: 'coside' | 'opposing'; + support?: number; + opponentSupport?: number; + donorPayment?: number; + traitor?: boolean; +}; +export type NativeChoamOccupyCase = { + fixture: EcazOccupyFixture; + actor: string; + choam: string; + support: number; + opponentSupport: number; + donorPayment: number; + beforeFunding: Game; + before: Game; + revealed: Game; + afterReveal: Game; + game: Game; +}; + +/** Labelled physical Traitor Card exchange after original setup. Preserve the + * original deal and reserve; never synthesize a traitor identity or dead leader. */ +function holdPhysicalTraitor(game: Game, owner: string, identity: string): void { + const player = nativeOccupyPlayer(game, owner); + if (player.traitors.includes(identity)) return; + assert.ok(player.traitors[0], 'An original held Traitor Card must fund this physical exchange.'); + const reserve = game.traitorReserve; + const index = reserve?.indexOf(identity) ?? -1; + if (reserve && index >= 0) { + reserve[index] = player.traitors[0]; + player.traitors[0] = identity; + } else { + const donor = game.players.find(p => p.traitors.includes(identity)); + assert.ok(donor, 'The original source must contain the requested physical Traitor Card.'); + const donorIndex = donor.traitors.indexOf(identity); + donor.traitors[donorIndex] = player.traitors[0]; + player.traitors[0] = identity; + } +} + +/** Native CHOAM original family setup and actual opposing/coside armies. + * Any donor payment is offered and escrowed by the existing CHOAM decision. */ +export function nativeChoamOccupyCase(options: NativeChoamOccupyOptions = {}): NativeChoamOccupyCase { + const lead = options.lead ?? 'ecaz'; + const relationship = options.relationship ?? 'coside'; + const support = options.support ?? 3; + const opponentSupport = options.opponentSupport ?? 3; + const donorPayment = options.donorPayment ?? 0; + const fixture = ecazOccupyFixture({ initial: options.initial, + allyFaction: relationship === 'coside' ? 'choam' : 'guild', + opponentFaction: relationship === 'coside' ? 'guild' : 'choam', + expansions: options.initial?.expansions ?? ['ecaz', 'choam'], ecazForces: 5, allyForces: 8 }); + const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally; + const choam = relationship === 'coside' ? fixture.ally : fixture.opponent; + const beforeFunding = structuredClone(fixture.game); + let game = prepare(fixture, lead, false, donorPayment); + let opponentLeader = availableLeader(game, fixture.opponent, false); + if (options.traitor) { + const physical = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => p.traitors)]; + const leader = nativeOccupyPlayer(game, fixture.opponent).leaders.find(l => + !l.dead && !l.usedAt && physical.includes(l.id)); + assert.ok(leader, 'The actual opposing roster must supply a physical non-loyal Traitor Card.'); + opponentLeader = leader.id; + holdPhysicalTraitor(game, actor, opponentLeader); + } + const before = structuredClone(game); + const profile = viewGame(game, actor).battle?.ecazOccupy?.profile; + assert.ok(profile); + game = applyAction(game, actor, { type: 'battlePlan', dial: profile.fixedEcazDial + support, + support, allyPayment: donorPayment, leader: availableLeader(game, actor, true) }); + game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: opponentSupport, + support: opponentSupport, leader: opponentLeader }); + const revealed = structuredClone(game); + game = reveal(game, actor, options.traitor ? actor : undefined); + const afterReveal = structuredClone(game); + game = finishNativeOccupyAftermath(game); + return { fixture, actor, choam, support, opponentSupport, donorPayment, + beforeFunding, before, revealed, afterReveal, game }; +} diff --git a/tests/fixture-ecaz-occupy.ts b/tests/fixture-ecaz-occupy.ts index c1b0cd81..5ccdff6f 100644 --- a/tests/fixture-ecaz-occupy.ts +++ b/tests/fixture-ecaz-occupy.ts @@ -3,20 +3,31 @@ import { applyAction, createGame, initializeEcazOccupyGameForAudit, joinGame, ne viewGame, type Action, type Game, type Player } from '../game/engine'; import { botActions } from '../game/bots'; import { TERRITORIES, location as locationKey, splitLocation } from '../game/board'; -import { createAdvancedSourceFixture, finishToMovement, advanceToNextStorm } from './fixture-advanced-source'; +import { finishToMovement, advanceToNextStorm } from './fixture-advanced-source'; import type { FactionId } from '../game/catalog'; -import { baseDeck } from '../game/cards'; +import { treacheryDeck } from '../game/cards'; +import { traitorDeck } from '../game/traitors'; +import { withoutEcazLoyalty } from '../game/ecaz-loyalty'; +export type EcazOccupyFixtureFaction = Exclude; export type EcazOccupyFixtureOptions = { initial?: Game; ecazForces?: number; - allyFaction?: 'fremen' | 'emperor' | 'beneGesserit' | 'guild'; + allyFaction?: EcazOccupyFixtureFaction; order?: 'ecaz-first' | 'ally-first' | 'opponent-first'; - opponentFaction?: 'guild' | 'emperor' | 'beneGesserit' | 'fremen'; + opponentFaction?: EcazOccupyFixtureFaction; + expansions?: Game['expansions']; + roster?: EcazOccupyFixtureFaction[]; + allyForces?: number; + allyElite?: number; + opponentForces?: number; + opponentElite?: number; + /** Seed only a still-pending original Face Dancer shuffle, never a later draw. */ + faceDancerFaction?: EcazOccupyFixtureFaction; advisorAlly?: boolean; }; export type EcazOccupyFixture = { - game: Game; ecaz: string; ally: string; opponent: string; + game: Game; initial: Game; afterSetup: Game; ecaz: string; ally: string; opponent: string; territory: string; location: string; ecazForces: number; }; @@ -43,6 +54,54 @@ function step(game: Game): Game { throw new Error(`No native fixture action at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`); } +/** Complete existing native offers. Entropy is scoped to the final first-draw + * action and forwards the Ix starting-card remainder shuffle unchanged. */ +export function finishEcazOccupySetup(state: Game, faceDancerFaction?: EcazOccupyFixtureFaction): Game { + let game = structuredClone(state); + for (let attempt = 0; attempt < 200 && game.status === 'setup'; attempt++) { + const tleilaxu = game.players.find(p => p.faction === 'tleilaxu'); + const pending = viewGame(game, game.players[0].id).setupPending; + const finalForces = game.setupStage === 'forces' && !game.players.some(p => p.faction === 'ixians') && + pending.length === 1 && (game.players.find(p => p.id === pending[0])!.faction === 'beneGesserit' || + game.players.find(p => p.id === pending[0])!.faction === 'ecaz' && + !game.players.some(p => p.faction === 'beneGesserit')); + if (!faceDancerFaction || !tleilaxu || tleilaxu.faceDancers || + !(game.decision?.kind === 'ixSetup' || finalForces)) { + game = step(game); + continue; + } + const held = new Set(game.players.flatMap(p => p.traitors)); + const source = withoutEcazLoyalty(traitorDeck(game.players, game.expansions.includes('ix')), + game.ecazLoyalty).filter(id => !held.has(id)); + const owner = game.players.find(p => p.faction === faceDancerFaction); + assert.ok(owner); + const target = owner.leaders.filter(l => source.includes(l.id)).sort((a, b) => b.strength - a.strength)[0]; + assert.ok(target, 'A real leader of the selected faction remains in the first native Face Dancer source.'); + const targetIndex = source.indexOf(target.id); + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const originalRandom = crypto.getRandomValues.bind(crypto); + let prefixCalls = game.decision?.kind === 'ixSetup' ? Math.max(0, game.ixSetupCards!.length - 2) : 0; + let index = source.length - 1; + crypto.getRandomValues = (array: T): T => { + assert.ok(array instanceof Uint32Array && array.length === 1); + if (prefixCalls > 0) { prefixCalls--; originalRandom(array); return array; } + if (index <= 0) { originalRandom(array); return array; } + array[0] = index === targetIndex ? 0 : 0xffffffff; + index--; + return array; + }; + try { game = step(game); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } + assert.equal(game.status, 'playing', 'Seed only an actually pending first Face Dancer draw.'); + assert.ok(game.players.find(p => p.id === tleilaxu.id)!.faceDancers!.some(c => c.leader === target.id)); + } + assert.equal(game.status, 'playing', 'Original native setup must finish without a redeal.'); + return game; +} + export function settleEcazOccupyArrival(state: Game, accompany = false): Game { let game = state; for (let attempt = 0; attempt < 50 && (game.response || game.decision); attempt++) { @@ -106,40 +165,46 @@ export function stageEcazOccupyCard(game: Game, owner: string, /** Genuine fresh E3 faction setup, a natural worm Nexus, reciprocal alliance, * accepted shared shipment and actual chooseBattle. Returns BEFORE lead choice. * A supplied fresh setup retains its original deal and printed seat positions. */ -export function ecazOccupyFixture(options: EcazOccupyFixtureOptions = {}): EcazOccupyFixture { +export function createEcazOccupySetup(options: EcazOccupyFixtureOptions = {}): Game { const allyFaction = options.allyFaction ?? 'fremen'; const opponentFaction = options.opponentFaction ?? (allyFaction === 'guild' ? 'emperor' : 'guild'); assert.notEqual(allyFaction, opponentFaction); - const ecazForces = options.ecazForces ?? 3; - assert.ok(Number.isInteger(ecazForces) && ecazForces >= 1 && ecazForces <= 5); let setup: Game; if (options.initial) { setup = structuredClone(options.initial); assert.equal(setup.status, 'setup', 'Captured input must be its original admitted fresh setup.'); assert.equal(setup.ecazOccupyPreview, true); assert.equal(setup.advanced, true); - assert.deepEqual(setup.expansions, ['ecaz']); + if (options.expansions) assert.deepEqual(setup.expansions, options.expansions); + assert.ok(setup.expansions.includes('ecaz') && new Set(setup.expansions).size === setup.expansions.length); assert.equal(setup.turn, 1, 'Captured Occupy input is never a started-game retrofit.'); assert.ok(!setup.ecazTreachery && !setup.homeworlds && !setup.nexusCards && !setup.leaderSkills && !setup.strongholdCards && !setup.techTokens && !setup.discoveryEnabled && !setup.discoveries && !setup.discoveryStash && !setup.greatMaker && !setup.moritaniAssassinatePreview, 'Captured Occupy inputs admit the explicit ordinary Advanced profile only.'); - assert.deepEqual([...setup.deck, ...setup.discard, ...setup.players.flatMap(p => p.hand)] - .map(card => card.id).sort(), baseDeck().map(card => card.id).sort(), - 'The original captured deal must retain the ordinary 33-card source.'); + assert.deepEqual([...setup.deck, ...setup.discard, ...(setup.ixSetupCards ?? []), + ...setup.players.flatMap(p => p.hand)].map(card => card.id).sort(), + treacheryDeck(setup.expansions).map(card => card.id).sort(), + 'The original captured deal must retain every selected physical card exactly once.'); } else { - const roster: FactionId[] = ['ecaz', allyFaction, opponentFaction]; - setup = createGame('ECAZOCCUPY', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']); - for (const faction of roster.slice(1)) joinGame(setup, newPlayer(faction, faction, faction)); - const desired = options.order === 'ally-first' ? [allyFaction, opponentFaction, 'ecaz'] : + const roster: EcazOccupyFixtureFaction[] = options.roster ?? ['ecaz', allyFaction, opponentFaction]; + const expansions = options.expansions ?? ['ecaz', + ...(roster.some(f => f === 'ixians' || f === 'tleilaxu') ? ['ix'] as const : []), + ...(roster.includes('choam') ? ['choam'] as const : [])]; + const desired: EcazOccupyFixtureFaction[] = options.order === 'ally-first' ? [allyFaction, opponentFaction, 'ecaz'] : options.order === 'opponent-first' || opponentFaction === 'beneGesserit' ? [opponentFaction, 'ecaz', allyFaction] : ['ecaz', allyFaction, opponentFaction]; - // Circles 3..5 remain in this order for every genuine second-turn Storm - // distance (1..6); the occupied initial circles are vacated via real actions. + desired.push(...roster.filter(f => !desired.includes(f))); + const joinOrder = roster.length > 4 ? desired : roster; + setup = createGame('ECAZOCCUPY', newPlayer(joinOrder[0], joinOrder[0], joinOrder[0]), true, expansions); + for (const faction of joinOrder.slice(1)) joinGame(setup, newPlayer(faction, faction, faction)); + const firstCircle = Math.min(3, 7 - desired.length); + // Printed circles preserve the source order; all initial forces are + // subsequently returned through their actual physical reserves. for (let i = desired.length - 1; i >= 0; i--) - if (viewGame(setup, desired[i]).playerPositions[desired[i]] !== i + 3) - setup = applyAction(setup, desired[i], { type: 'seatPosition', position: i + 3 }); + if (viewGame(setup, desired[i]).playerPositions[desired[i]] !== i + firstCircle) + setup = applyAction(setup, desired[i], { type: 'seatPosition', position: i + firstCircle }); for (const player of setup.players) setup = applyAction(setup, player.id, { type: 'ready' }); setup = initializeEcazOccupyGameForAudit(setup); } @@ -147,16 +212,27 @@ export function ecazOccupyFixture(options: EcazOccupyFixtureOptions = {}): EcazO setup.players.some(p => p.faction === allyFaction) && setup.players.some(p => p.faction === opponentFaction), 'The original source roster must contain the three actual factions for this rule case.'); + return setup; +} + +export function ecazOccupyFixture(options: EcazOccupyFixtureOptions = {}): EcazOccupyFixture { + const allyFaction = options.allyFaction ?? 'fremen'; + const opponentFaction = options.opponentFaction ?? (allyFaction === 'guild' ? 'emperor' : 'guild'); + const ecazForces = options.ecazForces ?? 3; + assert.ok(Number.isInteger(ecazForces) && ecazForces >= 1 && ecazForces <= 5); + const setup = createEcazOccupySetup(options); + const initial = structuredClone(setup); const ecaz = setup.players.find(p => p.faction === 'ecaz')!.id; const ally = setup.players.find(p => p.faction === allyFaction)!.id; const opponent = setup.players.find(p => p.faction === opponentFaction)!.id; - let game = createAdvancedSourceFixture({ initial: setup }).game; - assert.equal(game.status, 'playing'); + let game = finishEcazOccupySetup(setup, options.faceDancerFaction); + const afterSetup = structuredClone(game); returnBoardToReserves(game); orderBlow(game, false, 0); orderBlow(game, false, 1); game = finishToMovement(game); - while (game.phase === 5) game = applyAction(game, game.active!, { type: 'endMovement' }); + while (game.phase === 5) game = game.response || game.phaseOpening || game.decision + ? step(game) : applyAction(game, game.active!, { type: 'endMovement' }); game = advanceToNextStorm(game); orderBlow(game, true, 0); orderBlow(game, false, 1); @@ -180,14 +256,20 @@ export function ecazOccupyFixture(options: EcazOccupyFixtureOptions = {}): EcazO const location = locationKey(territory, sector); stageEcazOccupyCard(game, opponent, card => card.effect === 'karama'); while (game.phase === 5) { + if (game.response || game.phaseOpening || game.decision) { + game = step(game); + continue; + } const actor = game.active!; if (![ecaz, ally, opponent].includes(actor)) { game = applyAction(game, actor, { type: 'endMovement' }); continue; } const player = game.players.find(p => p.id === actor)!; - const amount = actor === ecaz ? ecazForces : actor === ally ? 4 : 8; - const elite = actor === ally && player.elites ? 2 : 0; + const amount = actor === ecaz ? ecazForces : + actor === ally ? options.allyForces ?? 4 : options.opponentForces ?? 8; + const elite = actor === ally ? options.allyElite ?? (player.elites ? 2 : 0) : + actor === opponent ? options.opponentElite ?? 0 : 0; const action: Action = { type: 'ship', territory, sector, amount, ...(elite ? { elite } : {}) }; game = settleEcazOccupyArrival(applyAction(game, actor, action), !!options.advisorAlly && actor === ecaz); if (actor === ally && options.advisorAlly) { @@ -205,7 +287,7 @@ export function ecazOccupyFixture(options: EcazOccupyFixtureOptions = {}): EcazO game = applyAction(game, chooser, { type: 'chooseBattle', territory, target: choice.attacker === chooser ? choice.defender : choice.attacker }); if (!options.advisorAlly) assert.equal(game.decision?.kind, 'ecazBattleLead'); - return { game, ecaz, ally, opponent, territory, location, ecazForces }; + return { game, initial, afterSetup, ecaz, ally, opponent, territory, location, ecazForces }; } /** Resolve only existing native counter windows, never create one. */ diff --git a/tools/faction-games.ts b/tools/faction-games.ts index b479d948..2682b444 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -158,13 +158,24 @@ const ECAZ_TREACHERY_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS ...scenario, profile: 'ecaz-treachery', ordinal: scenario.ordinal + 58, })); /** Ordinary Advanced Ecaz Occupy, without the independent three-card variant. */ -const ECAZ_OCCUPY_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players => ({ - ordinal: 169 + players - 2, - profile: 'ecaz-occupy', - rules: 'advanced', - expansions: ['ecaz'], - roster: (['ecaz', 'fremen', 'emperor', 'beneGesserit', 'harkonnen', 'guild'] as FactionId[]).slice(0, players), -})); +const ECAZ_OCCUPY_SCENARIOS: readonly Scenario[] = [ + ...[2, 3, 4, 5, 6].map((players): Scenario => ({ + ordinal: 169 + players - 2, profile: 'ecaz-occupy', rules: 'advanced', + expansions: ['ecaz'], + roster: (['ecaz', 'fremen', 'emperor', 'beneGesserit', 'harkonnen', 'guild'] as FactionId[]).slice(0, players), + })), + ...(['ixians', 'tleilaxu', 'choam'] as const).flatMap((native, family) => + [2, 3, 4, 5, 6].map((players): Scenario => ({ + ordinal: 174 + family * 5 + players - 2, profile: 'ecaz-occupy', rules: 'advanced', + expansions: ['ecaz', native === 'choam' ? 'choam' : 'ix'], + roster: (['ecaz', native, 'guild', 'fremen', 'emperor', 'beneGesserit'] as FactionId[]).slice(0, players), + }))), + ...[4, 5, 6].map((players): Scenario => ({ + ordinal: 189 + players - 4, profile: 'ecaz-occupy', rules: 'advanced', + expansions: ['ecaz', 'ix', 'choam'], + roster: (['ecaz', 'ixians', 'tleilaxu', 'choam', 'guild', 'fremen'] as FactionId[]).slice(0, players), + })), +]; const PAIRED_NEXUS_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS .map(scenario => ({ ...scenario, @@ -303,7 +314,7 @@ function usage() { '[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' + '[--resume FAILED_GAME.json]\n' + 'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: Ixians + CHOAM, native Ixians + Tleilaxu, or native CHOAM + Richese with classic opponents, two through six seats; mixed four-native rosters also run at four through six seats. Selected Ix/CHOAM decks determine the canonical 47/35 Treachery Cards; Richese has a separate ten-card cache and all samples have six separate Stronghold Cards without other modules. Scenario names identify their native roster. --players requires a supported profile. Output must be a new private directory outside the checkout.' + - '\nEcaz-occupy is Advanced only: two through six seats, native Ecaz and classic opponents, 33 ordinary Treachery Cards and the E3 faction source. No three-card variant or optional overlays.' + '\nEcaz-occupy is Advanced only: native Ecaz plus classic/Ixian/Tleilaxu/CHOAM paired or mixed rosters, two through six seats; selected E3/E1/E2 sources determine ordinary33/47/35 cards. No Richese mixed planning, independent three-card variant or optional overlays.' ); } @@ -339,6 +350,10 @@ function scenarioName(scenario: Scenario) { ['ixians', 'tleilaxu', 'choam', 'richese'].includes(faction)); return `${scenario.profile}-${native.join('-')}-${scenario.roster.length}-${scenario.rules}`; } + if (scenario.profile === 'ecaz-occupy') { + const native = scenario.roster.filter(faction => ['ixians', 'tleilaxu', 'choam'].includes(faction)); + if (native.length) return `${scenario.profile}-${native.join('-')}-${scenario.roster.length}-${scenario.rules}`; + } return scenario.profile === 'base' || scenario.profile === 'homeworld' || scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus' || scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ecaz-treachery' || scenario.profile === 'ecaz-occupy' || scenario.profile === 'moritani-assassinate' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || skillProfile(scenario.profile) ? `${scenario.profile}-${scenario.roster.length}-${scenario.rules}` @@ -811,9 +826,12 @@ async function main() { } : {}), ...(selected.some(scenario => scenario.profile === 'ecaz-occupy') ? { ecazOccupy: { - treacheryCards: 33, - factionSource: 'E3', - scope: 'Advanced native Ecaz and classic opponents, two through six seats. Ordinary deck only; no independent Ecaz three-card variant or optional overlays. Authorized revised rulebook Advanced ceil contribution/floor survivors; not a designer erratum or Basic ruling.', + families: selected.filter(scenario => scenario.profile === 'ecaz-occupy').map(scenario => ({ + roster: scenario.roster, + expansions: scenario.expansions, + treacheryCards: scenario.expansions.includes('ix') ? 47 : scenario.expansions.includes('choam') ? 35 : 33, + })), + scope: 'Advanced native Ecaz/classic/Ixian/Tleilaxu/CHOAM paired or mixed families, two through six seats. Selected E3/E1/E2 ordinary decks only; no Richese mixed planning, independent Ecaz three-card variant or optional overlays. Authorized revised Advanced ceil contribution/floor survivors; not a designer erratum or Basic ruling.', }, } : {}), options: { diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index ac3ee999..bd074e70 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -21,7 +21,7 @@ async function main() { if (values.help) { console.log( 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|banker-income|factions|stronghold-factions|nexus|moritani-assassinate|ecaz-treachery|ecaz-occupy|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Stronghold-factions opts a fresh ready 2–6-seat Advanced roster with at least one selected native Ixians, Tleilaxu, CHOAM or Richese faction and otherwise classic opponents into Stronghold Cards alone. Select one or both distinct Ix/CHOAM decks; each native faction requires its family deck. The selected decks supply 47 Ix or combined, or 35 CHOAM canonical physical Treachery Cards, with a separate ten-card Richese cache when Richese plays. Native setup choices, HMS placement, Face Dancers, Richese cache and CHOAM Auditor remain genuine; custody starts unowned and is claimed by actual END Mentat. It does not redeal, reset or retrofit a saved game. Banker-income opts a fresh 2–6-seat classic Basic/Advanced roster with base 33-card Treachery and all fourteen Leader Skills alone into automatic deferred normal-band income and native Mentat collection; already-earned spice stays with its original faction under this explicit local policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced roster with base Treachery and Nexus only into the optional exact normal-auction purchased-card replacement; native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.', - '\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat Advanced lobby with native Ecaz and classic opponents (optional native Moritani), expansion ecaz alone, into the authorized Advanced combined battle rule. It preserves the ordinary 33-card Treachery deck and real E3 faction setup; no Ecaz three-card variant, modules or overlays.', + '\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat Advanced lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM opponents (optional native Moritani). Distinct selected decks include ecaz and each native ix/choam family; ordinary33/47/35 cards retain real original E3/E1/E2 offers/HMS/Auditor/Face Dancers. No Richese mixed planning, independent Ecaz three-card variant, modules or overlays.', ); return; }