Expand recognized Truthtrance fact questions
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 04:34:29 +11:00
1 parent 8ae275f598
commit 9dd386ff7a
11 files changed
+279 -16

No files matched your search

+15 -2
View File
@@ -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, /<strong>Yes<\/strong>/);
assert.doesNotMatch(observer, /Your current game information establishes:/);
});
@@ -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);
});
+3 -3
View File
@@ -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',
@@ -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}});
});
+1 -1
View File
@@ -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);