Interpret bounded Truthtrance fact questions
Verify and publish checkpoint container / container (push) Successful in 10m38s

This commit is contained in:
Quintine committed 2026-10-29 04:31:15 +11:00
1 parent 88c0ab1547
commit 8ae275f598
16 files changed
+431 -31

No files matched your search

+4 -2
View File
@@ -47098,8 +47098,10 @@ export function viewGame(state: Game, id: string, now?: number) {
truthAnswer:
g.truthtrance?.stage === 'answer' &&
g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'fact'
? truthFactAnswer(me, g.truthtrance.question.fact, truthKnowledgeOf(g, me))
(g.truthtrance.question.kind === 'fact' ||
g.truthtrance.question.kind === 'freeform' && g.truthtrance.question.interpreted)
? truthFactAnswer(me, g.truthtrance.question.kind === 'fact'
? g.truthtrance.question.fact : g.truthtrance.question.interpreted!.fact, truthKnowledgeOf(g, me))
: null,
schema: g.schema,
botsPending: g.botsPending ?? false,
+4 -3
View File
@@ -3293,7 +3293,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Choose a recorded prediction faction, compare its turn, or compare the target’s current submitted storm dial or known Fremen forecast. Exactly, at least and at most are available for numeric facts.',
'Ask whether Atreides’ currently known next Spice Card is a sandworm, Sandtrout, or a blow at a named printed territory. If the peek was canceled or is no longer available, the answer is I don’t know.',
'Ask whether the current ordinary auction card or Advanced Richese Black Market card inspected by Atreides has a named Treachery Card face. If the inspection was canceled or the lot is no longer available, the answer is I don’t know. Other special-auction inspections and occupied Homeworld inspections are not yet included.',
'In a Basic Richese cache auction, ask any participant whether the currently offered public cache card has a named face. If no supported cache lot is active, the answer is I don’t know. This is separate from the Atreides inspection question.',
'In a Basic or Advanced Richese cache auction, ask any participant whether the currently offered public cache card has a named face. If no supported cache lot is active, the answer is I don’t know. This is separate from the Atreides inspection question.',
'In Basic or Advanced Bidding, ask Ixians whether their recorded inspected auction group contains a named card. This refers to the recorded group, including cards later sold, rather than what remains for sale. If the inspection is unavailable or belongs to another player, the answer is I don’t know.',
'Ask an entitled participant whether an already answered Prescience reveals a named leader, weapon or defense, no element, or an exact force dial. A pending, unavailable or differently inspected element yields I don’t know.',
'These questions describe a choice already recorded or information currently known. They do not require anyone to win on a future turn, change a choice, or make the storm move a promised distance.',
@@ -3304,7 +3304,7 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['card-truthtrance', 'truthtrance-spice', 'privacy'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Stored prediction, entitled storm knowledge, native Atreides peeks including Advanced Black Market, revealed Prescience, the Ixian recorded auction group and the current public Basic Richese cache face use shared three-valued fact evaluation; other special auctions and occupied inspection remain separate.', evidence: ['game/truthtrance-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Stored prediction, entitled storm knowledge, native Atreides peeks including Advanced Black Market, revealed Prescience, the Ixian recorded auction group and the current public Richese cache face use shared three-valued fact evaluation; other special auctions and occupied inspection remain separate.', evidence: ['game/truthtrance-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Fact choices, numeric comparisons, public faction and card names, bounded inputs and compound question previews.', evidence: ['components/truthtrance-knowledge.tsx', 'components/mixed-shipment-fields.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles answer the owner-only verified scalar through the existing Truthtrance flow. Strategic question selection and freeform understanding remain incomplete.', evidence: ['game/bots.ts', 'tests/truthtrance-knowledge.test.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Current-knowledge semantics, unknown cases and original FAQ authority are explicit.', evidence: ['docs/TRUTHTRANCE_KNOWLEDGE.md'] },
@@ -3989,11 +3989,12 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.',
'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
'In a new freeform game-fact question, the exact forms “Do you have at least/exactly/at most N spice?” and “Do you have a [card name]?” are checked as current personal spice or hand facts. The recognized interpretation appears before asking and with the question. Other wording and current-turn promises remain player-managed; bots answer unknown. Already-pending older questions keep their original behavior.',
'For current spice, choose Exactly, At least or At most and a nonnegative whole amount. This counts spice personally held when answering, excluding separately pledged aid and incoming payments. It does not ask whether a particular action is affordable or promise that the balance will stay unchanged. A true current-spice answer does not prevent later legal spending.',
'For a concealed No-Field, choose its current printed value0,3 or5, not the prospective force pool. A missing or already revealed marker answers No, including for0; a concealed5 with empty reserves still answers Yes to5. Only the respondent sees the expected aggregate before public reply. Neither question nor answer turns up the physical token or commits later use; current-force facts continue to exclude markers.',
'The questioned player answers publicly. A definite answer discards the card. If the answer cannot be known, the holder may ask a different question or save Truthtrance for later.',
'A saved definite answer resumes automatically: its public answer, binding promise and consumed card are recorded once, then the next queued question or interrupted action resumes. Private answer and preparation guidance is unavailable during this automatic recovery.',
'Freeform questions also accept public Yes, No, or I don’t know answers. Their truthfulness relies on the players. AI answers structured facts and battle-plan questions; it cannot yet interpret freeform questions.',
'Recognized freeform current-fact questions use the same verified answer as structured facts. Other freeform questions accept Yes, No, or I don’t know and rely on the players; AI answers I don’t know for wording it cannot interpret.',
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Ground-movement minima include earlier Basic phases with declared native families, owned Charity/ordinary revival preparation and exact normal/native auction funding, plus active or unused waiting phase5 turns before or after shipment. Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.',
'After both plans reveal, a separate current-Traitor question binds an actual unsubmitted native voter to Reveal or decline. The declaration fulfills it before later Karama/betrayal prevention. Cunning cannot voluntarily return a matching card needed by a live Yes; completed declarations retain their native later custody rights. See the current-Traitor guide for scope.',
'An ordinary-bid question can bind one new qualifying bid in the current lot for a supported active or waiting bidder. Yes requires that declaration while possible; No forbids it to lot close. A bid need not win to fulfill Yes, but a winning bid still follows its original payment rules. See the current-lot guide for funding, expiry and unfinished configurations.',
+25
View File
@@ -0,0 +1,25 @@
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
import type { TruthFact } from './truthtrance';
/**
* Version 1 deliberately recognizes only complete, unambiguous current facts.
* Keep its semantics stable for questions that persist an interpretation.
* The caller must restrict use to fact-scoped questions; this function never
* interprets current-turn commitments or consults private game state.
*/
export function parseFreeformFactV1(text: string): Extract<TruthFact, { kind: 'hand' | 'spice' }> | null {
if (typeof text !== 'string' || text.length > 500) return null;
const input = text.trim();
const spice = /^do[ \t]+you[ \t]+have[ \t]+(at[ \t]+least|exactly|at[ \t]+most)[ \t]+([0-9]+)[ \t]+spice[ \t]*\??$/i.exec(input);
if (spice) {
const value = Number(spice[2]);
if (!Number.isSafeInteger(value) || value < 0) return null;
const relation = spice[1].toLowerCase().replace(/[ \t]+/g, ' ');
return { kind: 'spice', compare: relation === 'at least' ? 'gte' : relation === 'at most' ? 'lte' : 'eq', value };
}
const card = /^do[ \t]+you[ \t]+have[ \t]+(?:a|an)[ \t]+(.+?)[ \t]*\??$/i.exec(input);
if (!card) return null;
const name = card[1].trim().toLowerCase();
const matches = TRUTH_CARD_NAMES.filter(candidate => candidate.toLowerCase() === name);
return matches.length === 1 ? { kind: 'hand', name: matches[0] } : null;
}
+4 -4
View File
@@ -38,7 +38,7 @@ export type TruthKnowledge = {
stormForecast: number | null;
spicePeek: string | null;
auctionPeek: string | null;
/** The currently offered public Basic Richese cache card, not the cache. */
/** The currently offered public Richese cache card, not the cache. */
cacheLot?: string | null;
/** The authorized respondent's recorded Ixian group, not remaining lots. */
ixPoolPeek?: readonly string[] | null;
@@ -117,9 +117,9 @@ export function truthKnowledgeOf(
? g.players.find(seat => seat.id === richeseLot.owner)?.hand.find(card => card.id === richeseLot.cardId)
: undefined;
const lotName = lot?.peekKnown ? lot.cards[lot.index]?.name : offered?.name;
// A Basic Richese cache lot is shown to every seat. It is not the owner's
// private cache inventory or an Advanced Black Market card.
const cacheLot = g.phase === 3 && !g.advanced && g.players.some(seat => seat.id === p.id) &&
// A Richese cache lot is public in Basic and Advanced; it is not the
// owner's private cache inventory or an Advanced Black Market card.
const cacheLot = g.phase === 3 && g.players.some(seat => seat.id === p.id) &&
g.richeseAuction?.source === 'cache' && !g.richeseAuction.outcome
? g.richeseCache?.find(card => card.id === g.richeseAuction!.cardId)?.name : undefined;
// Native Ixians may answer from their own successful inspection. The
+27 -7
View File
@@ -64,6 +64,7 @@ import { parseForceCountFact, forceCountFactMatches, forceCountFactText, forceCo
import type { Action, Game, Player } from './engine';
import { coalitionAllies } from './coalition-alliance';
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
import { parseFreeformFactV1 } from './truthtrance-freeform-facts';
import { CHEAP_HERO_TRAITOR } from './traitors';
import {
TRUTH_FACT_MAX_NODES, TRUTH_FACT_MAX_DEPTH, TRUTH_FACT_MAX_TERMS,
@@ -126,6 +127,8 @@ export type TruthQuestion =
target: string;
text: string;
scope: 'fact' | 'currentTurn';
/** Only newly asked, explicitly recognized facts carry this stable interpretation. */
interpreted?: { version: 1; fact: Extract<TruthFact, { kind: 'hand' | 'spice' }> };
};
export type TruthAnswer = 'yes' | 'no' | 'unknown';
export type TruthQueueEntry = {
@@ -643,12 +646,20 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
v.scope === 'fact' || v.scope === 'currentTurn',
'Commitments may only concern the current turn.',
);
return {
const question: Extract<TruthQuestion, { kind: 'freeform' }> = {
kind: 'freeform',
target: v.target,
text: v.text.trim(),
scope: v.scope,
};
if (v.interpreted !== undefined) {
const fact = question.scope === 'fact' ? parseFreeformFactV1(question.text) : null;
const expected = fact ? { version: 1 as const, fact } : null;
check(expected !== null && JSON.stringify(v.interpreted) === JSON.stringify(expected),
'The saved freeform interpretation does not match its original question.');
question.interpreted = expected;
}
return question;
}
/** Reparse a committed question without recomputing its answer or binding again.
* The caller supplies the unchanged parent opportunity with its controls restored. */
@@ -691,6 +702,7 @@ export function validateSavedTruthtrance(g: Game) {
);
}
if (window?.question?.kind === 'fact' || window?.question?.kind === 'movement' ||
window?.question?.kind === 'freeform' && window.question.interpreted ||
window?.question?.kind === 'traitorCall' || window?.question?.kind === 'futureBattleLeader') {
check((window.stage === 'answer' || window.stage === 'unknown') && window.queue.length > 0,
'The saved fact question must retain its asking player and answer stage.');
@@ -1095,7 +1107,15 @@ export function resolveTruthAction(
p.id === current.player,
'Waiting for the Truthtrance holder to ask a question.',
);
w.question = parseQuestion(g, p.id, a.question);
// Derive the v1 interpretation at submission, never from client state.
// Old saved questions without this stamp retain their previous semantics.
const supplied = record(a.question);
const { interpreted: _ignoredInterpretation, ...questionInput } = supplied;
w.question = parseQuestion(g, p.id, questionInput);
if (w.question.kind === 'freeform' && w.question.scope === 'fact') {
const fact = parseFreeformFactV1(w.question.text);
if (fact) w.question.interpreted = { version: 1, fact };
}
if (w.question.kind === 'mixedBattlePlan')
check(effects.mixedBattleAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a whole mixed battle-plan question compatible with this battle.');
@@ -1192,9 +1212,9 @@ export function resolveTruthAction(
a.answer === 'yes' || a.answer === 'no' || a.answer === 'unknown',
'Answer Yes, No, or I don’t know.',
);
if (q.kind === 'fact')
if (q.kind === 'fact' || q.kind === 'freeform' && q.interpreted)
check(
a.answer === truthFactAnswer(p, q.fact, truthKnowledgeOf(g, p)),
a.answer === truthFactAnswer(p, q.kind === 'fact' ? q.fact : q.interpreted!.fact, truthKnowledgeOf(g, p)),
'Answer this fact question truthfully.',
);
if (q.kind === 'shipment' || q.kind === 'mixedShipment') {
@@ -1347,11 +1367,11 @@ export function resolveTruthAction(
'Choose an answer compatible with your available native leaders and earlier commitments.');
if (a.answer !== 'unknown') effects.bindFutureBattleLeader(g, p, q, a.answer === 'yes', current.player);
}
// Freeform semantics cannot be certified by a deterministic engine. AI does not invent an answer.
if (q.kind === 'freeform' && p.bot)
// Unrecognized freeform semantics cannot be certified; AI does not invent an answer.
if (q.kind === 'freeform' && !q.interpreted && p.bot)
check(
a.answer === 'unknown',
'AI cannot yet interpret freeform questions; ask a structured fact question.',
'AI cannot interpret this freeform question; ask a recognized or structured fact question.',
);
(g.truthHistory ??= []).push({
turn: g.turn,