Prototype Discovery spice encounters, private tokens and stash rewards
Connect real Basic/Advanced base-faction audit setup to all seven Discovery Spice Cards and eight physical tokens. Add Great Maker's ordered majority vote and Fremen reserve ride, destructive Discovery blows, private faction peeks and Collection inspection/reveal, stash rewards and mandatory saved card overflow, and revealed nested board locations. Share private legal choices with human controls and all four AI profiles. Preserve normal gates. Independent review repairs orphaned worm/vote/ride controls, retargeted reserve arrivals, actor bindings and incomplete overflow discard receipts. Harden room-aware admission against unrevealed nested locations. Extend the backed-up local prototype starter with a Discovery profile; preserve seats. Still missing: next-turn free entry, five special location benefits and Ornithopter movement spending. Current prototype playthroughs do not certify those effects or complete module/expansion compliance. Verification: npm run check passed 4264 offline tests plus types/lint; npm run build passed; npm run test:integration passed 40 tests. The single source-bound broad run took 129358ms / 6782ms / 23918ms respectively. Report: /home/quintine/.local/share/dune/checkpoints/20260913-discovery-prototype/verification/report.json Source fingerprint: 4ea7b5209a58c369b0b1f8dc239b5dd2f3302bb759d83c32ba5406e80d8ae154 Two seeded Basic/Advanced games complete with component custody intact. SQLite races pay stashes once and resume committed discards without replay. Browser 3RJBH26Y verifies genuine human Fremen setup, a Discovery blow from one reordered physical card, native private Hiereg visibility, enlarged card/rules reading and refresh. Room remains paused at spice acceptance. All 125 opening saved rooms unchanged; 31 added, 156 total. No server restart. Efficiency: three reused bounded helpers; aggregate counter 60591430 to 61812513 after verification, 1221083 tokens before final Git/push overhead. 47 added offline tests. One stable broad run after focused review fixes; no calibration or exhaustive optional-module interaction matrix.
This commit is contained in:
1 parent
4af6e08b3a
commit
f0256c1b38
44 files changed
+4924
-104
No files matched your search
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { territory } from '@/game/board';
|
||||
import { DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '@/game/discoveries';
|
||||
import { discoveryAction, discoveryDiscardAction } from '@/game/discovery-options';
|
||||
import { CardInspector } from './card-inspector';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
|
||||
|
||||
/** Authored gameplay guidance from the component contract, never hidden state. */
|
||||
const tokenRules: Record<DiscoveryTokenFace, string> = {
|
||||
'jacurutu-sietch': 'Jacurutu Sietch counts as a normal stronghold. After winning a battle here, receive one spice for each opposing undialed force sent to the Tanks.',
|
||||
cistern: 'During Spice Collection, an occupant of Cistern receives two spice from the bank.',
|
||||
'ecological-testing-station': 'An occupant may increase or decrease storm movement by one during the Storm phase. This ability does not change movement set by Weather Control.',
|
||||
shrine: 'An occupant of Shrine may use a Truthtrance card as Karama, or a Karama card as Truthtrance.',
|
||||
'orgiz-processing-station': 'During Spice Collection, an occupant takes one spice from each spice blow that is collected.',
|
||||
'treachery-card-stash': 'Receive one Treachery Card, then remove this token. If your hand is already full, take the new card first and then choose any card in your hand to discard, including the new card.',
|
||||
'spice-stash': 'Receive seven spice from the bank, then remove this token.',
|
||||
ornithopter: 'Take this token. On a later turn, you may spend it to move up to three territories instead of your usual allowance for one movement action. Remove the token after that action.',
|
||||
};
|
||||
|
||||
function DiscoveryRules({ face }: { face: DiscoveryTokenFace }) {
|
||||
const definition = DISCOVERY_TOKEN_BY_ID[face];
|
||||
return (
|
||||
<details className="rounded-lg border border-[#a88b60]/40 bg-[#171d18]">
|
||||
<summary className="min-h-11 cursor-pointer px-3 py-3 font-semibold text-[#efd9a8] focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||
Read {definition.name} rules
|
||||
</summary>
|
||||
<div className="space-y-3 border-t border-[#a88b60]/40 px-3 py-4 text-base leading-7 text-[#eee5d2]">
|
||||
<p>{tokenRules[face]}</p>
|
||||
{definition.kind === 'location' && (
|
||||
<>
|
||||
<p>This location is a separate territory inside the surrounding territory. Revealing it leaves it empty. Normal ground movement enters the surrounding territory before entering the location; shipment uses stronghold prices.</p>
|
||||
<p>At most two factions may occupy the location. Forces inside are protected from storms and sandworms.</p>
|
||||
<p>At the start of the turn after this location is revealed, before the storm and the mobile stronghold move, factions in the surrounding territory may move some or all of their non-advisor forces inside.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiscoveryPanel({ game, act, busy }: Props) {
|
||||
const offer = game.discoveries;
|
||||
if (!offer) return null;
|
||||
const disabled = busy || !!game.players.find(player => player.id === game.me)?.autopilot;
|
||||
return (
|
||||
<section aria-label="Discovery tokens" className="space-y-4 rounded-lg border border-[#a88b60]/50 p-3">
|
||||
<div>
|
||||
<h3>Discoveries</h3>
|
||||
<p className="fine">{offer.supplyCount} tokens remain in the supply.</p>
|
||||
<p>During Spice Collection, non-advisor forces in a token’s surrounding territory can inspect it privately and choose whether to reveal it. Fremen can always see placed Hiereg faces; the Guild can always see placed Smuggler faces.</p>
|
||||
</div>
|
||||
{offer.blocked && <p className="notice">{offer.blocked}</p>}
|
||||
{!offer.tokens.length && <p>No Discovery tokens have been placed.</p>}
|
||||
<div className="grid items-start gap-3 lg:grid-cols-2">
|
||||
{offer.tokens.map(token => {
|
||||
const definition = token.face ? DISCOVERY_TOKEN_BY_ID[token.face] : null;
|
||||
const typeName = token.type === 'hiereg' ? 'Hiereg' : 'Smuggler';
|
||||
const name = definition?.name ?? `${typeName} discovery`;
|
||||
const inspect = discoveryAction(game, token.id, false);
|
||||
const reveal = discoveryAction(game, token.id, true);
|
||||
const owner = game.players.find(player => player.id === token.owner);
|
||||
const status = token.status === 'removed' ? 'Resolved and removed' : token.status === 'carried'
|
||||
? `Carried by ${token.owner === game.me ? 'you' : owner?.name ?? 'another faction'}`
|
||||
: token.revealedTurn === null ? token.face ? 'Face down · known to you' : 'Face down · unknown to you'
|
||||
: `Revealed on turn ${token.revealedTurn}`;
|
||||
return (
|
||||
<article key={token.id} aria-label={name} className="min-w-0 space-y-3 rounded-xl border border-[#a88b60]/50 bg-[#20271f] p-4 text-[#f2e8d3]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div aria-hidden="true" className="flex size-14 shrink-0 items-center justify-center rounded-full border-2 border-[#a88b60] bg-[#171d18] font-serif text-2xl text-[#ddbc77]">{token.face ? '◇' : '?'}</div>
|
||||
<div className="min-w-0">
|
||||
<p className="fine m-0">{typeName} token</p>
|
||||
<h4 className="m-0 break-words font-serif text-xl">{name}</h4>
|
||||
<p className="fine m-0">{status}</p>
|
||||
</div>
|
||||
</div>
|
||||
{token.territory && <p>In {territory(token.territory).name} · sector {token.sector}</p>}
|
||||
{token.face && <DiscoveryRules face={token.face} />}
|
||||
{(inspect || reveal) && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{inspect && <Button className="game-action min-h-11 whitespace-normal" disabled={disabled}
|
||||
onClick={() => { if (!disabled) act(inspect); }}>Inspect {typeName} token privately</Button>}
|
||||
{reveal && <Button className="game-action min-h-11 whitespace-normal" disabled={disabled}
|
||||
onClick={() => { if (!disabled) act(reveal); }}>Reveal {name} to everyone</Button>}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="fine">Inspection and reveal are optional. Location abilities, next-turn free entry and Ornithopter use are still in development.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiscoveryDiscardDecision({ game, act, busy }: Props) {
|
||||
const [selected, select] = useState('');
|
||||
const decision = game.decision;
|
||||
if (decision?.kind !== 'discoveryDiscard' || decision.player !== game.me) return null;
|
||||
const me = game.players.find(player => player.id === game.me);
|
||||
const hand = me?.hand ?? [];
|
||||
const card = hand.find(held => held.id === selected) ?? hand.find(held => held.kind === 'worthless') ?? hand[0];
|
||||
const action = card ? discoveryDiscardAction(game, card.id) : null;
|
||||
const disabled = busy || !!me?.autopilot;
|
||||
return (
|
||||
<section aria-label="Discovery stash card discard" className="space-y-3 rounded-lg border border-[#a88b60]/50 p-3">
|
||||
<h3>Treachery Card Stash · choose a discard</h3>
|
||||
<p>The new card is already in your hand. Choose any one of your cards to discard, including the card you just received.</p>
|
||||
<label className="block space-y-1">
|
||||
<span>Card to discard</span>
|
||||
<select className="min-h-11 w-full" value={card?.id ?? ''} disabled={disabled || !action}
|
||||
onChange={event => select(event.target.value)}>
|
||||
{hand.map(held => <option key={held.id} value={held.id}>{held.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{card && <CardInspector card={card} />}
|
||||
<Button className="game-action min-h-11 whitespace-normal" disabled={disabled || !action}
|
||||
onClick={() => { if (!disabled && action) act(action); }}>Discard {card?.name ?? 'a card'}</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { GrummanCollection } from './grumman-collection';
|
||||
import { TupileIntelligence } from './tupile-intelligence';
|
||||
import { NexusCards } from './nexus-cards';
|
||||
import { NexusTraitors } from './nexus-traitors';
|
||||
import { GreatMakerDecision } from './great-maker';
|
||||
import { DiscoveryPanel, DiscoveryDiscardDecision } from './discoveries';
|
||||
import { NexusChoamTrade } from './nexus-choam-trade';
|
||||
import { NexusTleilaxu } from './nexus-tleilaxu';
|
||||
import { NexusSuboids } from './nexus-suboids';
|
||||
@@ -1436,6 +1438,7 @@ export function GameTable({
|
||||
<PrivateBattlePlan game={g} />
|
||||
<NexusCards game={g} act={act} busy={busy} />
|
||||
<NexusTraitors game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
<DiscoveryPanel game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
<NexusChoamTrade game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
<NexusTleilaxu game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
<NexusSuboids game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
@@ -2938,6 +2941,10 @@ export function GameTable({
|
||||
accept: false,
|
||||
})}
|
||||
</>
|
||||
) : g.decision.kind === 'discoveryDiscard' ? (
|
||||
<DiscoveryDiscardDecision game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
) : g.decision.kind === 'greatMakerVote' || g.decision.kind === 'greatMakerRide' ? (
|
||||
<GreatMakerDecision game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
) : g.decision.kind === 'wormProtection' ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { greatMakerRideAction } from '@/game/great-maker-options';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
|
||||
export function GreatMakerDecision({ game, act, busy }: Props) {
|
||||
const [destination, setDestination] = useState(''),
|
||||
[amount, setAmount] = useState(1),
|
||||
[elite, setElite] = useState(0);
|
||||
const d = game.decision,
|
||||
frame = game.greatMaker;
|
||||
if (!frame || !d || d.player !== game.me) return null;
|
||||
if (d.kind === 'greatMakerVote')
|
||||
return (
|
||||
<section aria-label="Great Maker vote" className="space-y-3">
|
||||
<h3>Great Maker · Nexus vote</h3>
|
||||
<p>
|
||||
Vote in storm order. A majority must vote yes for this encounter to
|
||||
create a Nexus; a tie means no Nexus.
|
||||
</p>
|
||||
{frame.votes.map((vote) => (
|
||||
<p key={vote.player}>
|
||||
{game.players.find((p) => p.id === vote.player)?.name}:{' '}
|
||||
{vote.yes ? 'Yes' : 'No'}
|
||||
</p>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[true, false].map((yes) => (
|
||||
<Button
|
||||
key={String(yes)}
|
||||
disabled={busy}
|
||||
onClick={() => act({ type: 'decision', event: d.event, yes })}
|
||||
>
|
||||
{yes ? 'Yes — create a Nexus' : 'No Nexus'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
const offer = frame.ride;
|
||||
if (d.kind !== 'greatMakerRide' || !offer) return null;
|
||||
const to =
|
||||
offer.destinations.find(
|
||||
(to) => `${to.territory}:${to.sector}` === destination,
|
||||
) ?? offer.destinations[0];
|
||||
const n = Math.max(1, Math.min(amount, offer.max)),
|
||||
minimumElite = Math.max(0, n - (offer.max - offer.eliteMax));
|
||||
const e = Math.max(minimumElite, Math.min(elite, n, offer.eliteMax));
|
||||
const action = to
|
||||
? greatMakerRideAction(game, to.territory, to.sector, n, e)
|
||||
: null;
|
||||
return (
|
||||
<section aria-label="Great Maker reserve ride" className="space-y-3">
|
||||
<h3>Ride the Great Maker</h3>
|
||||
<p>
|
||||
Move any number of reserve forces to one legal destination. This uses no
|
||||
spice, shipment or movement. Forces already on the board stay where they
|
||||
are.
|
||||
</p>
|
||||
<label className="block">
|
||||
Destination
|
||||
<select
|
||||
value={to ? `${to.territory}:${to.sector}` : ''}
|
||||
disabled={busy}
|
||||
onChange={(event) => setDestination(event.target.value)}
|
||||
>
|
||||
{offer.destinations.map((to) => (
|
||||
<option
|
||||
key={`${to.territory}:${to.sector}`}
|
||||
value={`${to.territory}:${to.sector}`}
|
||||
>
|
||||
{to.name} · sector {to.sector}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
Forces
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={offer.max}
|
||||
value={n}
|
||||
disabled={busy}
|
||||
onChange={(event) => setAmount(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
{offer.eliteMax > 0 && (
|
||||
<label className="block">
|
||||
Fedaykin included
|
||||
<input
|
||||
type="number"
|
||||
min={minimumElite}
|
||||
max={Math.min(n, offer.eliteMax)}
|
||||
value={e}
|
||||
disabled={busy}
|
||||
onChange={(event) => setElite(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={busy || !action}
|
||||
onClick={() => {
|
||||
if (action) act(action);
|
||||
}}
|
||||
>
|
||||
Ride with {n} forces
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
act({ type: 'decision', event: d.event, accept: false })
|
||||
}
|
||||
>
|
||||
Leave forces in reserves
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import type { SpiceCard } from '@/game/cards';
|
||||
import { territory } from '@/game/board';
|
||||
import {
|
||||
isDiscoverySpiceCardId,
|
||||
DISCOVERY_CARD_PLACEMENTS,
|
||||
} from '@/game/discoveries';
|
||||
import { PHASE_HELP } from '@/game/reference';
|
||||
|
||||
/** Only the supplied visible face is read. No deck, discard pile or game is accepted. */
|
||||
@@ -25,9 +29,9 @@ export type SpiceCardInspectorProps = {
|
||||
|
||||
export function spiceCardTitle(card: Readonly<SpiceCard>): string {
|
||||
if ('territory' in card)
|
||||
return `${territory(card.territory).name} · ${card.amount} spice · sector ${card.sector}`;
|
||||
return `${card.discovery ? 'Discovery · ' : ''}${territory(card.territory).name} · ${card.amount} spice · sector ${card.sector}`;
|
||||
if ('sandtrout' in card) return 'Sandtrout';
|
||||
return `Shai-Hulud${card.thumper ? ' · Thumper encounter' : ''}${card.suppressed ? ' · suppressed' : ''}`;
|
||||
return `${card.greatMaker ? 'Great Maker' : 'Shai-Hulud'}${card.thumper ? ' · Thumper encounter' : ''}${card.suppressed ? ' · suppressed' : ''}`;
|
||||
}
|
||||
|
||||
/** Original vector illustration, with no claim to reproduce a printed card face. */
|
||||
@@ -122,13 +126,23 @@ export function SpiceCardInspector({ card, context }: SpiceCardInspectorProps) {
|
||||
'territory' in card
|
||||
? territory(card.territory).name
|
||||
: kind === 'worm'
|
||||
? 'Shai-Hulud'
|
||||
? 'worm' in card && card.greatMaker
|
||||
? 'Great Maker'
|
||||
: 'Shai-Hulud'
|
||||
: 'Sandtrout';
|
||||
const placement =
|
||||
'territory' in card && isDiscoverySpiceCardId(card.discovery)
|
||||
? DISCOVERY_CARD_PLACEMENTS[card.discovery]
|
||||
: null;
|
||||
const description =
|
||||
kind === 'territory'
|
||||
? 'This spice blow names the territory, sector and base spice amount. The storm and card effects can change how much spice actually appears.'
|
||||
? placement
|
||||
? `Destroy all previous spice and forces in the named territory, including Fremen, then place six spice in the indicated sector. Draw a random ${placement.type === 'hiereg' ? 'Hiereg' : 'Smuggler'} Discovery token and place it face down in ${territory(placement.territory).name}, sector ${placement.sector}. The storm can destroy the new spice.`
|
||||
: 'This spice blow names the territory, sector and base spice amount. The storm and card effects can change how much spice actually appears.'
|
||||
: kind === 'worm'
|
||||
? 'A Shai-Hulud encounter can consume forces and spice in the territory identified by the preceding card in its spice discard pile. Resolve protection, Nexus and riding decisions through the table.'
|
||||
? 'worm' in card && card.greatMaker
|
||||
? 'Resolve the normal worm destruction, then vote in storm order. A strict yes majority creates a Nexus; a tie does not. Fremen may afterward ride with any number of reserve forces, following storm and occupancy rules. Preventing Fremen survival in the devoured territory does not prevent this reserve ride.'
|
||||
: 'A Shai-Hulud encounter can consume forces and spice in the territory identified by the preceding card in its spice discard pile. Resolve protection, Nexus and riding decisions through the table.'
|
||||
: 'Sandtrout breaks existing alliances and waits for the next active drawn Shai-Hulud. It suppresses that encounter, then the immediate replacement determines whether a spice blow is doubled.';
|
||||
return (
|
||||
<Dialog>
|
||||
@@ -168,7 +182,9 @@ export function SpiceCardInspector({ card, context }: SpiceCardInspectorProps) {
|
||||
<article className="min-w-0 rounded-xl border border-[#a58b5c] bg-gradient-to-br from-[#493d29] via-[#2f3023] to-[#171b17] p-5 shadow-lg sm:p-6">
|
||||
<p className="m-0 text-sm font-semibold tracking-wider text-[#f1d79f] uppercase">
|
||||
{kind === 'territory'
|
||||
? 'Spice blow'
|
||||
? placement
|
||||
? 'Discovery spice blow'
|
||||
: 'Spice blow'
|
||||
: kind === 'worm'
|
||||
? 'Worm encounter'
|
||||
: 'Sandtrout'}
|
||||
|
||||
Reference in new issue
Block a user