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

+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'}));
});