Prototype Jacurutu income, Testing Station and Shrine effects

Connect automatic unambiguous Jacurutu physical-force income, a sole-occupant
Testing Station storm choice and Shrine reciprocal physical card roles to
engine actions, player controls, legal AI and saved continuation. Preserve
Weather Control exclusion, exact card/force custody, private hand boundaries
and committed effects after restart. Shared role checks cover existing ordinary
and special Karama paths; reserved cards cannot enter another pending use.

Keep Orgiz, mixed-force Jacurutu allocation and contested Cistern/Testing
Station benefits explicitly pending. These are bounded prototypes; complete
Discovery/Advanced/expansion and publication gates remain closed.

Validation: types, lint and 4,345 offline tests passed (32 added), production
build passed, and 40 HTTP tests passed. Independent cross-reviews repaired
reserved-card deadlock, physical-card selection, alternate-role permission,
storm source/token binding and public-entitlement bot ordering. The first broad
run found five failures; focused repairs preceded the stable repeated check.

Browser A5EJQ2W4 used genuine Discovery setup/reveal/free entry with targeted QA
positioning. A human increased storm movement from two to three sectors,
moving sector 6 to 9. Refresh restored station forces and the private Snooper.
This was targeted control/recovery evidence, not an unmodified full game.
All 187 opening saved rooms remained unchanged; 31 QA/HTTP rooms were added
(218 total). Reused the running server; no restart or save reset.

Source-bound report:
/home/quintine/.local/share/dune/checkpoints/20260913-discovery-location-effects/verification-final/report.json
Source fingerprint: beecb85ab7ec39319115389c115434d161591e8549802c542a6343a0fc19bea6
Durations: check 140540ms; build 6741ms; integration 24956ms.
Aggregate goal counter 63,116,444 to 64,295,091 at final verification:
1,178,647 tokens before final Git/push overhead for three bounded functions.
Standing authorization: Push all from now on.
This commit is contained in:
Quintine committed 2026-09-13 14:50:09 +10:00
1 parent ab0ab52849
commit f0d3d9c464
31 files changed
+2524 -138

No files matched your search

