Support Truthtrance on Atreides auction inspection

This commit is contained in:
Quintine committed 2026-10-27 23:57:01 +11:00
1 parent 235c2eb03f
commit aa6f77372f
11 files changed
+228 -11

No files matched your search

+3 -1
View File
@@ -66,6 +66,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
stormDial: { kind: 'stormDial', compare: 'gte', value: 0 }, stormDial: { kind: 'stormDial', compare: 'gte', value: 0 },
stormForecast: { kind: 'stormForecast', compare: 'gte', value: 1 }, stormForecast: { kind: 'stormForecast', compare: 'gte', value: 1 },
spicePeek: { kind: 'spicePeek', value: 'worm' }, spicePeek: { kind: 'spicePeek', value: 'worm' },
auctionPeek: { kind: 'auctionPeek', name: 'Shield' },
spice: { kind: 'spice', compare: 'gte', value: 6 }, spice: { kind: 'spice', compare: 'gte', value: 6 },
forceCount: { kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 6 }, forceCount: { kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 6 },
noFieldValue: { kind: 'noFieldValue', value: 3 }, noFieldValue: { kind: 'noFieldValue', value: 3 },
@@ -85,6 +86,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
<option value="stormDial">Known storm dial</option> <option value="stormDial">Known storm dial</option>
<option value="stormForecast">Known storm forecast</option> <option value="stormForecast">Known storm forecast</option>
<option value="spicePeek">Known next Spice Blow card</option> <option value="spicePeek">Known next Spice Blow card</option>
<option value="auctionPeek">Inspected ordinary auction card</option>
<option value="spice">Current personal spice</option> <option value="spice">Current personal spice</option>
<option value="forceCount">Current physical forces</option> <option value="forceCount">Current physical forces</option>
<option value="noFieldValue">Current concealed No-Field printed value</option> <option value="noFieldValue">Current concealed No-Field printed value</option>
@@ -100,7 +102,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
<FaceDancerFactFields value={fact} leaders={game.allLeaders} onChange={onChange} /> : fact.kind === 'noFieldValue' ? <FaceDancerFactFields value={fact} leaders={game.allLeaders} onChange={onChange} /> : fact.kind === 'noFieldValue' ?
<NoFieldValueFields value={fact} onChange={onChange} /> : fact.kind === 'forceCount' ? <NoFieldValueFields value={fact} onChange={onChange} /> : fact.kind === 'forceCount' ?
<ForceCountFields value={fact} game={game} player={game.players.find(player => player.id === target)!} onChange={onChange} /> : <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 === 'prediction' || fact.kind === 'stormDial' || fact.kind === 'stormForecast' || fact.kind === 'spicePeek' || fact.kind === 'auctionPeek' ?
<KnowledgeFactFields value={fact} onChange={onChange} /> : fact.kind === 'spice' ? <> <KnowledgeFactFields value={fact} onChange={onChange} /> : fact.kind === 'spice' ? <>
<label> <label>
Compare current personal spice Compare current personal spice
+14 -3
View File
@@ -4,11 +4,12 @@ import { useId } from 'react';
import { FACTIONS } from '@/game/catalog'; import { FACTIONS } from '@/game/catalog';
import { territory } from '@/game/board'; import { territory } from '@/game/board';
import { knowledgeFactInputError, SPICE_PEEK_VALUES, type KnowledgeFact } from '@/game/truthtrance-knowledge'; import { knowledgeFactInputError, SPICE_PEEK_VALUES, type KnowledgeFact } from '@/game/truthtrance-knowledge';
import { TRUTH_CARD_NAMES } from '@/game/truthtrance-card-names';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
type NumericKnowledgeFact = Exclude<KnowledgeFact, { kind: 'prediction'; field: 'faction' } | { kind: 'spicePeek' }>; type NumericKnowledgeFact = Exclude<KnowledgeFact, { kind: 'prediction'; field: 'faction' } | { kind: 'spicePeek' } | { kind: 'auctionPeek' }>;
function isNumericKnowledgeFact(fact: KnowledgeFact): fact is NumericKnowledgeFact { function isNumericKnowledgeFact(fact: KnowledgeFact): fact is NumericKnowledgeFact {
return fact.kind !== 'spicePeek' && (fact.kind !== 'prediction' || fact.field === 'turn'); return fact.kind !== 'spicePeek' && fact.kind !== 'auctionPeek' && (fact.kind !== 'prediction' || fact.field === 'turn');
} }
export function KnowledgeFactFields({ export function KnowledgeFactFields({
@@ -27,7 +28,17 @@ export function KnowledgeFactFields({
const invalid = knowledgeFactInputError(value); const invalid = knowledgeFactInputError(value);
return ( return (
<> <>
{value.kind === 'spicePeek' ? ( {value.kind === 'auctionPeek' ? (
<>
<label>
Inspected auction card
<select value={value.name} onChange={(event) => onChange({ kind: 'auctionPeek', name: event.target.value })}>
{TRUTH_CARD_NAMES.map((name) => <option key={name} value={name}>{name}</option>)}
</select>
</label>
<p className="fine">This asks about the current ordinary auction card the target has privately inspected. If no current card is known, the answer is I don’t know. Only the answer is published.</p>
</>
) : value.kind === 'spicePeek' ? (
<> <>
<label> <label>
Next Spice Blow card Next Spice Blow card
+9
View File
@@ -16,6 +16,15 @@ open mode gates.
## Current checkpoint and work ## Current checkpoint and work
**Truthtrance and Atreides ordinary auction knowledge — 27 October. Prototyped /
Partial.** A structured fact compares the current successfully inspected
ordinary auction card with a public printed name. Single/compound controls,
private answers and existing legal bots are connected. Eight related unit files
pass 32 cases, including genuine Basic/Advanced auction progression; an
unavailable inspection is staged separately. Richese, Ixian and non-Atreides
Homeworld inspection, general Truthtrance and deployed acceptance remain open.
[Scope](TRUTHTRANCE_KNOWLEDGE.md#atreides-ordinary-auction-knowledge--27-october-2026).
**Truthtrance and Atreides Spice Blow knowledge — 27 October. Prototyped / **Truthtrance and Atreides Spice Blow knowledge — 27 October. Prototyped /
Partial.** A structured question can compare the currently known next Spice Partial.** A structured question can compare the currently known next Spice
Card with a printed location, sandworm or Sandtrout. Only Atreides' active, Card with a printed location, sandworm or Sandtrout. Only Atreides' active,
+11
View File
@@ -1,5 +1,16 @@
# Dune implementation status # Dune implementation status
## 27 October 2026 — Truthtrance and Atreides ordinary auction knowledge
A single or compound structured question can compare a named Treachery Card
with Atreides' current successfully inspected ordinary lot. Canceled or missing
inspections answer unknown; special auctions and non-Atreides Homeworld
entitlement remain separate. Eight related unit files pass 32 cases, including
native Basic/Advanced auction progression, the question and all four legal bot
answer paths. The unavailable-inspection case is staged. General freeform
enforcement, broader interactions and deployed acceptance remain open.
[Scope](TRUTHTRANCE_KNOWLEDGE.md#atreides-ordinary-auction-knowledge--27-october-2026).
## 27 October 2026 — Truthtrance and Atreides Spice Blow knowledge ## 27 October 2026 — Truthtrance and Atreides Spice Blow knowledge
A single or compound structured question may ask whether the Spice Card A single or compound structured question may ask whether the Spice Card
+9
View File
@@ -1,5 +1,14 @@
# Rule decision index # Rule decision index
## Truthtrance and Atreides ordinary auction knowledge — 27 October 2026
The publisher FAQ permits game-related yes/no and compound questions. A named
ordinary auction card can now be compared with Atreides' successful current
inspection. Unknown, canceled and unavailable inspections do not reveal a
card; special-auction and non-Atreides Homeworld entitlements remain separate.
This is a bounded software interpretation, not a printed restriction.
[Scope](TRUTHTRANCE_KNOWLEDGE.md#atreides-ordinary-auction-knowledge--27-october-2026).
## Truthtrance and Atreides Spice Blow knowledge — 27 October 2026 ## Truthtrance and Atreides Spice Blow knowledge — 27 October 2026
The publisher FAQ permits game-related yes/no and compound questions. The The publisher FAQ permits game-related yes/no and compound questions. The
+20
View File
@@ -1,5 +1,25 @@
# Truthtrance: recorded predictions and storm knowledge # Truthtrance: recorded predictions and storm knowledge
## Atreides ordinary auction knowledge — 27 October 2026
**Prototyped / Partial.** The structured fact `{kind:'auctionPeek', name}` asks
whether Atreides' currently inspected ordinary auction card has the named
printed face. The answer uses only the current lot's successful native
inspection. Other factions, canceled or expired inspections and missing lots
return unknown. Active Ixian and Richese special auctions and non-Atreides
occupied Homeworld inspection are separate and remain unfinished. The card identity is
not added to another seat's view; the selected answer is public through the
normal Truthtrance flow. Single and compound selectors use the public printed
name registry, and existing bots answer from their own projected scalar.
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 selector and its
software boundary are a bounded interpretation, not a printed restriction.
Native Basic/Advanced auction progression exercises an actual card inspection,
question, answer and all four minimal AI profiles; the unavailable-inspection
case is staged. Eight related unit files pass 32 cases. Broader question
language and special-auction interactions remain open.
## Atreides Spice Blow knowledge — 27 October 2026 ## Atreides Spice Blow knowledge — 27 October 2026
**Prototyped / Partial.** The structured fact `{kind:'spicePeek', value}` asks **Prototyped / Partial.** The structured fact `{kind:'spicePeek', value}` asks
+5 -4
View File
@@ -3246,10 +3246,11 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards', category: 'Cards',
coverage: 'Partial', coverage: 'Partial',
developmentStage: 'Prototyped', developmentStage: 'Prototyped',
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.', summary: 'Ask about a recorded prediction, submitted storm dial, Fremen forecast or Atreides Spice Blow or auction peek, with a single fact or an AND/OR combination.',
steps: [ 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.', '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 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.',
'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.', '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.', '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.', '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.',
@@ -3258,11 +3259,11 @@ export const RULE_TOPICS: RuleTopic[] = [
], ],
related: ['card-truthtrance', 'truthtrance-spice', 'privacy'], related: ['card-truthtrance', 'truthtrance-spice', 'privacy'],
checklist: [ checklist: [
{ 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: 'Implementation', status: 'Partial', detail: 'Stored prediction, entitled storm knowledge, native Atreides Spice and ordinary auction peeks 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: '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: 'Player controls', status: 'Partial', detail: 'Six 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: '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: '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 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'] }, { area: 'Verification', status: 'Partial', detail: 'Focused rules, UI, native Atreides peeks 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-auction-peek-runtime.test.ts', 'tests/truthtrance-knowledge-recovery.test.ts'] },
], ],
}, },
{ {
+19 -2
View File
@@ -1,6 +1,7 @@
import { FACTIONS, type FactionId } from './catalog'; import { FACTIONS, type FactionId } from './catalog';
import { SPICE_CARDS } from './cards'; import { SPICE_CARDS } from './cards';
import { territory } from './board'; import { territory } from './board';
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
import type { Game, Player } from './engine'; import type { Game, Player } from './engine';
type Comparison = 'eq' | 'gte' | 'lte'; type Comparison = 'eq' | 'gte' | 'lte';
@@ -13,6 +14,7 @@ export type KnowledgeFact =
| { kind: 'prediction'; field: 'faction'; faction: FactionId } | { kind: 'prediction'; field: 'faction'; faction: FactionId }
| { kind: 'prediction'; field: 'turn'; compare: Comparison; value: number } | { kind: 'prediction'; field: 'turn'; compare: Comparison; value: number }
| { kind: 'spicePeek'; value: string } | { kind: 'spicePeek'; value: string }
| { kind: 'auctionPeek'; name: string }
| { kind: 'stormDial' | 'stormForecast'; compare: Comparison; value: number }; | { kind: 'stormDial' | 'stormForecast'; compare: Comparison; value: number };
/** Only values already chosen or actually known by this respondent. */ /** Only values already chosen or actually known by this respondent. */
@@ -21,16 +23,20 @@ export type TruthKnowledge = {
stormDial: number | null; stormDial: number | null;
stormForecast: number | null; stormForecast: number | null;
spicePeek: string | null; spicePeek: string | null;
auctionPeek: string | null;
}; };
export function isKnowledgeFact(value: unknown): value is KnowledgeFact { export function isKnowledgeFact(value: unknown): value is KnowledgeFact {
if (!value || typeof value !== 'object') return false; if (!value || typeof value !== 'object') return false;
const kind = (value as { kind?: unknown }).kind; const kind = (value as { kind?: unknown }).kind;
return kind === 'prediction' || kind === 'stormDial' || kind === 'stormForecast' || kind === 'spicePeek'; return kind === 'prediction' || kind === 'stormDial' || kind === 'stormForecast' || kind === 'spicePeek' || kind === 'auctionPeek';
} }
export function knowledgeFactInputError(value: unknown): string | null { export function knowledgeFactInputError(value: unknown): string | null {
if (!isKnowledgeFact(value)) return 'Choose a stored prediction, storm dial or known forecast.'; if (!isKnowledgeFact(value)) return 'Choose a stored prediction, storm dial or known forecast.';
if (value.kind === 'auctionPeek')
return typeof value.name === 'string' && TRUTH_CARD_NAMES.includes(value.name)
? null : 'Choose a known Treachery Card name.';
if (value.kind === 'spicePeek') if (value.kind === 'spicePeek')
return typeof value.value === 'string' && SPICE_PEEK_VALUES.includes(value.value) return typeof value.value === 'string' && SPICE_PEEK_VALUES.includes(value.value)
? null : 'Choose a printed Spice Blow territory, a sandworm, or Sandtrout.'; ? null : 'Choose a printed Spice Blow territory, a sandworm, or Sandtrout.';
@@ -51,6 +57,7 @@ export function knowledgeFactInputError(value: unknown): string | null {
export function parseKnowledgeFact(value: unknown): KnowledgeFact { export function parseKnowledgeFact(value: unknown): KnowledgeFact {
const error = knowledgeFactInputError(value); const error = knowledgeFactInputError(value);
if (error || !isKnowledgeFact(value)) throw new Error(error ?? 'Choose a valid private-knowledge question.'); if (error || !isKnowledgeFact(value)) throw new Error(error ?? 'Choose a valid private-knowledge question.');
if (value.kind === 'auctionPeek') return { kind: value.kind, name: value.name };
if (value.kind === 'spicePeek') return { kind: value.kind, value: value.value }; if (value.kind === 'spicePeek') return { kind: value.kind, value: value.value };
if (value.kind === 'prediction' && value.field === 'faction') if (value.kind === 'prediction' && value.field === 'faction')
return { kind: value.kind, field: value.field, faction: value.faction }; return { kind: value.kind, field: value.field, faction: value.faction };
@@ -60,12 +67,16 @@ export function parseKnowledgeFact(value: unknown): KnowledgeFact {
} }
export function truthKnowledgeOf( export function truthKnowledgeOf(
g: Pick<Game, 'phase' | 'turn' | 'advanced' | 'stormDials' | 'stormDialers' | 'stormCard' | 'stormCardKnown' | 'spicePeekKnown' | 'spiceDeck'>, g: Pick<Game, 'phase' | 'turn' | 'advanced' | 'stormDials' | 'stormDialers' | 'stormCard' | 'stormCardKnown' | 'spicePeekKnown' | 'spiceDeck' | 'auction' | 'richeseAuction' | 'ixAuction'>,
p: Pick<Player, 'id' | 'faction' | 'prediction'>, p: Pick<Player, 'id' | 'faction' | 'prediction'>,
): TruthKnowledge { ): TruthKnowledge {
const dial = g.stormDials[p.id]; const dial = g.stormDials[p.id];
const peek = g.phase === 5 && p.faction === 'atreides' && g.spicePeekKnown const peek = g.phase === 5 && p.faction === 'atreides' && g.spicePeekKnown
? g.spiceDeck[0] : undefined; ? g.spiceDeck[0] : undefined;
const lot = g.phase === 3 && p.faction === 'atreides' && !g.richeseAuction && !g.ixAuction ? g.auction : null;
// The private lot is read only after the same inspection entitlement used by
// the ordinary auction view has resolved successfully for this faction.
const lotName = lot?.peekKnown ? lot.cards[lot.index]?.name : undefined;
const peekFace = peek && 'worm' in peek && peek.worm === true ? 'worm' const peekFace = peek && 'worm' in peek && peek.worm === true ? 'worm'
: peek && 'sandtrout' in peek && peek.sandtrout === true ? 'sandtrout' : peek && 'sandtrout' in peek && peek.sandtrout === true ? 'sandtrout'
: peek && 'territory' in peek && typeof peek.territory === 'string' && SPICE_PEEK_VALUES.includes(peek.territory) : peek && 'territory' in peek && typeof peek.territory === 'string' && SPICE_PEEK_VALUES.includes(peek.territory)
@@ -80,6 +91,7 @@ export function truthKnowledgeOf(
Number.isSafeInteger(g.stormCard) && g.stormCard! >= 1 && g.stormCard! <= 6 Number.isSafeInteger(g.stormCard) && g.stormCard! >= 1 && g.stormCard! <= 6
? g.stormCard : null, ? g.stormCard : null,
spicePeek: peekFace, spicePeek: peekFace,
auctionPeek: typeof lotName === 'string' && lotName.length > 0 ? lotName : null,
}; };
} }
@@ -90,6 +102,9 @@ export function knowledgeFactAnswer(
const error = knowledgeFactInputError(fact); const error = knowledgeFactInputError(fact);
if (error) throw new Error(error); if (error) throw new Error(error);
if (!knowledge) return 'unknown'; if (!knowledge) return 'unknown';
if (fact.kind === 'auctionPeek')
return knowledge.auctionPeek == null ? 'unknown'
: knowledge.auctionPeek === fact.name ? 'yes' : 'no';
if (fact.kind === 'spicePeek') if (fact.kind === 'spicePeek')
return knowledge.spicePeek === null || knowledge.spicePeek === undefined ? 'unknown' return knowledge.spicePeek === null || knowledge.spicePeek === undefined ? 'unknown'
: knowledge.spicePeek === fact.value ? 'yes' : 'no'; : knowledge.spicePeek === fact.value ? 'yes' : 'no';
@@ -104,6 +119,8 @@ export function knowledgeFactAnswer(
} }
export function knowledgeFactText(fact: KnowledgeFact): string { export function knowledgeFactText(fact: KnowledgeFact): string {
if (fact.kind === 'auctionPeek')
return `the current auction card you inspected as Atreides is ${fact.name}`;
if (fact.kind === 'spicePeek') { if (fact.kind === 'spicePeek') {
const face = fact.value === 'worm' ? 'a sandworm' : fact.value === 'sandtrout' ? 'Sandtrout' const face = fact.value === 'worm' ? 'a sandworm' : fact.value === 'sandtrout' ? 'Sandtrout'
: `a Spice Blow at ${territory(fact.value).name}`; : `a Spice Blow at ${territory(fact.value).name}`;
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { createGame, newPlayer, type Auction } from '../game/engine';
import { truthFactAnswer } from '../game/truthtrance';
import { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names';
import { knowledgeFactAnswer, knowledgeFactText, parseKnowledgeFact, truthKnowledgeOf } from '../game/truthtrance-knowledge';
void test('auction knowledge accepts canonical Treachery Card names and normalizes inputs', () => {
for (const name of TRUTH_CARD_NAMES)
assert.deepEqual(parseKnowledgeFact({ kind: 'auctionPeek', name, extra: true }), { kind: 'auctionPeek', name });
for (const name of ['', 'a hidden card', null, 7, {}, ['Shield']])
assert.throws(() => parseKnowledgeFact({ kind: 'auctionPeek', name }));
assert.match(knowledgeFactText({ kind: 'auctionPeek', name: 'Shield' }), /auction card.*Shield/);
});
void test('only Atreides with an active successful ordinary-lot inspection can answer', () => {
const game = createGame('LOTTEST', newPlayer('a', 'Atreides', 'atreides'));
const owner = game.players[0];
const other = newPlayer('b', 'Other', 'guild');
const cards = baseDeck();
const shield = cards.find(card => card.name === 'Shield')!;
const otherCard = cards.find(card => card.name !== 'Shield')!;
const auction: Auction = { cards: [shield, otherCard], index: 0, bid: 0, bidder: null,
active: 'a', passed: [], opener: 0, peekKnown: true };
game.phase = 3;
game.auction = auction;
const fact = { kind: 'auctionPeek', name: 'Shield' } as const;
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(game, owner)), 'yes');
assert.equal(knowledgeFactAnswer({ kind: 'auctionPeek', name: otherCard.name }, truthKnowledgeOf(game, owner)), 'no');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(game, other)), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, phase: 4 }, owner)), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, auction: null }, owner)), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, auction: { ...auction, peekKnown: false } }, owner)), 'unknown');
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf({ ...game, auction: { ...auction, index: 8 } }, owner)), 'unknown');
assert.equal(knowledgeFactAnswer(fact), 'unknown');
const next = { ...game, auction: { ...auction, index: 1 } };
assert.equal(knowledgeFactAnswer(fact, truthKnowledgeOf(next, owner)), 'no');
assert.equal(knowledgeFactAnswer({ kind: 'auctionPeek', name: otherCard.name }, truthKnowledgeOf(next, owner)), 'yes');
});
void test('non-entitled callers cannot read private lot cards; compound questions expose only the aggregate', () => {
const game = createGame('LOTTEST', newPlayer('a', 'Atreides', 'atreides'));
const owner = game.players[0];
const other = newPlayer('b', 'Other', 'guild');
game.phase = 3;
const shield = baseDeck().find(card => card.name === 'Shield')!;
const lot: Auction = { cards: [shield], index: 0, bid: 0, bidder: null,
active: 'a', passed: [], opener: 0, peekKnown: true };
game.auction = lot;
const hidden = { ...game, get auction(): never { throw new Error('Unentitled auction access'); } };
assert.equal(truthKnowledgeOf(hidden, other).auctionPeek, null);
hidden.phase = 4;
assert.equal(truthKnowledgeOf(hidden, owner).auctionPeek, null);
const uninspected = { ...lot, peekKnown: false, get cards(): never { throw new Error('Uninspected card access'); } };
assert.equal(truthKnowledgeOf({ ...game, auction: uninspected }, owner).auctionPeek, null);
const knowledge = truthKnowledgeOf(game, owner);
const fact = { kind: 'auctionPeek', name: 'Shield' } as const;
assert.equal(truthFactAnswer(owner, { kind: 'and', terms: [fact, { kind: 'spice', compare: 'gte', value: 0 }] }, knowledge), 'yes');
assert.equal(truthFactAnswer(owner, { kind: 'or', terms: [fact, { kind: 'spice', compare: 'gte', value: 999 }] }, knowledge), 'yes');
});
@@ -0,0 +1,60 @@
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';
/** Real setup and ordinary auction progression. The fixture stages only the
* holder's acquisition of the two physical Truthtrance cards. */
function withInspection(advanced = false) {
let g = knowledgeGame(advanced);
for (let n = 0; n < 200 && !(g.phase === 3 && g.auction?.peekKnown && !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 auction progression needs a legal decision in phase ${g.phase}`);
g = applyAction(g, candidate.id, candidate.action);
}
assert.equal(g.phase, 3);
assert.equal(g.auction?.peekKnown, true);
assert.ok(g.auction?.cards[g.auction.index]);
return g;
}
void test('actual ordinary auction inspection supplies a private Truthtrance answer for all AI profiles', () => {
for (const advanced of [false, true]) {
const initial = withInspection(advanced);
const card = initial.auction!.cards[initial.auction!.index];
const g = askKnowledge(initial, 'o', { kind: 'auctionPeek', name: card.name });
assert.equal(viewGame(g, 'o').truthAnswer, 'yes');
assert.equal(viewGame(g, 'o').auction?.card?.id, card.id);
for (const id of ['a', 'b', 'f']) {
assert.equal(viewGame(g, id).truthAnswer, null);
assert.equal(viewGame(g, id).auction?.card, 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.equal(done.auction!.cards[done.auction!.index].id, card.id);
assert.deepEqual(knowledgePhysical(done), knowledgePhysical(initial));
}
}
});
void test('an unavailable auction inspection yields unknown and retains the physical card', () => {
const initial = withInspection();
initial.auction!.peekKnown = false;
const name = initial.auction!.cards[initial.auction!.index].name;
const g = askKnowledge(initial, 'o', { kind: 'auctionPeek', name });
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));
});
+17 -1
View File
@@ -9,7 +9,7 @@ import { knowledgeFactInputError } from '../game/truthtrance-knowledge';
import { createGame, joinGame, newPlayer, viewGame } from '../game/engine'; import { createGame, joinGame, newPlayer, viewGame } from '../game/engine';
import type { TruthFact } from '../game/truthtrance'; import type { TruthFact } from '../game/truthtrance';
function markup(value: Extract<TruthFact, { kind: 'prediction' | 'stormDial' | 'stormForecast' | 'spicePeek' }>) { function markup(value: Extract<TruthFact, { kind: 'prediction' | 'stormDial' | 'stormForecast' | 'spicePeek' | 'auctionPeek' }>) {
return renderToStaticMarkup(createElement(KnowledgeFactFields, { value, onChange() {} })); return renderToStaticMarkup(createElement(KnowledgeFactFields, { value, onChange() {} }));
} }
@@ -36,6 +36,22 @@ void test('knowledge controls expose full public faction registry and bounded st
assert.match(forecast, /If no such value is available/); assert.match(forecast, /If no such value is available/);
}); });
void test('auction peek selector is available in single and compound current-fact questions', () => {
const game = createGame('AUCTIONCONTROLS', newPlayer('a', 'Asker', 'emperor'));
joinGame(game, newPlayer('b', 'Target', 'atreides'));
const view = viewGame(game, 'a');
const fact: TruthFact = { kind: 'auctionPeek', name: 'Shield' };
const single = renderToStaticMarkup(createElement(TruthFactFields, { value: fact, game: view, target: 'b', onChange() {} }));
assert.match(single, /value="auctionPeek" selected/);
assert.match(single, /value="Shield" selected/);
assert.match(single, /current ordinary auction card/);
assert.match(single, /If no current card is known/);
const compound: TruthFact = { kind: 'or', 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, /Inspected ordinary auction card/);
assert.match(grouped, /value="Shield" selected/);
});
void test('spice peek controls offer public card identities with private-knowledge guidance', () => { void test('spice peek controls offer public card identities with private-knowledge guidance', () => {
const markupText = markup({ kind: 'spicePeek', value: 'worm' }); const markupText = markup({ kind: 'spicePeek', value: 'worm' });
assert.match(markupText, /value="worm"/); assert.match(markupText, /value="worm"/);