From e112df858a5a3d27992d22478c5c0d4f3dfac642 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:06:00 +1000 Subject: [PATCH] Integrate authorized Advanced Ecaz Occupy combined battles --- README.md | 13 ++ components/ecaz-occupy.tsx | 81 ++++++++ components/game-table.tsx | 55 ++++-- docs/CURRENT_STATUS.md | 29 ++- docs/ECAZ_OCCUPY_RULES.md | 85 +++++++- docs/IMPLEMENTATION_STATUS.md | 47 +++++ docs/RULE_DECISIONS.md | 28 ++- game/admin-seat-ai-shape.ts | 2 + game/battle-resolution-quote.ts | 22 ++- game/board-resolution-quote.ts | 62 +++--- game/bots.ts | 18 +- game/combat-response-quote.ts | 39 ++-- game/ecaz-occupy-battle.ts | 243 +++++++++++++++++++++++ game/ecaz-occupy-options.ts | 87 +++++++++ game/engine.ts | 190 ++++++++++++++---- game/faction-reference.ts | 6 +- game/karama-context.ts | 1 + game/movement-phase-quote.ts | 2 +- game/reference.ts | 25 ++- game/richese-cancellation.ts | 2 + game/traitor-declarations.ts | 21 +- tests/board-resolution-quote.test.ts | 29 +++ tests/ecaz-occupy-battle.test.ts | 237 +++++++++++++++++++++++ tests/ecaz-occupy-options.test.ts | 127 ++++++++++++ tests/ecaz-occupy-runtime.test.ts | 277 +++++++++++++++++++++++++++ tests/fixture-ecaz-occupy.ts | 246 ++++++++++++++++++++++++ tools/faction-games.ts | 39 +++- tools/prototype-room.ts | 4 + tools/start-prototype.ts | 3 +- 29 files changed, 1897 insertions(+), 123 deletions(-) create mode 100644 components/ecaz-occupy.tsx create mode 100644 game/ecaz-occupy-battle.ts create mode 100644 game/ecaz-occupy-options.ts create mode 100644 tests/ecaz-occupy-battle.test.ts create mode 100644 tests/ecaz-occupy-options.test.ts create mode 100644 tests/ecaz-occupy-runtime.test.ts create mode 100644 tests/fixture-ecaz-occupy.ts diff --git a/README.md b/README.md index 36695587..ecf63d74 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,19 @@ rules, legal AI and human phone controls are bounded Development Verified; save/privacy/custody assurance follows all rules. See the [bounded integration](docs/STRONGHOLD_CARDS.md#expanded-e1e2-faction-rules). +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/ +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 +choice remains. Rule/390px controls are bounded Development Verified, not full +Ecaz/Basic/public-start acceptance. See the +[authorized source and native proof](docs/ECAZ_OCCUPY_RULES.md#authorized-advanced-cutover--2-october-2026). + Above the board, **Treachery draw pile** and **Spice draw pile** show live card counts separately from hands, auction cards and discards. See [draw-pile counts](docs/DRAW_PILES.md). diff --git a/components/ecaz-occupy.tsx b/components/ecaz-occupy.tsx new file mode 100644 index 00000000..e73d2583 --- /dev/null +++ b/components/ecaz-occupy.tsx @@ -0,0 +1,81 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { ecazOccupyLeadOptions, ecazOccupyPlanControl } from '@/game/ecaz-occupy-options'; +import { Button } from './ui/button'; + +export function EcazOccupyLeadChoice({ game, act, busy }: { + game: GameView; + act: (action: Action) => void; + busy: boolean; +}) { + const options = ecazOccupyLeadOptions(game); + const occupy = game.battle?.ecazOccupy; + if (!occupy || !options.length) return null; + const ecaz = game.players.find((player) => player.id === occupy.ecaz)?.name ?? 'Ecaz'; + const ally = game.players.find((player) => player.id === occupy.ally)?.name ?? 'Ecaz’s ally'; + return
+

+ {ecaz} chooses who forms the plan. The lead uses their own leaders, + Treachery cards and spice. With Occupy active, {ally} supplies the variable + dialed army regardless of who leads; Ecaz’s contribution is mandatory and free. +

+

+ Karama may cancel Occupy after this choice, before battle powers and plans. + The chosen lead then dials only their own army; the other ally contributes zero. +

+ {options.map((option) =>
+ + {option.blocked &&

{option.blocked}

} +
)} +
; +} + +/** Public owner labels belong above the existing native plan/power editor. */ +export function EcazOccupyBattleSummary({ game }: { game: GameView }) { + const occupy = game.battle?.ecazOccupy; + const profile = occupy?.profile; + if (!occupy || !profile) return null; + const lead = game.players.find((player) => player.id === profile.planOwner)?.name ?? profile.planOwner; + const forceOwner = game.players.find((player) => player.id === profile.forceOwner)?.name ?? profile.forceOwner; + const other = game.players.find((player) => player.id === + (profile.lead === profile.ecaz ? profile.ally : profile.ecaz))?.name ?? 'The other ally'; + return
+

{lead} forms the plan, chooses their leaders and cards, and pays any spice support.

+ {profile.canceled ?

+ Occupy canceled: {forceOwner} dials their own army. {other} contributes zero; + there is no fixed Ecaz strength. Co-occupancy permission is unchanged. +

:

+ The variable dial uses {forceOwner}’s army, including its native force properties. + The total dial must include {profile.fixedEcazDial} fixed Ecaz strength at full strength, + with no spice cost, plus the chosen variable army strength. +

} + {game.response?.kind === 'ecazOccupy' &&

+ Allow this power to keep the combined army, or use an eligible Karama below. + Cancellation keeps {lead} as lead and replaces the variable army with {lead}’s own forces before planning. +

} +
; +} + +/** Render next to the native total wheel/support input, not a second plan editor. */ +export function EcazOccupyDialExplanation({ game, dial, support }: { + game: GameView; + dial: number; + support: number; +}) { + const control = ecazOccupyPlanControl(game, dial, support); + if (!control) return null; + const forceOwner = game.players.find((player) => player.id === control.profile.forceOwner)?.name ?? control.profile.forceOwner; + return
+

+ Total dial {dial} = fixed Ecaz {control.fixedEcazDial} + {forceOwner}’s variable strength{' '} + {control.variableDial ?? '—'}. Spice support applies only to the variable army. + Your personal spice: {control.personalSpice}. Current legal support budget: {control.supportMaximum}. +

+ {control.blocked && {control.blocked}} +
; +} diff --git a/components/game-table.tsx b/components/game-table.tsx index fc65ea6d..eccb0c97 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -12,6 +12,8 @@ import { HandBrowser } from './hand-browser'; import { LobbyBotControls } from './lobby-bot-controls'; import { AdvancedPreviewNotice, RulesetControls } from './ruleset-controls'; import { IxRicheseTechnology } from './ix-richese-technology'; +import { EcazOccupyLeadChoice, EcazOccupyBattleSummary, EcazOccupyDialExplanation } from './ecaz-occupy'; +import { ecazOccupyOwnProfile, ecazOccupyPlanControl } from '@/game/ecaz-occupy-options'; import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options'; import { matchesShipment, @@ -424,27 +426,34 @@ export function GameTable({ ), ); const ownBattleForces = g.battle?.ownForces; + const occupyProfile = ecazOccupyOwnProfile(g); + const battleDialMinimum = occupyProfile?.fixedEcazDial ?? 0; + const battleFreeSupport = ownBattleForces?.freeSupport ?? + (me.faction === 'fremen' && !g.battle?.fremenSupportBlocked); + const battleForceOwnerName = occupyProfile + ? g.players.find((player) => player.id === occupyProfile.forceOwner)?.name ?? occupyProfile.forceOwner + : me.name; const battleForces = ownBattleForces ? ownBattleForces.normal + ownBattleForces.elite : g.battle ? fighterCount(me, g.battle.territory) : 0; - const battleDialMaximum = ownBattleForces + const battleDialMaximum = occupyProfile?.maxDial ?? (ownBattleForces ? maxCombatDial(ownBattleForces) : g.advanced || me.faction === 'ixians' ? 40 - : battleForces; + : battleForces); const battleDialStep = g.advanced || me.faction === 'ixians' ? 0.5 : 1; // A draft carried from a previous battle must show and submit the same bounded value. const battleDial = Number.isFinite(dial) ? Math.max( - 0, + battleDialMinimum, Math.min( Math.round(dial / battleDialStep) * battleDialStep, battleDialMaximum, ), ) - : 0; + : battleDialMinimum; const [support, setSupport] = useState(0); const [bankerSpice, setBankerSpice] = useState(0); const strongholdBankSupport = @@ -537,12 +546,15 @@ export function GameTable({ leader: me.leaders.find(l => l.id === selectedBattleLeader), weapon: selectedBattleWeapon, defense: selectedBattleDefense, kwisatz: selectedBattleKwisatz, }, smugglerBattleModeSupported(g), { territory: g.battle.territory, spice: g.spice }) : null; + const occupyControl = ecazOccupyPlanControl(g, + committed.dial ? Number(committed.dial.value) : battleDial, + battleFreeSupport ? 0 : battleSupport); const battlePairValid = validBattleSlotPair(selectedBattleWeapon, selectedBattleDefense, !!selectedBattleWeapon && planetologistSpecial(selectedBattleWeapon)); const harassControl = harassWithdrawControlState(g.battle?.harassWithdraw, selectedWeaponId === 'ecaz-harass-withdraw' || selectedDefenseId === 'ecaz-harass-withdraw', committed.dial ? Number(committed.dial.value) : battleDial, - me.faction === 'fremen' && !g.battle?.fremenSupportBlocked ? 0 : battleSupport); + battleFreeSupport ? 0 : battleSupport); const selectedSpecialWeapon = me.hand?.find( (c) => c.id === selectedWeaponId && (isStoneBurner(c) || isMirrorWeapon(c)), ); @@ -2128,6 +2140,7 @@ export function GameTable({ moritaniDuke: 'Moritani · Duke Vidal acquisition', ecazPlacement: 'Ecaz Ambassador placement', ecazCollection: 'Ecaz stronghold collection', + ecazOccupy: 'Ecaz combined-army Occupy', moritaniPlacement: 'Moritani Terror placement or relocation', choamRevival: 'CHOAM force revival', @@ -2193,6 +2206,7 @@ export function GameTable({ }[g.response.kind] } + {g.paymentIncome?.low && (

