Implement Emperor Nexus Cunning with physical casualty recovery

This commit is contained in:
Quintine committed 2026-09-10 11:42:01 +10:00
1 parent b05125de9a
commit ba0b5647e1
27 files changed
+2487 -64

No files matched your search

+7 -5
View File
@@ -181,11 +181,13 @@ export function BattlePromises({
{index + 1}.{' '}
{action.type === 'nexusSuboids'
? 'Use Ixian Cunning to give Suboids full strength without spice support'
: action.mode === 'special'
? `Spend Karama and discard ${(action.cards as string[]).map(name).join(', ')} to gain ${(action.cards as string[]).length * 3} spice`
: typeof action.leader === 'string'
? `Play Ghola to revive ${action.leader === 'kwisatz' ? 'Kwisatz Haderach' : name(action.leader)}`
: `Play Ghola to revive ${Number(action.amount)} force${Number(action.amount) === 1 ? '' : 's'}${Number(action.elite) ? `, including ${Number(action.elite)} elite` : ''}`}
: action.type === 'nexusSardaukar'
? 'Declare Emperor Cunning to count five ordinary forces as Sardaukar for this battle'
: action.mode === 'special'
? `Spend Karama and discard ${(action.cards as string[]).map(name).join(', ')} to gain ${(action.cards as string[]).length * 3} spice`
: typeof action.leader === 'string'
? `Play Ghola to revive ${action.leader === 'kwisatz' ? 'Kwisatz Haderach' : name(action.leader)}`
: `Play Ghola to revive ${Number(action.amount)} force${Number(action.amount) === 1 ? '' : 's'}${Number(action.elite) ? `, including ${Number(action.elite)} elite` : ''}`}
.
</p>
))}
+12 -7
View File
@@ -14,6 +14,7 @@ import { NexusTraitors } from './nexus-traitors';
import { NexusTleilaxu } from './nexus-tleilaxu';
import { NexusSuboids } from './nexus-suboids';
import { NexusAdvisors } from './nexus-advisors';
import { NexusSardaukar } from './nexus-sardaukar';
import { TerrorBoardMarkers } from './terror-board-markers';
import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options';
import { GuildHomeworldShipment } from './guild-homeworld-shipment';
@@ -27,7 +28,7 @@ import {
} from './homeworld-table';
import { nativeShipmentSources } from '@/game/homeworld-options';
import { botBattleChoices } from '@/game/bot-battle-choices';
import { specialForceName } from '@/game/combat';
import { specialForceName, maxCombatDial, maxCombatSupport } from '@/game/combat';
import type { NativeReserveSelections } from '@/game/homeworld-native-reserves';
import {
StrongholdCardGallery,
@@ -325,8 +326,7 @@ export function GameTable({
? fighterCount(me, g.battle.territory)
: 0;
const battleDialMaximum = ownBattleForces
? ownBattleForces.normal * (ownBattleForces.normalFixedHalf ? 0.5 : 1) +
ownBattleForces.elite * ownBattleForces.eliteStrength
? maxCombatDial(ownBattleForces)
: g.advanced || me.faction === 'ixians'
? 40
: battleForces;
@@ -347,10 +347,7 @@ export function GameTable({
const battleSupportMaximum = Math.min(
(me.spice ?? 0) + g.aid.available + strongholdBankSupport,
ownBattleForces
? ownBattleForces.freeSupport
? 0
: (ownBattleForces.normalFixedHalf || ownBattleForces.normalFreeSupport ? 0 : ownBattleForces.normal) +
(ownBattleForces.eliteFreeSupport ? 0 : ownBattleForces.elite)
? maxCombatSupport(ownBattleForces)
: Number.POSITIVE_INFINITY,
);
const battleSupport = Number.isFinite(support)
@@ -1419,6 +1416,7 @@ export function GameTable({
<NexusTleilaxu game={g} act={act} busy={transportBusy || !!me.autopilot} />
<NexusSuboids game={g} act={act} busy={transportBusy || !!me.autopilot} />
<NexusAdvisors game={g} act={act} busy={busy} />
<NexusSardaukar game={g} act={act} busy={busy} />
{!g.nexusCards?.waiting.length && <TupileIntelligence game={g} act={act} busy={busy} />}
{g.biddingEnd && <BiddingEnd game={g} act={act} busy={busy} />}
{g.junctionTransport && [g.junctionTransport.owner, g.junctionTransport.recipient].includes(me.id) && (
@@ -1805,6 +1803,7 @@ export function GameTable({
worthlessKarama: 'Worthless card as Karama',
advisorFlip: 'Bene Gesserit token flip',
nexusAdvisorFlip: 'Bene Gesserit Nexus advisor conversion',
nexusSardaukar: 'Emperor Nexus temporary Sardaukar',
eliteStrength: 'Elite battle strength',
fremenSupport: 'Fremen full strength without spice',
capture: 'Harkonnen leader capture',
@@ -1978,6 +1977,7 @@ export function GameTable({
{g.response.intent &&
g.response.source !== 'ambassador' &&
g.response.kind !== 'nexusAdvisorFlip' &&
g.response.kind !== 'nexusSardaukar' &&
g.response.kind !== 'faceDancerReplacement' && (
<p className="notice">{g.response.intent}</p>
)}
@@ -4152,6 +4152,11 @@ export function GameTable({
? `Your concealed No-Field can reveal ${battleForces} physical forces from your current reserves. This estimate is private.`
: `You have ${battleForces} fighting forces here.`}
</p>
{!!ownBattleForces?.temporaryElite && (
<p className="notice">
{ownBattleForces.temporaryElite} ordinary counters count as Sardaukar for this battle. Their losses are ordinary counters, not starred Sardaukar.
</p>
)}
{ownBattleForces?.normalFreeSupport && (
<p className="notice">
Your Suboids fight at full strength without spice support this turn. Spice support here applies only to Cyborgs.
+1 -1
View File
@@ -118,7 +118,7 @@ export function NexusCards({ game, act, busy }: { game: GameView; act: (action:
<div>
<h3>Nexus cards</h3>
<p className="fine">{offer.deckCount} in deck · {offer.discardCount} discarded · {Object.values(offer.held).filter((held) => held === true).length} held</p>
<p>Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian and Bene Gesserit Cunning. Use the controls shown at each effect’s timing; other effects remain unavailable.</p>
<p>Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit and Emperor Cunning. Use the controls shown at each effect’s timing; other effects remain unavailable.</p>
</div>
{game.nexusAtreides && offer.card === 'atreides' && (
<div aria-label="Atreides Nexus effect" className="space-y-2">
+63
View File
@@ -0,0 +1,63 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { nexusSardaukarAction } from '@/game/nexus-sardaukar-options';
import { Button } from './ui/button';
type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
export function NexusSardaukar({ game, act, busy }: Props) {
const state = game.nexusSardaukar;
if (!state) return null;
if (state.pending)
return (
<section aria-label="Emperor Nexus response" className="notice">
<p>
{game.players.find((p) => p.id === state.pending!.owner)?.name ??
'Emperor'}{' '}
declared Cunning. Karama may cancel the entire enhancement before the
five counters count as Sardaukar.
</p>
</section>
);
if (state.active)
return (
<section aria-label="Emperor Nexus active" className="notice">
<p>
Five ordinary Emperor counters count as Sardaukar for this battle.
They remain ordinary physical counters; any losses go to the ordinary
Tanks.
</p>
</section>
);
const offer = state.offer;
if (!offer) return null;
const action = nexusSardaukarAction(game, offer.event);
return (
<section
aria-label="Emperor Nexus Cunning"
className="space-y-3 rounded-lg border border-[#a88b60]/50 p-3"
>
<h3>Emperor Nexus · Cunning</h3>
<p>
Count five of your ordinary forces as Sardaukar for this battle. Use
this before submitting your plan, with no actual Sardaukar in the
battle.
</p>
<p className="fine">
This spends your Nexus card once. Karama may cancel the enhancement. The
five counters keep their ordinary physical identity, including when
lost.
</p>
{offer.blocked && <p className="notice">{offer.blocked}</p>}
<Button
className="game-action min-h-11 whitespace-normal"
disabled={busy || !action}
onClick={() => {
if (!busy && action) act(action);
}}
>
Use five forces as Sardaukar
</Button>
</section>
);
}