Files
Dune/components/discoveries.tsx
T
Quintine ab0ab52849 Prototype Discovery free entry, Cistern income and carried flight
Connect next-turn optional entry before storm/HMS movement, with typed
multi-sector groups, normal source-storm/occupancy checks, legal bot and human
choices, signed saved arrivals and Bene Gesserit reaction continuation.
Cistern pays its sole occupant two bank spice through canonical Collection.
A carried Ornithopter grants fixed range three on a later-turn actual movement,
preserves pending responses and independent Treachery/No-Field custody, and is
removed only after the selected group moves.

Independent reviews bind pending entry destination baselines, typed custody and
exact reaction continuations. Browser QA caught and fixed an incorrect decision
heading. Orgiz remains disabled pending the stacked-Blow ruling; Jacurutu battle
income, Ecological Testing Station and Shrine remain missing. Mode and publication
gates stay closed; this checkpoint does not certify full Discovery compliance.

Verification: types/lint and 4,313 offline tests passed (49 added), production
build passed, and 40 HTTP tests passed. Final stable run durations: check
139145ms, build 6530ms, integration 24421ms. An earlier run found one stale
Discovery notice assertion, now updated; the next run was interrupted by a
user-confirmed power outage. No interrupted result is claimed as success.

Targeted browser room CA782FQE used genuine human Atreides and two-AI setup,
then physical QA positioning for partial free entry and a three-territory
Ornithopter move. Refresh preserved its private hand, forces, spice and token
removal. The power outage stopped both server and checks; the absent server was
restarted after a private backup. The same browser seat restored correctly and
all 157 pre-restart rooms were unchanged. Final preservation: all 156 opening
rooms unchanged, 31 added QA/integration rooms, total 187. No database reset.

Source fingerprint:
7b639d7f6559a88b00f3cbf913cfa7e6c400bfabb8c392e7b732e8ea77f6dcbc
Private report:
/home/quintine/.local/share/dune/checkpoints/20260913-discovery-entry-flight/verification-recovered/report.json
Aggregate goal counter 61,819,586 -> 63,110,032 at completed verification:
1,290,446 tokens before final Git overhead for three connected functions,
including helper coordination, reviews, browser QA and interrupted checks.
2026-09-13 14:13:40 +10:00

126 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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. Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement are available in the development prototype. Jacurutu battle income, Ecological Testing Station, Shrine and Orgiz abilities remain 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>
);
}