+2 -2
View File
@@ -29,9 +29,9 @@ export function ChoamCashIn({
<label>
Karama to spend{' '}
<select value={card.id} onChange={(e) => setActivation(e.target.value)}>
{cash.karamas.map((c, i) => (
{cash.karamas.map((c) => (
<option key={c.id} value={c.id}>
Karama {i + 1}
{c.name}
</option>
))}
</select>
+1 -1
View File
@@ -92,7 +92,7 @@ export function DiscoveryPanel({ game, act, busy }: Props) {
);
})}
</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>
<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 income, Testing Station storm choices and Shrine card conversion are prototyped. Orgiz, mixed-force Jacurutu rewards and contested Cistern/Testing Station benefits remain pending.</p>
</section>
);
}
+53
View File
@@ -0,0 +1,53 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { discoveryStormActions } from '@/game/discovery-storm-options';
import { Button } from './ui/button';
export function DiscoveryStormDecision({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const actions = discoveryStormActions(game),
offer = game.ecologicalStorm;
if (!offer || !actions.length) return null;
const me = game.players.find((player) => player.id === game.me)!;
return (
<section aria-label="Ecological Testing Station" className="space-y-3">
<p>
Your forces occupy Ecological Testing Station. The storm will move{' '}
{offer.base} sectors. You may decrease or increase this by one, or keep
the ordinary movement.
</p>
<p className="fine">This choice cannot change Weather Control.</p>
<div className="flex flex-wrap gap-2">
{offer.options.map((option) => {
const action = actions.find(
(candidate) => candidate.delta === option.delta,
)!;
return (
<Button
key={option.delta}
className="game-action"
variant={option.delta === 0 ? 'outline' : 'default'}
disabled={busy || !!me.autopilot}
onClick={() => act(action)}
>
{option.delta === -1
? 'Decrease'
: option.delta === 1
? 'Increase'
: 'Keep'}
: {option.distance} {option.distance === 1 ? 'sector' : 'sectors'}
</Button>
);
})}
</div>
</section>
);
}
+52 -23
View File
@@ -15,6 +15,7 @@ import { NexusTraitors } from './nexus-traitors';
import { GreatMakerDecision } from './great-maker';
import { DiscoveryPanel, DiscoveryDiscardDecision } from './discoveries';
import { DiscoveryEntryDecision } from './discovery-entry';
import { DiscoveryStormDecision } from './discovery-storm';
import { NexusChoamTrade } from './nexus-choam-trade';
import { NexusTleilaxu } from './nexus-tleilaxu';
import { NexusSuboids } from './nexus-suboids';
@@ -111,7 +112,10 @@ import {
import { stoneBurnerPlanBlock } from '@/game/stone-burner';
import { TECH_TOKENS, ownedTech } from '@/game/tech-tokens';
import Link from 'next/link';
import { canUseAsKarama } from '@/game/karama';
import {
canUseAsKaramaRole,
canUseAsTruthtranceRole,
} from '@/game/shrine';
import { LeaderPortrait } from './leader-portrait';
import { ShipmentQuote } from './shipment-quote';
import { reserveShipmentCost } from '@/game/shipment-price';
@@ -295,7 +299,7 @@ export function GameTable({
bidDraft.auction === auctionKey ? bidDraft.value : minimumBid,
);
const maximumBid = me.hand?.some((c) =>
canUseAsKarama(g.advanced, me.faction, c),
canUseAsKaramaRole(g, me, c),
)
? Number.MAX_SAFE_INTEGER
: (me.spice ?? 0) + g.aid.available;
@@ -2213,6 +2217,8 @@ export function GameTable({
.kind ===
'discoveryEntry'
? 'Enter a Discovery location'
: g.decision.kind === 'ecologicalStorm'
? 'Ecological Testing Station'
: g
.decision
.kind ===
@@ -2408,7 +2414,7 @@ export function GameTable({
<p role="status">{g.revival.specialKaramaBlock}</p>
)}
{me.hand
?.filter((c) => c.effect === 'karama')
?.filter((c) => canUseAsKaramaRole(g, me, c))
.map((c) => (
<div key={c.id}>
{actionButton(
@@ -2507,7 +2513,7 @@ export function GameTable({
</p>
<HelpTip topic="fullPlan" />
{me.hand
?.filter((c) => c.effect === 'karama')
?.filter((c) => canUseAsKaramaRole(g, me, c))
.flatMap((c) =>
[g.battle!.attacker, g.battle!.defender].map((id) => (
<div key={`${c.id}-${id}`}>
@@ -2540,7 +2546,7 @@ export function GameTable({
</p>
<HelpTip topic="guildShipment" />
{actionButton('Allow Homeworld shipment', { type: 'decision', event: g.decision.event, allow: true })}
{me.hand?.filter((c) => c.effect === 'karama').map((c) => (
{me.hand?.filter((c) => canUseAsKaramaRole(g, me, c)).map((c) => (
<div key={c.id}>{actionButton('Spend Karama · stop shipment', { type: 'card', mode: 'special', card: c.id })}</div>
))}
</>
@@ -2567,7 +2573,7 @@ export function GameTable({
allow: true,
})}
{me.hand
?.filter((c) => c.effect === 'karama')
?.filter((c) => canUseAsKaramaRole(g, me, c))
.map((c) => (
<div key={c.id}>
{actionButton('Spend Karama · stop shipment', {
@@ -2872,7 +2878,7 @@ export function GameTable({
(g.auction?.bid ?? 0) > (me.spice ?? 0) + g.aid.available,
)}
{me.hand
?.filter((c) => canUseAsKarama(g.advanced, me.faction, c))
?.filter((c) => canUseAsKaramaRole(g, me, c))
.map((c) => (
<div key={c.id}>
{actionButton(
@@ -2950,6 +2956,8 @@ export function GameTable({
</>
) : g.decision.kind === 'discoveryEntry' ? (
<DiscoveryEntryDecision game={g} act={act} busy={transportBusy || !!me.autopilot} />
) : g.decision.kind === 'ecologicalStorm' ? (
<DiscoveryStormDecision 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' ? (
@@ -3694,7 +3702,7 @@ export function GameTable({
g.mobileStronghold?.location &&
(me.forces['hidden_mobile_stronghold:0'] ?? 0) > 0 &&
me.hand
?.filter((c) => c.effect === 'karama')
?.filter((c) => canUseAsKaramaRole(g, me, c))
.slice(0, 1)
.map((c) => (
<details key={c.id}>
@@ -4607,7 +4615,7 @@ export function GameTable({
: richese?.card.effect === 'nullentropyBox'
? 'Use the Nullentropy Box panel above to begin the paid search.'
: richeseCardActionBlock(c)) ??
(canUseAsKarama(g.advanced, me.faction, c)
(canUseAsKaramaRole(g, me, c)
? g.response
? !g.responseControls?.cancelCards.includes(c.id)
? 'This card cannot cancel the current power.'
@@ -4705,9 +4713,12 @@ export function GameTable({
? '◇'
: '†'}
</span>
{c.effect === 'truthtrance' && (
{canUseAsTruthtranceRole(g, me, c) && (
<>
<p className="fine">
{c.effect === 'karama'
? 'Shrine lets you use this physical Karama as Truthtrance while you occupy it. '
: ''}
Ask another player a public yes/no question, including
during another player’s decision. Competing plays
follow storm order.
@@ -4719,12 +4730,22 @@ export function GameTable({
!!g.truthtrance ||
!['setup', 'playing'].includes(g.status)
}
onClick={() => act({ type: 'card', card: c.id })}
onClick={() =>
act({
type: 'card',
card: c.id,
...(c.effect === 'karama'
? { shrineTruthtrance: [c.id] }
: {}),
})
}
>
Declare Truthtrance
{c.effect === 'karama'
? 'Use Karama as Truthtrance'
: 'Declare Truthtrance'}
</Button>
{(me.hand?.filter(
(other) => other.effect === 'truthtrance',
(other) => canUseAsTruthtranceRole(g, me, other),
).length ?? 0) > 1 && (
<Button
variant="outline"
@@ -4740,10 +4761,17 @@ export function GameTable({
cards: me
.hand!.filter(
(other) =>
other.effect === 'truthtrance' &&
canUseAsTruthtranceRole(g, me, other) &&
other.id !== c.id,
)
.map((other) => other.id),
shrineTruthtrance: me
.hand!.filter(
(other) =>
canUseAsTruthtranceRole(g, me, other) &&
other.effect === 'karama',
)
.map((other) => other.id),
})
}
>
@@ -4819,7 +4847,7 @@ export function GameTable({
</>
)}
{g.advanced &&
c.effect === 'karama' &&
canUseAsKaramaRole(g, me, c) &&
!me.specialKaramaUsed &&
me.faction === 'harkonnen' &&
g.phase === 3 && (
@@ -4875,7 +4903,7 @@ export function GameTable({
</>
)}
{g.advanced &&
c.effect === 'karama' &&
canUseAsKaramaRole(g, me, c) &&
!me.specialKaramaUsed &&
me.faction === 'fremen' &&
g.phase === 1 && (
@@ -4926,7 +4954,7 @@ export function GameTable({
</>
)}
{g.advanced &&
c.effect === 'karama' &&
canUseAsKaramaRole(g, me, c) &&
!me.specialKaramaUsed &&
me.faction === 'emperor' &&
g.phase === 4 && (
@@ -5007,7 +5035,7 @@ export function GameTable({
/>
</label>
)}
{canUseAsKarama(g.advanced, me.faction, c) &&
{canUseAsKaramaRole(g, me, c) &&
g.phase === 5 &&
g.active &&
(g.active === me.id ? shipmentAvailable : !g.players.find((p) => p.id === g.active)?.shipped) && (
@@ -5033,9 +5061,10 @@ export function GameTable({
{g.players.find((p) => p.id === g.active)?.name}
</Button>
)}
{c.effect !== 'truthtrance' &&
{(c.effect !== 'truthtrance' ||
canUseAsKaramaRole(g, me, c)) &&
(c.kind === 'special' ||
canUseAsKarama(g.advanced, me.faction, c)) && (
canUseAsKaramaRole(g, me, c)) && (
<Button
variant="outline"
aria-describedby={
@@ -5052,7 +5081,7 @@ export function GameTable({
!(
g.status === 'setup' &&
g.response &&
canUseAsKarama(g.advanced, me.faction, c)
canUseAsKaramaRole(g, me, c)
)) ||
(!!g.phaseOpening &&
(c.effect !== 'amal' ||
@@ -5068,7 +5097,7 @@ export function GameTable({
type: 'card',
elite: eliteAmount,
card: c.id,
mode: canUseAsKarama(g.advanced, me.faction, c)
mode: canUseAsKaramaRole(g, me, c)
? g.response
? 'cancel'
: 'purchase'
@@ -5083,7 +5112,7 @@ export function GameTable({
})
}
>
{canUseAsKarama(g.advanced, me.faction, c)
{canUseAsKaramaRole(g, me, c)
? g.response
? 'Cancel this power'
: 'Take current auction card'
+27 -18
View File
@@ -16,6 +16,7 @@ import {
type TruthQuestion,
} from '@/game/truthtrance';
import type { Action, GameView } from '@/game/engine';
import { canUseAsTruthtranceRole } from '@/game/shrine';
export function Truthtrance({
game: g,
@@ -29,6 +30,20 @@ export function Truthtrance({
const w = g.truthtrance!;
const me = g.players.find((p) => p.id === g.me)!;
const current = w.queue[0];
const truthCards =
me.hand?.filter((card) => canUseAsTruthtranceRole(g, me, card)) ?? [];
const declaration = (cards: typeof truthCards): Action => ({
type: 'card',
card: cards[0].id,
...(cards.length > 1 ? { cards: cards.slice(1).map((card) => card.id) } : {}),
...(cards.some((card) => card.effect === 'karama')
? {
shrineTruthtrance: cards
.filter((card) => card.effect === 'karama')
.map((card) => card.id),
}
: {}),
});
const [target, setTarget] = useState(
g.players.find((p) => p.id !== me.id)!.id,
);
@@ -135,27 +150,21 @@ export function Truthtrance({
</ul>
{!w.passed.includes(me.id) && (
<>
{me.hand
?.filter((c) => c.effect === 'truthtrance')
.slice(0, 1)
.map((c) => (
{truthCards.map((c) => (
<div key={c.id}>
{button('Declare my Truthtrance', {
type: 'card',
card: c.id,
})}
{button(
c.effect === 'karama'
? 'Use my Karama as Truthtrance'
: 'Declare my Truthtrance',
declaration([c]),
)}
</div>
))}
{(me.hand?.filter((c) => c.effect === 'truthtrance').length ??
0) > 1 &&
button('Declare both Truthtrances', {
type: 'card',
card: me.hand!.find((c) => c.effect === 'truthtrance')!.id,
cards: me
.hand!.filter((c) => c.effect === 'truthtrance')
.slice(1)
.map((c) => c.id),
})}
{truthCards.length > 1 &&
button(
`Declare all ${truthCards.length} as Truthtrance`,
declaration(truthCards),
)}
{button('Pass Truthtrance priority', { type: 'truthPass' })}
</>
)}