Integrate authorized Advanced Ecaz Occupy combined battles
This commit is contained in:
1 parent
bf4122029e
commit
e112df858a
29 files changed
+1897
-123
No files matched your search
@@ -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
@@ -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,
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
|
||||
Reference in new issue
Block a user