Interpret compound Truthtrance hand facts
Verify and publish checkpoint container / container (push) Successful in 10m54s

This commit is contained in:
Quintine committed 2026-10-29 17:56:30 +11:00
1 parent 4224bb3e2a
commit 15a4f3038a
9 files changed
+268 -17

No files matched your search

+20 -11
View File
@@ -69,6 +69,7 @@ import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import { parseFreeformFactV2 } from '@/game/truthtrance-freeform-facts-v2';
import { parseFreeformFactV3 } from '@/game/truthtrance-freeform-facts-v3';
import { parseFreeformFactV4 } from '@/game/truthtrance-freeform-facts-v4';
import { parseTruthFreeformFactsV5 } from '@/game/truthtrance-freeform-facts-v5';
import {
truthQuestionText,
type TruthFact,
@@ -102,6 +103,23 @@ export function EarlyRicheseBidFields({ minimum, onMinimum, disabled }: {
</fieldset>;
}
/** Show only a recognized current-fact interpretation; unsupported text and
* current-turn promises keep their existing manual-answer guidance. */
export function FreeformFactPreview({ scope, text, target, leaderName, locationName, playerName }: {
scope: 'fact' | 'currentTurn'; text: string; target: string;
leaderName: (id: string) => string; locationName: (id: string) => string; playerName: (id: string) => string;
}) {
const fact = scope === 'fact' ? parseFreeformFactV2(text) || parseFreeformFactV3(text) ||
parseFreeformFactV4(text) || parseTruthFreeformFactsV5(text) : null;
return fact ? <p className="notice" aria-label="Recognized freeform question">
This will be checked as: {truthQuestionText({ kind: 'fact', target, fact }, leaderName, locationName, playerName)}
</p> : <p className="notice">
This wording is not automatically interpreted. Players must answer
facts truthfully; AI will answer I don’t know. Automatic checks
for arbitrary promises remain unfinished.
</p>;
}
export function Truthtrance({
game: g,
act,
@@ -979,17 +997,8 @@ export function Truthtrance({
placeholder="Will you ship at least six forces to Carthag this turn?"
/>
</label>
{scope === 'fact' && (parseFreeformFactV2(text) || parseFreeformFactV3(text) || parseFreeformFactV4(text)) ? (
<p className="notice" aria-label="Recognized freeform question">
This will be checked as: {truthQuestionText({ kind: 'fact', target, fact: (parseFreeformFactV2(text) || parseFreeformFactV3(text) || parseFreeformFactV4(text))! }, leaderName, locationName, name)}
</p>
) : (
<p className="notice">
This wording is not automatically interpreted. Players must answer
facts truthfully; AI will answer I don’t know. Automatic checks
for arbitrary promises remain unfinished.
</p>
)}
<FreeformFactPreview scope={scope} text={text} target={target}
leaderName={leaderName} locationName={locationName} playerName={name} />
</>
)}
{kind === 'pledgeAid' && <>
+10
View File
@@ -17,6 +17,16 @@ open mode gates.
## Current checkpoint and work
**Compound freeform Truthtrance facts — 29 October. Prototyped / Partial.**
New fact-scoped questions recognize an unambiguous AND/OR of two supported
current hand or spice facts, including category and negative conditions. The
editor previews the interpretation; the target and bots use the same checked
answer. Earlier versioned and unstamped questions retain their semantics. The
runtime fixture stages conserved card custody; eleven related files pass 54
cases. Unsupported language remains manual, and full rules and deployed
verification remain open.
[Contract](TRUTHTRANCE_KNOWLEDGE.md#freeform-compound-hand-category-facts--29-october-2026).
**Ghola promise during Box selection — 29 October. Prototyped / Partial.**
A paid Nullentropy Box selection must preserve an outstanding named Ghola Yes;
an incompatible card is rejected before shuffle while the paid search remains
+25 -2
View File
@@ -1,5 +1,27 @@
# Truthtrance: recorded predictions and storm knowledge
## Freeform compound hand-category facts — 29 October 2026
**Prototyped / Partial.** New fact-scoped freeform questions can combine two
supported current-hand facts with one unambiguous AND or OR, including printed
weapon/defense categories and supported negative card or category counts. For
example, a player may ask whether the respondent holds both a poison weapon and
a defense, or holds a Shield and no poison weapons. The public editor previews
the interpreted aggregate; the target alone sees the verified answer before
publishing it, and minimal bots follow the same authoritative fact. Category
meanings use the existing printed primary-role classification.
Newly recognized compositions carry version 5. Existing versions 1–4 and
unstamped saved questions keep their original behavior. Ambiguous forms, mixed
operators, unsupported wording and current-turn promises remain player-managed;
this is a bounded interpretation of the FAQ's general game-related yes/no
permission, not general language understanding. The focused runtime fixture
stages conserved physical card custody, rather than claiming the exact starting
hand was earned. See [parser](../game/truthtrance-freeform-facts-v5.ts),
[pure cases](../tests/truth-freeform-facts-v5.test.ts) and
[runtime cases](../tests/truthtrance-freeform-v5-runtime.test.ts). Full rules,
deployed and broader combinations remain open.
## Freeform negative hand facts — 29 October 2026
**Prototyped / Partial.** New current-fact questions recognize bounded forms
@@ -8,8 +30,9 @@ The editor shows the checked zero-card interpretation, the respondent sees
the private answer, and bots use the same truthful answer. Named faces and
the existing primary-role categories use their existing count predicates.
Newly recognized questions carry version 4; older versions and unstamped
questions retain their prior semantics. Ambiguous, compound, future and other
unrecognized wording remains player-managed. The parser is a provisional
questions retain their prior semantics. Ambiguous, unsupported compound, future and other
unrecognized wording remains player-managed; the later V5 section adds bounded
compound forms. The parser is a provisional
online interpretation of the FAQ's game-related yes/no permission, not
general language understanding. [Parser](../game/truthtrance-freeform-facts-v4.ts),
[runtime cases](../tests/truthtrance-freeform-v4-runtime.test.ts).
+1 -1
View File
@@ -4031,7 +4031,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 spice or named-card facts may be joined by one 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 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 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.',
+38
View File
@@ -0,0 +1,38 @@
import { parseFreeformFactV3 } from './truthtrance-freeform-facts-v3';
import { parseFreeformFactV4 } from './truthtrance-freeform-facts-v4';
import type { TruthFact } from './truthtrance';
type Compound = Extract<TruthFact, { kind: 'and' | 'or' }>;
/**
* A new, deliberately narrow grammar: two existing current-fact atoms joined
* by one unambiguous AND/OR. Older parser versions and their saved receipts
* remain unchanged. Call only for fact-scoped text and show the interpretation.
*/
export function parseTruthFreeformFactsV5(text: string): Compound | null {
if (typeof text !== 'string' || text.length > 500) return null;
const input = text.trim();
// A complete old interpretation, including a card name containing "and",
// retains its original meaning rather than being reinterpreted by V5.
if (parseFreeformFactV3(input) || parseFreeformFactV4(input)) return null;
const question = /^do[ \t]+you[ \t]+have[ \t]+(.+?)[ \t]*\??$/i.exec(input);
if (!question) return null;
const body = question[1];
const atom = (part: string): TruthFact | null => {
const value = part.trim();
if (!value || /[?\r\n]/.test(value)) return null;
const full = /^do[ \t]+you[ \t]+(?:have|not[ \t]+have)[ \t]+/i.test(value)
? value : /^not[ \t]+have[ \t]+/i.test(value) ? `Do you ${value}`
: /^have[ \t]+/i.test(value) ? `Do you ${value}` : `Do you have ${value}`;
const fact = parseFreeformFactV3(full) ?? parseFreeformFactV4(full);
return fact && fact.kind !== 'and' && fact.kind !== 'or' ? fact : null;
};
const matches: Compound[] = [];
for (const connector of body.matchAll(/[ \t]+(and|or)[ \t]+/gi)) {
const index = connector.index ?? 0;
const left = atom(body.slice(0, index));
const right = atom(body.slice(index + connector[0].length));
if (left && right) matches.push({ kind: connector[1].toLowerCase() as 'and' | 'or', terms: [left, right] });
}
return matches.length === 1 ? matches[0] : null;
}
+11 -2
View File
@@ -71,6 +71,7 @@ import { parseFreeformFactV1 } from './truthtrance-freeform-facts';
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 { CHEAP_HERO_TRAITOR } from './traitors';
import {
TRUTH_FACT_MAX_NODES, TRUTH_FACT_MAX_DEPTH, TRUTH_FACT_MAX_TERMS,
@@ -136,7 +137,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; fact: TruthFact };
interpreted?: { version: 1; fact: Extract<TruthFact, { kind: 'hand' | 'spice' }> } | { version: 2 | 3 | 4 | 5; fact: TruthFact };
};
export type TruthAnswer = 'yes' | 'no' | 'unknown';
export type TruthQueueEntry = {
@@ -698,10 +699,14 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
? parseFreeformFactV3(question.text) : null;
const negativeFact = question.scope === 'fact' && marker.version === 4 && !parseFreeformFactV3(question.text)
? parseFreeformFactV4(question.text) : null;
const compoundFact = question.scope === 'fact' && marker.version === 5 &&
!parseFreeformFactV3(question.text) && !parseFreeformFactV4(question.text)
? parseTruthFreeformFactsV5(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 } : null;
: negativeFact ? { version: 4 as const, fact: negativeFact }
: compoundFact ? { version: 5 as const, fact: compoundFact } : null;
check(expected !== null && JSON.stringify(v.interpreted) === JSON.stringify(expected),
'The saved freeform interpretation does not match its original question.');
question.interpreted = expected;
@@ -1175,6 +1180,10 @@ export function resolveTruthAction(
else {
const negativeFact = parseFreeformFactV4(w.question.text);
if (negativeFact) w.question.interpreted = { version: 4, fact: negativeFact };
else {
const compoundFact = parseTruthFreeformFactsV5(w.question.text);
if (compoundFact) w.question.interpreted = { version: 5, fact: compoundFact };
}
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseTruthFreeformFactsV5 as parse } from '../game/truthtrance-freeform-facts-v5';
void test('v5 composes current category, card, spice and negative facts', () => {
assert.deepEqual(parse('Do you have a poison weapon and a defense?'), {
kind: 'and', terms: [
{ kind: 'handInventory', category: 'poisonWeapon', compare: 'gte', value: 1 },
{ kind: 'handInventory', category: 'defense', compare: 'gte', value: 1 },
],
});
assert.deepEqual(parse('Do you have a Shield and no poison weapons?'), {
kind: 'and', terms: [
{ kind: 'hand', name: 'Shield' },
{ kind: 'handInventory', category: 'poisonWeapon', compare: 'eq', value: 0 },
],
});
assert.deepEqual(parse('Do you have at least 5 spice OR do you not have any defenses?'), {
kind: 'or', terms: [
{ kind: 'spice', compare: 'gte', value: 5 },
{ kind: 'handInventory', category: 'defense', compare: 'eq', value: 0 },
],
});
assert.deepEqual(parse('Do you have a weapon and have exactly 2 cards in your hand?'), {
kind: 'and', terms: [
{ kind: 'handInventory', category: 'weapon', compare: 'gte', value: 1 },
{ kind: 'handInventory', category: 'all', compare: 'eq', value: 2 },
],
});
});
void test('v5 leaves old, ambiguous, mixed, future and unsupported wording untouched', () => {
for (const question of [
'Do you have a Shield?', 'Do you have a Shield and a Snooper?',
'Do you have a weapon and a defense or a Shield?',
'Do you have a weapon and a defense and a Shield?',
'Do you have a weapon or a special card?',
'Will you have a weapon and a defense?',
'Do you have a weapon and a defense this turn?',
'Do you have a weapon and a defense??',
'Do you have a weapon and a defense?\nAnswer now',
`Do you have ${'x'.repeat(501)} and a weapon?`,
]) assert.equal(parse(question), null, question);
});
+41 -1
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { Truthtrance } from '../components/truthtrance';
import { FreeformFactPreview, Truthtrance } from '../components/truthtrance';
import { applyAction, viewGame, type Game } from '../game/engine';
import { knowledgeGame } from './truthtrance-knowledge-fixture';
@@ -79,3 +79,43 @@ void test('a negative freeform displays the checked zero-card meaning without ex
assert.match(target, /Your current game information establishes:/);
assert.doesNotMatch(observer, /Your current game information establishes:/);
});
void test('a category and negative-card compound displays its checked aggregate without revealing the answer', () => {
const game = pending(5, 'Do you have a weapon and no Shield?');
const target = markup(game, 'b'), observer = markup(game, 'o');
for (const html of [target, observer]) {
assert.match(html, /Checked as:/);
assert.match(html, /you currently hold at least 1 card whose primary role is a weapon/);
assert.match(html, /AND/);
assert.match(html, /you currently hold exactly 0 cards named Shield/);
}
assert.match(target, /Your current game information establishes:/);
assert.doesNotMatch(observer, /Your current game information establishes:/);
});
void test('unsupported mixed conjunctions keep their manual answer control', () => {
const game = pending(5, 'Do you have a weapon and a defense or a Shield?');
const target = markup(game, 'b');
assert.doesNotMatch(target, /Checked as:/);
assert.doesNotMatch(target, /Your current game information establishes:/);
assert.match(target, /Answer Yes/);
assert.match(target, /Answer No/);
});
void test('the question editor previews the v5 interpretation only for current facts', () => {
const props = { scope: 'fact' as const, text: 'Do you have a weapon and no Shield?', target: 'b',
leaderName: (id: string) => id, locationName: (id: string) => id, playerName: (id: string) => id };
const recognized = renderToStaticMarkup(createElement(FreeformFactPreview, props));
assert.match(recognized, /Recognized freeform question/);
assert.match(recognized, /primary role is a weapon/);
assert.match(recognized, /AND/);
assert.match(recognized, /exactly 0 cards named Shield/);
for (const other of [
{ ...props, scope: 'currentTurn' as const },
{ ...props, text: 'Do you have a weapon and a defense or a Shield?' },
]) {
const manual = renderToStaticMarkup(createElement(FreeformFactPreview, other));
assert.match(manual, /not automatically interpreted/);
assert.doesNotMatch(manual, /Recognized freeform question/);
}
});
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
import { knowledgeGame, knowledgePhysical, rejectKnowledge } from './truthtrance-knowledge-fixture';
/** Preserve every physical card while staging only the target's hand. */
function pending(names: readonly string[], text: string, scope: 'fact' | 'currentTurn' = 'fact') {
let g = knowledgeGame();
const target = g.players.find(p => p.id === 'b')!;
for (const card of target.hand.slice()) moveSemutaEmperorCard(g, card.id, 'f');
for (const name of names) {
const card = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)].find(c => c.name === name);
assert.ok(card, `Find physical ${name}`);
moveSemutaEmperorCard(g, card.id, target.id);
}
const truth = g.players[0].hand.find(card => card.effect === 'truthtrance');
assert.ok(truth);
g = applyAction(g, 'a', {type: 'card', card: truth.id});
while (g.truthtrance?.stage === 'priority') {
const p = g.players.find(row => !g.truthtrance!.passed.includes(row.id));
assert.ok(p); g = applyAction(g, p.id, {type:'truthPass'});
}
return applyAction(g, 'a', {type:'truthAsk', question: {kind:'freeform', target:'b', scope, text}});
}
function question(g: Game) {
const q = g.truthtrance?.question;
assert.equal(q?.kind, 'freeform');
if (q?.kind !== 'freeform') throw Error('Expected a freeform question');
return q;
}
for (const row of [
{name:'both category terms hold', cards:['Chaumas', 'Shield'], text:'Do you have a poison weapon and a defense?', answer:'yes'},
{name:'one category is absent', cards:['Shield'], text:'Do you have a poison weapon and a defense?', answer:'no'},
{name:'named card and negative category', cards:['Shield'], text:'Do you have a Shield and no poison weapons?', answer:'yes'},
{name:'OR accepts the negative alternative', cards:['Shield'], text:'Do you have a poison weapon or no poison weapons?', answer:'yes'},
] as const) {
void test(`v5 ${row.name} enforces the target's actual answer`, () => {
const g = pending(row.cards, row.text);
assert.equal(question(g).interpreted?.version, 5);
assert.equal(viewGame(g, 'b').truthAnswer, row.answer);
for (const observer of ['a', 'f', 'o']) assert.equal(viewGame(g, observer).truthAnswer, null);
rejectKnowledge(g, 'b', {type:'truthAnswer', answer:row.answer === 'yes' ? 'no' : 'yes'});
rejectKnowledge(g, 'b', {type:'truthAnswer', answer:'unknown'});
for (const difficulty of DIFFICULTIES) {
const view = viewGame(g, 'b');
view.players.find(p => p.id === 'b')!.bot = difficulty;
assert.deepEqual(botActions(view), [{type:'truthAnswer', answer:row.answer}]);
}
const physical = knowledgePhysical(g), restored = JSON.parse(JSON.stringify(g)) as Game;
const done = applyAction(restored, 'b', {type:'truthAnswer', answer:row.answer});
assert.equal(done.truthHistory!.at(-1)!.answer, row.answer);
assert.deepEqual(knowledgePhysical(done), physical);
});
}
void test('uninterpreted old receipts and current-turn wording keep their existing manual semantics', () => {
const text = 'Do you have a Shield and no poison weapons?';
const future = pending(['Shield'], text, 'currentTurn');
assert.equal(question(future).interpreted, undefined);
const old = pending(['Shield'], text);
delete question(old).interpreted;
assert.equal(viewGame(old, 'b').truthAnswer, null);
assert.equal(applyAction(old, 'b', {type:'truthAnswer', answer:'no'}).truthHistory!.at(-1)!.answer, 'no');
});
void test('a modified version-5 interpreted receipt is rejected', () => {
const g = pending(['Shield'], 'Do you have a Shield and no poison weapons?');
const q = question(g);
assert.equal(q.interpreted?.version, 5);
if (!q.interpreted) throw Error('Expected the recognized receipt');
q.interpreted.fact = {kind:'hand', name:'Shield'};
assert.throws(() => viewGame(g, 'b'));
assert.throws(() => applyAction(g, 'b', {type:'truthAnswer', answer:'yes'}));
});