Integrate authorized Advanced Ecaz Occupy combined battles

This commit is contained in:
Quintine committed 2026-10-02 13:06:00 +10:00
1 parent bf4122029e
commit e112df858a
29 files changed
+1897 -123

No files matched your search

+81
View File
@@ -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 <section aria-label="Ecaz combined-army lead" className="space-y-3">
<p>
{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.
</p>
<p className="fine">
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.
</p>
{options.map((option) => <div key={option.lead}>
<Button className="game-action min-h-11 whitespace-normal"
disabled={busy || !!option.blocked}
onClick={() => { if (!busy && !option.blocked) act(option.action); }}>
{option.label} leads · their leaders, cards and spice
</Button>
{option.blocked && <p className="fine">{option.blocked}</p>}
</div>)}
</section>;
}
/** 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 <section aria-label="Ecaz battle army and plan owners" className="notice space-y-2">
<p><strong>{lead}</strong> forms the plan, chooses their leaders and cards, and pays any spice support.</p>
{profile.canceled ? <p>
Occupy canceled: {forceOwner} dials their own army. {other} contributes zero;
there is no fixed Ecaz strength. Co-occupancy permission is unchanged.
</p> : <p>
The variable dial uses <strong>{forceOwner}’s army</strong>, including its native force properties.
The total dial must include <strong>{profile.fixedEcazDial} fixed Ecaz strength</strong> at full strength,
with no spice cost, plus the chosen variable army strength.
</p>}
{game.response?.kind === 'ecazOccupy' && <p className="fine">
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.
</p>}
</section>;
}
/** 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 <div aria-label="Ecaz total dial and payment" className="space-y-2">
<p className="fine">
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}.
</p>
{control.blocked && <output className="notice">{control.blocked}</output>}
</div>;
}
+39 -16
View File
@@ -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]
}
</h2>
<EcazOccupyBattleSummary game={g} />
{g.paymentIncome?.low && (
<p className="notice">
Of the {g.paymentIncome.gross} spice payment,{' '}
@@ -2432,7 +2446,9 @@ export function GameTable({
<>
<span className="eyebrow">Player decision</span>
<h2>
{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' ? (
<AuditorDecision game={g} act={act} busy={busy} />
) : g.decision.kind === 'ecazBattleLead' ? (
<EcazOccupyLeadChoice game={g} act={act} busy={busy} />
) : g.decision.kind === 'strongholdCopy' ? (
<StrongholdCopyChoice
event={g.decision.event}
@@ -4722,6 +4740,7 @@ export function GameTable({
{' '}Tied battle: {g.players.find(p => p.id === g.battle!.tieWinner)?.name}
{g.battle.tieWinner !== g.battle.aggressor ? ' (Habbanya Stronghold advantage)' : ''}.
</p>
<EcazOccupyBattleSummary game={g} />
{!g.battle.revealed && g.battle.leaderless.length > 0 && (
<p className="notice block" role="status">
{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) ? (
<>
<label htmlFor="forces-dialed">
Forces dialed <HelpTip topic="dial" />
{occupyProfile ? 'Total forces dialed' : 'Forces dialed'} <HelpTip topic="dial" />
{me.faction === 'ixians' && (
<HelpTip topic="ixForces" />
)}
</label>
<BattleWheel
id="forces-dialed"
min={battleDialMinimum}
sliderLabel={occupyProfile ? 'Total forces dialed slider' : 'Forces dialed slider'}
max={battleDialMaximum}
step={battleDialStep}
value={
@@ -4874,11 +4895,16 @@ export function GameTable({
disabled={busy || !!committed.dial}
onChange={setDial}
/>
<EcazOccupyDialExplanation game={g}
dial={committed.dial ? Number(committed.dial.value) : battleDial}
support={battleFreeSupport ? 0 : battleSupport} />
<p className="fine">
{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.`}
</p>
{!!ownBattleForces?.temporaryElite && (
<p className="notice">
@@ -4887,20 +4913,18 @@ export function GameTable({
)}
{ownBattleForces?.normalFreeSupport && (
<p className="notice">
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.
</p>
)}
{ownBattleForces?.eliteFreeSupport && (
<p className="notice">
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.
</p>
)}
{g.advanced &&
(me.faction !== 'fremen' ||
g.battle.fremenSupportBlocked) && (
{g.advanced && !battleFreeSupport && (
<label htmlFor="battle-spice">
Spice support
{strongholdBankSupport > 0 && (
@@ -5121,8 +5145,7 @@ export function GameTable({
? { allyPayment: Number(allyPayment) }
: {}),
support:
me.faction === 'fremen' &&
!g.battle.fremenSupportBlocked
battleFreeSupport
? 0
: battleSupport,
kwisatz: selectedBattleKwisatz,
@@ -5131,7 +5154,7 @@ export function GameTable({
weapon,
defense,
}),
!!stonePlanReason || !!harassControl.blocked || !!smugglerPlanReason || !!reinforcementsPlanReason || !battlePairValid,
!!occupyControl?.blocked || !!stonePlanReason || !!harassControl.blocked || !!smugglerPlanReason || !!reinforcementsPlanReason || !battlePairValid,
)}
</>
) : (