Support Truthtrance on Atreides auction inspection
This commit is contained in:
1 parent
235c2eb03f
commit
aa6f77372f
11 files changed
+228
-11
No files matched your search
@@ -66,6 +66,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
|
||||
stormDial: { kind: 'stormDial', compare: 'gte', value: 0 },
|
||||
stormForecast: { kind: 'stormForecast', compare: 'gte', value: 1 },
|
||||
spicePeek: { kind: 'spicePeek', value: 'worm' },
|
||||
auctionPeek: { kind: 'auctionPeek', name: 'Shield' },
|
||||
spice: { kind: 'spice', compare: 'gte', value: 6 },
|
||||
forceCount: { kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 6 },
|
||||
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="stormForecast">Known storm forecast</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="forceCount">Current physical forces</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' ?
|
||||
<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 === 'prediction' || fact.kind === 'stormDial' || fact.kind === 'stormForecast' || fact.kind === 'spicePeek' || fact.kind === 'auctionPeek' ?
|
||||
<KnowledgeFactFields value={fact} onChange={onChange} /> : fact.kind === 'spice' ? <>
|
||||
<label>
|
||||
Compare current personal spice
|
||||
|
||||
@@ -4,11 +4,12 @@ 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 { TRUTH_CARD_NAMES } from '@/game/truthtrance-card-names';
|
||||
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 {
|
||||
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({
|
||||
@@ -27,7 +28,17 @@ export function KnowledgeFactFields({
|
||||
const invalid = knowledgeFactInputError(value);
|
||||
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>
|
||||
Next Spice Blow card
|
||||
|
||||
@@ -16,6 +16,15 @@ open mode gates.
|
||||
|
||||
|
||||
## 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 /
|
||||
Partial.** A structured question can compare the currently known next Spice
|
||||
Card with a printed location, sandworm or Sandtrout. Only Atreides' active,
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# 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
|
||||
|
||||
A single or compound structured question may ask whether the Spice Card
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 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
|
||||
|
||||
The publisher FAQ permits game-related yes/no and compound questions. The
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# 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
|
||||
|
||||
**Prototyped / Partial.** The structured fact `{kind:'spicePeek', value}` asks
|
||||
|
||||
+5
-4
@@ -3246,10 +3246,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
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: [
|
||||
'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.',
|
||||
'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.',
|
||||
@@ -3258,11 +3259,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['card-truthtrance', 'truthtrance-spice', 'privacy'],
|
||||
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: '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: '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: '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: '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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
import { SPICE_CARDS } from './cards';
|
||||
import { territory } from './board';
|
||||
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
|
||||
import type { Game, Player } from './engine';
|
||||
|
||||
type Comparison = 'eq' | 'gte' | 'lte';
|
||||
@@ -13,6 +14,7 @@ export type KnowledgeFact =
|
||||
| { kind: 'prediction'; field: 'faction'; faction: FactionId }
|
||||
| { kind: 'prediction'; field: 'turn'; compare: Comparison; value: number }
|
||||
| { kind: 'spicePeek'; value: string }
|
||||
| { kind: 'auctionPeek'; name: string }
|
||||
| { kind: 'stormDial' | 'stormForecast'; compare: Comparison; value: number };
|
||||
|
||||
/** Only values already chosen or actually known by this respondent. */
|
||||
@@ -21,16 +23,20 @@ export type TruthKnowledge = {
|
||||
stormDial: number | null;
|
||||
stormForecast: number | null;
|
||||
spicePeek: string | null;
|
||||
auctionPeek: 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' || kind === 'spicePeek';
|
||||
return kind === 'prediction' || kind === 'stormDial' || kind === 'stormForecast' || kind === 'spicePeek' || kind === 'auctionPeek';
|
||||
}
|
||||
|
||||
export function knowledgeFactInputError(value: unknown): string | null {
|
||||
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')
|
||||
return typeof value.value === 'string' && SPICE_PEEK_VALUES.includes(value.value)
|
||||
? 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 {
|
||||
const error = knowledgeFactInputError(value);
|
||||
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 === 'prediction' && value.field === 'faction')
|
||||
return { kind: value.kind, field: value.field, faction: value.faction };
|
||||
@@ -60,12 +67,16 @@ export function parseKnowledgeFact(value: unknown): KnowledgeFact {
|
||||
}
|
||||
|
||||
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'>,
|
||||
): TruthKnowledge {
|
||||
const dial = g.stormDials[p.id];
|
||||
const peek = g.phase === 5 && p.faction === 'atreides' && g.spicePeekKnown
|
||||
? 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'
|
||||
: peek && 'sandtrout' in peek && peek.sandtrout === true ? 'sandtrout'
|
||||
: 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
|
||||
? g.stormCard : null,
|
||||
spicePeek: peekFace,
|
||||
auctionPeek: typeof lotName === 'string' && lotName.length > 0 ? lotName : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,6 +102,9 @@ export function knowledgeFactAnswer(
|
||||
const error = knowledgeFactInputError(fact);
|
||||
if (error) throw new Error(error);
|
||||
if (!knowledge) return 'unknown';
|
||||
if (fact.kind === 'auctionPeek')
|
||||
return knowledge.auctionPeek == null ? 'unknown'
|
||||
: knowledge.auctionPeek === fact.name ? 'yes' : 'no';
|
||||
if (fact.kind === 'spicePeek')
|
||||
return knowledge.spicePeek === null || knowledge.spicePeek === undefined ? 'unknown'
|
||||
: knowledge.spicePeek === fact.value ? 'yes' : 'no';
|
||||
@@ -104,6 +119,8 @@ export function knowledgeFactAnswer(
|
||||
}
|
||||
|
||||
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') {
|
||||
const face = fact.value === 'worm' ? 'a sandworm' : fact.value === 'sandtrout' ? 'Sandtrout'
|
||||
: `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));
|
||||
});
|
||||
@@ -9,7 +9,7 @@ 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' | 'spicePeek' }>) {
|
||||
function markup(value: Extract<TruthFact, { kind: 'prediction' | 'stormDial' | 'stormForecast' | 'spicePeek' | 'auctionPeek' }>) {
|
||||
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/);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const markupText = markup({ kind: 'spicePeek', value: 'worm' });
|
||||
assert.match(markupText, /value="worm"/);
|
||||
|
||||
Reference in new issue
Block a user