Support Truthtrance questions about battle prescience
This commit is contained in:
1 parent
7d26929abd
commit
ee3e1a0ab2
10 files changed
+395
-11
No files matched your search
+4
-3
@@ -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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user