Compose freeform Truthtrance facts
Verify and publish checkpoint container / container (push) Successful in 11m2s
Verify and publish checkpoint container / container (push) Successful in 11m2s
This commit is contained in:
1 parent
cc9cc0945d
commit
593ff33ca8
10 files changed
+306
-4
No files matched your search
@@ -71,6 +71,7 @@ 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 { parseFreeformFactV6 } from '@/game/truthtrance-freeform-facts-v6';
|
||||
import { parseTruthFreeformFactsV7 } from '@/game/truthtrance-freeform-facts-v7';
|
||||
import {
|
||||
truthQuestionText,
|
||||
type TruthFact,
|
||||
@@ -111,7 +112,8 @@ export function FreeformFactPreview({ scope, text, target, leaderName, locationN
|
||||
leaderName: (id: string) => string; locationName: (id: string) => string; playerName: (id: string) => string;
|
||||
}) {
|
||||
const fact = scope === 'fact' ? parseFreeformFactV2(text) || parseFreeformFactV3(text) ||
|
||||
parseFreeformFactV4(text) || parseTruthFreeformFactsV5(text) || parseFreeformFactV6(text) : null;
|
||||
parseFreeformFactV4(text) || parseTruthFreeformFactsV5(text) || parseFreeformFactV6(text) ||
|
||||
parseTruthFreeformFactsV7(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">
|
||||
|
||||
@@ -17,6 +17,15 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Compound freeform Truthtrance facts — 29 October. Prototyped / Partial.**
|
||||
Two to eight recognized current-fact clauses can be combined with one AND or
|
||||
OR, including predictions, forecasts, physical forces, card facts and spice.
|
||||
The editor previews the expression; the existing evaluator supplies one
|
||||
private aggregate answer. Seventeen related files pass 79 cases; the runtime
|
||||
fixture stages conserved Truthtrance custody. Mixed operators, arbitrary
|
||||
wording, future commitments and deployed verification remain open.
|
||||
[Scope](TRUTHTRANCE_KNOWLEDGE.md#freeform-compound-current-facts--29-october-2026).
|
||||
|
||||
**Freeform Truthtrance prediction, forecast and force facts — 29 October.
|
||||
Prototyped / Partial.** New current-fact questions can use bounded freeform
|
||||
phrases for predictions, storm dials and forecasts, known Spice Blows and
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Rule decision index
|
||||
|
||||
## Compound freeform Truthtrance facts — 29 October 2026
|
||||
|
||||
Bounded single-operator freeform expressions compose the existing checked
|
||||
current facts and retain their three-valued answers. This grammar is a
|
||||
provisional online interpretation, not general language understanding.
|
||||
[Scope](TRUTHTRANCE_KNOWLEDGE.md#freeform-compound-current-facts--29-october-2026).
|
||||
|
||||
## Freeform Truthtrance prediction, forecast and force facts — 29 October 2026
|
||||
|
||||
Bounded current-fact wording for recorded predictions, forecasts and physical
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# Truthtrance: recorded predictions and storm knowledge
|
||||
|
||||
## Freeform compound current facts — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** New current-fact questions can combine two to eight
|
||||
recognized fact clauses with a single AND or OR. This includes combining
|
||||
prediction or forecast knowledge with physical force counts, card facts and
|
||||
personal spice. For example, “Did you predict Guild and do you have at least
|
||||
3 forces in your reserves?” is checked as one aggregate; the editor previews
|
||||
its interpretation and the target alone sees the result before responding.
|
||||
Unavailable knowledge retains the existing three-valued AND/OR behavior.
|
||||
|
||||
Newly recognized expressions carry version 7. Earlier versioned and
|
||||
unstamped questions retain their previous meaning. Mixed operators without
|
||||
explicit supported grouping, ambiguous partitions, current-turn commitments
|
||||
and unrecognized wording remain player-managed. This bounded grammar is a
|
||||
provisional online interpretation, not general language understanding. The
|
||||
runtime fixture stages the Truthtrance card while preserving its physical
|
||||
inventory; seventeen related freeform files pass 79 cases. Full rules and
|
||||
deployed verification remain open.
|
||||
|
||||
## Freeform predictions, forecasts and forces — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** New current-fact questions recognize bounded wording
|
||||
|
||||
+1
-1
@@ -4036,7 +4036,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 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. Bounded questions about predictions, storm dials and forecasts, known Spice Blows, and physical forces in reserves, Tanks or on the board also use the existing checked 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, 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. Bounded questions about predictions, storm dials and forecasts, known Spice Blows, and physical forces in reserves, Tanks or on the board also use the existing checked facts. Two to eight recognized clauses can be joined by one unambiguous 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 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.',
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
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 { parseFreeformFactV6 } from './truthtrance-freeform-facts-v6';
|
||||
import { TRUTH_FACT_MAX_TERMS } from './truth-fact-expression';
|
||||
import type { TruthFact } from './truthtrance';
|
||||
|
||||
type Compound = Extract<TruthFact, { kind: 'and' | 'or' }>;
|
||||
type Separator = { start: number; end: number; kind: 'and' | 'or' };
|
||||
|
||||
/** Recognize only a uniquely partitioned, flat current-fact expression.
|
||||
* Existing whole questions retain their original parser version. Each atom
|
||||
* uses the established public grammar; this parser never inspects game state. */
|
||||
export function parseTruthFreeformFactsV7(text: string): Compound | null {
|
||||
if (typeof text !== 'string' || text.length > 500 || /[\r\n]/.test(text)) return null;
|
||||
const input = text.trim();
|
||||
if (parseFreeformFactV2(input) || parseFreeformFactV3(input) || parseFreeformFactV4(input) ||
|
||||
parseTruthFreeformFactsV5(input) || parseFreeformFactV6(input)) return null;
|
||||
const body = input.endsWith('?') ? input.slice(0, -1).trimEnd() : input;
|
||||
if (!body || body.includes('?')) return null;
|
||||
const separators: Separator[] = [...body.matchAll(/[ \t]+(and|or)[ \t]+/gi)].map(match => ({
|
||||
start: match.index!, end: match.index! + match[0].length,
|
||||
kind: match[1].toLowerCase() as 'and' | 'or',
|
||||
}));
|
||||
// Keep the search bounded even if a malicious input contains many words
|
||||
// that resemble connectors. A separator may still be part of a card name.
|
||||
if (!separators.length || separators.length > 24) return null;
|
||||
const shorthand = /^do[ \t]+you[ \t]+have[ \t]+/i.test(body);
|
||||
const atomCache = new Map<string, TruthFact | null>();
|
||||
const atom = (raw: string, first: boolean): TruthFact | null => {
|
||||
const segment = raw.trim();
|
||||
if (!segment) return null;
|
||||
const key = `${first ? 'first' : 'later'}:${segment}`;
|
||||
if (atomCache.has(key)) return atomCache.get(key)!;
|
||||
let qualified = segment;
|
||||
if (!first && /^not[ \t]+have[ \t]+/i.test(segment)) qualified = `Do you ${segment}`;
|
||||
else if (!first && /^have[ \t]+/i.test(segment)) qualified = `Do you ${segment}`;
|
||||
else if (!first && shorthand && !/^(?:do|did|is)\b/i.test(segment)) qualified = `Do you have ${segment}`;
|
||||
const fact = parseFreeformFactV3(qualified) ?? parseFreeformFactV4(qualified) ?? parseFreeformFactV6(qualified);
|
||||
const result = fact && fact.kind !== 'and' && fact.kind !== 'or' ? fact : null;
|
||||
atomCache.set(key, result);
|
||||
return result;
|
||||
};
|
||||
// Retain at most two paths per suffix; two prove ambiguity. Memoization
|
||||
// bounds repeated exploration even when many separators form card names.
|
||||
const memo = new Map<string, TruthFact[][]>();
|
||||
const suffix = (offset: number, kind: 'and' | 'or', count: number): TruthFact[][] => {
|
||||
const key = `${offset}:${kind}:${count}`;
|
||||
const cached = memo.get(key); if (cached) return cached;
|
||||
const results: TruthFact[][] = [];
|
||||
const final = atom(body.slice(offset), false);
|
||||
if (final && count + 1 <= TRUTH_FACT_MAX_TERMS) results.push([final]);
|
||||
if (count + 1 < TRUTH_FACT_MAX_TERMS) for (const sep of separators) {
|
||||
if (sep.start <= offset || sep.kind !== kind) continue;
|
||||
const head = atom(body.slice(offset, sep.start), false);
|
||||
if (!head) continue;
|
||||
for (const tail of suffix(sep.end, kind, count + 1)) {
|
||||
results.push([head, ...tail]);
|
||||
if (results.length >= 2) break;
|
||||
}
|
||||
if (results.length >= 2) break;
|
||||
}
|
||||
memo.set(key, results);
|
||||
return results;
|
||||
};
|
||||
const matches: Compound[] = [];
|
||||
for (const sep of separators) {
|
||||
const first = atom(body.slice(0, sep.start), true);
|
||||
if (!first) continue;
|
||||
for (const tail of suffix(sep.end, sep.kind, 1)) {
|
||||
matches.push({kind: sep.kind, terms: [first, ...tail]});
|
||||
if (matches.length >= 2) return null;
|
||||
}
|
||||
}
|
||||
return matches.length === 1 ? matches[0] : null;
|
||||
}
|
||||
+13
-2
@@ -73,6 +73,7 @@ import { parseFreeformFactV3 } from './truthtrance-freeform-facts-v3';
|
||||
import { parseFreeformFactV4 } from './truthtrance-freeform-facts-v4';
|
||||
import { parseTruthFreeformFactsV5 } from './truthtrance-freeform-facts-v5';
|
||||
import { parseFreeformFactV6 } from './truthtrance-freeform-facts-v6';
|
||||
import { parseTruthFreeformFactsV7 } from './truthtrance-freeform-facts-v7';
|
||||
import { CHEAP_HERO_TRAITOR } from './traitors';
|
||||
import {
|
||||
TRUTH_FACT_MAX_NODES, TRUTH_FACT_MAX_DEPTH, TRUTH_FACT_MAX_TERMS,
|
||||
@@ -138,7 +139,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 | 5 | 6; fact: TruthFact };
|
||||
interpreted?: { version: 1; fact: Extract<TruthFact, { kind: 'hand' | 'spice' }> } | { version: 2 | 3 | 4 | 5 | 6 | 7; fact: TruthFact };
|
||||
};
|
||||
export type TruthAnswer = 'yes' | 'no' | 'unknown';
|
||||
export type TruthQueueEntry = {
|
||||
@@ -729,12 +730,17 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
!parseFreeformFactV2(question.text) && !parseFreeformFactV3(question.text) &&
|
||||
!parseFreeformFactV4(question.text) && !parseTruthFreeformFactsV5(question.text)
|
||||
? parseFreeformFactV6(question.text) : null;
|
||||
const aggregateFact = question.scope === 'fact' && marker.version === 7 &&
|
||||
!parseFreeformFactV2(question.text) && !parseFreeformFactV3(question.text) &&
|
||||
!parseFreeformFactV4(question.text) && !parseTruthFreeformFactsV5(question.text) &&
|
||||
!parseFreeformFactV6(question.text) ? parseTruthFreeformFactsV7(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 }
|
||||
: compoundFact ? { version: 5 as const, fact: compoundFact }
|
||||
: currentFact ? { version: 6 as const, fact: parseFact(g, currentFact, g.players.find(player => player.id === question.target)!) } : null;
|
||||
: currentFact ? { version: 6 as const, fact: parseFact(g, currentFact, g.players.find(player => player.id === question.target)!) }
|
||||
: aggregateFact ? { version: 7 as const, fact: parseFact(g, aggregateFact, g.players.find(player => player.id === question.target)!) } : null;
|
||||
check(expected !== null && JSON.stringify(v.interpreted) === JSON.stringify(expected),
|
||||
'The saved freeform interpretation does not match its original question.');
|
||||
question.interpreted = expected;
|
||||
@@ -1225,6 +1231,11 @@ export function resolveTruthAction(
|
||||
const currentFact = parseFreeformFactV6(w.question.text);
|
||||
if (currentFact) w.question.interpreted = { version: 6,
|
||||
fact: parseFact(g, currentFact, g.players.find(player => player.id === w.question!.target)!) };
|
||||
else {
|
||||
const aggregateFact = parseTruthFreeformFactsV7(w.question.text);
|
||||
if (aggregateFact) w.question.interpreted = { version: 7,
|
||||
fact: parseFact(g, aggregateFact, g.players.find(player => player.id === w.question!.target)!) };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseTruthFreeformFactsV7 as parse } from '../game/truthtrance-freeform-facts-v7';
|
||||
|
||||
void test('v7 composes the existing factual families without reading game state', () => {
|
||||
assert.deepEqual(parse('Did you predict Guild and do you have at least 3 forces in your reserves?'), {
|
||||
kind:'and', terms:[
|
||||
{kind:'prediction',field:'faction',faction:'guild'},
|
||||
{kind:'forceCount',zone:{kind:'reserves'},counter:'total',compare:'gte',value:3},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(parse('Did you predict Guild and is your predicted turn at least 5 and do you have at least 3 forces in your reserves?'), {
|
||||
kind:'and', terms:[
|
||||
{kind:'prediction',field:'faction',faction:'guild'},
|
||||
{kind:'prediction',field:'turn',compare:'gte',value:5},
|
||||
{kind:'forceCount',zone:{kind:'reserves'},counter:'total',compare:'gte',value:3},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(parse('Do you have a weapon and no defenses and at least 3 forces in your reserves?'), {
|
||||
kind:'and', terms:[
|
||||
{kind:'handInventory',category:'weapon',compare:'gte',value:1},
|
||||
{kind:'handInventory',category:'defense',compare:'eq',value:0},
|
||||
{kind:'forceCount',zone:{kind:'reserves'},counter:'total',compare:'gte',value:3},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(parse('Is your storm forecast exactly 3 or is the next Spice Blow a sandworm?'), {
|
||||
kind:'or',terms:[{kind:'stormForecast',compare:'eq',value:3},{kind:'spicePeek',value:'worm'}],
|
||||
});
|
||||
});
|
||||
|
||||
void test('v7 keeps old whole-question interpretations and rejects unclear or non-factual syntax', () => {
|
||||
for (const input of [
|
||||
'Do you have a Shield?',
|
||||
'Do you have a Shield and a Snooper?',
|
||||
'Do you have a weapon and a defense?',
|
||||
'Did you predict Guild?',
|
||||
'Did you predict Guild and is your predicted turn exactly 5 or do you have a Shield?',
|
||||
'Will you predict Guild and do you have a Shield?',
|
||||
'Did you predict Guild and a Shield?',
|
||||
'Did you predict Guild and do you have a Shield this turn?',
|
||||
'Did you predict Guild and do you have a Shield??',
|
||||
'Did you predict Guild and do you have a Shield?\nAnswer now',
|
||||
]) assert.equal(parse(input),null,input);
|
||||
});
|
||||
|
||||
void test('v7 bounds flat groups and separator-heavy inputs', () => {
|
||||
const atom = 'do you have a Shield';
|
||||
const eight = Array.from({length:8},() => atom).join(' and ');
|
||||
assert.equal(parse(eight)?.terms.length,8);
|
||||
const nine = Array.from({length:9},() => atom).join(' and ');
|
||||
assert.equal(parse(nine),null);
|
||||
assert.equal(parse(`Did you predict Guild ${'and '.repeat(25)}do you have a Shield`),null);
|
||||
assert.equal(parse(`Did you predict Guild and do you have ${'x'.repeat(501)}`),null);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
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 props = {
|
||||
target: 'target', leaderName: (id: string) => id,
|
||||
locationName: (id: string) => id, playerName: (id: string) => id,
|
||||
};
|
||||
const preview = (text: string, scope: 'fact' | 'currentTurn' = 'fact') =>
|
||||
renderToStaticMarkup(createElement(FreeformFactPreview, { ...props, scope, text }));
|
||||
|
||||
void test('the editor previews a compound prediction and physical-force question', () => {
|
||||
const html = preview('Did you predict Guild and do you have at least 3 forces in your reserves?');
|
||||
assert.match(html, /Recognized freeform question/);
|
||||
assert.match(html, /your recorded Bene Gesserit prediction names Spacing Guild/);
|
||||
assert.match(html, /AND/);
|
||||
assert.match(html, /currently have at least 3 physical forces in your reserves/);
|
||||
assert.doesNotMatch(html, /Your current game information establishes/);
|
||||
});
|
||||
|
||||
void test('the editor previews a three-clause aggregate without exposing answers', () => {
|
||||
const text = 'Did you predict Guild and is your predicted turn at least 5 and do you have at least 3 forces in your reserves?';
|
||||
const html = preview(text);
|
||||
assert.match(html, /Recognized freeform question/);
|
||||
assert.match(html, /your recorded Bene Gesserit prediction names Spacing Guild/);
|
||||
assert.match(html, /the turn in your recorded Bene Gesserit prediction is at least 5/);
|
||||
assert.match(html, /currently have at least 3 physical forces in your reserves/);
|
||||
assert.equal((html.match(/AND/g) ?? []).length, 2);
|
||||
assert.doesNotMatch(html, /Your current game information establishes/);
|
||||
assert.match(preview(text, 'currentTurn'), /not automatically interpreted/);
|
||||
});
|
||||
|
||||
void test('ambiguous mixed operators remain player-managed', () => {
|
||||
const html = preview('Did you predict Guild and is your predicted turn at least 5 or do you have at least 3 forces in your reserves?');
|
||||
assert.match(html, /not automatically interpreted/);
|
||||
assert.doesNotMatch(html, /Recognized freeform question/);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
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, knowledgePhysical, rejectKnowledge } from './truthtrance-knowledge-fixture';
|
||||
|
||||
/** Actual Basic setup and locked prediction; acquisition of Truthtrance is
|
||||
* staged by the shared fixture while preserving the physical card inventory. */
|
||||
function ask(target: string, text: string, scope: 'fact' | 'currentTurn' = 'fact') {
|
||||
let game = knowledgeGame();
|
||||
const card = game.players[0].hand.find(candidate => candidate.effect === 'truthtrance'); assert.ok(card);
|
||||
game = applyAction(game, 'a', { 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, 'a', { 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;
|
||||
}
|
||||
|
||||
for (const row of [
|
||||
{ name: 'all three AND clauses are true', target: 'b', answer: 'yes',
|
||||
text: 'Did you predict Guild and do you have at least 3 forces in your reserves and do you have at least 0 spice?' },
|
||||
{ name: 'a false prediction makes AND false', target: 'b', answer: 'no',
|
||||
text: 'Did you predict Harkonnen and do you have at least 3 forces in your reserves?' },
|
||||
{ name: 'an unavailable prediction leaves AND unknown', target: 'o', answer: 'unknown',
|
||||
text: 'Did you predict Guild and do you have at least 0 forces in your reserves?' },
|
||||
{ name: 'a true force clause makes OR true despite unknown knowledge', target: 'o', answer: 'yes',
|
||||
text: 'Did you predict Guild or do you have at least 0 forces in your reserves?' },
|
||||
{ name: 'both OR clauses are false', target: 'b', answer: 'no',
|
||||
text: 'Did you predict Harkonnen or do you have at least 100 forces in your reserves?' },
|
||||
{ name: 'unavailable knowledge leaves OR unknown', target: 'o', answer: 'unknown',
|
||||
text: 'Did you predict Guild or do you have at least 100 forces in your reserves?' },
|
||||
] as const) {
|
||||
void test(`V7 ${row.name} publishes only the checked aggregate`, () => {
|
||||
const game = ask(row.target, row.text), physical = knowledgePhysical(game);
|
||||
assert.equal(question(game).interpreted?.version, 7);
|
||||
assert.equal(viewGame(game, row.target).truthAnswer, row.answer);
|
||||
for (const observer of game.players.filter(player => player.id !== row.target)) {
|
||||
const view = viewGame(game, observer.id);
|
||||
assert.equal(view.truthAnswer, null);
|
||||
if (observer.id !== 'b')
|
||||
assert.equal('prediction' in view.players.find(player => player.id === 'b')!, false);
|
||||
}
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, row.target);
|
||||
view.players.find(player => player.id === row.target)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view), [{ type: 'truthAnswer', answer: row.answer }]);
|
||||
}
|
||||
for (const answer of (['yes', 'no', 'unknown'] as const).filter(answer => answer !== row.answer))
|
||||
rejectKnowledge(game, row.target, { type: 'truthAnswer', answer });
|
||||
const restored = JSON.parse(JSON.stringify(game)) as Game;
|
||||
const done = applyAction(restored, row.target, { type: 'truthAnswer', answer: row.answer });
|
||||
if (row.answer === 'unknown') assert.equal(done.truthtrance?.stage, 'unknown');
|
||||
else assert.equal(done.truthHistory!.at(-1)!.answer, row.answer);
|
||||
assert.deepEqual(knowledgePhysical(done), physical);
|
||||
});
|
||||
}
|
||||
|
||||
void test('old unstamped and current-turn compound questions remain manually managed', () => {
|
||||
const text = 'Did you predict Guild and do you have at least 3 forces in your reserves?';
|
||||
const future = ask('b', text, 'currentTurn');
|
||||
assert.equal(question(future).interpreted, undefined);
|
||||
const old = ask('b', text);
|
||||
delete question(old).interpreted;
|
||||
assert.equal(viewGame(old, 'b').truthAnswer, null);
|
||||
const done = applyAction(old, 'b', { type: 'truthAnswer', answer: 'no' });
|
||||
assert.equal(done.truthHistory!.at(-1)!.answer, 'no');
|
||||
});
|
||||
|
||||
void test('changing a V7 receipt cannot alter the checked aggregate', () => {
|
||||
const game = ask('b', 'Did you predict Guild and do you have at least 3 forces in your reserves?');
|
||||
const value = question(game); assert.equal(value.interpreted?.version, 7);
|
||||
if (!value.interpreted) throw Error('Expected a recognized receipt');
|
||||
value.interpreted.fact = { kind: 'prediction', field: 'faction', faction: 'harkonnen' };
|
||||
assert.throws(() => viewGame(game, 'b'));
|
||||
assert.throws(() => applyAction(game, 'b', { type: 'truthAnswer', answer: 'yes' }));
|
||||
});
|
||||
Reference in new issue
Block a user