Connect Ecaz Betrayal and Fremen worm Cunning with build revision

This commit is contained in:
Quintine committed 2026-09-27 18:52:18 +10:00
1 parent 1e690b7062
commit 07c739fcf7
30 files changed
+2878 -25

No files matched your search

+86
View File
@@ -30,6 +30,8 @@ import { DiscoveryEntryDecision } from './discovery-entry';
import { DiscoveryStormDecision } from './discovery-storm';
import { NexusChoamTrade } from './nexus-choam-trade';
import { NexusMoritaniBetrayal } from './nexus-moritani-betrayal';
import { buildRevision, buildRevisionLabel } from '@/lib/build-revision';
import { NexusEcazBetrayal } from './nexus-ecaz-betrayal';
import { NexusTleilaxu } from './nexus-tleilaxu';
import { NexusSuboids } from './nexus-suboids';
import { NexusAdvisors } from './nexus-advisors';
@@ -939,6 +941,9 @@ export function GameTable({
}} />
<span className="status-dot" />
{g.status === 'lobby' ? 'Gathering players' : `Turn ${g.turn} / 10`}
<span className="build-revision" title={buildRevision}>
Git revision: <code>{buildRevisionLabel}</code>
</span>
<Button
variant="outline"
onClick={async () => {
@@ -1583,6 +1588,7 @@ export function GameTable({
<MentatHistory game={g} />
<NexusChoamTrade game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
<NexusMoritaniBetrayal game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
<NexusEcazBetrayal game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
<NexusTleilaxu game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
<NexusSuboids game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
<NexusAdvisors game={g} act={act} busy={busy} />
@@ -1996,6 +2002,8 @@ export function GameTable({
kwisatz: 'Kwisatz Haderach battle protection',
guildTiming: 'Guild shipment and movement timing',
nexusGuildCunning: 'Guild Cunning second shipment',
nexusFremenCunning: 'Fremen Cunning remote worm ride',
nexusEcazBetrayal: 'Ecaz Nexus forced ally return',
atreidesAuction: 'Atreides auction foresight',
atreidesSpice: 'Atreides spice foresight',
stormPeek: 'Fremen storm foresight',
@@ -2165,11 +2173,19 @@ export function GameTable({
Allow one second Guild shipment at its normal price, or cancel this extra opportunity.
The completed first shipment and movement stay unchanged; the Nexus is already spent.
</p>}
{g.response.kind === 'nexusFremenCunning' && <p className="notice">
The Fremen Nexus card was spent for a remote ride after this Nexus. Karama can prevent that ride, but the original worm still resolves.
</p>}
{g.response.kind === 'nexusEcazBetrayal' && <p className="notice">
Ecaz Nexus Betrayal was spent to return Ecaz’s ally’s forces from {territory(g.response.location!).name}.
Karama can prevent that return; the card stays spent.
</p>}
{g.response.intent &&
g.response.kind !== 'nexusGuildCunning' &&
g.response.source !== 'ambassador' &&
g.response.kind !== 'nexusAdvisorFlip' &&
g.response.kind !== 'nexusSardaukar' &&
g.response.kind !== 'nexusEcazBetrayal' &&
g.response.kind !== 'faceDancerReplacement' && (
<p className="notice">{g.response.intent}</p>
)}
@@ -2392,6 +2408,10 @@ export function GameTable({
? 'Enter a Discovery location'
: g.decision.kind === 'ecologicalStorm'
? 'Ecological Testing Station'
: g.decision.kind === 'nexusFremenCunningOffer'
? 'Empty worm: Fremen Cunning'
: g.decision.kind === 'nexusFremenCunningRide'
? 'Fremen Cunning remote ride'
: g
.decision
.kind ===
@@ -3170,6 +3190,72 @@ export function GameTable({
accept: false,
})}
</>
) : g.decision.kind === 'nexusFremenCunningOffer' ? (
<>
<p className="muted">An empty sandworm appearance can trigger Fremen Cunning.
If you hold your own Fremen Nexus Card, spend it now to arrange a remote
ride from one occupied desert territory after this Nexus. The worm still resolves normally.</p>
{g.nexusCards?.card === 'fremen' && actionButton('Spend Fremen Cunning', {
type: 'decision', event: g.decision.event, accept: true,
})}
{actionButton('Continue without Cunning', {
type: 'decision', event: g.decision.event, accept: false,
})}
</>
) : g.decision.kind === 'nexusFremenCunningRide' ? (
<>
<p className="muted">The Nexus has ended. Choose one occupied desert territory,
typed forces and a legal destination for the extra ride. The original
worm appearance has already resolved; this does not consume normal movement.</p>
<label>Desert source
<select value={source} onChange={e => {
setSource(e.target.value);
setRideForces({});
setEliteForces({});
}}>
<option value="">Choose source</option>
{[...new Set(Object.entries(me.forces)
.filter(([key, count]) => count > 0 &&
splitLocation(key).sector !== g.storm &&
TERRITORIES.some(t => t.id === splitLocation(key).territory && t.type === 'sand'))
.map(([key]) => splitLocation(key).territory))].map(id =>
<option key={id} value={id}>{combatName(id)}</option>)}
</select>
</label>
{Object.entries(me.forces).filter(([key, count]) =>
count > 0 && splitLocation(key).territory === source &&
splitLocation(key).sector !== g.storm).map(([key, count]) =>
<div key={key}>
<label>Sector {splitLocation(key).sector} · {count} forces
<Input type="number" min={0} max={count}
value={rideForces[key] ?? 0}
onChange={e => setRideForces({
...rideForces, [key]: Number(e.target.value),
})} />
</label>
{me.elites && (
<EliteCount label={`Fedaykin from sector ${splitLocation(key).sector}`}
max={Math.min(rideForces[key] ?? 0, me.elites.forces[key] ?? 0)}
value={eliteForces[key] ?? 0}
onChange={n => setEliteForces({ ...eliteForces, [key]: n })} />
)}
</div>)}
{destination}
{actionButton('Ride remotely', {
type: 'decision', event: g.decision.event, accept: true,
source, territory: selected, sector,
forces: Object.fromEntries(Object.entries(rideForces)
.filter(([key, count]) => count > 0 && splitLocation(key).territory === source)
.map(([key, count]) => [key, {
normal: count - (eliteForces[key] ?? 0),
elite: eliteForces[key] ?? 0,
}])),
}, !source || !Object.entries(rideForces).some(([key, count]) =>
count > 0 && splitLocation(key).territory === source))}
{actionButton('Decline remote ride', {
type: 'decision', event: g.decision.event, accept: false,
})}
</>
) : g.decision.kind === 'wormRide' ? (
<>
<p className="muted">
+40
View File
@@ -0,0 +1,40 @@
'use client';
import { useState } from 'react';
import { territory as boardTerritory } from '@/game/board';
import type { Action, GameView } from '@/game/engine';
import { nexusCardReference } from '@/game/nexus-card-reference';
import { ecazBetrayalAction } from '@/game/nexus-ecaz-betrayal-options';
import { Button } from './ui/button';
type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
export function NexusEcazBetrayal({ game, act, busy }: Props) {
const [selected, select] = useState('');
const offer = game.nexusEcazBetrayal;
if (!offer || game.nexusCards?.card !== 'ecaz' ||
!game.players.some(player => player.id === game.me)) return null;
const choice = offer.territories.find(candidate => candidate.territory === selected) ?? offer.territories[0];
const action = choice ? ecazBetrayalAction(game, choice.territory) : null;
const ally = game.players.find(player => player.id === offer.ally);
return (
<section aria-label="Ecaz Nexus Betrayal" className="space-y-3 rounded-lg border border-[#a88b60]/50 p-3">
<h3>Ecaz Nexus · Betrayal</h3>
<p>{nexusCardReference('ecaz').betrayal}</p>
{ally && <p className="fine">Ecaz’s ally: {ally.name}. Their entire physical group in the selected territory returns to reserves after the Karama response.</p>}
{offer.territories.length > 1 && <label className="block space-y-1">
<span>Shared territory</span>
<select className="min-h-11 w-full" value={choice?.territory ?? ''} disabled={busy || !!offer.blocked}
onChange={event => select(event.target.value)}>
{offer.territories.map(candidate => <option key={candidate.territory} value={candidate.territory}>{boardTerritory(candidate.territory).name}</option>)}
</select>
</label>}
{choice && <p>Selected: {boardTerritory(choice.territory).name} · {choice.total.normal} normal · {choice.total.elite} elite forces</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 Betrayal: return ally’s forces
</Button>
</section>
);
}