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

+99 -16
View File
@@ -25,6 +25,7 @@ import type { Action, Game, Player } from './engine';
import { treacheryDeck } from './cards';
import { CHEAP_HERO_TRAITOR } from './traitors';
import { DUKE_VIDAL_ID } from './duke-vidal';
import { canUseAsTruthtranceRole } from './shrine';
export type TruthFact =
| CardCountFact
@@ -44,9 +45,15 @@ export type TruthQuestion =
scope: 'fact' | 'currentTurn';
};
export type TruthAnswer = 'yes' | 'no' | 'unknown';
export type TruthQueueEntry = {
player: string;
card: string;
/** Durable authorization for a Karama committed through an occupied Shrine. */
source?: 'shrine';
};
export type TruthWindow = {
stage: 'priority' | 'ask' | 'answer' | 'unknown';
queue: { player: string; card: string }[];
queue: TruthQueueEntry[];
passed: string[];
question: TruthQuestion | null;
};
@@ -234,9 +241,33 @@ export function validateTruthQuestionReceipt(
'The completed Truthtrance question does not match its recorded opportunity.',
);
}
/** Revalidate the only new persisted future-action question before any write or projection. */
export function validateSavedShipmentQuestion(g: Game) {
/** Revalidate persisted physical card receipts before any write or projection. */
export function validateSavedTruthtrance(g: Game) {
const window = g.truthtrance;
// Older timing-only fixtures may represent the instant before the first
// declaration with an empty priority queue. No card is committed there.
if (window && window.queue.length > 0) {
check(
Array.isArray(window.queue) &&
window.queue.length > 0 &&
new Set(window.queue.map((entry) => entry?.card)).size ===
window.queue.length &&
window.queue.every((entry) => {
if (
!entry ||
typeof entry.player !== 'string' ||
typeof entry.card !== 'string' ||
(entry.source !== undefined && entry.source !== 'shrine')
)
return false;
return queuedTruthCardMatches(
g.players.find((player) => player.id === entry.player)?.hand ?? [],
entry,
);
}),
'The saved Truthtrance must retain each committed physical card in its holder’s hand.',
);
}
if (window?.question?.kind !== 'shipment') return;
check(
(window.stage === 'answer' || window.stage === 'unknown') &&
@@ -244,21 +275,33 @@ export function validateSavedShipmentQuestion(g: Game) {
window.queue.length > 0 &&
new Set(window.queue.map((entry) => entry?.card)).size ===
window.queue.length &&
window.queue.every(
(entry) =>
entry &&
typeof entry.player === 'string' &&
typeof entry.card === 'string' &&
g.players
.find((p) => p.id === entry.player)
?.hand.filter(
(c) => c.id === entry.card && c.effect === 'truthtrance',
).length === 1,
window.queue.every((entry) =>
queuedTruthCardMatches(
g.players.find((p) => p.id === entry.player)?.hand ?? [],
entry,
),
),
'The saved shipment question must retain each physical Truthtrance in its holder’s hand.',
);
parseQuestion(g, window.queue[0].player, window.question);
}
/** Historical name retained for source references and focused callers. */
export const validateSavedShipmentQuestion = validateSavedTruthtrance;
/** Receipt validation uses the printed card identity, never current Shrine occupation. */
export function queuedTruthCardMatches(
hand: readonly { id: string; effect?: string }[],
entry: TruthQueueEntry,
): boolean {
return (
hand.filter(
(card) =>
card.id === entry.card &&
card.effect === (entry.source === 'shrine' ? 'karama' : 'truthtrance'),
).length === 1
);
}
/** A fact answer never returns which disjunct matched or any underlying private cards. */
export function truthFactAnswer(
p: Pick<Player, 'hand' | 'traitors' | 'traitorChoices' | 'spice'>,
@@ -345,12 +388,23 @@ export function resolveTruthAction(
answer: boolean,
asker: string,
) => void;
cardBlock: (g: Game, p: Player, card: Player['hand'][number]) => string | null;
},
): boolean {
const selectedCard =
a.type === 'card' && typeof a.card === 'string'
? p.hand.find((card) => card.id === a.card)
: undefined;
const shrineCards = Array.isArray(a.shrineTruthtrance)
? a.shrineTruthtrance
: [];
const isPlay =
a.type === 'card' &&
!a.mode &&
p.hand.some((c) => c.id === a.card && c.effect === 'truthtrance');
!!selectedCard &&
canUseAsTruthtranceRole(g, p, selectedCard) &&
(selectedCard.effect === 'truthtrance' ||
shrineCards.includes(selectedCard.id));
if (!g.truthtrance && !isPlay && !a.type.startsWith('truth')) return false;
check(
g.status === 'playing' || g.status === 'setup',
@@ -383,12 +437,41 @@ export function resolveTruthAction(
selected.every(
(id) =>
typeof id === 'string' &&
p.hand.some((c) => c.id === id && c.effect === 'truthtrance'),
p.hand.some(
(card) =>
card.id === id &&
canUseAsTruthtranceRole(g, p, card) &&
(card.effect === 'truthtrance' || shrineCards.includes(id)),
),
),
'Each declared Truthtrance must be a different card in your hand.',
);
const selectedCards = selected.map((id) =>
p.hand.find((card) => card.id === id),
);
const reservation = selectedCards
.map((card) => (card ? effects.cardBlock(g, p, card) : null))
.find((reason): reason is string => !!reason);
check(
!reservation,
reservation ?? 'Choose uncommitted physical cards.',
);
check(
new Set(shrineCards).size === shrineCards.length &&
shrineCards.every(
(id) =>
typeof id === 'string' &&
selected.includes(id) &&
p.hand.some((card) => card.id === id && card.effect === 'karama'),
),
'Choose only selected Karama cards for the Shrine conversion.',
);
for (const card of selected)
w.queue.push({ player: p.id, card: card as string });
w.queue.push({
player: p.id,
card: card as string,
...(shrineCards.includes(card) ? { source: 'shrine' as const } : {}),
});
w.passed.push(p.id);
effects.log(
g,