Compose freeform Truthtrance facts
Verify and publish checkpoint container / container (push) Successful in 11m2s

This commit is contained in:
Quintine committed 2026-10-29 18:50:03 +11:00
1 parent cc9cc0945d
commit 593ff33ca8
10 files changed
+306 -4

No files matched your search

+1 -1
View File
@@ -4036,7 +4036,7 @@ 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, exact personal-spice, named-card, total-hand-size and selected weapon, defense or Worthless category forms are checked as current facts. Supported negative hand questions check that the named face or category count is zero. Category questions use the displayed printed primary role. Two supported current facts may be joined by one unambiguous AND or OR, including selected printed hand categories and negative card or category counts; only their aggregate answer is published. Bounded questions about predictions, storm dials and forecasts, known Spice Blows, and physical forces in reserves, Tanks or on the board also use the existing checked 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.',
'In a new freeform game-fact question, exact personal-spice, named-card, total-hand-size and selected weapon, defense or Worthless category forms are checked as current facts. Supported negative hand questions check that the named face or category count is zero. Category questions use the displayed printed primary role. Bounded questions about predictions, storm dials and forecasts, known Spice Blows, and physical forces in reserves, Tanks or on the board also use the existing checked facts. Two to eight recognized clauses can be joined by one unambiguous AND or OR; only their aggregate answer is published. 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.',
'In supported quiet Basic Bidding or Shipment and Movement, ask whether a player will update funding for their current mutual ally to at least a chosen amount this phase. Yes requires a future accepted update while possible; No forbids reaching the amount. An existing pledge alone does not count, a repeated same total does, and the ally chooses whether to spend it. Only one definite funding question is supported per phase; other future-action questions in that phase and optional modules remain separate.',
'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.',
+77
View File
@@ -0,0 +1,77 @@
import { parseFreeformFactV2 } from './truthtrance-freeform-facts-v2';
import { parseFreeformFactV3 } from './truthtrance-freeform-facts-v3';
import { parseFreeformFactV4 } from './truthtrance-freeform-facts-v4';
import { parseTruthFreeformFactsV5 } from './truthtrance-freeform-facts-v5';
import { parseFreeformFactV6 } from './truthtrance-freeform-facts-v6';
import { TRUTH_FACT_MAX_TERMS } from './truth-fact-expression';
import type { TruthFact } from './truthtrance';
type Compound = Extract<TruthFact, { kind: 'and' | 'or' }>;
type Separator = { start: number; end: number; kind: 'and' | 'or' };
/** Recognize only a uniquely partitioned, flat current-fact expression.
* Existing whole questions retain their original parser version. Each atom
* uses the established public grammar; this parser never inspects game state. */
export function parseTruthFreeformFactsV7(text: string): Compound | null {
if (typeof text !== 'string' || text.length > 500 || /[\r\n]/.test(text)) return null;
const input = text.trim();
if (parseFreeformFactV2(input) || parseFreeformFactV3(input) || parseFreeformFactV4(input) ||
parseTruthFreeformFactsV5(input) || parseFreeformFactV6(input)) return null;
const body = input.endsWith('?') ? input.slice(0, -1).trimEnd() : input;
if (!body || body.includes('?')) return null;
const separators: Separator[] = [...body.matchAll(/[ \t]+(and|or)[ \t]+/gi)].map(match => ({
start: match.index!, end: match.index! + match[0].length,
kind: match[1].toLowerCase() as 'and' | 'or',
}));
// Keep the search bounded even if a malicious input contains many words
// that resemble connectors. A separator may still be part of a card name.
if (!separators.length || separators.length > 24) return null;
const shorthand = /^do[ \t]+you[ \t]+have[ \t]+/i.test(body);
const atomCache = new Map<string, TruthFact | null>();
const atom = (raw: string, first: boolean): TruthFact | null => {
const segment = raw.trim();
if (!segment) return null;
const key = `${first ? 'first' : 'later'}:${segment}`;
if (atomCache.has(key)) return atomCache.get(key)!;
let qualified = segment;
if (!first && /^not[ \t]+have[ \t]+/i.test(segment)) qualified = `Do you ${segment}`;
else if (!first && /^have[ \t]+/i.test(segment)) qualified = `Do you ${segment}`;
else if (!first && shorthand && !/^(?:do|did|is)\b/i.test(segment)) qualified = `Do you have ${segment}`;
const fact = parseFreeformFactV3(qualified) ?? parseFreeformFactV4(qualified) ?? parseFreeformFactV6(qualified);
const result = fact && fact.kind !== 'and' && fact.kind !== 'or' ? fact : null;
atomCache.set(key, result);
return result;
};
// Retain at most two paths per suffix; two prove ambiguity. Memoization
// bounds repeated exploration even when many separators form card names.
const memo = new Map<string, TruthFact[][]>();
const suffix = (offset: number, kind: 'and' | 'or', count: number): TruthFact[][] => {
const key = `${offset}:${kind}:${count}`;
const cached = memo.get(key); if (cached) return cached;
const results: TruthFact[][] = [];
const final = atom(body.slice(offset), false);
if (final && count + 1 <= TRUTH_FACT_MAX_TERMS) results.push([final]);
if (count + 1 < TRUTH_FACT_MAX_TERMS) for (const sep of separators) {
if (sep.start <= offset || sep.kind !== kind) continue;
const head = atom(body.slice(offset, sep.start), false);
if (!head) continue;
for (const tail of suffix(sep.end, kind, count + 1)) {
results.push([head, ...tail]);
if (results.length >= 2) break;
}
if (results.length >= 2) break;
}
memo.set(key, results);
return results;
};
const matches: Compound[] = [];
for (const sep of separators) {
const first = atom(body.slice(0, sep.start), true);
if (!first) continue;
for (const tail of suffix(sep.end, sep.kind, 1)) {
matches.push({kind: sep.kind, terms: [first, ...tail]});
if (matches.length >= 2) return null;
}
}
return matches.length === 1 ? matches[0] : null;
}
+13 -2
View File
@@ -73,6 +73,7 @@ import { parseFreeformFactV3 } from './truthtrance-freeform-facts-v3';
import { parseFreeformFactV4 } from './truthtrance-freeform-facts-v4';
import { parseTruthFreeformFactsV5 } from './truthtrance-freeform-facts-v5';
import { parseFreeformFactV6 } from './truthtrance-freeform-facts-v6';
import { parseTruthFreeformFactsV7 } from './truthtrance-freeform-facts-v7';
import { CHEAP_HERO_TRAITOR } from './traitors';
import {
TRUTH_FACT_MAX_NODES, TRUTH_FACT_MAX_DEPTH, TRUTH_FACT_MAX_TERMS,
@@ -138,7 +139,7 @@ export type TruthQuestion =
text: string;
scope: 'fact' | 'currentTurn';
/** Only newly asked, explicitly recognized facts carry this stable interpretation. */
interpreted?: { version: 1; fact: Extract<TruthFact, { kind: 'hand' | 'spice' }> } | { version: 2 | 3 | 4 | 5 | 6; fact: TruthFact };
interpreted?: { version: 1; fact: Extract<TruthFact, { kind: 'hand' | 'spice' }> } | { version: 2 | 3 | 4 | 5 | 6 | 7; fact: TruthFact };
};
export type TruthAnswer = 'yes' | 'no' | 'unknown';
export type TruthQueueEntry = {
@@ -729,12 +730,17 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
!parseFreeformFactV2(question.text) && !parseFreeformFactV3(question.text) &&
!parseFreeformFactV4(question.text) && !parseTruthFreeformFactsV5(question.text)
? parseFreeformFactV6(question.text) : null;
const aggregateFact = question.scope === 'fact' && marker.version === 7 &&
!parseFreeformFactV2(question.text) && !parseFreeformFactV3(question.text) &&
!parseFreeformFactV4(question.text) && !parseTruthFreeformFactsV5(question.text) &&
!parseFreeformFactV6(question.text) ? parseTruthFreeformFactsV7(question.text) : null;
const expected = oldFact ? { version: 1 as const, fact: oldFact }
: newFact ? { version: 2 as const, fact: newFact }
: categoryFact ? { version: 3 as const, fact: categoryFact }
: negativeFact ? { version: 4 as const, fact: negativeFact }
: compoundFact ? { version: 5 as const, fact: compoundFact }
: currentFact ? { version: 6 as const, fact: parseFact(g, currentFact, g.players.find(player => player.id === question.target)!) } : null;
: currentFact ? { version: 6 as const, fact: parseFact(g, currentFact, g.players.find(player => player.id === question.target)!) }
: aggregateFact ? { version: 7 as const, fact: parseFact(g, aggregateFact, g.players.find(player => player.id === question.target)!) } : null;
check(expected !== null && JSON.stringify(v.interpreted) === JSON.stringify(expected),
'The saved freeform interpretation does not match its original question.');
question.interpreted = expected;
@@ -1225,6 +1231,11 @@ export function resolveTruthAction(
const currentFact = parseFreeformFactV6(w.question.text);
if (currentFact) w.question.interpreted = { version: 6,
fact: parseFact(g, currentFact, g.players.find(player => player.id === w.question!.target)!) };
else {
const aggregateFact = parseTruthFreeformFactsV7(w.question.text);
if (aggregateFact) w.question.interpreted = { version: 7,
fact: parseFact(g, aggregateFact, g.players.find(player => player.id === w.question!.target)!) };
}
}
}
}