Connect Ecaz Betrayal and Fremen worm Cunning with build revision
This commit is contained in:
1 parent
1e690b7062
commit
07c739fcf7
30 files changed
+2878
-25
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user