Implement Guild Nexus Secret Ally shipping routes and recovery

This commit is contained in:
Quintine committed 2026-09-10 13:56:36 +10:00
1 parent 90bf699627
commit 06d0a66fc5
25 files changed
+2095 -52

No files matched your search

+27 -6
View File
@@ -110,6 +110,7 @@ import { canUseAsKarama } from '@/game/karama';
import { LeaderPortrait } from './leader-portrait';
import { ShipmentQuote } from './shipment-quote';
import { reserveShipmentCost } from '@/game/shipment-price';
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyQuote } from '@/game/nexus-guild-secret-ally-options';
import { nexusRicheseAction, nexusRicheseQuote } from '@/game/nexus-richese-options';
import { guildTransportQuote } from '@/game/transport-quote';
import { fremenReserveEntry, botGroundMoveAllowed } from '@/game/bot-mobility';
@@ -253,7 +254,10 @@ export function GameTable({
const noTraitorReason = traitorPlan?.kwisatz
? 'Kwisatz Haderach prevents this leader from turning traitor.'
: 'You do not hold the opposing leader as a traitor.';
const [guildSecretEvent, setGuildSecretEvent] = useState('');
const useGuildSecret = !!g.nexusGuildSecretAlly && guildSecretEvent === g.nexusGuildSecretAlly.event;
const guildTransport =
useGuildSecret ||
me.faction === 'guild' ||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally);
const [botFaction, setBotFaction] = useState('');
@@ -414,6 +418,7 @@ export function GameTable({
const payment =
!me.ally || allyPayment === '' ? {} : { allyPayment: Number(allyPayment) };
const shipmentProblems: string[] = [];
if (useGuildSecret && g.nexusGuildSecretAlly?.blocked) shipmentProblems.push(g.nexusGuildSecretAlly.blocked);
if (useRicheseShipment && g.nexusRichese?.blocked) shipmentProblems.push(g.nexusRichese.blocked);
const shipmentMaximum = useRicheseShipment ? Math.min(me.reserves, 5, g.nexusRichese!.maxForces) : me.reserves;
if (homeworldSources) {
@@ -441,11 +446,11 @@ export function GameTable({
'This shipment conflicts with your Truthtrance answer. Choose a count and destination that honor your shipment promises.',
);
const selectedDestinationInStorm = sector !== 0 && sector === g.storm;
if (selectedDestinationInStorm && !(g.advanced && me.faction === 'fremen'))
if (selectedDestinationInStorm && !(!useGuildSecret && g.advanced && me.faction === 'fremen'))
shipmentProblems.push(
`Sector ${sector} of ${territory(selected).name} is in the storm. Choose a different sector or territory.`,
);
if (me.faction === 'fremen' && !fremenReserveEntry(selected))
if (!useGuildSecret && me.faction === 'fremen' && !fremenReserveEntry(selected))
shipmentProblems.push(
`${territory(selected).name} is beyond the Fremen reinforcement area. Choose the Great Flat or a territory within two territories of it.`,
);
@@ -475,10 +480,11 @@ export function GameTable({
g.karamaShipping?.player === me.id,
};
const shipmentCost = validShipmentAmount
? useRicheseShipment
? useGuildSecret ? (nexusGuildSecretAllyQuote(g, selected, amount)?.cost ?? null) : useRicheseShipment
? (nexusRicheseQuote(g, selected, amount)?.cost ?? null)
: reserveShipmentCost(shipmentRate, territory(selected).type, amount)
: null;
if (useGuildSecret && shipmentCost === null && !g.nexusGuildSecretAlly?.blocked) shipmentProblems.push('Choose a current Guild Secret Ally shipment.');
if (useRicheseShipment && shipmentCost === null && !g.nexusRichese?.blocked)
shipmentProblems.push('Choose a current Richese Secret Ally shipment of up to five physical forces.');
const selectedAllyPayment =
@@ -576,6 +582,7 @@ export function GameTable({
: physicalMovementGroup;
const transportAction: Action = {
type: 'guildShip',
...(useGuildSecret ? {nexus: guildSecretEvent} : {}),
...payment,
...movementGroup,
territory: selected,
@@ -593,6 +600,7 @@ export function GameTable({
};
const returnTransportAction: Action = {
type: 'guildShip',
...(useGuildSecret ? {nexus: guildSecretEvent} : {}),
...payment,
...movementGroup,
territory: 'reserves',
@@ -603,11 +611,11 @@ export function GameTable({
? guildTransportQuote(g, transportAction)
: null;
const southernTransportPreview =
guildTransport && shipmentAvailable && me.faction === 'fremen'
guildTransport && !useGuildSecret && shipmentAvailable && me.faction === 'fremen'
? guildTransportQuote(g, southernTransportAction)
: null;
const returnTransportPreview =
guildTransport && shipmentAvailable && me.faction === 'guild'
guildTransport && shipmentAvailable && (me.faction === 'guild' || useGuildSecret)
? guildTransportQuote(g, returnTransportAction)
: null;
const canceledFremenRouteBlocked =
@@ -3684,6 +3692,19 @@ export function GameTable({
/>
</details>
))}
{!me.shipped && g.nexusGuildSecretAlly && (
<div className="notice">
<label className="decision-checkbox min-h-11">
<input type="checkbox" checked={useGuildSecret} disabled={busy || !!g.nexusGuildSecretAlly.blocked}
onChange={event => setGuildSecretEvent(event.target.checked ? g.nexusGuildSecretAlly!.event : '')} />
Use Guild Nexus Secret Ally
</label>
<p>Spend the Nexus for this shipment at Guild prices: one spice per two forces to a stronghold or reserves, rounded up, or one per force elsewhere. Cross-shipment and return use the same shipment opportunity; normal movement remains available.</p>
{me.faction === 'fremen' && <p>Leave this unchecked for free Fremen reinforcement within its normal range. Selecting the Nexus pays Guild prices and permits destinations beyond that range, outside the storm.</p>}
{g.homeworlds?.worlds?.length && <p>Returning Arrakis forces to native Homeworld reserves with this card awaits a ruling.</p>}
{g.nexusGuildSecretAlly.blocked && <p>{g.nexusGuildSecretAlly.blocked}</p>}
</div>
)}
{!me.shipped && g.nexusRichese && (
<div className="notice">
<label className="decision-checkbox min-h-11">
@@ -3744,7 +3765,7 @@ export function GameTable({
territory: selected, sector, amount,
...(homeworldSources ? { homeworldSources } : {}),
};
const action = useRicheseShipment
const action = useGuildSecret ? nexusGuildSecretAllyAction(g, guildSecretEvent, shipment) : useRicheseShipment
? nexusRicheseAction(g, richeseShipmentEvent, shipment) : shipment;
if (action) act(action);
}}
+39 -4
View File
@@ -37,6 +37,9 @@ export function HomeworldShipment({
);
const [sources, setSources] = useState<HomeworldShipmentSources>({});
const [allyPayment, setAllyPayment] = useState(0);
const [nexusEvent, setNexusEvent] = useState('');
const useNexus =
!!g.nexusGuildSecretAlly && nexusEvent === g.nexusGuildSecretAlly.event;
const option = g.homeworldShipment;
if (!option) return null;
const imperial = g.advanced && me.faction === 'emperor';
@@ -51,7 +54,13 @@ export function HomeworldShipment({
: [];
}),
);
const quote = homeworldShipmentChoice(g, destination, selected, allyPayment);
const quote = homeworldShipmentChoice(
g,
destination,
selected,
allyPayment,
useNexus ? nexusEvent : undefined,
);
const special = specialForceName(me.faction);
return (
<details className="rounded-lg border border-[#a88b60]/50 p-3">
@@ -67,11 +76,37 @@ export function HomeworldShipment({
Allied Homeworlds cannot be destinations.
</p>
<p className="fine">
{me.faction === 'guild'
? 'Guild pays one spice per two forces, rounded up.'
: 'Pay one spice per force.'}{' '}
{useNexus
? 'Selected Guild Nexus price: one spice per two forces, rounded up.'
: me.faction === 'guild'
? 'Guild pays one spice per two forces, rounded up.'
: 'Pay one spice per force.'}{' '}
This uses your shipment; your normal movement remains available.
</p>
{g.nexusGuildSecretAlly && (
<div className="notice">
<label className="decision-checkbox min-h-11">
<input
type="checkbox"
checked={useNexus}
disabled={busy || !!g.nexusGuildSecretAlly.blocked}
onChange={(event) =>
setNexusEvent(
event.target.checked ? g.nexusGuildSecretAlly!.event : '',
)
}
/>
Use Guild Nexus Secret Ally for Homeworld shipment
</label>
<p>
Spend the Nexus to pay one spice per two forces, rounded up.
Existing source and destination restrictions still apply.
</p>
{g.nexusGuildSecretAlly.blocked && (
<p>{g.nexusGuildSecretAlly.blocked}</p>
)}
</div>
)}
{option.blocked && (
<output className="notice block">{option.blocked}</output>
)}
+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, Bene Gesserit, Emperor, CHOAM and Moritani Cunning, plus Richese Secret Ally reserve shipping and Guild Cunning’s second shipment. Use the controls shown at each effect’s timing. CHOAM’s Kull Wahad effect, Moritani Cunning relocation and Grumman combinations, and other unfinished effects remain unavailable.</p>
<p>Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor, CHOAM and Moritani Cunning, plus Richese Secret Ally reserve shipping and Guild Cunning’s second shipment or absent-Guild Secret Ally shipping. Use the controls shown at each effect’s timing. CHOAM’s Kull Wahad effect, Moritani Cunning relocation and Grumman combinations, and other unfinished effects remain unavailable.</p>
</div>
{game.nexusAtreides && offer.card === 'atreides' && (
<div aria-label="Atreides Nexus effect" className="space-y-2">