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

@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseFreeformFactV6 } from '../game/truthtrance-freeform-facts-v6';
import type { KnowledgeFact } from '../game/truthtrance-knowledge';
import type { ForceCountFact } from '../game/truthtrance-force-count';
void test('v6 recognizes named stored predictions and current forecast questions', () => {
const cases: Array<[string, KnowledgeFact]> = [
['Did you predict Harkonnen?', {kind:'prediction', field:'faction', faction:'harkonnen'}],
['Is your predicted faction Spacing Guild?', {kind:'prediction', field:'faction', faction:'guild'}],
['Did you predict Bene Gesserit to win?', {kind:'prediction', field:'faction', faction:'beneGesserit'}],
['Is your predicted turn at least 5?', {kind:'prediction', field:'turn', compare:'gte', value:5}],
['Is your storm forecast exactly 3?', {kind:'stormForecast', compare:'eq', value:3}],
['Is your submitted storm dial at most 4?', {kind:'stormDial', compare:'lte', value:4}],
['Is your storm dial exactly 0?', {kind:'stormDial', compare:'eq', value:0}],
['Is the next Spice Blow a sandworm?', {kind:'spicePeek', value:'worm'}],
['Is the next Spice Blow Sandtrout?', {kind:'spicePeek', value:'sandtrout'}],
['Is the next Spice Blow in The Great Flat?', {kind:'spicePeek', value:'the_great_flat'}],
['Is the next Spice Blow at Great Flat?', {kind:'spicePeek', value:'the_great_flat'}],
];
for (const [text, expected] of cases) assert.deepEqual(parseFreeformFactV6(text), expected, text);
});
void test('v6 maps explicit current force pools to total physical counter facts', () => {
const cases: Array<[string, ForceCountFact]> = [
['Do you have at least 3 forces in your reserves?', {kind:'forceCount', zone:{kind:'reserves'}, counter:'total', compare:'gte', value:3}],
['Do you have exactly 2 forces in the Tanks?', {kind:'forceCount', zone:{kind:'tanks'}, counter:'total', compare:'eq', value:2}],
['Do you have at most 5 physical forces on the board?', {kind:'forceCount', zone:{kind:'board'}, counter:'total', compare:'lte', value:5}],
['Do you have exactly 1 physical force in reserves?', {kind:'forceCount', zone:{kind:'reserves'}, counter:'total', compare:'eq', value:1}],
];
for (const [text, expected] of cases) assert.deepEqual(parseFreeformFactV6(text), expected, text);
});
void test('v6 leaves ambiguous, invalid, future and unsupported sentences manual', () => {
for (const text of [
'Do you have a Shield?', 'Will you predict Harkonnen?', 'Did you predict Arrakeen?',
'Is your predicted turn exactly 0?', 'Is your predicted turn exactly 11?',
'Is your storm forecast at least 0?', 'Is your storm forecast exactly 7?',
'Is your storm dial exactly 21?', 'Is your storm dial exactly -1?',
'Is your storm dial exactly 1.5?', 'Is your storm dial exactly 9007199254740992?',
'Is the next Spice Blow in Arrakeen?', 'Is the next Spice Blow a sandworm or Sandtrout?',
'Do you have exactly -1 forces in your reserves?', 'Do you have exactly 1.5 forces in your reserves?',
'Do you have at most 9007199254740992 forces on the board?',
'Will you have 3 forces in reserves?', 'Do you have exactly 2 forces?',
'Do you have exactly 2 advisors on the board?', 'Do you have exactly 2 forces on the board this turn?',
'Is your predicted faction Harkonnen??', 'Is your storm forecast exactly 3?\nExplain',
'Is your storm forecast exactly 3?'.padEnd(501, ' '),
]) assert.equal(parseFreeformFactV6(text), null, text);
});
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { FreeformFactPreview } from '../components/truthtrance';
const base = { scope: 'fact' as const, target: 'target',
leaderName: (id: string) => id, locationName: (id: string) => id, playerName: (id: string) => id };
const preview = (scope: 'fact' | 'currentTurn', text: string) =>
renderToStaticMarkup(createElement(FreeformFactPreview, { ...base, scope, text }));
void test('the editor previews v6 knowledge and physical force facts only for current facts', () => {
const prediction = preview('fact', 'Did you predict Harkonnen?');
assert.match(prediction, /Recognized freeform question/);
assert.match(prediction, /your recorded Bene Gesserit prediction names Harkonnen/);
const forces = preview('fact', 'Do you have at least 3 forces in your reserves?');
assert.match(forces, /Recognized freeform question/);
assert.match(forces, /currently have at least 3 physical forces in your reserves/);
const forecast = preview('fact', 'Is your storm forecast exactly 3?');
assert.match(forecast, /Recognized freeform question/);
assert.match(forecast, /storm card currently known to you as Fremen shows exactly 3 sectors/);
const spice = preview('fact', 'Is the next Spice Blow a sandworm?');
assert.match(spice, /Recognized freeform question/);
assert.match(spice, /next Spice Card currently known to you as Atreides is a sandworm/);
for (const text of ['Did you predict Harkonnen?', 'Do you have at least 3 forces in your reserves?']) {
const manual = preview('currentTurn', text);
assert.match(manual, /not automatically interpreted/);
assert.doesNotMatch(manual, /Recognized freeform question/);
}
});
@@ -0,0 +1,109 @@
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 { knowledgeGame, knownForecastGame, knowledgePhysical, rejectKnowledge } from './truthtrance-knowledge-fixture';
/** Native setup and prediction; the fixture stages Truthtrance acquisition. */
function ask(initial: Game, target: string, text: string, scope: 'fact' | 'currentTurn' = 'fact'): Game {
const asker = initial.players[0];
const card = asker.hand.find(candidate => candidate.effect === 'truthtrance'); assert.ok(card);
let game = applyAction(initial, asker.id, { type: 'card', card: card.id });
while (game.truthtrance?.stage === 'priority') {
const player = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id)); assert.ok(player);
game = applyAction(game, player.id, { type: 'truthPass' });
}
return applyAction(game, asker.id, { type: 'truthAsk', question: { kind: 'freeform', target, scope, text } });
}
function question(game: Game) {
const value = game.truthtrance?.question;
assert.equal(value?.kind, 'freeform');
if (value?.kind !== 'freeform') throw Error('Expected a freeform question');
return value;
}
function assertPrivateAndBots(game: Game, target: string, answer: 'yes' | 'no' | 'unknown') {
assert.equal(question(game).interpreted?.version, 6);
assert.equal(viewGame(game, target).truthAnswer, answer);
for (const observer of game.players.filter(player => player.id !== target))
assert.equal(viewGame(game, observer.id).truthAnswer, null);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, target); view.players.find(player => player.id === target)!.bot = difficulty;
assert.deepEqual(botActions(view), [{ type: 'truthAnswer', answer }]);
}
for (const incorrect of (['yes', 'no', 'unknown'] as const).filter(value => value !== answer))
rejectKnowledge(game, target, { type: 'truthAnswer', answer: incorrect });
}
void test('V6 prediction questions check private current knowledge and all bot answers', () => {
for (const [text, answer] of [['Did you predict Guild?', 'yes'], ['Did you predict Harkonnen?', 'no']] as const) {
const initial = knowledgeGame(), physical = knowledgePhysical(initial);
const game = ask(initial, 'b', text); assertPrivateAndBots(game, 'b', answer);
assert.equal('prediction' in viewGame(game, 'a').players.find(player => player.id === 'b')!, false);
const restored = JSON.parse(JSON.stringify(game)) as Game;
const done = applyAction(restored, 'b', { type: 'truthAnswer', answer });
assert.equal(done.truthHistory!.at(-1)!.answer, answer);
assert.deepEqual(knowledgePhysical(done), physical);
}
});
void test('V6 unknown knowledge stays private and retains Truthtrance for another question', () => {
const initial = knowledgeGame(), before = knowledgePhysical(initial);
const game = ask(initial, 'o', 'Did you predict Harkonnen?');
assertPrivateAndBots(game, 'o', 'unknown');
const done = applyAction(game, 'o', { type: 'truthAnswer', answer: 'unknown' });
assert.equal(done.truthtrance?.stage, 'unknown');
assert.deepEqual(knowledgePhysical(done), before);
});
void test('V6 uses the native Fremen storm forecast without revealing it to other seats', () => {
const initial = knownForecastGame(); assert.ok(initial.stormCard);
const game = ask(initial, 'f', `Is your storm forecast exactly ${initial.stormCard}?`);
assertPrivateAndBots(game, 'f', 'yes');
for (const id of ['a', 'b', 'o']) assert.equal(viewGame(game, id).stormForecast, null);
const done = applyAction(game, 'f', { type: 'truthAnswer', answer: 'yes' });
assert.equal(done.stormCard, initial.stormCard);
});
void test('V6 reads the current Atreides Spice forecast only for its entitled owner', () => {
const initial = knowledgeGame();
// Stage the Movement forecast and choose an existing undealt worm without
// changing the physical Spice deck; acquisition itself is not claimed.
initial.phase = 5; initial.active = 'o'; initial.spicePeekKnown = true;
const worm = initial.spiceDeck.findIndex(card => 'worm' in card && card.worm);
assert.ok(worm >= 0);
[initial.spiceDeck[0], initial.spiceDeck[worm]] = [initial.spiceDeck[worm], initial.spiceDeck[0]];
const game = ask(initial, 'o', 'Is the next Spice Blow a sandworm?');
assertPrivateAndBots(game, 'o', 'yes');
const done = applyAction(game, 'o', { type: 'truthAnswer', answer: 'yes' });
assert.deepEqual(done.spiceDeck, initial.spiceDeck);
});
void test('V6 checks physical reserve forces using the existing force-count fact', () => {
const initial = knowledgeGame(), target = initial.players.find(player => player.id === 'b')!;
assert.ok(target.reserves >= 3);
const game = ask(initial, 'b', 'Do you have at least 3 forces in your reserves?');
assertPrivateAndBots(game, 'b', 'yes');
const done = applyAction(game, 'b', { type: 'truthAnswer', answer: 'yes' });
assert.equal(done.players.find(player => player.id === 'b')!.reserves, target.reserves);
});
void test('older unstamped and current-turn questions keep their existing manual semantics', () => {
const text = 'Did you predict Harkonnen?';
const future = ask(knowledgeGame(), 'b', text, 'currentTurn');
assert.equal(question(future).interpreted, undefined);
const old = ask(knowledgeGame(), 'b', text);
delete question(old).interpreted;
assert.equal(viewGame(old, 'b').truthAnswer, null);
const done = applyAction(old, 'b', { type: 'truthAnswer', answer: 'yes' });
assert.equal(done.truthHistory!.at(-1)!.answer, 'yes');
});
void test('a modified V6 receipt cannot change the interpreted fact', () => {
const game = ask(knowledgeGame(), 'b', 'Did you predict Harkonnen?');
const q = question(game); assert.equal(q.interpreted?.version, 6);
if (!q.interpreted) throw Error('Expected a recognized receipt');
q.interpreted.fact = { kind: 'prediction', field: 'faction', faction: 'guild' };
assert.throws(() => viewGame(game, 'b'));
assert.throws(() => applyAction(game, 'b', { type: 'truthAnswer', answer: 'yes' }));
});