Expand freeform Truthtrance current facts
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
a10e019b01
commit
cc9cc0945d
10 files changed
+290
-4
No files matched your search
@@ -70,6 +70,7 @@ import { parseFreeformFactV2 } from '@/game/truthtrance-freeform-facts-v2';
|
||||
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 {
|
||||
truthQuestionText,
|
||||
type TruthFact,
|
||||
@@ -110,7 +111,7 @@ 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) : null;
|
||||
parseFreeformFactV4(text) || parseTruthFreeformFactsV5(text) || parseFreeformFactV6(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,16 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**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
|
||||
physical reserve, Tanks or board counts. The existing facts determine private
|
||||
answers; the public editor previews their meaning. Older and unstamped
|
||||
questions retain their semantics. Fourteen freeform files pass 65 cases; the
|
||||
Spice fixture stages a conserved card. Arbitrary wording, broader commitments
|
||||
and deployed verification remain open.
|
||||
[Scope](TRUTHTRANCE_KNOWLEDGE.md#freeform-predictions-forecasts-and-forces--29-october-2026).
|
||||
|
||||
**Basic No-Field revelation during Emperor extra revival — 29 October.
|
||||
Prototyped / Partial.** Richese may reveal during a clean pending Emperor
|
||||
allied-revival response, including when Richese is the recipient. Revelation
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Rule decision index
|
||||
|
||||
## Freeform Truthtrance prediction, forecast and force facts — 29 October 2026
|
||||
|
||||
Bounded current-fact wording for recorded predictions, forecasts and physical
|
||||
force counts maps to existing checked facts. This grammar is a provisional
|
||||
online interpretation of game-related yes/no questions, not a general natural
|
||||
language rule. [Scope](TRUTHTRANCE_KNOWLEDGE.md#freeform-predictions-forecasts-and-forces--29-october-2026).
|
||||
|
||||
## Basic No-Field reveal during Emperor extra revival — 29 October 2026
|
||||
|
||||
Richese may reveal during a clean pending Emperor extra-revival response,
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# Truthtrance: recorded predictions and storm knowledge
|
||||
|
||||
## Freeform predictions, forecasts and forces — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** New current-fact questions recognize bounded wording
|
||||
for the recorded Bene Gesserit prediction, a submitted storm dial, Fremen's
|
||||
storm forecast, Atreides' known next Spice Blow, and counts of the respondent's
|
||||
physical forces in reserves, Tanks or on the board. For example, “Did you
|
||||
predict Harkonnen?” and “Do you have at least 3 forces in your reserves?” use
|
||||
the same checked facts as the structured editor. Unavailable knowledge yields
|
||||
I don't know; the preview names the interpretation and only the respondent sees
|
||||
the checked answer before replying. Physical counts exclude concealed markers.
|
||||
|
||||
Newly recognized questions carry version 6; older versions and unstamped
|
||||
questions retain their original behavior. The bounded wording is an online
|
||||
interpretation, not general natural-language understanding. The focused Spice
|
||||
fixture stages a conserved card to arrange the known forecast; wider grammar,
|
||||
current-turn commitments and deployed verification remain open. Fourteen
|
||||
related freeform files pass 65 cases.
|
||||
|
||||
## Freeform compound hand-category facts — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** New fact-scoped freeform questions can combine two
|
||||
|
||||
+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. 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. 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 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,51 @@
|
||||
import { TERRITORIES } from './board';
|
||||
import { FACTIONS } from './catalog';
|
||||
import { knowledgeFactInputError, SPICE_PEEK_VALUES, type KnowledgeFact } from './truthtrance-knowledge';
|
||||
import type { ForceCountFact } from './truthtrance-force-count';
|
||||
|
||||
/** Anchored present facts only. This version is independent of earlier saved
|
||||
* interpretations and consults printed public registries, never a game state. */
|
||||
export function parseFreeformFactV6(text: string): KnowledgeFact | ForceCountFact | null {
|
||||
if (typeof text !== 'string' || text.length > 500 || /[\r\n]/.test(text)) return null;
|
||||
const input = text.trim().toLowerCase().replace(/[ \t]+/g, ' ');
|
||||
const comparison = (value: string): 'eq' | 'gte' | 'lte' =>
|
||||
value === 'at least' ? 'gte' : value === 'at most' ? 'lte' : 'eq';
|
||||
const knowledge = (fact: KnowledgeFact): KnowledgeFact | null =>
|
||||
knowledgeFactInputError(fact) ? null : fact;
|
||||
|
||||
const faction = /^(?:did you predict (.+?)(?: to win)?|is your predicted faction (.+?))\??$/.exec(input);
|
||||
if (faction) {
|
||||
const name = faction[1] ?? faction[2];
|
||||
const matches = FACTIONS.filter(candidate => candidate.name.toLowerCase() === name || candidate.id.toLowerCase() === name);
|
||||
return matches.length === 1 ? knowledge({kind:'prediction', field:'faction', faction:matches[0].id}) : null;
|
||||
}
|
||||
|
||||
const numeric = /^is your (predicted turn|storm forecast|(?:submitted )?storm dial) (at least|exactly|at most) ([0-9]+)\??$/.exec(input);
|
||||
if (numeric) {
|
||||
const compare = comparison(numeric[2]), value = Number(numeric[3]);
|
||||
const fact: KnowledgeFact = numeric[1] === 'predicted turn'
|
||||
? {kind:'prediction', field:'turn', compare, value}
|
||||
: {kind:numeric[1] === 'storm forecast' ? 'stormForecast' : 'stormDial', compare, value};
|
||||
return knowledge(fact);
|
||||
}
|
||||
|
||||
const spice = /^is the next spice blow (?:a |an )?(sandworm|worm|sandtrout)\??$/.exec(input);
|
||||
if (spice) return knowledge({kind:'spicePeek', value:spice[1] === 'sandworm' ? 'worm' : spice[1]});
|
||||
const blow = /^is the next spice blow (?:in|at) (.+?)\??$/.exec(input);
|
||||
if (blow) {
|
||||
const name = blow[1];
|
||||
const matches = TERRITORIES.filter(candidate => SPICE_PEEK_VALUES.includes(candidate.id) &&
|
||||
(candidate.name.toLowerCase() === name || candidate.name.toLowerCase().replace(/^the /, '') === name));
|
||||
return matches.length === 1 ? knowledge({kind:'spicePeek', value:matches[0].id}) : null;
|
||||
}
|
||||
|
||||
const physical = /^do you have (at least|exactly|at most) ([0-9]+) (?:physical )?forces? (in (?:your )?reserves|in (?:the |your )?tanks|on the board)\??$/.exec(input);
|
||||
if (physical) {
|
||||
const value = Number(physical[2]);
|
||||
if (!Number.isSafeInteger(value) || value < 0) return null;
|
||||
const zone: ForceCountFact['zone'] = physical[3] === 'on the board' ? {kind:'board'}
|
||||
: physical[3].endsWith('reserves') ? {kind:'reserves'} : {kind:'tanks'};
|
||||
return {kind:'forceCount', zone, counter:'total', compare:comparison(physical[1]), value};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+13
-2
@@ -72,6 +72,7 @@ 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 { CHEAP_HERO_TRAITOR } from './traitors';
|
||||
import {
|
||||
TRUTH_FACT_MAX_NODES, TRUTH_FACT_MAX_DEPTH, TRUTH_FACT_MAX_TERMS,
|
||||
@@ -137,7 +138,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; fact: TruthFact };
|
||||
interpreted?: { version: 1; fact: Extract<TruthFact, { kind: 'hand' | 'spice' }> } | { version: 2 | 3 | 4 | 5 | 6; fact: TruthFact };
|
||||
};
|
||||
export type TruthAnswer = 'yes' | 'no' | 'unknown';
|
||||
export type TruthQueueEntry = {
|
||||
@@ -724,11 +725,16 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
const compoundFact = question.scope === 'fact' && marker.version === 5 &&
|
||||
!parseFreeformFactV3(question.text) && !parseFreeformFactV4(question.text)
|
||||
? parseTruthFreeformFactsV5(question.text) : null;
|
||||
const currentFact = question.scope === 'fact' && marker.version === 6 &&
|
||||
!parseFreeformFactV2(question.text) && !parseFreeformFactV3(question.text) &&
|
||||
!parseFreeformFactV4(question.text) && !parseTruthFreeformFactsV5(question.text)
|
||||
? parseFreeformFactV6(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 } : null;
|
||||
: 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;
|
||||
check(expected !== null && JSON.stringify(v.interpreted) === JSON.stringify(expected),
|
||||
'The saved freeform interpretation does not match its original question.');
|
||||
question.interpreted = expected;
|
||||
@@ -1215,6 +1221,11 @@ export function resolveTruthAction(
|
||||
else {
|
||||
const compoundFact = parseTruthFreeformFactsV5(w.question.text);
|
||||
if (compoundFact) w.question.interpreted = { version: 5, fact: compoundFact };
|
||||
else {
|
||||
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)!) };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }));
|
||||
});
|
||||
Reference in new issue
Block a user