Support Truthtrance on Atreides spice foresight
This commit is contained in:
1 parent
24255905ed
commit
235c2eb03f
11 files changed
+255
-16
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
Reference in new issue
Block a user