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.
284 lines
12 KiB
TypeScript
284 lines
12 KiB
TypeScript
'use client';
|
||
|
||
import { useId } from 'react';
|
||
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
|
||
import { Eye, X } from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import {
|
||
Dialog,
|
||
DialogClose,
|
||
DialogDescription,
|
||
DialogOverlay,
|
||
DialogPortal,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} 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. */
|
||
export type SpiceCardInspectorProps = {
|
||
card: Readonly<SpiceCard>;
|
||
context?: string;
|
||
};
|
||
|
||
export function spiceCardTitle(card: Readonly<SpiceCard>): string {
|
||
if ('territory' in card)
|
||
return `${card.discovery ? 'Discovery · ' : ''}${territory(card.territory).name} · ${card.amount} spice · sector ${card.sector}`;
|
||
if ('sandtrout' in card) return 'Sandtrout';
|
||
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. */
|
||
function SpiceIllustration({
|
||
kind,
|
||
}: {
|
||
kind: 'territory' | 'worm' | 'sandtrout';
|
||
}) {
|
||
const paint = `${useId()}-dune`;
|
||
return (
|
||
<svg
|
||
viewBox="0 0 260 190"
|
||
className="my-5 w-full"
|
||
aria-hidden="true"
|
||
focusable="false"
|
||
>
|
||
<defs>
|
||
<linearGradient id={paint} x1="0" y1="0" x2="0" y2="1">
|
||
<stop
|
||
offset="0"
|
||
stopColor={kind === 'sandtrout' ? '#344a42' : '#4f3d29'}
|
||
/>
|
||
<stop offset="1" stopColor="#171b17" />
|
||
</linearGradient>
|
||
</defs>
|
||
<rect
|
||
x="1"
|
||
y="1"
|
||
width="258"
|
||
height="188"
|
||
rx="90"
|
||
fill={`url(#${paint})`}
|
||
stroke="#ac8c54"
|
||
strokeOpacity="0.5"
|
||
/>
|
||
<circle cx="194" cy="47" r="16" fill="#dfbd7b" opacity="0.65" />
|
||
<path
|
||
d="M 12 121 Q 62 72 126 119 T 248 119 M 8 151 Q 76 95 144 147 T 252 143 M 30 174 Q 104 129 228 172"
|
||
fill="none"
|
||
stroke="#c5a76b"
|
||
strokeWidth="2"
|
||
strokeOpacity="0.65"
|
||
/>
|
||
{kind === 'territory' ? (
|
||
<g fill="#dfb976" stroke="#fff0c4" strokeWidth="1.5">
|
||
<path d="M 97 105 L 110 69 L 122 105 L 110 123 Z" />
|
||
<path d="M 126 112 L 144 58 L 161 112 L 144 135 Z" />
|
||
<path d="M 164 118 L 174 93 L 184 118 L 174 132 Z" />
|
||
</g>
|
||
) : kind === 'worm' ? (
|
||
<g fill="none" stroke="#dec18a">
|
||
<path
|
||
d="M 59 136 C 66 88 67 59 106 45 C 143 31 170 73 165 127"
|
||
strokeWidth="8"
|
||
/>
|
||
<ellipse
|
||
cx="116"
|
||
cy="77"
|
||
rx="31"
|
||
ry="37"
|
||
fill="#141814"
|
||
strokeWidth="3"
|
||
/>
|
||
<ellipse cx="116" cy="77" rx="20" ry="25" strokeWidth="1" />
|
||
<path
|
||
d="M 116 42 V 58 M 116 96 V 113 M 85 77 H 99 M 134 77 H 147 M 94 51 L 103 61 M 131 94 L 140 103 M 94 103 L 103 94 M 131 61 L 140 51"
|
||
strokeWidth="2"
|
||
/>
|
||
</g>
|
||
) : (
|
||
<g fill="#7c9579" fillOpacity="0.3" stroke="#c6d1aa" strokeWidth="2">
|
||
<path d="M 72 90 C 80 45 122 39 150 70 C 191 56 204 95 167 114 C 144 145 109 137 103 114 C 76 127 53 113 72 90 Z" />
|
||
<path
|
||
d="M 83 87 Q 120 63 159 87 M 96 107 Q 127 86 164 101"
|
||
fill="none"
|
||
/>
|
||
</g>
|
||
)}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
export function SpiceCardInspector({ card, context }: SpiceCardInspectorProps) {
|
||
const kind =
|
||
'territory' in card
|
||
? 'territory'
|
||
: 'sandtrout' in card
|
||
? 'sandtrout'
|
||
: 'worm';
|
||
const title = spiceCardTitle(card);
|
||
const faceName =
|
||
'territory' in card
|
||
? territory(card.territory).name
|
||
: kind === 'worm'
|
||
? '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'
|
||
? 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'
|
||
? '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>
|
||
<DialogTrigger
|
||
render={
|
||
<Button
|
||
variant="outline"
|
||
className="min-h-11 min-w-11 motion-reduce:transition-none"
|
||
/>
|
||
}
|
||
aria-label={`Inspect spice card: ${title}${context ? ` · ${context}` : ''}`}
|
||
>
|
||
<Eye aria-hidden="true" />
|
||
Inspect spice card
|
||
</DialogTrigger>
|
||
<DialogPortal>
|
||
<DialogOverlay className="bg-black/75 motion-reduce:animate-none motion-reduce:transition-none" />
|
||
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-3xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-[#8e7952] bg-[#141814] text-[#eeeae0] shadow-2xl outline-none duration-150 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 motion-reduce:animate-none motion-reduce:transition-none sm:max-h-[calc(100dvh-3rem)] sm:w-[calc(100%-3rem)]">
|
||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#414536] px-4 py-3">
|
||
<span className="text-xs font-semibold tracking-[0.16em] text-[#ddbc77] uppercase">
|
||
Spice card inspection
|
||
</span>
|
||
<DialogClose
|
||
render={
|
||
<Button
|
||
variant="outline"
|
||
className="min-h-11 min-w-11 motion-reduce:transition-none"
|
||
/>
|
||
}
|
||
>
|
||
<X aria-hidden="true" />
|
||
Close
|
||
</DialogClose>
|
||
</div>
|
||
<div className="min-h-0 overflow-y-auto overscroll-contain p-4 sm:p-6">
|
||
<div className="grid items-start gap-6 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||
<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'
|
||
? placement
|
||
? 'Discovery spice blow'
|
||
: 'Spice blow'
|
||
: kind === 'worm'
|
||
? 'Worm encounter'
|
||
: 'Sandtrout'}
|
||
</p>
|
||
<SpiceIllustration kind={kind} />
|
||
<DialogTitle className="break-words font-serif text-3xl leading-tight text-[#fff3d6] sm:text-4xl">
|
||
{faceName}
|
||
</DialogTitle>
|
||
{'territory' in card && (
|
||
<dl className="mt-5 grid grid-cols-2 gap-4 border-y border-[#b59a64]/40 py-4">
|
||
<div>
|
||
<dt className="text-sm text-[#d6c9ad]">Card amount</dt>
|
||
<dd className="m-0 mt-1 font-serif text-4xl text-[#f6e4b9]">
|
||
{card.amount}
|
||
<span className="block font-sans text-sm">spice</span>
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-sm text-[#d6c9ad]">Sector</dt>
|
||
<dd className="m-0 mt-1 font-serif text-4xl text-[#f6e4b9]">
|
||
{card.sector}
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
)}
|
||
{'worm' in card && card.thumper && (
|
||
<p className="mt-4 text-base leading-7 text-[#eee5d2]">
|
||
Thumper-created encounter
|
||
</p>
|
||
)}
|
||
{'worm' in card && card.suppressed && (
|
||
<p className="mt-4 text-base leading-7 text-[#eee5d2]">
|
||
Suppressed by Sandtrout
|
||
</p>
|
||
)}
|
||
<p className="mt-5 mb-0 text-xs tracking-wider text-[#cebea0] uppercase">
|
||
Spice · gameplay guide
|
||
</p>
|
||
</article>
|
||
<section
|
||
className="flex min-w-0 flex-col gap-5"
|
||
aria-label="Spice card guidance"
|
||
>
|
||
{context && (
|
||
<p className="m-0 text-sm font-semibold text-[#ddbc77]">
|
||
{context}
|
||
</p>
|
||
)}
|
||
<DialogDescription className="m-0 text-base leading-7 text-[#eee5d2]">
|
||
{description}
|
||
</DialogDescription>
|
||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||
Inspection does not draw a card or resolve an effect. Close
|
||
this view to use the table’s phase controls.
|
||
</p>
|
||
{kind === 'territory' ? (
|
||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||
Spice cannot appear in the storm’s sector. A fresh blow
|
||
remains open for Harvester before acceptance. The amount on
|
||
this face is the card’s base value, not the total spice
|
||
currently on the board.
|
||
</p>
|
||
) : kind === 'worm' ? (
|
||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||
First-turn drawn worms are ignored and later shuffled back.
|
||
A worm suppressed by Sandtrout does not devour the preceding
|
||
territory or create its own Nexus or Fremen ride. A normal
|
||
encounter follows the table’s worm and alliance decisions.
|
||
</p>
|
||
) : (
|
||
<>
|
||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||
Territories drawn before the next active Shai-Hulud
|
||
receive their usual spice. Sandtrout remains pending until
|
||
that worm is suppressed, even across spice blows or turns.
|
||
</p>
|
||
<p className="m-0 text-base leading-7 text-[#e0e4d8]">
|
||
If the immediate replacement is a territory, double its
|
||
spice; Harvester may enhance that fresh blow again. If the
|
||
replacement is another Shai-Hulud, resolve it normally and
|
||
do not double a later territory. Spice still cannot appear
|
||
in the storm.
|
||
</p>
|
||
</>
|
||
)}
|
||
<p className="m-0 rounded-lg border border-[#454b3c] bg-[#1d221b] p-4 text-base leading-7 text-[#e0e4d8]">
|
||
{PHASE_HELP[1]}
|
||
</p>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</DialogPrimitive.Popup>
|
||
</DialogPortal>
|
||
</Dialog>
|
||
);
|
||
}
|