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:
1 parent
ab0ab52849
commit
f0d3d9c464
31 files changed
+2524
-138
No files matched your search
+99
-16
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user