From 9dd386ff7a7b13c07570b3a521b8cead5c7cc92c Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 04:34:29 +1100 Subject: [PATCH] Expand recognized Truthtrance fact questions --- components/truthtrance.tsx | 6 +- docs/CURRENT_STATUS.md | 11 ++ docs/TRUTHTRANCE_KNOWLEDGE.md | 18 +++ game/reference.ts | 2 +- game/truthtrance-freeform-facts-v2.ts | 45 +++++++ game/truthtrance.ts | 16 ++- tests/truthtrance-freeform-controls.test.tsx | 17 ++- tests/truthtrance-freeform-facts-v2.test.ts | 52 ++++++++ tests/truthtrance-freeform-runtime.test.ts | 6 +- tests/truthtrance-freeform-v2-runtime.test.ts | 120 ++++++++++++++++++ tests/truthtrance.test.ts | 2 +- 11 files changed, 279 insertions(+), 16 deletions(-) create mode 100644 game/truthtrance-freeform-facts-v2.ts create mode 100644 tests/truthtrance-freeform-facts-v2.test.ts create mode 100644 tests/truthtrance-freeform-v2-runtime.test.ts diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 41839fec..7230bac5 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -64,7 +64,7 @@ import { useId, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { CHEAP_HERO_TRAITOR } from '@/game/traitors'; -import { parseFreeformFactV1 } from '@/game/truthtrance-freeform-facts'; +import { parseFreeformFactV2 } from '@/game/truthtrance-freeform-facts-v2'; import { truthQuestionText, type TruthFact, @@ -947,9 +947,9 @@ export function Truthtrance({ placeholder="Will you ship at least six forces to Carthag this turn?" /> - {scope === 'fact' && parseFreeformFactV1(text) ? ( + {scope === 'fact' && parseFreeformFactV2(text) ? (

- This will be checked as: {truthQuestionText({ kind: 'fact', target, fact: parseFreeformFactV1(text)! }, leaderName, locationName, name)} + This will be checked as: {truthQuestionText({ kind: 'fact', target, fact: parseFreeformFactV2(text)! }, leaderName, locationName, name)}

) : (

diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index be76a443..270adc4a 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,17 @@ open mode gates. ## Current checkpoint and work +**Freeform hand size and combined facts — 29 October. Prototyped / Partial.** +New fact-scoped freeform questions recognize total hand-size comparisons and +one AND/OR combining two named-card or spice facts. The table sees the +interpretation, while only the target sees the verified aggregate before +answering. Saved version-one questions retain their original interpretation; +older unstamped questions and unsupported wording keep manual behavior. +Fifty-nine related cases, type/lint checks and the production build pass, +including human and legal bot answers. +General language understanding and deployed acceptance remain open. +[Scope](TRUTHTRANCE_KNOWLEDGE.md#freeform-hand-size-and-combined-facts--29-october-2026). + **Advanced cache and recognized freeform facts — 29 October. Prototyped / Partial.** Structured Truthtrance can name the current public Advanced Richese cache lot. New fact-scoped freeform questions also recognize exact current-spice comparisons diff --git a/docs/TRUTHTRANCE_KNOWLEDGE.md b/docs/TRUTHTRANCE_KNOWLEDGE.md index 37a32244..5b940d35 100644 --- a/docs/TRUTHTRANCE_KNOWLEDGE.md +++ b/docs/TRUTHTRANCE_KNOWLEDGE.md @@ -1,5 +1,23 @@ # Truthtrance: recorded predictions and storm knowledge +## Freeform hand size and combined facts — 29 October 2026 + +**Prototyped / Partial.** New fact-scoped questions recognize exact comparisons +of total cards in hand and two current-spice or named-card conditions joined by +one AND or OR. For example, “Do you have at least 3 cards in your hand?” and +“Do you have at least 5 spice and a Shield?” receive a displayed interpretation +and a verified aggregate answer. The table does not receive separate condition +results; all minimal bot profiles use that same aggregate. + +Version 2 retains the earlier exact forms for newly asked questions. Saved +version 1 questions are validated against the unchanged version 1 parser, and +unstamped older questions keep their original behavior. Mixed operators, +ambiguous names, negation, future promises and unsupported grammar remain +player-managed with unknown bot answers. This bounded grammar is an online +interpretation of the FAQ's game-related yes/no permission, not general natural +language understanding. See the [parser](../game/truthtrance-freeform-facts-v2.ts) +and [runtime evidence](../tests/truthtrance-freeform-v2-runtime.test.ts). + ## Recognized freeform current facts — 29 October 2026 **Prototyped / Partial.** New freeform questions explicitly scoped as current diff --git a/game/reference.ts b/game/reference.ts index 85c879e0..df04a9f6 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3989,7 +3989,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, 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.', + 'In a new freeform game-fact question, exact personal-spice, named-card and total-hand-size forms are checked as current facts. Two spice or 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.', '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.', diff --git a/game/truthtrance-freeform-facts-v2.ts b/game/truthtrance-freeform-facts-v2.ts new file mode 100644 index 00000000..cfb207a0 --- /dev/null +++ b/game/truthtrance-freeform-facts-v2.ts @@ -0,0 +1,45 @@ +import { parseFreeformFactV1 } from './truthtrance-freeform-facts'; +import type { HandInventoryFact } from './truthtrance-hand-inventory'; +import type { TruthFact } from './truthtrance'; + +type V1Fact = Extract; +export type FreeformFactV2 = V1Fact | HandInventoryFact | + { kind: 'and' | 'or'; terms: [V1Fact, V1Fact] }; + +/** + * Version 2 preserves version 1 and recognizes a small additional set of + * complete current facts. Do not change this grammar for saved v2 questions. + * Callers must still restrict interpretation to fact-scoped questions. + */ +export function parseFreeformFactV2(text: string): FreeformFactV2 | null { + if (typeof text !== 'string' || text.length > 500) return null; + const input = text.trim(); + // Check both whole-name and compound parses before deciding; a future + // printed name might itself contain a conjunction between known names. + const existing = parseFreeformFactV1(input); + const count = /^do[ \t]+you[ \t]+have[ \t]+(at[ \t]+least|exactly|at[ \t]+most)[ \t]+([0-9]+)[ \t]+cards?[ \t]+in[ \t]+your[ \t]+hand[ \t]*\??$/i.exec(input); + if (count) { + const value = Number(count[2]); + if (!Number.isSafeInteger(value) || value < 0) return null; + const relation = count[1].toLowerCase().replace(/[ \t]+/g, ' '); + return { kind: 'handInventory', category: 'all', + compare: relation === 'at least' ? 'gte' : relation === 'at most' ? 'lte' : 'eq', value }; + } + const compound = /^do[ \t]+you[ \t]+have[ \t]+(.+?)[ \t]*\??$/i.exec(input); + if (!compound) return existing; + const body = compound[1]; + const separator = /[ \t]+(and|or)[ \t]+/gi; + const matches: Array> = []; + for (const match of body.matchAll(separator)) { + const index = match.index ?? 0; + const left = body.slice(0, index); + const right = body.slice(index + match[0].length); + const first = parseFreeformFactV1(`Do you have ${left}`); + const second = parseFreeformFactV1(/^do[ \t]+you[ \t]+have[ \t]+/i.test(right) + ? right : `Do you have ${right}`); + if (first && second) matches.push({ kind: match[1].toLowerCase() as 'and' | 'or', terms: [first, second] }); + } + // A whole printed name and a compound are also ambiguous if both fit. + if (matches.length > 1 || existing && matches.length > 0) return null; + return existing ?? matches[0] ?? null; +} diff --git a/game/truthtrance.ts b/game/truthtrance.ts index ca11edf8..af9025ac 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -65,6 +65,7 @@ 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 { parseFreeformFactV2 } from './truthtrance-freeform-facts-v2'; import { CHEAP_HERO_TRAITOR } from './traitors'; import { TRUTH_FACT_MAX_NODES, TRUTH_FACT_MAX_DEPTH, TRUTH_FACT_MAX_TERMS, @@ -128,7 +129,7 @@ export type TruthQuestion = text: string; scope: 'fact' | 'currentTurn'; /** Only newly asked, explicitly recognized facts carry this stable interpretation. */ - interpreted?: { version: 1; fact: Extract }; + interpreted?: { version: 1; fact: Extract } | { version: 2; fact: TruthFact }; }; export type TruthAnswer = 'yes' | 'no' | 'unknown'; export type TruthQueueEntry = { @@ -653,8 +654,11 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion { 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; + const marker = record(v.interpreted); + const oldFact = question.scope === 'fact' && marker.version === 1 ? parseFreeformFactV1(question.text) : null; + const newFact = question.scope === 'fact' && marker.version === 2 ? parseFreeformFactV2(question.text) : null; + const expected = oldFact ? { version: 1 as const, fact: oldFact } + : newFact ? { version: 2 as const, fact: newFact } : null; check(expected !== null && JSON.stringify(v.interpreted) === JSON.stringify(expected), 'The saved freeform interpretation does not match its original question.'); question.interpreted = expected; @@ -1107,14 +1111,14 @@ export function resolveTruthAction( p.id === current.player, 'Waiting for the Truthtrance holder to ask a question.', ); - // Derive the v1 interpretation at submission, never from client state. + // Derive the current 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 }; + const fact = parseFreeformFactV2(w.question.text); + if (fact) w.question.interpreted = { version: 2, fact }; } if (w.question.kind === 'mixedBattlePlan') check(effects.mixedBattleAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, diff --git a/tests/truthtrance-freeform-controls.test.tsx b/tests/truthtrance-freeform-controls.test.tsx index e0f709f4..27cbb630 100644 --- a/tests/truthtrance-freeform-controls.test.tsx +++ b/tests/truthtrance-freeform-controls.test.tsx @@ -8,7 +8,7 @@ import { knowledgeGame } from './truthtrance-knowledge-fixture'; /** Actual native game and Truthtrance actions, with the target's scalar * spice staged to show both personalized result branches. */ -function pending(spice: number): Game { +function pending(spice: number, text = 'Do you have at least 5 spice?'): Game { let game = knowledgeGame(); game.players.find(player => player.id === 'b')!.spice = spice; const card = game.players[0].hand.find(item => item.effect === 'truthtrance'); @@ -19,7 +19,7 @@ function pending(spice: number): Game { assert.ok(voter); game = applyAction(game,voter.id,{type:'truthPass'}); } return applyAction(game,'a',{type:'truthAsk',question:{kind:'freeform',target:'b', - scope:'fact',text:'Do you have at least 5 spice?'}}); + scope:'fact',text}}); } function markup(game: Game, seat: string) { return renderToStaticMarkup(createElement(Truthtrance, {game:viewGame(game,seat), busy:false, act() {}})); @@ -44,3 +44,16 @@ for (const [spice, answer] of [[5,'Yes'],[4,'No']] as const) { assert.match(observer, /Waiting for .* to answer/); }); } + +void test('a compound freeform shows its aggregate interpretation to the table', () => { + const game = pending(5, 'Do you have at least 5 spice or a 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 5 spice/); + assert.match(html, /OR/); + assert.match(html, /Shield/); + } + assert.match(target, /Yes<\/strong>/); + assert.doesNotMatch(observer, /Your current game information establishes:/); +}); diff --git a/tests/truthtrance-freeform-facts-v2.test.ts b/tests/truthtrance-freeform-facts-v2.test.ts new file mode 100644 index 00000000..6178cd5d --- /dev/null +++ b/tests/truthtrance-freeform-facts-v2.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parseFreeformFactV1 } from '../game/truthtrance-freeform-facts'; +import { parseFreeformFactV2 } from '../game/truthtrance-freeform-facts-v2'; +import { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names'; + +void test('freeform v2 preserves each supported v1 form and every canonical named card', () => { + for (const text of ['Do you have at least 5 spice?', 'Do you have exactly 0 spice?', + 'Do you have at most 23 spice', ...TRUTH_CARD_NAMES.map(name => `Do you have a ${name}?`)]) { + assert.ok(parseFreeformFactV1(text), text); + assert.deepEqual(parseFreeformFactV2(text), parseFreeformFactV1(text), text); + } +}); + +void test('freeform v2 recognizes explicit total physical hand comparisons', () => { + for (const [text, compare, value] of [ + ['Do you have at least 3 cards in your hand?', 'gte', 3], + ['Do you have exactly 1 card in your hand', 'eq', 1], + ['DO YOU HAVE AT MOST 0 CARDS IN YOUR HAND?', 'lte', 0], + ] as const) assert.deepEqual(parseFreeformFactV2(text), { kind: 'handInventory', category: 'all', compare, value }); + assert.equal(parseFreeformFactV2('Do you have exactly 9007199254740992 cards in your hand?'), null); + assert.equal(parseFreeformFactV2('Do you have at least 3 cards in your hand for battle?'), null); +}); + +void test('freeform v2 recognizes one explicit AND or OR of v1 current facts', () => { + const shield = { kind: 'hand', name: 'Shield' }; + const snooper = { kind: 'hand', name: 'Snooper' }; + const spice = { kind: 'spice', compare: 'gte', value: 5 }; + assert.deepEqual(parseFreeformFactV2('Do you have at least 5 spice and a Shield?'), + { kind: 'and', terms: [spice, shield] }); + assert.deepEqual(parseFreeformFactV2('Do you have a Shield or a Snooper?'), + { kind: 'or', terms: [shield, snooper] }); + assert.deepEqual(parseFreeformFactV2('Do you have a Shield AND do you have at least 5 spice?'), + { kind: 'and', terms: [shield, spice] }); + assert.deepEqual(parseFreeformFactV2(' do\tyou have an ARTILLERY STRIKE or a Shield '), + { kind: 'or', terms: [{ kind: 'hand', name: 'Artillery Strike' }, shield] }); +}); + +void test('freeform v2 never guesses at ambiguous, mixed, future or unsupported language', () => { + for (const text of [ + '', 'Do you have 5 spice?', 'Do you have more than 5 spice?', + 'Do you not have a Shield?', 'Do you have a Shield this turn?', + 'Will you have at least 5 spice?', 'Do you have a Shield??', + 'Do you have a Shield and a Snooper or a Lasgun?', + 'Do you have a Shield and (a Snooper or a Lasgun)?', + 'Do you have at least 5 spice and will you win?', + 'Do you have a Shield or a secret weapon?', + 'Do you have at least 3 cards in your hand and a Shield?', + 'Do you have at least 5 spice?\nOr a Shield?', + 'Do you have a Shield?'.padEnd(501, ' '), + ]) assert.equal(parseFreeformFactV2(text), null, text); +}); diff --git a/tests/truthtrance-freeform-runtime.test.ts b/tests/truthtrance-freeform-runtime.test.ts index b1d0b8de..be98d091 100644 --- a/tests/truthtrance-freeform-runtime.test.ts +++ b/tests/truthtrance-freeform-runtime.test.ts @@ -6,7 +6,7 @@ import { DIFFICULTIES } from '../game/bot-profiles'; import { moveSemutaEmperorCard } from './fixture-semuta-emperor-share'; import { knowledgeGame, knowledgePhysical, rejectKnowledge } from './truthtrance-knowledge-fixture'; -type Stamp = { version: 1; fact: unknown }; +type Stamp = { version: 1 | 2; fact: unknown }; type Freeform = {kind: 'freeform'; target: string; text: string; scope: 'fact' | 'currentTurn'; interpreted?: Stamp}; /** Actual Truthtrance declaration, priority, question and response actions. @@ -51,7 +51,7 @@ for (const expected of ['yes','no'] as const) for (const kind of ['spice','hand' const physical = knowledgePhysical(initial); const card = initial.players[0].hand.find(item => item.effect === 'truthtrance')!; const pending = ask(initial, {kind:'freeform', target:target.id, scope:'fact', text}); - assert.deepEqual(freeform(pending).interpreted, {version:1, fact}); + assert.deepEqual(freeform(pending).interpreted, {version:2, fact}); assert.equal(viewGame(pending, target.id).truthAnswer, expected); for (const id of ['a','f','o']) assert.equal(viewGame(pending,id).truthAnswer, null); rejectKnowledge(pending, target.id, {type:'truthAnswer', answer:expected === 'yes' ? 'no' : 'yes'}); @@ -74,7 +74,7 @@ void test('only server interpretation is used when a client forges a stamp', () initial.players.find(player => player.id === 'b')!.spice = 4; const asked = ask(initial, {kind:'freeform', target:'b', scope:'fact', text:'Do you have at least 5 spice?', interpreted:{version:1, fact:{kind:'spice', compare:'gte', value:0}}}); - assert.deepEqual(freeform(asked).interpreted, {version:1, fact:{kind:'spice',compare:'gte',value:5}}); + assert.deepEqual(freeform(asked).interpreted, {version:2, fact:{kind:'spice',compare:'gte',value:5}}); assert.equal(viewGame(asked,'b').truthAnswer,'no'); rejectKnowledge(asked,'b',{type:'truthAnswer',answer:'yes'}); const unsupported = ask(initial, {kind:'freeform', target:'b', scope:'fact', diff --git a/tests/truthtrance-freeform-v2-runtime.test.ts b/tests/truthtrance-freeform-v2-runtime.test.ts new file mode 100644 index 00000000..c6224500 --- /dev/null +++ b/tests/truthtrance-freeform-v2-runtime.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, 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'; + +type Stamp = { version: 1 | 2; fact: unknown }; +type Freeform = { kind: 'freeform'; target: string; text: string; scope: 'fact' | 'currentTurn'; interpreted?: Stamp }; + +/** Native declaration, priority and question; the shared fixture stages only + * original physical cards following setup. */ +function ask(initial: Game, question: Freeform): Game { + const owner = initial.players[0]; + const card = owner.hand.find(row => row.effect === 'truthtrance'); assert.ok(card); + let game = applyAction(initial, owner.id, {type: 'card', card: card.id}); + while (game.truthtrance?.stage === 'priority') { + const player = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(player); + game = applyAction(game, player.id, {type: 'truthPass'}); + } + return applyAction(game, owner.id, {type: 'truthAsk', question} as Action); +} +function question(game: Game): Freeform { + const current = game.truthtrance?.question; + assert.equal(current?.kind, 'freeform'); + return current as Freeform; +} +function held(game: Game, name: string, present: boolean) { + const target = game.players.find(row => row.id === 'b')!; + for (const card of [...target.hand].filter(row => row.name === name)) + moveSemutaEmperorCard(game, card.id, 'f'); + if (present) { + const card = [...game.deck, ...game.discard, ...game.players.flatMap(row => row.hand)].find(row => row.name === name); + assert.ok(card); moveSemutaEmperorCard(game, card.id, target.id); + } +} + +for (const expected of ['yes', 'no'] as const) for (const mode of ['count', 'and', 'or'] as const) { + void test(`v2 ${mode} freeform yields only the truthful aggregate ${expected}`, () => { + const initial = knowledgeGame(); + const target = initial.players.find(row => row.id === 'b')!; + let text: string, fact: unknown; + if (mode === 'count') { + const value = target.hand.length + (expected === 'yes' ? 0 : 1); + text = `Do you have at least ${value} cards in your hand?`; + fact = {kind: 'handInventory', category: 'all', compare: 'gte', value}; + } else if (mode === 'and') { + target.spice = expected === 'yes' ? 5 : 4; + held(initial, 'Shield', true); + text = 'Do you have at least 5 spice and a Shield?'; + fact = {kind: 'and', terms: [ + {kind: 'spice', compare: 'gte', value: 5}, {kind: 'hand', name: 'Shield'}, + ]}; + } else { + held(initial, 'Shield', expected === 'yes'); + held(initial, 'Snooper', false); + text = 'Do you have a Shield or a Snooper?'; + fact = {kind: 'or', terms: [{kind: 'hand', name: 'Shield'}, {kind: 'hand', name: 'Snooper'}]}; + } + const physical = knowledgePhysical(initial); + const source = initial.players[0].hand.find(row => row.effect === 'truthtrance')!; + const pending = ask(initial, {kind: 'freeform', target: 'b', scope: 'fact', text}); + assert.deepEqual(question(pending).interpreted, {version: 2, fact}); + assert.equal(viewGame(pending, 'b').truthAnswer, expected); + for (const observer of ['a', 'f', 'o']) assert.equal(viewGame(pending, observer).truthAnswer, null); + rejectKnowledge(pending, 'b', {type: 'truthAnswer', answer: expected === 'yes' ? 'no' : 'yes'}); + rejectKnowledge(pending, 'b', {type: 'truthAnswer', answer: 'unknown'}); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(pending, 'b'); + view.players.find(row => row.id === 'b')!.bot = difficulty; + assert.deepEqual(botActions(view), [{type: 'truthAnswer', answer: expected}]); + const done = applyAction(structuredClone(pending), 'b', {type: 'truthAnswer', answer: expected}); + assert.equal(done.truthtrance, null); + assert.equal(done.truthHistory!.at(-1)!.answer, expected); + assert.equal(done.discard.filter(row => row.id === source.id).length, 1); + assert.deepEqual(knowledgePhysical(done), physical); + } + }); +} + +void test('v2 ignores a forged client interpretation and stamps supported v1 syntax anew', () => { + const game = knowledgeGame(); + game.players.find(row => row.id === 'b')!.spice = 4; + const pending = ask(game, {kind:'freeform', target:'b', scope:'fact', text:'Do you have at least 5 spice?', + interpreted: {version:2, fact:{kind:'spice', compare:'gte', value:0}}}); + assert.deepEqual(question(pending).interpreted, {version:2, fact:{kind:'spice', compare:'gte', value:5}}); + assert.equal(viewGame(pending, 'b').truthAnswer, 'no'); + rejectKnowledge(pending, 'b', {type:'truthAnswer', answer:'yes'}); +}); + +void test('unsupported combinations and current-turn questions keep the manual semantics', () => { + for (const q of [ + {kind:'freeform', target:'b', scope:'fact', text:'Do you have a Shield or a Snooper and a Lasgun?'}, + {kind:'freeform', target:'b', scope:'currentTurn', text:'Do you have at least 3 cards in your hand?'}, + ] as Freeform[]) { + const pending = ask(knowledgeGame(), q); + assert.equal(question(pending).interpreted, undefined); + assert.equal(viewGame(pending, 'b').truthAnswer, null); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(pending, 'b'); view.players.find(row => row.id === 'b')!.bot = difficulty; + assert.deepEqual(botActions(view), [{type:'truthAnswer', answer:'unknown'}]); + } + const done = applyAction(pending, 'b', {type:'truthAnswer', answer:'yes'}); + assert.equal(done.truthHistory!.at(-1)!.answer, 'yes'); + } +}); + +void test('a saved v1-stamped question is validated and answered with v1 semantics', () => { + const initial = knowledgeGame(); + initial.players.find(row => row.id === 'b')!.spice = 5; + const pending = ask(initial, {kind:'freeform', target:'b', scope:'fact', text:'Do you have at least 5 spice?'}); + question(pending).interpreted = {version:1, fact:{kind:'spice', compare:'gte', value:5}}; + const restored = JSON.parse(JSON.stringify(pending)) as Game; + assert.equal(viewGame(restored, 'b').truthAnswer, 'yes'); + rejectKnowledge(restored, 'b', {type:'truthAnswer', answer:'no'}); + const done = applyAction(restored, 'b', {type:'truthAnswer', answer:'yes'}); + assert.deepEqual((done.truthHistory!.at(-1)!.question as Freeform).interpreted, + {version:1, fact:{kind:'spice', compare:'gte', value:5}}); +}); diff --git a/tests/truthtrance.test.ts b/tests/truthtrance.test.ts index c188afdf..2d692b26 100644 --- a/tests/truthtrance.test.ts +++ b/tests/truthtrance.test.ts @@ -475,7 +475,7 @@ void test('AI handles unparseable freeform questions honestly and can complete a text: 'Will you win two battles?', }; const pending = ask(priority(declare(initial)), q); - assert.throws(() => answer(pending, 'yes'), /cannot yet interpret/); + assert.throws(() => answer(pending, 'yes'), /cannot interpret this freeform question/); const unknown = runBots(pending); assert.equal(unknown.truthtrance!.stage, 'unknown'); assert.equal(unknown.discard.length, 0);