Of the {g.paymentIncome.gross} spice payment,{' '} @@ -2432,7 +2446,9 @@ export function GameTable({ <> Player decision

- {g.decision.kind === 'ixRicheseTechnology' + {g.decision.kind === 'ecazBattleLead' + ? 'Ecaz · choose the combined-army lead' + : g.decision.kind === 'ixRicheseTechnology' ? 'Ixian Technology · Richese lot' : g.decision.kind === 'caladanReinforcement' ? 'Caladan victory reinforcement' @@ -2857,6 +2873,8 @@ export function GameTable({ ) : g.decision.kind === 'choamAudit' || g.decision.kind === 'choamAuditPayment' ? ( + ) : g.decision.kind === 'ecazBattleLead' ? ( + ) : g.decision.kind === 'strongholdCopy' ? ( p.id === g.battle!.tieWinner)?.name} {g.battle.tieWinner !== g.battle.aggressor ? ' (Habbanya Stronghold advantage)' : ''}.

+ {!g.battle.revealed && g.battle.leaderless.length > 0 && (

{g.battle.leaderless.map(id => g.players.find(p => p.id === id)?.name).join(', ')} @@ -4857,13 +4876,15 @@ export function GameTable({ {!g.battle.submitted.includes(me.id) ? ( <> +

{ownNoField && g.battle.noFieldPlayers.includes(me.id) ? `Your concealed No-Field can reveal ${battleForces} physical forces from your current reserves. This estimate is private.` - : `You have ${battleForces} fighting forces here.`} + : occupyProfile + ? `${battleForceOwnerName} supplies ${battleForces} physical fighters for the variable dial.` + : `You have ${battleForces} fighting forces here.`}

{!!ownBattleForces?.temporaryElite && (

@@ -4887,20 +4913,18 @@ export function GameTable({ )} {ownBattleForces?.normalFreeSupport && (

- Your Suboids fight at full strength without spice support this turn. Spice support here applies only to Cyborgs. + The variable army’s Suboids fight at full strength without spice support this turn. Spice support here applies only to Cyborgs.

)} {ownBattleForces?.eliteFreeSupport && (

- Salusa Secundus is at high population. Your + Salusa Secundus is at high population. The variable army’s Sardaukar fight at their full current strength without spice support; normal forces still require support.

)} - {g.advanced && - (me.faction !== 'fremen' || - g.battle.fremenSupportBlocked) && ( + {g.advanced && !battleFreeSupport && (