Add freeform No-Field and auction facts
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 18:53:48 +11:00
1 parent 593ff33ca8
commit 152476c348
10 files changed
+387 -4

No files matched your search

+2 -1
View File
@@ -72,6 +72,7 @@ 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 { parseTruthFreeformFactsV8 } from '@/game/truthtrance-freeform-facts-v8';
import {
truthQuestionText,
type TruthFact,
@@ -113,7 +114,7 @@ export function FreeformFactPreview({ scope, text, target, leaderName, locationN
}) {
const fact = scope === 'fact' ? parseFreeformFactV2(text) || parseFreeformFactV3(text) ||
parseFreeformFactV4(text) || parseTruthFreeformFactsV5(text) || parseFreeformFactV6(text) ||
parseTruthFreeformFactsV7(text) : null;
parseTruthFreeformFactsV7(text) || parseTruthFreeformFactsV8(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">
+9
View File
@@ -17,6 +17,15 @@ open mode gates.
## Current checkpoint and work
**Freeform No-Field and auction knowledge — 29 October. Prototyped / Partial.**
New current-fact questions can name a concealed No-Field value or a current
auction, public cache or recorded Ixian group card; they can combine these
with other recognized facts. The editor previews the checked interpretation.
Twenty related freeform files pass 92 cases; the runtime fixture stages a
conserved Truthtrance card. Arbitrary wording, wider commitments and deployed
verification remain open.
[Scope](TRUTHTRANCE_KNOWLEDGE.md#freeform-no-field-and-auction-knowledge--29-october-2026).
**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.
+7
View File
@@ -1,5 +1,12 @@
# Rule decision index
## Freeform No-Field and auction knowledge — 29 October 2026
Bounded questions about a concealed marker and actual current auction knowledge
map to existing checked facts, including supported compound expressions. This
grammar is a provisional online interpretation.
[Scope](TRUTHTRANCE_KNOWLEDGE.md#freeform-no-field-and-auction-knowledge--29-october-2026).
## Compound freeform Truthtrance facts — 29 October 2026
Bounded single-operator freeform expressions compose the existing checked
+18
View File
@@ -1,5 +1,23 @@
# Truthtrance: recorded predictions and storm knowledge
## Freeform No-Field and auction knowledge — 29 October 2026
**Prototyped / Partial.** New current-fact questions recognize bounded
wording for a concealed No-Field's printed value, an inspected current auction
card, the current public Richese cache card or a card in Ixians' recorded
inspected auction group. Recognized questions can also combine these with
other supported current facts using one AND or OR. The editor previews the
interpretation and the target alone sees the checked aggregate before replying.
Unavailable inspection knowledge yields I don't know; an absent or already
revealed No-Field answers No under the existing fact rule.
New interpretations carry version 8; earlier versions and unstamped questions
retain their existing meaning. The wording is a provisional online
interpretation, not general language understanding. Runtime evidence includes
an actual marker shipment and ordinary auction and Ixian group inspections,
with a conserved Truthtrance card staged by the fixture. Twenty related freeform files pass 92
cases; broader questions, full rules and deployed verification remain open.
## Freeform compound current facts — 29 October 2026
**Prototyped / Partial.** New current-fact questions can combine two to eight
+1 -1
View File
@@ -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. 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 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, physical force pools, concealed No-Field values and current auction knowledge 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.',
+112
View File
@@ -0,0 +1,112 @@
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
import { knowledgeFactInputError, type KnowledgeFact } from './truthtrance-knowledge';
import { parseNoFieldValueFact } from './truthtrance-no-field-value';
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 { parseTruthFreeformFactsV7 } from './truthtrance-freeform-facts-v7';
import { TRUTH_FACT_MAX_TERMS } from './truth-fact-expression';
import type { TruthFact } from './truthtrance';
type Atom = { fact: TruthFact; fresh: boolean };
type Separator = { start: number; end: number; kind: 'and' | 'or' };
/** New questions refer only to an actual current observation or concealed
* marker. Resolve printed names from the public catalog, never a game state. */
function newAtom(input: string): TruthFact | null {
const text = input.trim().replace(/[ \t]+/g, ' ');
const field = /^do you have a concealed no-field with printed value (0|3|5)$/i.exec(text);
if (field) return parseNoFieldValueFact({kind:'noFieldValue', value:Number(field[1])});
const descriptions = [
{ kind: 'auctionPeek' as const, pattern: /^is the current auction card you inspected (.+)$/i },
{ kind: 'cacheLot' as const, pattern: /^is the current public cache card (.+)$/i },
{ kind: 'ixPoolPeek' as const, pattern: /^does your recorded ixian auction group contain (.+)$/i },
];
for (const {kind, pattern} of descriptions) {
const match = pattern.exec(text);
if (!match) continue;
const raw = match[1].trim().toLowerCase();
// Consider the exact name as well as a grammatical article; never guess
// if a future printed catalog creates a collision between those forms.
const withoutArticle = raw.replace(/^(?:a|an) /, '');
const names = TRUTH_CARD_NAMES.filter(name => {
const folded = name.toLowerCase();
return folded === raw || folded === withoutArticle;
});
if (names.length !== 1) return null;
const fact: KnowledgeFact = {kind, name:names[0]};
return knowledgeFactInputError(fact) ? null : fact;
}
return null;
}
/** Accept one new atom, or a unique homogeneous AND/OR of two to eight
* established and new atoms. Earlier whole interpretations always win. */
export function parseTruthFreeformFactsV8(text: string): TruthFact | 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) || parseTruthFreeformFactsV7(input)) return null;
const body = input.endsWith('?') ? input.slice(0, -1).trimEnd() : input;
if (!body || body.includes('?')) return null;
const single = newAtom(body);
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',
}));
if (separators.length > 24) return null;
const shorthand = /^do[ \t]+you[ \t]+have[ \t]+/i.test(body);
const atomCache = new Map<string, Atom | null>();
const atom = (raw: string, first: boolean): Atom | 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|does|did|is)\b/i.test(segment)) qualified = `Do you have ${segment}`;
const fresh = newAtom(qualified);
const old = parseFreeformFactV3(qualified) ?? parseFreeformFactV4(qualified) ?? parseFreeformFactV6(qualified);
// Refuse any future collision between previously distinct grammars.
const result = fresh && old ? null : fresh ? {fact:fresh, fresh:true}
: old && old.kind !== 'and' && old.kind !== 'or' ? {fact:old, fresh:false} : null;
atomCache.set(key, result);
return result;
};
const memo = new Map<string, Atom[][]>();
const suffix = (offset: number, kind: 'and' | 'or', count: number): Atom[][] => {
const key = `${offset}:${kind}:${count}`;
const cached = memo.get(key); if (cached) return cached;
const results: Atom[][] = [];
const last = atom(body.slice(offset), false);
if (last && count + 1 <= TRUTH_FACT_MAX_TERMS) results.push([last]);
if (count + 1 < TRUTH_FACT_MAX_TERMS) for (const separator of separators) {
if (separator.start <= offset || separator.kind !== kind) continue;
const head = atom(body.slice(offset, separator.start), false);
if (!head) continue;
for (const tail of suffix(separator.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: Array<{ kind:'and'|'or'; atoms:Atom[] }> = [];
for (const separator of separators) {
const head = atom(body.slice(0, separator.start), true);
if (!head) continue;
for (const tail of suffix(separator.end, separator.kind, 1)) {
matches.push({kind:separator.kind, atoms:[head, ...tail]});
if (matches.length >= 2) return null;
}
}
if (single) return matches.length ? null : single;
const match = matches[0];
return match?.atoms.some(item => item.fresh)
? {kind:match.kind, terms:match.atoms.map(item => item.fact)} : null;
}
+14 -2
View File
@@ -74,6 +74,7 @@ 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 { parseTruthFreeformFactsV8 } from './truthtrance-freeform-facts-v8';
import { CHEAP_HERO_TRAITOR } from './traitors';
import {
TRUTH_FACT_MAX_NODES, TRUTH_FACT_MAX_DEPTH, TRUTH_FACT_MAX_TERMS,
@@ -139,7 +140,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 | 7; fact: TruthFact };
interpreted?: { version: 1; fact: Extract<TruthFact, { kind: 'hand' | 'spice' }> } | { version: 2 | 3 | 4 | 5 | 6 | 7 | 8; fact: TruthFact };
};
export type TruthAnswer = 'yes' | 'no' | 'unknown';
export type TruthQueueEntry = {
@@ -734,13 +735,19 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
!parseFreeformFactV2(question.text) && !parseFreeformFactV3(question.text) &&
!parseFreeformFactV4(question.text) && !parseTruthFreeformFactsV5(question.text) &&
!parseFreeformFactV6(question.text) ? parseTruthFreeformFactsV7(question.text) : null;
const inspectedFact = question.scope === 'fact' && marker.version === 8 &&
!parseFreeformFactV2(question.text) && !parseFreeformFactV3(question.text) &&
!parseFreeformFactV4(question.text) && !parseTruthFreeformFactsV5(question.text) &&
!parseFreeformFactV6(question.text) && !parseTruthFreeformFactsV7(question.text)
? parseTruthFreeformFactsV8(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)!) }
: aggregateFact ? { version: 7 as const, fact: parseFact(g, aggregateFact, g.players.find(player => player.id === question.target)!) } : null;
: aggregateFact ? { version: 7 as const, fact: parseFact(g, aggregateFact, g.players.find(player => player.id === question.target)!) }
: inspectedFact ? { version: 8 as const, fact: parseFact(g, inspectedFact, 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;
@@ -1235,6 +1242,11 @@ export function resolveTruthAction(
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)!) };
else {
const inspectedFact = parseTruthFreeformFactsV8(w.question.text);
if (inspectedFact) w.question.interpreted = { version: 8,
fact: parseFact(g, inspectedFact, g.players.find(player => player.id === w.question!.target)!) };
}
}
}
}
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseTruthFreeformFactsV8 as parse } from '../game/truthtrance-freeform-facts-v8';
void test('v8 recognizes current concealed values and actual recorded auction facts', () => {
for (const value of [0,3,5] as const)
assert.deepEqual(parse(`Do you have a concealed No-Field with printed value ${value}?`), {kind:'noFieldValue', value});
assert.deepEqual(parse('Is the current auction card you inspected a Shield?'), {kind:'auctionPeek', name:'Shield'});
assert.deepEqual(parse('Is the current public cache card a shield?'), {kind:'cacheLot', name:'Shield'});
assert.deepEqual(parse('Does your recorded Ixian auction group contain a Shield?'), {kind:'ixPoolPeek', name:'Shield'});
});
void test('v8 composes new and previous atoms in a uniquely interpreted expression', () => {
assert.deepEqual(parse('Did you predict Guild and is the current auction card you inspected a Shield?'), {
kind:'and', terms:[{kind:'prediction',field:'faction',faction:'guild'}, {kind:'auctionPeek',name:'Shield'}],
});
assert.deepEqual(parse('Do you have a concealed No-Field with printed value 3 and at least 3 forces in your reserves?'), {
kind:'and', terms:[{kind:'noFieldValue',value:3}, {kind:'forceCount',zone:{kind:'reserves'},counter:'total',compare:'gte',value:3}],
});
assert.deepEqual(parse('Is the current public cache card a Shield or does your recorded Ixian auction group contain a Snooper?'), {
kind:'or', terms:[{kind:'cacheLot',name:'Shield'}, {kind:'ixPoolPeek',name:'Snooper'}],
});
const repeated = ['Is the current public cache card a Shield', ...Array(7).fill('do you have a Snooper')].join(' and ');
const result = parse(repeated);
assert.equal(result?.kind, 'and');
if (result?.kind === 'and') assert.equal(result.terms.length, 8);
assert.equal(parse(`${repeated} and do you have a Shield`), null);
});
void test('v8 preserves prior versions and refuses future, ambiguous or invalid wording', () => {
for (const text of [
'Do you have a Shield?',
'Did you predict Guild and do you have at least 3 forces in your reserves?',
'Do you have a concealed No-Field with printed value 4?',
'Do you have a concealed No-Field with printed value 03?',
'Is the next auction card a Shield?',
'Is the current auction card you inspected an unknown card?',
'Does your auction group contain a Shield?',
'Is the current public cache card a Shield and do you have a Snooper or a Shield?',
'Is the current public cache card a Shield??',
'Is the current public cache card a Shield?\nAnswer now',
`Is the current public cache card a Shield ${'and '.repeat(25)}do you have a Snooper`,
`Is the current public cache card ${'x'.repeat(501)}`,
]) assert.equal(parse(text), null, text);
});
@@ -0,0 +1,44 @@
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 No-Field and auction knowledge without revealing an answer', () => {
const noField = preview('Do you have a concealed No-Field with printed value 3?');
assert.match(noField, /Recognized freeform question/);
assert.match(noField, /you currently have a concealed No-Field with printed value 3/);
const auction = preview('Is the current auction card you inspected a Shield?');
assert.match(auction, /Recognized freeform question/);
assert.match(auction, /the current auction card you inspected as Atreides is Shield/);
assert.doesNotMatch(auction, /Your current game information establishes/);
});
void test('the editor previews a mixed auction and force aggregate', () => {
const text = 'Is the current auction card you inspected a Shield and do you have at least 3 forces in your reserves?';
const html = preview(text);
assert.match(html, /Recognized freeform question/);
assert.match(html, /the current auction card you inspected as Atreides is Shield/);
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/);
assert.match(preview(text, 'currentTurn'), /not automatically interpreted/);
});
void test('the editor leaves invalid or predictive inspection questions player-managed', () => {
for (const text of [
'Do you have a concealed No-Field with printed value 2?',
'Will the next auction card be a Shield?',
]) {
const html = preview(text);
assert.match(html, /not automatically interpreted/);
assert.doesNotMatch(html, /Recognized freeform question/);
}
});
@@ -0,0 +1,135 @@
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 { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names';
import { knowledgeBotActions, knowledgeGame, rejectKnowledge } from './truthtrance-knowledge-fixture';
import { createTruthtranceNoFieldFixture, declareNoFieldTruthtrance,
passNoFieldTruthPriority, truthtranceNoFieldCards, truthtranceNoFieldPlayer } from './fixture-truthtrance-no-field';
import { advanceTruthMovementNativeEarly, createTruthMovementNativeEarlyFixture,
truthMovementNativeEarlyInventory } from './fixture-truth-movement-native-early';
function ask(game: Game, asker: string, target: string, text: string, scope: 'fact' | 'currentTurn' = 'fact') {
const waiting = passNoFieldTruthPriority(declareNoFieldTruthtrance(game, asker));
return applyAction(waiting, asker, { type: 'truthAsk', question: { kind: 'freeform', target, scope, text } });
}
function question(game: Game) {
const q = game.truthtrance?.question;
assert.equal(q?.kind, 'freeform');
if (q?.kind !== 'freeform') throw Error('Expected a freeform question');
return q;
}
function verify(game: Game, target: string, answer: 'yes' | 'no' | 'unknown') {
assert.equal(question(game).interpreted?.version, 8);
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 wrong of (['yes', 'no', 'unknown'] as const).filter(value => value !== answer))
rejectKnowledge(game, target, { type: 'truthAnswer', answer: wrong });
const restored = JSON.parse(JSON.stringify(game)) as Game;
const done = applyAction(restored, target, { type: 'truthAnswer', answer });
if (answer === 'unknown') assert.equal(done.truthtrance?.stage, 'unknown');
else assert.equal(done.truthHistory!.at(-1)!.answer, answer);
return done;
}
for (const [wording, answer] of [
['Do you have a concealed No-Field with printed value 3?', 'yes'],
['Do you have a concealed No-Field with printed value 5?', 'no'],
['Do you have a concealed No-Field with printed value 3 and do you have at least 0 forces in your reserves?', 'yes'],
] as const) {
void test(`V8 checks the existing concealed marker privately: ${answer}`, () => {
const f = createTruthtranceNoFieldFixture({ marker: 3 });
const before = structuredClone(truthtranceNoFieldPlayer(f.game, f.owner).noField);
const cards = truthtranceNoFieldCards(f.game);
const game = ask(f.game, f.asker, f.owner, wording);
for (const player of game.players.filter(player => player.id !== f.owner))
assert.equal(viewGame(game, player.id).richeseNoField!.private, null);
const done = verify(game, f.owner, answer);
assert.deepEqual(truthtranceNoFieldPlayer(done, f.owner).noField, before);
assert.deepEqual(truthtranceNoFieldCards(done), cards);
});
}
void test('V8 asks about absent No-Field custody without inferring a printed zero', () => {
const f = createTruthtranceNoFieldFixture({ marker: 3, boundary: 'beforeShipment' });
const game = ask(f.game, f.asker, f.owner, 'Do you have a concealed No-Field with printed value 0?');
verify(game, f.owner, 'no');
});
/** Real setup and ordinary auction progression; the shared fixture stages
* physical Truthtrance acquisition without inventing a card. */
function inspectedAuction() {
let game = knowledgeGame();
for (let n = 0; n < 200 && !(game.phase === 3 && game.auction?.peekKnown && !game.response && !game.phaseOpening); n++) {
const candidate = game.players.flatMap(player => knowledgeBotActions(game, player.id)
.map(action => ({ id: player.id, action })))[0];
assert.ok(candidate, `Expected a native auction action in phase ${game.phase}`);
game = applyAction(game, candidate.id, candidate.action);
}
assert.equal(game.phase, 3);
assert.equal(game.auction?.peekKnown, true);
assert.ok(game.auction?.cards[game.auction.index]);
return game;
}
void test('V8 checks the actual inspected auction face privately', () => {
const initial = inspectedAuction(), card = initial.auction!.cards[initial.auction!.index];
for (const [name, answer] of [[card.name, 'yes'], [card.name === 'Shield' ? 'Snooper' : 'Shield', 'no']] as const) {
const game = ask(structuredClone(initial), 'a', 'o', `Is the current auction card you inspected a ${name}?`);
for (const id of ['a', 'b', 'f']) assert.equal(viewGame(game, id).auction?.card, null);
const done = verify(game, 'o', answer);
assert.equal(done.auction!.cards[done.auction!.index].id, card.id);
}
const missing = structuredClone(initial);
missing.auction!.peekKnown = false;
const unknown = ask(missing, 'a', 'o', `Is the current auction card you inspected a ${card.name}?`);
verify(unknown, 'o', 'unknown');
});
void test('V8 checks the recorded Ixian group rather than the remaining auction lots', () => {
const fixture = createTruthMovementNativeEarlyFixture({ roster: 'ixians-tleilaxu' });
let initial = advanceTruthMovementNativeEarly(fixture.game, state =>
state.phase === 3 && state.decision?.kind === 'ixAuction' && !state.response);
const returned = initial.ixAuction!.cards[0].id;
initial = applyAction(initial, 'ixians', { type: 'decision', card: returned, position: 'bottom' });
initial = advanceTruthMovementNativeEarly(initial, state =>
state.phase === 3 && !!state.auction && !!state.ixAuctionKnown &&
!state.response && !state.decision && !state.phaseOpening);
const observed = structuredClone(initial.ixAuctionKnown!.cards), inventory = truthMovementNativeEarlyInventory(initial);
const present = observed[0].name, absent = TRUTH_CARD_NAMES.find(name => !observed.some(card => card.name === name));
assert.ok(absent);
for (const [name, answer] of [[present, 'yes'], [absent, 'no']] as const) {
const game = ask(structuredClone(initial), 'atreides', 'ixians', `Does your recorded Ixian auction group contain ${name}?`);
const done = verify(game, 'ixians', answer);
assert.deepEqual(done.ixAuctionKnown!.cards, observed);
assert.deepEqual(truthMovementNativeEarlyInventory(done), inventory);
}
const stale = structuredClone(initial);
stale.ixAuctionKnown = null;
verify(ask(stale, 'atreides', 'ixians', `Does your recorded Ixian auction group contain ${present}?`), 'ixians', 'unknown');
});
void test('V8 preserves unstamped and current-turn questions and rejects changed receipts', () => {
const f = createTruthtranceNoFieldFixture({ marker: 3 });
const text = 'Do you have a concealed No-Field with printed value 3?';
const future = ask(structuredClone(f.game), f.asker, f.owner, text, 'currentTurn');
assert.equal(question(future).interpreted, undefined);
const old = ask(structuredClone(f.game), f.asker, f.owner, text);
delete question(old).interpreted;
assert.equal(viewGame(old, f.owner).truthAnswer, null);
const legacy = applyAction(old, f.owner, { type: 'truthAnswer', answer: 'no' });
assert.equal(legacy.truthHistory!.at(-1)!.answer, 'no');
const changed = ask(structuredClone(f.game), f.asker, f.owner, text);
const q = question(changed); assert.equal(q.interpreted?.version, 8);
if (!q.interpreted) throw Error('Expected an interpreted question');
q.interpreted.fact = { kind: 'noFieldValue', value: 5 };
assert.throws(() => viewGame(changed, f.owner));
assert.throws(() => applyAction(changed, f.owner, { type: 'truthAnswer', answer: 'yes' }));
});