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.
This commit is contained in:
1 parent
f0256c1b38
commit
ab0ab52849
28 files changed
+3873
-59
No files matched your search
@@ -92,7 +92,7 @@ export function DiscoveryPanel({ game, act, busy }: Props) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="fine">Inspection and reveal are optional. Location abilities, next-turn free entry and Ornithopter use are still in development.</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { splitLocation, territory } from '@/game/board';
|
||||
import { DISCOVERY_LOCATION_BY_ID } from '@/game/discoveries';
|
||||
import { specialForceName } from '@/game/combat';
|
||||
import {
|
||||
discoveryEntryDeclineAction,
|
||||
discoveryEntryMoveAction,
|
||||
} from '@/game/discovery-entry-options';
|
||||
import type { DiscoveryEntrySource } from '@/game/discovery-entry';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
type Props = {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
};
|
||||
type Counts = Record<string, { normal: number; elite: number }>;
|
||||
|
||||
export function DiscoveryEntryDecision({ game, act, busy }: Props) {
|
||||
const decision = game.decision,
|
||||
offer = game.discoveryEntry;
|
||||
if (
|
||||
decision?.kind !== 'discoveryEntry' ||
|
||||
decision.player !== game.me ||
|
||||
!offer ||
|
||||
offer.owner !== game.me ||
|
||||
offer.event !== decision.event
|
||||
)
|
||||
return null;
|
||||
return (
|
||||
<DiscoveryEntryChoice
|
||||
key={`${offer.event}:${offer.token}:${offer.owner}`}
|
||||
game={game}
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DiscoveryEntryChoice({ game, act, busy }: Props) {
|
||||
const controlId = useId();
|
||||
const offer = game.discoveryEntry!;
|
||||
const [counts, setCounts] = useState<Counts>(() =>
|
||||
Object.fromEntries(
|
||||
offer.sources.map((source) => [
|
||||
source.source,
|
||||
{ normal: source.normal, elite: source.elite },
|
||||
]),
|
||||
),
|
||||
);
|
||||
const me = game.players.find((player) => player.id === game.me)!;
|
||||
const eliteName = specialForceName(me.faction);
|
||||
const groups: DiscoveryEntrySource[] = offer.sources.flatMap((source) => {
|
||||
const selected = counts[source.source] ?? { normal: 0, elite: 0 };
|
||||
return selected.normal + selected.elite > 0
|
||||
? [{ source: source.source, ...selected }]
|
||||
: [];
|
||||
});
|
||||
const amount = groups.reduce(
|
||||
(sum, group) => sum + group.normal + group.elite,
|
||||
0,
|
||||
);
|
||||
const move = discoveryEntryMoveAction(game, groups);
|
||||
const decline = discoveryEntryDeclineAction(game);
|
||||
const disabled = busy || !!me.autopilot || !!offer.blocked;
|
||||
|
||||
const setCount = (
|
||||
source: DiscoveryEntrySource,
|
||||
kind: 'normal' | 'elite',
|
||||
value: number,
|
||||
) => {
|
||||
const maximum = source[kind];
|
||||
const next = Number.isFinite(value)
|
||||
? Math.max(0, Math.min(maximum, Math.floor(value)))
|
||||
: 0;
|
||||
setCounts((current) => ({
|
||||
...current,
|
||||
[source.source]: {
|
||||
...(current[source.source] ?? { normal: 0, elite: 0 }),
|
||||
[kind]: next,
|
||||
},
|
||||
}));
|
||||
};
|
||||
const selectAll = (selected: boolean) =>
|
||||
setCounts(
|
||||
Object.fromEntries(
|
||||
offer.sources.map((source) => [
|
||||
source.source,
|
||||
selected
|
||||
? { normal: source.normal, elite: source.elite }
|
||||
: { normal: 0, elite: 0 },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<section aria-label="Discovery location entry" className="space-y-4">
|
||||
<div>
|
||||
<h3>Enter {DISCOVERY_LOCATION_BY_ID[offer.destination].name}</h3>
|
||||
<p>
|
||||
Move any number of your non-advisor forces from{' '}
|
||||
{territory(offer.parent).name} into this newly revealed location.
|
||||
This free entry happens before the storm and does not use shipment or
|
||||
movement.
|
||||
</p>
|
||||
</div>
|
||||
{offer.blocked && (
|
||||
<p className="notice" aria-live="polite">
|
||||
{offer.blocked}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={disabled || !offer.sources.length}
|
||||
onClick={() => selectAll(true)}
|
||||
>
|
||||
Select all forces
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={disabled || !offer.sources.length}
|
||||
onClick={() => selectAll(false)}
|
||||
>
|
||||
Clear selection
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{offer.sources.map((source) => {
|
||||
const selected = counts[source.source] ?? { normal: 0, elite: 0 };
|
||||
const where = splitLocation(source.source);
|
||||
const sourceId = `${controlId}-${source.source.replace(/[^a-z0-9_-]/gi, '-')}`;
|
||||
return (
|
||||
<fieldset
|
||||
key={source.source}
|
||||
className="space-y-2 rounded-lg border border-[#a88b60]/40 p-3"
|
||||
>
|
||||
<legend className="px-1 font-semibold">
|
||||
{territory(where.territory).name} · sector {where.sector}
|
||||
</legend>
|
||||
<p className="fine">
|
||||
Available: {source.normal} ordinary
|
||||
{source.elite > 0
|
||||
? ` · ${source.elite} ${eliteName}`
|
||||
: ''}
|
||||
</p>
|
||||
<label className="grid gap-1" htmlFor={`${sourceId}-normal`}>
|
||||
<span>Ordinary forces</span>
|
||||
<Input
|
||||
id={`${sourceId}-normal`}
|
||||
aria-label={`${source.source} ordinary forces`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={source.normal}
|
||||
value={selected.normal}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
setCount(source, 'normal', Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{source.elite > 0 && (
|
||||
<label className="grid gap-1" htmlFor={`${sourceId}-elite`}>
|
||||
<span>{eliteName}</span>
|
||||
<Input
|
||||
id={`${sourceId}-elite`}
|
||||
aria-label={`${source.source} ${eliteName}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={source.elite}
|
||||
value={selected.elite}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
setCount(source, 'elite', Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="game-action"
|
||||
disabled={disabled || !move}
|
||||
onClick={() => {
|
||||
if (move) act(move);
|
||||
}}
|
||||
>
|
||||
Move {amount} {amount === 1 ? 'force' : 'forces'} inside
|
||||
</Button>
|
||||
<Button
|
||||
className="game-action"
|
||||
variant="outline"
|
||||
disabled={busy || !!me.autopilot || !decline}
|
||||
onClick={() => {
|
||||
if (decline) act(decline);
|
||||
}}
|
||||
>
|
||||
Leave forces outside
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useId } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { discoveryFlightMove } from '@/game/discovery-flight-options';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function DiscoveryOrnithopterMovement({ game, act, busy, move }: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
move: Action | null;
|
||||
}) {
|
||||
const id = useId();
|
||||
if (!game.discoveryOrnithopter) return null;
|
||||
const quote = discoveryFlightMove(game, move);
|
||||
const unavailable = busy || !!game.players.find(p => p.id === game.me)?.autopilot ||
|
||||
!!game.automaticContinuationPending;
|
||||
return <section className="my-4 flex min-w-0 flex-col gap-3" aria-labelledby={`${id}-heading`}>
|
||||
<h3 id={`${id}-heading`}>Carried Discovery Ornithopter</h3>
|
||||
<p>Use a range of three territories for the selected group’s movement. This replaces its normal range. The token is removed after that group moves.</p>
|
||||
<p className="fine">Acquired on turn {game.discoveryOrnithopter.acquiredTurn}. Available from the following turn.</p>
|
||||
<Button disabled={unavailable || !quote.action} aria-describedby={quote.blocked ? `${id}-reason` : undefined}
|
||||
onClick={() => { if (!unavailable && quote.action) act(quote.action); }}>
|
||||
Move with Discovery Ornithopter
|
||||
</Button>
|
||||
{quote.blocked && <output id={`${id}-reason`} className="fine">{quote.blocked}</output>}
|
||||
</section>;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { NexusCards } from './nexus-cards';
|
||||
import { NexusTraitors } from './nexus-traitors';
|
||||
import { GreatMakerDecision } from './great-maker';
|
||||
import { DiscoveryPanel, DiscoveryDiscardDecision } from './discoveries';
|
||||
import { DiscoveryEntryDecision } from './discovery-entry';
|
||||
import { NexusChoamTrade } from './nexus-choam-trade';
|
||||
import { NexusTleilaxu } from './nexus-tleilaxu';
|
||||
import { NexusSuboids } from './nexus-suboids';
|
||||
@@ -47,6 +48,7 @@ import { Distrans } from './distrans';
|
||||
import { JuiceOfSapho } from './juice-of-sapho';
|
||||
import { NullentropyBox, NullentropySearch } from './nullentropy-box';
|
||||
import { OrnithopterMovement } from './ornithopter-movement';
|
||||
import { DiscoveryOrnithopterMovement } from './discovery-flight-movement';
|
||||
import { ResidualPoison, BattleLeaderOpportunity } from './residual-poison';
|
||||
import { PortableSnooper } from './portable-snooper';
|
||||
import {
|
||||
@@ -2206,6 +2208,11 @@ export function GameTable({
|
||||
.kind ===
|
||||
'wormProtection'
|
||||
? 'Protection of Shai-Hulud'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
'discoveryEntry'
|
||||
? 'Enter a Discovery location'
|
||||
: g
|
||||
.decision
|
||||
.kind ===
|
||||
@@ -2941,6 +2948,8 @@ export function GameTable({
|
||||
accept: false,
|
||||
})}
|
||||
</>
|
||||
) : g.decision.kind === 'discoveryEntry' ? (
|
||||
<DiscoveryEntryDecision game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
) : g.decision.kind === 'discoveryDiscard' ? (
|
||||
<DiscoveryDiscardDecision game={g} act={act} busy={transportBusy || !!me.autopilot} />
|
||||
) : g.decision.kind === 'greatMakerVote' || g.decision.kind === 'greatMakerRide' ? (
|
||||
@@ -3957,6 +3966,24 @@ export function GameTable({
|
||||
}
|
||||
}
|
||||
/>
|
||||
<DiscoveryOrnithopterMovement
|
||||
game={g}
|
||||
act={act}
|
||||
busy={busy}
|
||||
move={
|
||||
!source || !movementAvailable ||
|
||||
(includesNoField ? !validMarkerGroup : (me.forces[source] ?? 0) === 0)
|
||||
? null
|
||||
: {
|
||||
type: 'move',
|
||||
fighters: moveAsFighters && isAdvisor(me, splitLocation(source).territory),
|
||||
...movementGroup,
|
||||
territory: selected,
|
||||
sector,
|
||||
...(includesNoField ? {} : { amount }),
|
||||
}
|
||||
}
|
||||
/>
|
||||
<p className="fine">
|
||||
{guildCunning?.stage === 'secondShipment'
|
||||
? 'Choose or decline the second shipment before any Hajr movement.'
|
||||
|
||||
Reference in new issue
Block a user