Support Truthtrance on Atreides spice foresight

This commit is contained in:
Quintine committed 2026-10-27 23:53:13 +11:00
1 parent 24255905ed
commit 235c2eb03f
11 files changed
+255 -16

No files matched your search

+3 -1
View File
@@ -65,6 +65,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
predictionTurn: { kind: 'prediction', field: 'turn', compare: 'gte', value: 1 },
stormDial: { kind: 'stormDial', compare: 'gte', value: 0 },
stormForecast: { kind: 'stormForecast', compare: 'gte', value: 1 },
spicePeek: { kind: 'spicePeek', value: 'worm' },
spice: { kind: 'spice', compare: 'gte', value: 6 },
forceCount: { kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 6 },
noFieldValue: { kind: 'noFieldValue', value: 3 },
@@ -83,6 +84,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
<option value="predictionTurn">Stored turn prediction</option>
<option value="stormDial">Known storm dial</option>
<option value="stormForecast">Known storm forecast</option>
<option value="spicePeek">Known next Spice Blow card</option>
<option value="spice">Current personal spice</option>
<option value="forceCount">Current physical forces</option>
<option value="noFieldValue">Current concealed No-Field printed value</option>
@@ -98,7 +100,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
<FaceDancerFactFields value={fact} leaders={game.allLeaders} onChange={onChange} /> : fact.kind === 'noFieldValue' ?
<NoFieldValueFields value={fact} onChange={onChange} /> : fact.kind === 'forceCount' ?
<ForceCountFields value={fact} game={game} player={game.players.find(player => player.id === target)!} onChange={onChange} /> :
fact.kind === 'prediction' || fact.kind === 'stormDial' || fact.kind === 'stormForecast' ?
fact.kind === 'prediction' || fact.kind === 'stormDial' || fact.kind === 'stormForecast' || fact.kind === 'spicePeek' ?
<KnowledgeFactFields value={fact} onChange={onChange} /> : fact.kind === 'spice' ? <>
<label>
Compare current personal spice
+18 -7
View File
@@ -2,14 +2,13 @@
import { useId } from 'react';
import { FACTIONS } from '@/game/catalog';
import type { TruthFact } from '@/game/truthtrance';
import { knowledgeFactInputError } from '@/game/truthtrance-knowledge';
import { territory } from '@/game/board';
import { knowledgeFactInputError, SPICE_PEEK_VALUES, type KnowledgeFact } from '@/game/truthtrance-knowledge';
import { Input } from '@/components/ui/input';
type KnowledgeFact = Extract<TruthFact, { kind: 'prediction' | 'stormDial' | 'stormForecast' }>;
type NumericKnowledgeFact = Exclude<KnowledgeFact, { kind: 'prediction'; field: 'faction' }>;
type NumericKnowledgeFact = Exclude<KnowledgeFact, { kind: 'prediction'; field: 'faction' } | { kind: 'spicePeek' }>;
function isNumericKnowledgeFact(fact: KnowledgeFact): fact is NumericKnowledgeFact {
return fact.kind !== 'prediction' || fact.field === 'turn';
return fact.kind !== 'spicePeek' && (fact.kind !== 'prediction' || fact.field === 'turn');
}
export function KnowledgeFactFields({
@@ -20,7 +19,7 @@ export function KnowledgeFactFields({
onChange: (fact: KnowledgeFact) => void;
}) {
const id = useId();
const isFaction = !isNumericKnowledgeFact(value);
const isFaction = value.kind === 'prediction' && value.field === 'faction';
const numericValue = isNumericKnowledgeFact(value) ? value : null;
const isTurn = numericValue?.kind === 'prediction' && numericValue.field === 'turn';
const min = isTurn ? 1 : numericValue?.kind === 'stormForecast' ? 1 : 0;
@@ -28,7 +27,19 @@ export function KnowledgeFactFields({
const invalid = knowledgeFactInputError(value);
return (
<>
{isFaction ? (
{value.kind === 'spicePeek' ? (
<>
<label>
Next Spice Blow card
<select value={value.value} onChange={(event) => onChange({ kind: 'spicePeek', value: event.target.value })}>
{SPICE_PEEK_VALUES.map((option) => <option key={option} value={option}>
{option === 'worm' ? 'Sandworm' : option === 'sandtrout' ? 'Sandtrout' : territory(option).name}
</option>)}
</select>
</label>
<p className="fine">This asks about the card the target currently knows from their private Spice Blow peek. If no card is known, the answer is I don’t know. Only the answer is published.</p>
</>
) : isFaction ? (
<label>
Predicted faction
<select
+9
View File
@@ -16,6 +16,15 @@ open mode gates.
## Current checkpoint and work
**Truthtrance and Atreides Spice Blow knowledge — 27 October. Prototyped /
Partial.** A structured question can compare the currently known next Spice
Card with a printed location, sandworm or Sandtrout. Only Atreides' active,
uncanceled peek yields a definite answer. Single/compound controls, private
answer and existing legal bots are connected. Five focused files pass 24 cases,
including real Basic/Advanced progression through the native peek; a canceled
peek is staged separately. Arbitrary questions, broader interactions and
deployed acceptance remain open. [Scope](TRUTHTRANCE_KNOWLEDGE.md#atreides-spice-blow-knowledge--27-october-2026).
**Two people, four factions — 27 October. Prototyped / Partial.** Each person
can add a separately authenticated second faction and switch its private view
and actions. Exactly two controllers with two seats each are required to start;
+10
View File
@@ -1,5 +1,15 @@
# Dune implementation status
## 27 October 2026 — Truthtrance and Atreides Spice Blow knowledge
A single or compound structured question may ask whether the Spice Card
currently known to Atreides is a named printed blow, a sandworm or Sandtrout.
Only its active, uncanceled native peek yields a definite private answer.
Focused controls, helpers and real Basic/Advanced question flow with all four
legal bot answers pass 24 cases; the canceled-peek case is staged. General
freeform enforcement, broader interactions and deployed acceptance remain open.
[Scope](TRUTHTRANCE_KNOWLEDGE.md#atreides-spice-blow-knowledge--27-october-2026).
## 27 October 2026 — Two people, four factions
The provisional setup maps two controllers to four independent faction seats,
+9
View File
@@ -1,5 +1,14 @@
# Rule decision index
## Truthtrance and Atreides Spice Blow knowledge — 27 October 2026
The publisher FAQ permits game-related yes/no and compound questions. The
structured Spice Card fact reports only an active, successful native Atreides
peek and does not forecast an unknown card. Matching a Discovery blow by its
printed territory and treating unavailable knowledge as unknown are bounded
software interpretations; arbitrary questions remain unfinished.
[Scope](TRUTHTRANCE_KNOWLEDGE.md#atreides-spice-blow-knowledge--27-october-2026).
## Two people, two factions each — 27 October 2026
The classic rulebook and the supplied Advanced revision permit two people to
+22
View File
@@ -1,5 +1,27 @@
# Truthtrance: recorded predictions and storm knowledge
## Atreides Spice Blow knowledge — 27 October 2026
**Prototyped / Partial.** The structured fact `{kind:'spicePeek', value}` asks
whether Atreides' currently known next Spice Card is a sandworm, Sandtrout, or
a blow naming one of the printed territories. A Discovery card at the named
territory also matches that territory question. Only the native Atreides seat
with an active, uncanceled Shipment and Movement peek receives a definite
answer; an unavailable peek, another faction or an empty deck yields unknown.
This reports existing knowledge rather than granting a forecast, looking at a
future replacement card or promising a future outcome. Both single and
compound controls use public face choices; the owner-only scalar, physical
Truthtrance handling and four legal bot answer paths remain shared.
The existing general [GF9 FAQ p. 8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8)
permits game-related yes/no and combined questions. These particular selectors
and entitlement boundaries are software interpretations, not a new printed
restriction. Five focused files pass 24 cases; two runtime cases include real
Basic/Advanced progression to the successful peek, actual asking and answering,
all four bot profiles, a staged canceled-peek state and physical card custody.
Full Truthtrance language, broader interactions and deployed acceptance remain
open.
14 September 2026. **Prototyped**, with **Partial** coverage. This extends the
existing question flow with four typed predicates; freeform interpretation,
general future commitments and complete mode acceptance remain unfinished.
+6 -5
View File
@@ -3242,13 +3242,14 @@ export const RULE_TOPICS: RuleTopic[] = [
},
{
id: 'truthtrance-knowledge',
title: 'Truthtrance: recorded predictions and storm knowledge',
title: 'Truthtrance: recorded predictions and private forecasts',
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Ask about the target’s recorded Bene Gesserit prediction, submitted storm dial or currently known Fremen storm card, with a single fact or an AND/OR combination.',
summary: 'Ask about a recorded prediction, submitted storm dial, Fremen forecast or Atreides Spice Blow peek, with a single fact or an AND/OR combination.',
steps: [
'Choose a recorded prediction faction, compare its turn, or compare the target’s current submitted storm dial or known Fremen forecast. Exactly, at least and at most are available for numeric facts.',
'Ask whether Atreides’ currently known next Spice Card is a sandworm, Sandtrout, or a blow at a named printed territory. If the peek was canceled or is no longer available, the answer is I don’t know.',
'These questions describe a choice already recorded or information currently known. They do not require anyone to win on a future turn, change a choice, or make the storm move a promised distance.',
'A missing prediction, an unsubmitted or expired storm dial, or an unknown forecast yields I don’t know. A forecast question never gives another faction the Fremen’s private knowledge.',
'For a supported Basic dialer who has not submitted, the separate combined turn-action question can ask about their future exact choice; that is a commitment, not a fact about an already sealed dial.',
@@ -3257,11 +3258,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['card-truthtrance', 'truthtrance-spice', 'privacy'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Stored prediction and entitled current storm knowledge use shared three-valued fact evaluation; no future commitment or unknown deck lookup.', evidence: ['game/truthtrance-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Four fact choices, numeric comparisons, public faction names, bounded inputs and compound question previews.', evidence: ['components/truthtrance-knowledge.tsx', 'components/truthtrance.tsx'] },
{ area: 'Implementation', status: 'Partial', detail: 'Stored prediction, entitled storm knowledge and an active Atreides Spice peek use shared three-valued fact evaluation; no future commitment or unentitled deck lookup.', evidence: ['game/truthtrance-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Five fact choices, numeric comparisons, public faction and Spice Card names, bounded inputs and compound question previews.', evidence: ['components/truthtrance-knowledge.tsx', 'components/mixed-shipment-fields.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles answer the owner-only verified scalar through the existing Truthtrance flow. Strategic question selection and freeform understanding remain incomplete.', evidence: ['game/bots.ts', 'tests/truthtrance-knowledge.test.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Current-knowledge semantics, unknown cases and original FAQ authority are explicit.', evidence: ['docs/TRUTHTRANCE_KNOWLEDGE.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, UI, compound privacy, bots, JSON and production SQLite restoration; broader Truthtrance acceptance remains open.', evidence: ['tests/truthtrance-knowledge.test.ts', 'tests/truthtrance-knowledge-controls.test.tsx', 'tests/truthtrance-knowledge-recovery.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, UI, native Atreides peek and all-profile answers; earlier storm cases include JSON and SQLite restoration. Broader Truthtrance acceptance remains open.', evidence: ['tests/truthtrance-knowledge.test.ts', 'tests/truthtrance-knowledge-controls.test.tsx', 'tests/truthtrance-spice-peek-runtime.test.ts', 'tests/truthtrance-knowledge-recovery.test.ts'] },
],
},
{
+30 -2
View File
@@ -1,10 +1,18 @@
import { FACTIONS, type FactionId } from './catalog';
import { SPICE_CARDS } from './cards';
import { territory } from './board';
import type { Game, Player } from './engine';
type Comparison = 'eq' | 'gte' | 'lte';
/** Publicly selectable identities; a territory names a blow, including a
* Discovery card at that same printed territory. No deck contents enter here. */
export const SPICE_PEEK_VALUES: readonly string[] = [
'worm', 'sandtrout', ...SPICE_CARDS.map(([id]) => id),
];
export type KnowledgeFact =
| { kind: 'prediction'; field: 'faction'; faction: FactionId }
| { kind: 'prediction'; field: 'turn'; compare: Comparison; value: number }
| { kind: 'spicePeek'; value: string }
| { kind: 'stormDial' | 'stormForecast'; compare: Comparison; value: number };
/** Only values already chosen or actually known by this respondent. */
@@ -12,16 +20,20 @@ export type TruthKnowledge = {
prediction: { faction: FactionId; turn: number } | null;
stormDial: number | null;
stormForecast: number | null;
spicePeek: string | null;
};
export function isKnowledgeFact(value: unknown): value is KnowledgeFact {
if (!value || typeof value !== 'object') return false;
const kind = (value as { kind?: unknown }).kind;
return kind === 'prediction' || kind === 'stormDial' || kind === 'stormForecast';
return kind === 'prediction' || kind === 'stormDial' || kind === 'stormForecast' || kind === 'spicePeek';
}
export function knowledgeFactInputError(value: unknown): string | null {
if (!isKnowledgeFact(value)) return 'Choose a stored prediction, storm dial or known forecast.';
if (value.kind === 'spicePeek')
return typeof value.value === 'string' && SPICE_PEEK_VALUES.includes(value.value)
? null : 'Choose a printed Spice Blow territory, a sandworm, or Sandtrout.';
if (value.kind === 'prediction') {
if (value.field === 'faction')
return FACTIONS.some((faction) => faction.id === value.faction)
@@ -39,6 +51,7 @@ export function knowledgeFactInputError(value: unknown): string | null {
export function parseKnowledgeFact(value: unknown): KnowledgeFact {
const error = knowledgeFactInputError(value);
if (error || !isKnowledgeFact(value)) throw new Error(error ?? 'Choose a valid private-knowledge question.');
if (value.kind === 'spicePeek') return { kind: value.kind, value: value.value };
if (value.kind === 'prediction' && value.field === 'faction')
return { kind: value.kind, field: value.field, faction: value.faction };
if (value.kind === 'prediction')
@@ -47,10 +60,16 @@ export function parseKnowledgeFact(value: unknown): KnowledgeFact {
}
export function truthKnowledgeOf(
g: Pick<Game, 'phase' | 'turn' | 'advanced' | 'stormDials' | 'stormDialers' | 'stormCard' | 'stormCardKnown'>,
g: Pick<Game, 'phase' | 'turn' | 'advanced' | 'stormDials' | 'stormDialers' | 'stormCard' | 'stormCardKnown' | 'spicePeekKnown' | 'spiceDeck'>,
p: Pick<Player, 'id' | 'faction' | 'prediction'>,
): TruthKnowledge {
const dial = g.stormDials[p.id];
const peek = g.phase === 5 && p.faction === 'atreides' && g.spicePeekKnown
? g.spiceDeck[0] : undefined;
const peekFace = peek && 'worm' in peek && peek.worm === true ? 'worm'
: peek && 'sandtrout' in peek && peek.sandtrout === true ? 'sandtrout'
: peek && 'territory' in peek && typeof peek.territory === 'string' && SPICE_PEEK_VALUES.includes(peek.territory)
? peek.territory : null;
return {
prediction: p.faction === 'beneGesserit' && p.prediction
? { ...p.prediction } : null,
@@ -60,6 +79,7 @@ export function truthKnowledgeOf(
stormForecast: p.faction === 'fremen' && g.advanced && g.stormCardKnown &&
Number.isSafeInteger(g.stormCard) && g.stormCard! >= 1 && g.stormCard! <= 6
? g.stormCard : null,
spicePeek: peekFace,
};
}
@@ -70,6 +90,9 @@ export function knowledgeFactAnswer(
const error = knowledgeFactInputError(fact);
if (error) throw new Error(error);
if (!knowledge) return 'unknown';
if (fact.kind === 'spicePeek')
return knowledge.spicePeek === null || knowledge.spicePeek === undefined ? 'unknown'
: knowledge.spicePeek === fact.value ? 'yes' : 'no';
if (fact.kind === 'prediction' && fact.field === 'faction')
return knowledge.prediction === null ? 'unknown'
: knowledge.prediction.faction === fact.faction ? 'yes' : 'no';
@@ -81,6 +104,11 @@ export function knowledgeFactAnswer(
}
export function knowledgeFactText(fact: KnowledgeFact): string {
if (fact.kind === 'spicePeek') {
const face = fact.value === 'worm' ? 'a sandworm' : fact.value === 'sandtrout' ? 'Sandtrout'
: `a Spice Blow at ${territory(fact.value).name}`;
return `the next Spice Card currently known to you as Atreides is ${face}`;
}
if (fact.kind === 'prediction' && fact.field === 'faction')
return `your recorded Bene Gesserit prediction names ${FACTIONS.find((f) => f.id === fact.faction)!.name}`;
const comparison = fact.compare === 'eq' ? 'exactly' : fact.compare === 'gte' ? 'at least' : 'at most';
+28 -1
View File
@@ -3,10 +3,13 @@ import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { KnowledgeFactFields } from '../components/truthtrance-knowledge';
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { TruthFactExpressionFields } from '../components/truth-fact-expression-fields';
import { knowledgeFactInputError } from '../game/truthtrance-knowledge';
import { createGame, joinGame, newPlayer, viewGame } from '../game/engine';
import type { TruthFact } from '../game/truthtrance';
function markup(value: Extract<TruthFact, { kind: 'prediction' | 'stormDial' | 'stormForecast' }>) {
function markup(value: Extract<TruthFact, { kind: 'prediction' | 'stormDial' | 'stormForecast' | 'spicePeek' }>) {
return renderToStaticMarkup(createElement(KnowledgeFactFields, { value, onChange() {} }));
}
@@ -33,6 +36,30 @@ void test('knowledge controls expose full public faction registry and bounded st
assert.match(forecast, /If no such value is available/);
});
void test('spice peek controls offer public card identities with private-knowledge guidance', () => {
const markupText = markup({ kind: 'spicePeek', value: 'worm' });
assert.match(markupText, /value="worm"/);
assert.match(markupText, /Sandworm/);
assert.match(markupText, /value="sandtrout"/);
assert.match(markupText, /value="the_great_flat"/);
assert.match(markupText, /private Spice Blow peek/);
assert.match(markupText, /Only the answer is published/);
});
void test('standalone and compound fact editors both expose the spice peek question', () => {
const game = createGame('PEEKCONTROLS', newPlayer('a', 'Asker', 'emperor'));
joinGame(game, newPlayer('b', 'Target', 'atreides'));
const view = viewGame(game, 'a');
const fact: TruthFact = { kind: 'spicePeek', value: 'broken_land' };
const single = renderToStaticMarkup(createElement(TruthFactFields, { value: fact, game: view, target: 'b', onChange() {} }));
assert.match(single, /value="spicePeek" selected/);
assert.match(single, /value="broken_land" selected/);
const compound: TruthFact = { kind: 'and', terms: [fact, { kind: 'spice', compare: 'gte', value: 3 }] };
const grouped = renderToStaticMarkup(createElement(TruthFactExpressionFields, { value: compound, game: view, target: 'b', disabled: false, onChange() {} }));
assert.match(grouped, /Known next Spice Blow card/);
assert.match(grouped, /value="broken_land" selected/);
});
void test('knowledge helper rejects invalid stored bounds', () => {
assert.ok(knowledgeFactInputError({ kind: 'prediction', field: 'turn', compare: 'eq', value: 0 }));
assert.ok(knowledgeFactInputError({ kind: 'prediction', field: 'turn', compare: 'eq', value: 11 }));
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, newPlayer } from '../game/engine';
import { SPICE_CARDS } from '../game/cards';
import { truthFactAnswer } from '../game/truthtrance';
import {
SPICE_PEEK_VALUES, knowledgeFactAnswer, knowledgeFactText,
parseKnowledgeFact, truthKnowledgeOf,
} from '../game/truthtrance-knowledge';
void test('Spice knowledge questions accept only printed territories, worm and Sandtrout', () => {
assert.deepEqual(SPICE_PEEK_VALUES, ['worm', 'sandtrout', ...SPICE_CARDS.map(([id]) => id)]);
for (const value of SPICE_PEEK_VALUES)
assert.deepEqual(parseKnowledgeFact({ kind: 'spicePeek', value, extra: 'discarded' }), { kind: 'spicePeek', value });
for (const value of ['', 'arrakeen', 'unknown', 7, null, {}, ['worm']])
assert.throws(() => parseKnowledgeFact({ kind: 'spicePeek', value }));
assert.match(knowledgeFactText({ kind: 'spicePeek', value: 'worm' }), /sandworm/);
assert.match(knowledgeFactText({ kind: 'spicePeek', value: 'hagga_basin' }), /Hagga Basin/);
});
void test('only the Atreides respondent with a current successful peek can answer', () => {
const g = createGame('PEEKTEST', newPlayer('a', 'Atreides', 'atreides'));
const atreides = g.players[0];
const other = newPlayer('other', 'Other', 'guild');
g.phase = 5;
g.spicePeekKnown = true;
const fact = { kind: 'spicePeek', value: 'hagga_basin' } as const;
g.spiceDeck = [{ territory: 'hagga_basin', amount: 6, sector: 12 }];
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(g, atreides)), 'yes');
assert.equal(knowledgeFactAnswer({ kind: 'spicePeek', value: 'worm' }, truthKnowledgeOf(g, atreides)), 'no');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(g, other)), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...g, spicePeekKnown: false }, atreides)), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...g, phase: 6 }, atreides)), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...g, spiceDeck: [] }, atreides)), 'unknown');
assert.equal(knowledgeFactAnswer(fact), 'unknown');
// Private deck access itself is fenced when this respondent is not entitled.
const hidden = { ...g, get spiceDeck(): never { throw new Error('Private deck read'); } };
assert.equal(truthKnowledgeOf(hidden, other).spicePeek, null);
hidden.phase = 6;
assert.equal(truthKnowledgeOf(hidden, atreides).spicePeek, null);
});
void test('worm, Sandtrout and discovery blows stay distinct and compose with other facts', () => {
const g = createGame('PEEKTEST', newPlayer('a', 'Atreides', 'atreides'));
const p = g.players[0];
Object.assign(g, { phase: 5, spicePeekKnown: true, spiceDeck: [{ worm: true }] });
const worm = { kind: 'spicePeek', value: 'worm' } as const;
assert.equal(knowledgeFactAnswer(worm, truthKnowledgeOf(g, p)), 'yes');
g.spiceDeck = [{ sandtrout: true }];
assert.equal(knowledgeFactAnswer(worm, truthKnowledgeOf(g, p)), 'no');
assert.equal(knowledgeFactAnswer({ kind: 'spicePeek', value: 'sandtrout' }, truthKnowledgeOf(g, p)), 'yes');
g.spiceDeck = [{ territory: 'hagga_basin', amount: 6, sector: 12, discovery: 'discovery-hagga-basin' }];
const knowledge = truthKnowledgeOf(g, p);
const blow = { kind: 'spicePeek', value: 'hagga_basin' } as const;
assert.equal(knowledgeFactAnswer(blow, knowledge), 'yes');
assert.equal(truthFactAnswer(p, { kind: 'and', terms: [blow, { kind: 'spice', compare: 'gte', value: 0 }] }, knowledge), 'yes');
assert.equal(truthFactAnswer(p, { kind: 'or', terms: [worm, { kind: 'spice', compare: 'gte', value: 0 }] }, knowledge), 'yes');
});
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { knowledgeGame, knowledgeBotActions, askKnowledge, knowledgePhysical, rejectKnowledge } from './truthtrance-knowledge-fixture';
/** Native setup and phase progression; only the holder's Truthtrance acquisition
* is staged by knowledgeGame, with each physical card retained exactly once. */
function withPeek(advanced = false) {
let g = knowledgeGame(advanced);
for (let n = 0; n < 300 && !(g.phase === 5 && g.spicePeekKnown && !g.response && !g.phaseOpening); n++) {
const candidate = g.players.flatMap(player => knowledgeBotActions(g, player.id)
.map(action => ({ id: player.id, action })))[0];
assert.ok(candidate, `Native progression needs a legal decision in phase ${g.phase}`);
g = applyAction(g, candidate.id, candidate.action);
}
assert.equal(g.phase, 5);
assert.equal(g.spicePeekKnown, true);
assert.ok(g.spiceDeck[0]);
return g;
}
void test('actual Atreides peek supplies a private Truthtrance answer and native AI continuation', () => {
for (const advanced of [false, true]) {
const initial = withPeek(advanced);
const card = initial.spiceDeck[0];
const value = 'territory' in card ? card.territory : 'worm' in card ? 'worm' : 'sandtrout';
const question = { kind: 'spicePeek' as const, value };
const g = askKnowledge(initial, 'o', question);
assert.equal(viewGame(g, 'o').truthAnswer, 'yes');
assert.deepEqual(viewGame(g, 'o').spicePeek, card);
for (const id of ['a', 'b', 'f']) {
assert.equal(viewGame(g, id).truthAnswer, null);
assert.equal(viewGame(g, id).spicePeek, null);
}
rejectKnowledge(g, 'o', { type: 'truthAnswer', answer: 'no' });
for (const difficulty of DIFFICULTIES) {
const view = viewGame(g, 'o');
view.players.find(player => player.id === 'o')!.bot = difficulty;
const actions = botActions(view);
assert.deepEqual(actions, [{ type: 'truthAnswer', answer: 'yes' }]);
const done = applyAction(g, 'o', actions[0]);
assert.equal(done.truthtrance, null);
assert.equal(done.truthHistory!.at(-1)!.answer, 'yes');
assert.deepEqual(done.spiceDeck[0], card);
assert.deepEqual(knowledgePhysical(done), knowledgePhysical(initial));
}
}
});
void test('a canceled peek yields unknown and retains the physical Truthtrance', () => {
const initial = withPeek();
initial.spicePeekKnown = false;
const g = askKnowledge(initial, 'o', { kind: 'spicePeek', value: 'worm' });
assert.equal(viewGame(g, 'o').truthAnswer, 'unknown');
rejectKnowledge(g, 'o', { type: 'truthAnswer', answer: 'yes' });
const done = applyAction(g, 'o', { type: 'truthAnswer', answer: 'unknown' });
assert.equal(done.truthtrance?.stage, 'unknown');
assert.deepEqual(knowledgePhysical(done), knowledgePhysical(initial));
});