Support Truthtrance questions about battle prescience

This commit is contained in:
Quintine committed 2026-10-28 18:34:11 +11:00
1 parent 7d26929abd
commit ee3e1a0ab2
10 files changed
+395 -11

No files matched your search

+3 -1
View File
@@ -68,6 +68,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
spicePeek: { kind: 'spicePeek', value: 'worm' },
auctionPeek: { kind: 'auctionPeek', name: 'Shield' },
ixPoolPeek: { kind: 'ixPoolPeek', name: 'Shield' },
presciencePeek: { kind: 'presciencePeek', field: 'leader', name: 'None' },
spice: { kind: 'spice', compare: 'gte', value: 6 },
forceCount: { kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 6 },
noFieldValue: { kind: 'noFieldValue', value: 3 },
@@ -89,6 +90,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
<option value="spicePeek">Known next Spice Blow card</option>
<option value="auctionPeek">Inspected ordinary auction card</option>
<option value="ixPoolPeek">Recorded Ixian inspected auction pool includes a card</option>
<option value="presciencePeek">Inspected battle plan field</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>
@@ -104,7 +106,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 === 'spicePeek' || fact.kind === 'auctionPeek' || fact.kind === 'ixPoolPeek' ?
fact.kind === 'prediction' || fact.kind === 'stormDial' || fact.kind === 'stormForecast' || fact.kind === 'spicePeek' || fact.kind === 'auctionPeek' || fact.kind === 'ixPoolPeek' || fact.kind === 'presciencePeek' ?
<KnowledgeFactFields value={fact} onChange={onChange} /> : fact.kind === 'spice' ? <>
<label>
Compare current personal spice
+38 -4
View File
@@ -3,13 +3,13 @@
import { useId } from 'react';
import { FACTIONS } from '@/game/catalog';
import { territory } from '@/game/board';
import { knowledgeFactInputError, SPICE_PEEK_VALUES, type KnowledgeFact } from '@/game/truthtrance-knowledge';
import { knowledgeFactInputError, PRESCIENCE_LEADER_NAMES, SPICE_PEEK_VALUES, type KnowledgeFact } from '@/game/truthtrance-knowledge';
import { TRUTH_CARD_NAMES } from '@/game/truthtrance-card-names';
import { Input } from '@/components/ui/input';
type NumericKnowledgeFact = Exclude<KnowledgeFact, { kind: 'prediction'; field: 'faction' } | { kind: 'spicePeek' } | { kind: 'auctionPeek' } | { kind: 'ixPoolPeek' }>;
type NumericKnowledgeFact = Exclude<KnowledgeFact, { kind: 'prediction'; field: 'faction' } | { kind: 'spicePeek' } | { kind: 'auctionPeek' } | { kind: 'ixPoolPeek' } | { kind: 'presciencePeek' }>;
function isNumericKnowledgeFact(fact: KnowledgeFact): fact is NumericKnowledgeFact {
return fact.kind !== 'spicePeek' && fact.kind !== 'auctionPeek' && fact.kind !== 'ixPoolPeek' && (fact.kind !== 'prediction' || fact.field === 'turn');
return fact.kind !== 'spicePeek' && fact.kind !== 'auctionPeek' && fact.kind !== 'ixPoolPeek' && fact.kind !== 'presciencePeek' && (fact.kind !== 'prediction' || fact.field === 'turn');
}
export function KnowledgeFactFields({
@@ -28,7 +28,41 @@ export function KnowledgeFactFields({
const invalid = knowledgeFactInputError(value);
return (
<>
{value.kind === 'ixPoolPeek' ? (
{value.kind === 'presciencePeek' ? (
<>
<label>
Inspected battle plan field
<select value={value.field} onChange={(event) => {
const field = event.target.value;
if (field === 'dial') onChange({ kind: 'presciencePeek', field, value: 0 });
else if (field === 'leader' || field === 'weapon' || field === 'defense')
onChange({ kind: 'presciencePeek', field, name: 'None' });
}}>
<option value="leader">Leader</option>
<option value="weapon">Weapon</option>
<option value="defense">Defense</option>
<option value="dial">Dial</option>
</select>
</label>
{value.field === 'dial' ? <label htmlFor={`${id}-prescience-dial`}>
Inspected dial value
<Input id={`${id}-prescience-dial`} type="number" min={0} step={0.5} required
value={Number.isNaN(value.value) ? '' : value.value}
aria-invalid={!!invalid}
aria-describedby={`${id}-prescience-help${invalid ? ` ${id}-error` : ''}`}
onChange={(event) => onChange({ ...value, value: event.currentTarget.valueAsNumber })} />
</label> : <label>
Inspected {value.field} name
<select value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })}>
<option value="None">None</option>
{(value.field === 'leader' ? PRESCIENCE_LEADER_NAMES : TRUTH_CARD_NAMES)
.filter(name => name !== 'None')
.map(name => <option key={name} value={name}>{name}</option>)}
</select>
</label>}
<p id={`${id}-prescience-help`} className="fine">This asks what the target currently knows from Prescience in this battle. If that field is unknown or no longer available, the answer is I don’t know. Only the answer is published.</p>
</>
) : value.kind === 'ixPoolPeek' ? (
<>
<label>
Recorded Ixian inspected auction pool includes
+11
View File
@@ -16,6 +16,17 @@ open mode gates.
## Current checkpoint and work
**Truthtrance and revealed Prescience — 28 October. Prototyped / Partial.**
Single and compound questions can compare a live answered battle Prescience
field: a named leader/card or None, or an exact dial. Only recipients entitled
to the existing battle insight can produce a definite answer; pending, canceled,
wrong-field and expired insights remain unknown. Eleven related files pass 58
cases, including genuine Basic battles with all four fields, actual questions,
all four minimal bot answers and battle continuation; the native fixture
selects unopened deck order. Controls use component callback tests, not a
browser. General freeform AI, wider interactions and deployed acceptance remain
open. [Scope](TRUTTRANCE_KNOWLEDGE.md#already-answered-battle-prescience--28-october-2026).
**Hidden Mobile Stronghold passenger collection — 28 October. Prototyped /
Partial.** Explicit route choices count foreign fighters and one per deployed
No-Field, excluding advisors; only Ixians receive the spice. The count uses
+9
View File
@@ -1,5 +1,14 @@
# Rule decision index
## Truthtrance and answered battle Prescience — 28 October 2026
The publisher FAQ permits game-related yes/no and compound questions. The
structured predicate compares an already answered live battle Prescience field
only for a respondent entitled to the existing insight; unobserved, pending or
expired fields yield unknown. The field selector and its lifetime are a
bounded software interpretation, not a publisher ruling.
[Scope](TRUTHTRANCE_KNOWLEDGE.md#already-answered-battle-prescience--28-october-2026).
## Mobile Stronghold passenger collection — 28 October 2026
The E1 Ixian power permits collection per force in the stronghold without
+24
View File
@@ -1,5 +1,29 @@
# Truthtrance: recorded predictions and storm knowledge
## Already answered battle Prescience — 28 October 2026
**Prototyped / Partial.** A current-fact question can compare the leader,
weapon, defense or exact dial already revealed through native battle
Prescience. The field must match the answered inspection and the respondent
must be entitled to the existing battle insight. Pending or unavailable
inspection, a different field and an expired battle return unknown. The
normal inspection category conversion is reused, including an Ecaz battle
special represented as no element. The question exposes no inspected value to
the asker beyond the target's ordinary Yes/No response.
The [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 compound questions. This bounded selector
expresses that general permission and is not a new printed restriction.
Other forms of private knowledge, general freeform interpretation and complete
Truthtrance coverage remain unfinished.
Eleven focused files pass 58 cases, including genuine Basic Prescience answers,
Truthtrance questions, all four minimal bot responses and battle continuation
for all four fields. The native fixture orders unopened cards without staging
hands or positions. Controlled component callbacks exercise the selectors;
`npm run check:quick` and the production build also passed. Browser and
deployed acceptance remain open.
## Basic Ixian auction knowledge — 28 October 2026
**Prototyped / Partial.** The structured fact `{kind:'ixPoolPeek', name}`
+4 -3
View File
@@ -3272,12 +3272,13 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Ask about a recorded prediction, submitted storm dial, Fremen forecast, Atreides peek or Basic Ixian auction group, with a single fact or an AND/OR combination.',
summary: 'Ask about a recorded prediction, submitted storm dial, Fremen forecast, Atreides peek, revealed Prescience or Basic Ixian auction group, 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.',
'Ask whether the ordinary auction card currently inspected by Atreides has a named Treachery Card face. If the inspection was canceled or the lot is no longer available, the answer is I don’t know. Special auctions and other factions’ occupied Homeworld inspections are not yet included.',
'In Basic Bidding, ask Ixians whether their recorded inspected auction group contains a named card. This refers to the recorded group, including cards later sold, rather than what remains for sale. If the inspection is unavailable or belongs to another player, the answer is I don’t know.',
'Ask an entitled participant whether an already answered Prescience reveals a named leader, weapon or defense, no element, or an exact force dial. A pending, unavailable or differently inspected element yields 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.',
@@ -3286,11 +3287,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['card-truthtrance', 'truthtrance-spice', 'privacy'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Stored prediction, entitled storm knowledge, native Atreides peeks and the Basic Ixian recorded auction group use shared three-valued fact evaluation; special auction and occupied inspection remain separate.', evidence: ['game/truthtrance-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Stored prediction, entitled storm knowledge, native Atreides peeks, revealed Prescience and the Basic Ixian recorded auction group use shared three-valued fact evaluation; special auction and occupied inspection remain separate.', evidence: ['game/truthtrance-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Fact choices, numeric comparisons, public faction and 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, native Atreides peeks and Ixian auction knowledge 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-ix-pool-knowledge.test.ts', 'tests/truthtrance-ix-pool-runtime.test.ts', 'tests/truthtrance-spice-peek-runtime.test.ts', 'tests/truthtrance-auction-peek-runtime.test.ts', 'tests/truthtrance-knowledge-recovery.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, UI, native Atreides peeks, Prescience and Ixian auction knowledge 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-ix-pool-knowledge.test.ts', 'tests/truthtrance-ix-pool-runtime.test.ts', 'tests/truthtrance-spice-peek-runtime.test.ts', 'tests/truthtrance-auction-peek-runtime.test.ts', 'tests/truthtrance-prescience-knowledge.test.ts', 'tests/truthtrance-prescience-runtime.test.ts', 'tests/truthtrance-prescience-controls.test.tsx', 'tests/truthtrance-knowledge-recovery.test.ts'] },
],
},
{
+59 -3
View File
@@ -1,7 +1,8 @@
import { FACTIONS, type FactionId } from './catalog';
import { SPICE_CARDS } from './cards';
import { leaders, SPICE_CARDS } from './cards';
import { territory } from './board';
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
import { battleCategoryInspectionValue } from './battle-card-slots';
import type { Game, Player } from './engine';
type Comparison = 'eq' | 'gte' | 'lte';
@@ -10,7 +11,16 @@ type Comparison = 'eq' | 'gte' | 'lte';
export const SPICE_PEEK_VALUES: readonly string[] = [
'worm', 'sandtrout', ...SPICE_CARDS.map(([id]) => id),
];
/** Public printed choices, independent of the private inspected result. */
export const PRESCIENCE_LEADER_NAMES: readonly string[] = [
...new Set([...FACTIONS.flatMap(f => leaders(f.id).map(leader => leader.name)),
'Cheap Hero', 'Cheap Heroine', 'Auditor', 'Duke Prad Vidal', 'Kwisatz Haderach']),
].sort();
type PresciencePeekFact =
| { kind: 'presciencePeek'; field: 'leader' | 'weapon' | 'defense'; name: string }
| { kind: 'presciencePeek'; field: 'dial'; value: number };
export type KnowledgeFact =
| PresciencePeekFact
| { kind: 'prediction'; field: 'faction'; faction: FactionId }
| { kind: 'prediction'; field: 'turn'; compare: Comparison; value: number }
| { kind: 'spicePeek'; value: string }
@@ -27,16 +37,26 @@ export type TruthKnowledge = {
auctionPeek: string | null;
/** The authorized respondent's recorded Ixian group, not remaining lots. */
ixPoolPeek?: readonly string[] | null;
presciencePeek?: { field: 'leader' | 'weapon' | 'defense' | 'dial'; value: string | number } | 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' || kind === 'spicePeek' || kind === 'auctionPeek' || kind === 'ixPoolPeek';
return kind === 'prediction' || kind === 'stormDial' || kind === 'stormForecast' || kind === 'spicePeek' || kind === 'auctionPeek' || kind === 'ixPoolPeek' || kind === 'presciencePeek';
}
export function knowledgeFactInputError(value: unknown): string | null {
if (!isKnowledgeFact(value)) return 'Choose a stored prediction, storm dial or known forecast.';
if (value.kind === 'presciencePeek') {
if (value.field === 'dial') return typeof value.value === 'number' && Number.isFinite(value.value) && value.value >= 0
? null : 'Choose a nonnegative finite dial value.';
if (value.field !== 'leader' && value.field !== 'weapon' && value.field !== 'defense')
return 'Choose the observed battle plan field.';
const names = value.field === 'leader' ? PRESCIENCE_LEADER_NAMES : TRUTH_CARD_NAMES;
return typeof value.name === 'string' && (value.name === 'None' || names.includes(value.name))
? null : 'Choose a known printed face or None.';
}
if (value.kind === 'auctionPeek' || value.kind === 'ixPoolPeek')
return typeof value.name === 'string' && TRUTH_CARD_NAMES.includes(value.name)
? null : 'Choose a known Treachery Card name.';
@@ -60,6 +80,9 @@ 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 === 'presciencePeek') return value.field === 'dial'
? { kind: value.kind, field: value.field, value: value.value }
: { kind: value.kind, field: value.field, name: value.name };
if (value.kind === 'auctionPeek' || value.kind === 'ixPoolPeek') return { kind: value.kind, name: value.name };
if (value.kind === 'spicePeek') return { kind: value.kind, value: value.value };
if (value.kind === 'prediction' && value.field === 'faction')
@@ -70,7 +93,7 @@ export function parseKnowledgeFact(value: unknown): KnowledgeFact {
}
export function truthKnowledgeOf(
g: Pick<Game, 'phase' | 'turn' | 'advanced' | 'stormDials' | 'stormDialers' | 'stormCard' | 'stormCardKnown' | 'spicePeekKnown' | 'spiceDeck' | 'auction' | 'richeseAuction' | 'ixAuction' | 'ixAuctionKnown' | 'homeworldOccupationPreview'>,
g: Pick<Game, 'phase' | 'turn' | 'advanced' | 'stormDials' | 'stormDialers' | 'stormCard' | 'stormCardKnown' | 'spicePeekKnown' | 'spiceDeck' | 'auction' | 'richeseAuction' | 'ixAuction' | 'ixAuctionKnown' | 'homeworldOccupationPreview' | 'battle' | 'players' | 'dukeVidal'>,
p: Pick<Player, 'id' | 'faction' | 'prediction'>,
): TruthKnowledge {
const dial = g.stormDials[p.id];
@@ -92,6 +115,30 @@ export function truthKnowledgeOf(
: 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;
// Match the existing battle.insight audience and inspection normalization.
// Never inspect the selected value before verifying the viewer is entitled.
const battle = g.phase === 6 ? g.battle : null;
const prescience = battle?.prescience;
let presciencePeek: TruthKnowledge['presciencePeek'] = null;
if (battle && prescience && (prescience.player === p.id || [battle.attacker, battle.defender].includes(p.id)) &&
Object.hasOwn(prescience, 'value')) {
const target = g.players.find(seat => seat.id === (prescience.player === battle.attacker ? battle.defender : battle.attacker));
if (target) {
const field = prescience.field;
const raw = prescience.value;
const card = (field === 'weapon' || field === 'defense') && typeof raw === 'string'
? target.hand.find(item => item.id === raw) : undefined;
const value = battleCategoryInspectionValue(field, raw, card);
if (value === null) presciencePeek = { field, value: 'None' };
else if (field === 'dial' && typeof value === 'number' && Number.isFinite(value) && value >= 0)
presciencePeek = { field, value };
else if (field !== 'dial' && typeof value === 'string') {
const face = g.players.flatMap(seat => [...seat.leaders, ...seat.hand])
.concat(g.dukeVidal ? [g.dukeVidal.leader] : []).find(item => item.id === value);
if (face) presciencePeek = { field, value: face.name };
}
}
}
return {
prediction: p.faction === 'beneGesserit' && p.prediction
? { ...p.prediction } : null,
@@ -104,6 +151,7 @@ export function truthKnowledgeOf(
spicePeek: peekFace,
auctionPeek: typeof lotName === 'string' && lotName.length > 0 ? lotName : null,
ixPoolPeek: ixPool,
presciencePeek,
};
}
@@ -114,6 +162,12 @@ export function knowledgeFactAnswer(
const error = knowledgeFactInputError(fact);
if (error) throw new Error(error);
if (!knowledge) return 'unknown';
if (fact.kind === 'presciencePeek') {
const known = knowledge.presciencePeek;
if (!known || known.field !== fact.field) return 'unknown';
const expected = fact.field === 'dial' ? fact.value : fact.name;
return known.value === expected ? 'yes' : 'no';
}
if (fact.kind === 'auctionPeek')
return knowledge.auctionPeek == null ? 'unknown'
: knowledge.auctionPeek === fact.name ? 'yes' : 'no';
@@ -134,6 +188,8 @@ export function knowledgeFactAnswer(
}
export function knowledgeFactText(fact: KnowledgeFact): string {
if (fact.kind === 'presciencePeek')
return `the ${fact.field} you observed through battle Prescience is ${fact.field === 'dial' ? fact.value : fact.name}`;
if (fact.kind === 'auctionPeek')
return `the current auction card you inspected as Atreides is ${fact.name}`;
if (fact.kind === 'ixPoolPeek')
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import * as jsxRuntime from 'react/jsx-runtime';
import { renderToStaticMarkup } from 'react-dom/server';
import ts from 'typescript';
import * as board from '../game/board';
import * as catalog from '../game/catalog';
import { createGame, joinGame, newPlayer, viewGame } from '../game/engine';
import * as knowledge from '../game/truthtrance-knowledge';
import * as cardNames from '../game/truthtrance-card-names';
import { KnowledgeFactFields } from '../components/truthtrance-knowledge';
import { TruthFactFields, invalidTruthFact } from '../components/mixed-shipment-fields';
import { TruthFactExpressionFields } from '../components/truth-fact-expression-fields';
import type { TruthFact } from '../game/truthtrance';
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
return [node, ...elements(node.props.children)];
}
void test('single and compound current-fact editors offer named Prescience fields', () => {
const g = createGame('PRESCIENCEUI', newPlayer('ask', 'Asker', 'emperor'));
joinGame(g, newPlayer('target', 'Target', 'atreides'));
const view = viewGame(g, 'ask');
const fact: TruthFact = { kind: 'presciencePeek', field: 'leader', name: 'Duncan Idaho' };
const single = renderToStaticMarkup(createElement(TruthFactFields, {
value: fact, game: view, target: 'target', onChange() {},
}));
assert.match(single, /value="presciencePeek" selected/);
assert.match(single, /value="Duncan Idaho" selected/);
assert.match(single, /value="weapon"/);
assert.match(single, /value="defense"/);
assert.match(single, /value="dial"/);
assert.match(single, /If that field is unknown or no longer available/);
assert.match(single, /Only the answer is published/);
const compound: TruthFact = { kind: 'or', terms: [fact, { kind: 'spice', compare: 'gte', value: 3 }] };
const grouped = renderToStaticMarkup(createElement(TruthFactExpressionFields, {
value: compound, game: view, target: 'target', disabled: false, onChange() {},
}));
assert.match(grouped, /Inspected battle plan field/);
assert.match(grouped, /value="Duncan Idaho" selected/);
assert.equal(invalidTruthFact(fact, view, 'target'), false);
assert.equal(invalidTruthFact({ kind: 'presciencePeek', field: 'dial', value: -1 }, view, 'target'), true);
});
void test('Prescience field callbacks select printed cards, None and half-strength dial values', () => {
const exports: { KnowledgeFactFields?: typeof KnowledgeFactFields } = {};
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/truthtrance-knowledge.tsx', import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
}).outputText, {
exports,
require(name: string) {
if (name === 'react/jsx-runtime') return jsxRuntime;
if (name === 'react') return { useId: () => 'prescience-test' };
if (name === '@/game/catalog') return catalog;
if (name === '@/game/board') return board;
if (name === '@/game/truthtrance-knowledge') return knowledge;
if (name === '@/game/truthtrance-card-names') return cardNames;
if (name === '@/components/ui/input') return { Input: 'input' };
throw new Error(`Unexpected import ${name}`);
},
});
assert.ok(exports.KnowledgeFactFields);
let fact: knowledge.KnowledgeFact = { kind: 'presciencePeek', field: 'leader', name: 'None' };
const render = () => elements(exports.KnowledgeFactFields!({ value: fact, onChange(value) { fact = value; } }));
const change = (element: Element, value: string) =>
(element.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
change(render().filter(element => element.type === 'select')[0], 'weapon');
assert.deepEqual(JSON.parse(JSON.stringify(fact)), { kind: 'presciencePeek', field: 'weapon', name: 'None' });
change(render().filter(element => element.type === 'select')[1], 'Shield');
assert.deepEqual(JSON.parse(JSON.stringify(fact)), { kind: 'presciencePeek', field: 'weapon', name: 'Shield' });
change(render().filter(element => element.type === 'select')[0], 'dial');
const input = render().find(element => element.type === 'input'); assert.ok(input);
assert.equal(input.props.step, 0.5);
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber: 2.5 } });
assert.deepEqual(JSON.parse(JSON.stringify(fact)), { kind: 'presciencePeek', field: 'dial', value: 2.5 });
assert.equal(knowledge.knowledgeFactInputError(fact), null);
});
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { createGame, newPlayer, type Game } from '../game/engine';
import { truthFactAnswer } from '../game/truthtrance';
import {
knowledgeFactAnswer, knowledgeFactText, parseKnowledgeFact, truthKnowledgeOf,
PRESCIENCE_LEADER_NAMES,
} from '../game/truthtrance-knowledge';
function fixture() {
const game = createGame('PRESTEST', newPlayer('a', 'Atreides', 'atreides'));
const target = newPlayer('e', 'Emperor', 'emperor');
const outsider = newPlayer('g', 'Guild', 'guild');
game.players.push(target, outsider);
game.phase = 6;
const shield = baseDeck().find(card => card.name === 'Shield')!;
target.hand.push(shield);
game.battle = {
territory: 'arrakeen', attacker: 'a', defender: 'e', plans: {}, revealed: false,
traitorCalls: {}, prescience: { player: 'a', field: 'weapon', value: shield.id },
} as NonNullable<Game['battle']>;
return { game, owner: game.players[0], target, outsider, shield };
}
void test('Prescience questions validate public faces and normalize their input', () => {
assert.ok(PRESCIENCE_LEADER_NAMES.includes('Hasimir Fenring'));
assert.deepEqual(parseKnowledgeFact({ kind: 'presciencePeek', field: 'leader', name: 'Hasimir Fenring', extra: true }),
{ kind: 'presciencePeek', field: 'leader', name: 'Hasimir Fenring' });
assert.deepEqual(parseKnowledgeFact({ kind: 'presciencePeek', field: 'dial', value: 2.5, extra: true }),
{ kind: 'presciencePeek', field: 'dial', value: 2.5 });
for (const fact of [
{ kind: 'presciencePeek', field: 'leader', name: 'secret' },
{ kind: 'presciencePeek', field: 'weapon', name: 'secret' },
{ kind: 'presciencePeek', field: 'dial', value: -1 },
{ kind: 'presciencePeek', field: 'dial', value: Infinity },
{ kind: 'presciencePeek', field: 'anything', name: 'None' },
]) assert.throws(() => parseKnowledgeFact(fact));
assert.match(knowledgeFactText({ kind: 'presciencePeek', field: 'dial', value: 2.5 }), /dial.*2.5/);
});
void test('only an entitled viewer answers from the answered live battle element', () => {
const { game, owner, target, outsider, shield } = fixture();
const fact = { kind: 'presciencePeek', field: 'weapon', name: shield.name } as const;
const different = { kind: 'presciencePeek', field: 'weapon', name: 'None' } as const;
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(game, owner)), 'yes');
assert.equal(knowledgeFactAnswer(different, truthKnowledgeOf(game, owner)), 'no');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(game, target)), 'yes');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(game, outsider)), 'unknown');
assert.equal(knowledgeFactAnswer({ kind: 'presciencePeek', field: 'defense', name: shield.name }, truthKnowledgeOf(game, owner)), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, phase: 7 }, owner)), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, battle: null }, owner)), 'unknown');
const pending = { ...game.battle!, prescience: { player: 'a', field: 'weapon' as const } };
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, battle: pending }, owner)), 'unknown');
const canceled = { ...game.battle!, prescience: undefined };
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, battle: canceled }, owner)), 'unknown');
});
void test('dial, leader, absent card and suppressed Ecaz card follow visible battle insight', () => {
const { game, owner, target } = fixture();
game.battle!.prescience = { player: 'a', field: 'dial', value: 2.5 };
assert.equal(knowledgeFactAnswer({ kind: 'presciencePeek', field: 'dial', value: 2.5 }, truthKnowledgeOf(game, owner)), 'yes');
game.battle!.prescience = { player: 'a', field: 'leader', value: target.leaders[0].id };
assert.equal(knowledgeFactAnswer({ kind: 'presciencePeek', field: 'leader', name: target.leaders[0].name }, truthKnowledgeOf(game, owner)), 'yes');
game.battle!.prescience = { player: 'a', field: 'defense', value: null };
assert.equal(knowledgeFactAnswer({ kind: 'presciencePeek', field: 'defense', name: 'None' }, truthKnowledgeOf(game, owner)), 'yes');
const harass = ecazTreacheryCards().find(card => card.effect === 'harassWithdraw')!;
target.hand.push(harass);
game.battle!.prescience = { player: 'a', field: 'defense', value: harass.id };
const knowledge = truthKnowledgeOf(game, owner);
assert.equal(knowledgeFactAnswer({ kind: 'presciencePeek', field: 'defense', name: 'None' }, knowledge), 'yes');
assert.equal(knowledgeFactAnswer({ kind: 'presciencePeek', field: 'defense', name: harass.name }, knowledge), 'no');
assert.equal(truthFactAnswer(owner, { kind: 'and', terms: [
{ kind: 'presciencePeek', field: 'defense', name: 'None' }, { kind: 'spice', compare: 'gte', value: 0 },
] }, knowledge), 'yes');
});
void test('non-entitled players never inspect the concealed answer', () => {
const { game, outsider } = fixture();
game.battle!.prescience = {
player: 'a', field: 'weapon',
get value(): never { throw new Error('Unentitled prescience access'); },
};
assert.equal(truthKnowledgeOf(game, outsider).presciencePeek, null);
});
@@ -0,0 +1,77 @@
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 type { KnowledgeFact } from '../game/truthtrance-knowledge';
import {
actTruthTurnPower, advanceTruthTurnPower, createTruthTurnPowerFixture,
declareTruthTurnPower, finishTruthTurnPowerBattle, truthTurnPowerInventory,
truthTurnPowerPlayer,
} from './fixture-truth-turn-power';
/** Reach the original Basic battle through real setup, bidding and shipment,
* then actually declare and resolve the selected Prescience power. The source
* fixture selects unopened deck order but does not stage hands or combatants. */
function inspected(field: 'leader' | 'weapon' | 'defense' | 'dial') {
const fixture = createTruthTurnPowerFixture({ power: 'prescience' });
let game = actTruthTurnPower(fixture.game, fixture.atreides, { type: 'prescience', field });
game = advanceTruthTurnPower(game, state => !state.response &&
state.battle?.preparation?.kind === 'prescienceAnswer', [], true);
const respondent = game.battle!.preparation!.owner;
const subject = truthTurnPowerPlayer(game, respondent);
const leader = subject.leaders.find(item => !item.dead && !item.usedAt);
const weapon = subject.hand.find(item => item.kind === 'projectile');
assert.ok(leader); assert.ok(weapon);
const value = field === 'leader' ? leader.id : field === 'weapon' ? weapon.id :
field === 'defense' ? null : 0;
const name = field === 'leader' ? leader.name : field === 'weapon' ? weapon.name : 'None';
game = actTruthTurnPower(game, respondent, { type: 'prescienceAnswer', value });
assert.equal(game.battle?.prescience?.player, fixture.atreides);
assert.equal(game.battle?.prescience?.value, value);
return { fixture, game, respondent, name };
}
function ask(fixture: ReturnType<typeof createTruthTurnPowerFixture>, source: Game, fact: KnowledgeFact) {
const declared = declareTruthTurnPower(fixture, source).game;
return actTruthTurnPower(declared, fixture.asker, { type: 'truthAsk',
question: { kind: 'fact', target: fixture.atreides, fact } });
}
for (const field of ['leader', 'weapon', 'defense', 'dial'] as const) {
void test(`Basic native ${field} Prescience supports an actual truthful question and battle continuation`, () => {
const { fixture, game: source, name } = inspected(field);
const fact: KnowledgeFact = field === 'dial'
? { kind: 'presciencePeek', field, value: 0 }
: { kind: 'presciencePeek', field, name };
const asked = ask(fixture, source, fact);
assert.equal(viewGame(asked, fixture.atreides).truthAnswer, 'yes');
assert.equal(viewGame(asked, fixture.asker).truthAnswer, null);
assert.equal(viewGame(asked, fixture.asker).battle?.insight, null);
assert.throws(() => applyAction(asked, fixture.atreides, { type: 'truthAnswer', answer: 'no' }));
for (const difficulty of DIFFICULTIES) {
const view = viewGame(asked, fixture.atreides);
view.players.find(player => player.id === fixture.atreides)!.bot = difficulty;
const actions = botActions(view);
assert.deepEqual(actions, [{ type: 'truthAnswer', answer: 'yes' }]);
const done = applyAction(asked, fixture.atreides, actions[0]);
assert.equal(done.truthHistory?.at(-1)?.answer, 'yes');
assert.equal(done.truthtrance, null);
assert.deepEqual(truthTurnPowerInventory(done), fixture.originalCards);
}
const answered = applyAction(asked, fixture.atreides, { type: 'truthAnswer', answer: 'yes' });
const settled = finishTruthTurnPowerBattle(answered, fixture);
assert.notEqual(settled.battle?.event, fixture.event);
assert.deepEqual(truthTurnPowerInventory(settled), fixture.originalCards);
});
}
void test('a different, uninspected element remains unknown in a real battle', () => {
const { fixture, game } = inspected('dial');
const asked = ask(fixture, game, { kind: 'presciencePeek', field: 'weapon', name: 'None' });
assert.equal(viewGame(asked, fixture.atreides).truthAnswer, 'unknown');
assert.throws(() => applyAction(asked, fixture.atreides, { type: 'truthAnswer', answer: 'yes' }));
const answered = applyAction(asked, fixture.atreides, { type: 'truthAnswer', answer: 'unknown' });
assert.equal(answered.truthtrance?.stage, 'unknown');
assert.deepEqual(truthTurnPowerInventory(answered), fixture.originalCards);
});