Expand freeform Truthtrance current facts
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 18:46:17 +11:00
1 parent a10e019b01
commit cc9cc0945d
10 files changed
+290 -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. 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. 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 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.',
+51
View File
@@ -0,0 +1,51 @@
import { TERRITORIES } from './board';
import { FACTIONS } from './catalog';
import { knowledgeFactInputError, SPICE_PEEK_VALUES, type KnowledgeFact } from './truthtrance-knowledge';
import type { ForceCountFact } from './truthtrance-force-count';
/** Anchored present facts only. This version is independent of earlier saved
* interpretations and consults printed public registries, never a game state. */
export function parseFreeformFactV6(text: string): KnowledgeFact | ForceCountFact | null {
if (typeof text !== 'string' || text.length > 500 || /[\r\n]/.test(text)) return null;
const input = text.trim().toLowerCase().replace(/[ \t]+/g, ' ');
const comparison = (value: string): 'eq' | 'gte' | 'lte' =>
value === 'at least' ? 'gte' : value === 'at most' ? 'lte' : 'eq';
const knowledge = (fact: KnowledgeFact): KnowledgeFact | null =>
knowledgeFactInputError(fact) ? null : fact;
const faction = /^(?:did you predict (.+?)(?: to win)?|is your predicted faction (.+?))\??$/.exec(input);
if (faction) {
const name = faction[1] ?? faction[2];
const matches = FACTIONS.filter(candidate => candidate.name.toLowerCase() === name || candidate.id.toLowerCase() === name);
return matches.length === 1 ? knowledge({kind:'prediction', field:'faction', faction:matches[0].id}) : null;
}
const numeric = /^is your (predicted turn|storm forecast|(?:submitted )?storm dial) (at least|exactly|at most) ([0-9]+)\??$/.exec(input);
if (numeric) {
const compare = comparison(numeric[2]), value = Number(numeric[3]);
const fact: KnowledgeFact = numeric[1] === 'predicted turn'
? {kind:'prediction', field:'turn', compare, value}
: {kind:numeric[1] === 'storm forecast' ? 'stormForecast' : 'stormDial', compare, value};
return knowledge(fact);
}
const spice = /^is the next spice blow (?:a |an )?(sandworm|worm|sandtrout)\??$/.exec(input);
if (spice) return knowledge({kind:'spicePeek', value:spice[1] === 'sandworm' ? 'worm' : spice[1]});
const blow = /^is the next spice blow (?:in|at) (.+?)\??$/.exec(input);
if (blow) {
const name = blow[1];
const matches = TERRITORIES.filter(candidate => SPICE_PEEK_VALUES.includes(candidate.id) &&
(candidate.name.toLowerCase() === name || candidate.name.toLowerCase().replace(/^the /, '') === name));
return matches.length === 1 ? knowledge({kind:'spicePeek', value:matches[0].id}) : null;
}
const physical = /^do you have (at least|exactly|at most) ([0-9]+) (?:physical )?forces? (in (?:your )?reserves|in (?:the |your )?tanks|on the board)\??$/.exec(input);
if (physical) {
const value = Number(physical[2]);
if (!Number.isSafeInteger(value) || value < 0) return null;
const zone: ForceCountFact['zone'] = physical[3] === 'on the board' ? {kind:'board'}
: physical[3].endsWith('reserves') ? {kind:'reserves'} : {kind:'tanks'};
return {kind:'forceCount', zone, counter:'total', compare:comparison(physical[1]), value};
}
return null;
}
+13 -2
View File
@@ -72,6 +72,7 @@ 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 { CHEAP_HERO_TRAITOR } from './traitors';
import {
TRUTH_FACT_MAX_NODES, TRUTH_FACT_MAX_DEPTH, TRUTH_FACT_MAX_TERMS,
@@ -137,7 +138,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; fact: TruthFact };
interpreted?: { version: 1; fact: Extract<TruthFact, { kind: 'hand' | 'spice' }> } | { version: 2 | 3 | 4 | 5 | 6; fact: TruthFact };
};
export type TruthAnswer = 'yes' | 'no' | 'unknown';
export type TruthQueueEntry = {
@@ -724,11 +725,16 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
const compoundFact = question.scope === 'fact' && marker.version === 5 &&
!parseFreeformFactV3(question.text) && !parseFreeformFactV4(question.text)
? parseTruthFreeformFactsV5(question.text) : null;
const currentFact = question.scope === 'fact' && marker.version === 6 &&
!parseFreeformFactV2(question.text) && !parseFreeformFactV3(question.text) &&
!parseFreeformFactV4(question.text) && !parseTruthFreeformFactsV5(question.text)
? parseFreeformFactV6(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 } : null;
: 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;
check(expected !== null && JSON.stringify(v.interpreted) === JSON.stringify(expected),
'The saved freeform interpretation does not match its original question.');
question.interpreted = expected;
@@ -1215,6 +1221,11 @@ export function resolveTruthAction(
else {
const compoundFact = parseTruthFreeformFactsV5(w.question.text);
if (compoundFact) w.question.interpreted = { version: 5, fact: compoundFact };
else {
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)!) };
}
}
}
}