Implement Grumman collection and private stacked Terror choices

Add conserved token collection with four bank spice, owner-only stack selection, recovery proofs, controls and AI. Preserve unresolved removal and expansion release gates. Verified 3712 offline cases, types, lint, production build, 40 HTTP cases, three-seat desktop/mobile play, and unchanged 3081 preexisting saved games.
This commit is contained in:
Quintine committed 2026-09-10 08:38:06 +10:00
1 parent 28ba1618ba
commit 1db11a0187
27 files changed
+2451 -43

No files matched your search

+7 -30
View File
@@ -7,6 +7,8 @@ import { ShipmentPromises } from './shipment-promises';
import { HomeworldShipment } from './homeworld-shipment';
import { HomeworldRevivalDeployment } from './homeworld-revival-deployment';
import { CaladanReinforcement } from './caladan-reinforcement';
import { GrummanCollection } from './grumman-collection';
import { TerrorBoardMarkers } from './terror-board-markers';
import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options';
import { GuildHomeworldShipment } from './guild-homeworld-shipment';
import { JunctionTransport } from './junction-transport';
@@ -1287,36 +1289,7 @@ export function GameTable({
));
})}
<NoFieldBoardMarkers players={g.players} />
{g.moritaniTerror?.tokens
.filter((token) => token.status === 'placed' && token.location)
.map((token) => {
const t = territory(token.location!);
return (
<g
key={token.id}
transform={`translate(${t.center[0] + 42},${t.center[1] - 22})`}
pointerEvents="none"
aria-label={`Hidden Terror token in ${t.name}`}
>
<title>Hidden Terror token · {t.name}</title>
<circle
r="15"
fill="#392b41"
stroke="#d6b9dc"
strokeWidth="2"
/>
<text
y="5"
textAnchor="middle"
fill="#f4e1f1"
fontSize="15"
fontWeight="700"
>
T
</text>
</g>
);
})}
<TerrorBoardMarkers tokens={g.moritaniTerror?.tokens ?? []} />
{Object.entries(g.spice)
.filter(([, n]) => n > 0)
.map(([k, n]) => {
@@ -2030,6 +2003,8 @@ export function GameTable({
<h2>
{g.decision.kind === 'caladanReinforcement'
? 'Caladan victory reinforcement'
: g.decision.kind === 'grummanCollection'
? 'Grumman Collection'
: g.decision.kind === 'homeworldRevivalDeployment'
? 'Revival deployment'
: g.decision.kind === 'ecazSpice'
@@ -2308,6 +2283,8 @@ export function GameTable({
<HomeworldRevivalDeployment game={g} act={act} busy={busy} />
) : g.decision.kind === 'caladanReinforcement' ? (
<CaladanReinforcement game={g} act={act} busy={busy} />
) : g.decision.kind === 'grummanCollection' ? (
<GrummanCollection game={g} act={act} busy={busy} />
) : g.decision.kind === 'ecazSpice' ? (
g.ecazSpice?.allocation ? (
<EcazSpice
+112
View File
@@ -0,0 +1,112 @@
'use client';
import { useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { TERROR_DEFINITIONS } from '@/game/moritani-terror';
import {
grummanCollectionCanAct,
grummanCollectionChoice,
} from '@/game/grumman-collection-options';
import { Button } from './ui/button';
type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
export function GrummanCollection({ game, act, busy }: Props) {
if (
!game.grummanCollection ||
game.grummanCollection.player !== game.me ||
game.decision?.kind !== 'grummanCollection' ||
game.decision.player !== game.me
)
return null;
return (
<CollectionChoice
key={game.grummanCollection.event}
game={game}
act={act}
busy={busy}
/>
);
}
function CollectionChoice({ game, act, busy }: Props) {
const offer = game.grummanCollection!;
const [token, setToken] = useState(offer.tokens[0]?.id ?? '');
const [destination, setDestination] = useState(
offer.destinations[0]?.id ?? '',
);
const canAct = grummanCollectionCanAct(game);
const choice = grummanCollectionChoice(game, token, destination);
const disabled = busy || !canAct || !!offer.blocked;
return (
<section aria-label="Grumman collection" className="space-y-3">
<p>
Add one available Terror token to a stronghold to collect 4 spice. You
receive the spice only after the addition resolves.
</p>
<label className="block space-y-1">
<span>Your Terror token</span>
<select
aria-label="Your Terror token"
className="w-full"
value={token}
disabled={disabled || !offer.tokens.length}
onChange={(event) => setToken(event.target.value)}
>
{!offer.tokens.length && <option value="">No available token</option>}
{offer.tokens.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{TERROR_DEFINITIONS[candidate.kind].name}
</option>
))}
</select>
</label>
<label className="block space-y-1">
<span>Destination stronghold</span>
<select
aria-label="Destination stronghold"
className="w-full"
value={destination}
disabled={disabled || !offer.destinations.length}
onChange={(event) => setDestination(event.target.value)}
>
{!offer.destinations.length && (
<option value="">No available stronghold</option>
)}
{offer.destinations.map((target) => (
<option key={target.id} value={target.id}>
{target.name}
</option>
))}
</select>
</label>
{choice.blocked && (
<p className="fine" aria-live="polite">
{choice.blocked}
</p>
)}
<p className="fine">Removal unavailable: {offer.removeBlocked}</p>
<div className="flex flex-wrap gap-2">
<Button
className="game-action"
disabled={busy || !choice.action}
onClick={() => {
if (choice.action) act(choice.action);
}}
>
Add token and collect 4 spice
</Button>
<Button
className="game-action"
variant="outline"
disabled={busy || !canAct}
onClick={() =>
act({ type: 'decision', event: offer.event, decline: true })
}
>
Pass Grumman collection
</Button>
</div>
</section>
);
}
+52
View File
@@ -459,6 +459,58 @@ export function MoritaniEntry({
);
}
// Stacked tokens are selected privately before any selected-kind lookup.
if (entry.stage === 'select') {
if (me.faction !== 'moritani') return null;
const selectionDisabled =
disabled ||
game.status !== 'playing' ||
!!game.phaseOpening ||
!!game.automaticContinuationPending;
return (
<section className="notice min-w-0" aria-labelledby={`${formId}-title`}>
<h3 id={`${formId}-title`}>Choose a Terror token</h3>
<p className="text-sm leading-6">
Choose one token in {territory(entry.territory).name} for this
arrival. Selecting a token keeps its face private; its reaction
choices follow. You may instead leave all tokens hidden and continue
the arrival.
</p>
<div className="flex min-w-0 flex-col gap-3">
{entry.candidates?.map((candidate) => (
<div key={candidate.token} className="space-y-2">
<Button
className={controlClass}
disabled={selectionDisabled}
onClick={() =>
act({ type: 'decision', token: candidate.token })
}
>
Select {TERROR_DEFINITIONS[candidate.kind].name}
</Button>
{candidate.revealBlocked && (
<p className="text-sm leading-6">{candidate.revealBlocked}</p>
)}
{candidate.canOfferAlliance && (
<p className="text-sm leading-6">
An alliance offer is available after selection.
</p>
)}
</div>
))}
<Button
variant="outline"
className={controlClass}
disabled={selectionDisabled}
onClick={() => act({ type: 'decision', decline: true })}
>
Leave all tokens hidden
</Button>
</div>
</section>
);
}
if (me.faction !== 'moritani' || !('kind' in entry) || !entry.kind)
return null;
+26
View File
@@ -0,0 +1,26 @@
import { territory } from '@/game/board';
/** The map shows public stack counts without reading any hidden token face. */
export function TerrorBoardMarkers({ tokens }: {
tokens: readonly { status: string; location: string | null }[];
}) {
const groups = new Map<string, number>();
for (const token of tokens)
if (token.status === 'placed' && token.location)
groups.set(token.location, (groups.get(token.location) ?? 0) + 1);
return [...groups].map(([id, count]) => {
const place = territory(id);
const label = `${count} hidden Terror token${count === 1 ? '' : 's'} in ${place.name}`;
return (
<g key={id} transform={`translate(${place.center[0] + 42},${place.center[1] - 22})`}
pointerEvents="none" aria-label={label}>
<title>{label}</title>
{count > 1 && <circle cx="3" cy="-3" r="15" fill="#392b41" stroke="#d6b9dc" strokeWidth="2" />}
<circle r="15" fill="#392b41" stroke="#d6b9dc" strokeWidth="2" />
<text y="5" textAnchor="middle" fill="#f4e1f1" fontSize="15" fontWeight="700">
{count === 1 ? 'T' : `T${count}`}
</text>
</g>
);
});
}