Support Truthtrance on Basic Ixian auction knowledge
This commit is contained in:
1 parent
910e1f9b40
commit
c3f37a574d
11 files changed
+248
-13
No files matched your search
@@ -67,6 +67,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
|
||||
stormForecast: { kind: 'stormForecast', compare: 'gte', value: 1 },
|
||||
spicePeek: { kind: 'spicePeek', value: 'worm' },
|
||||
auctionPeek: { kind: 'auctionPeek', name: 'Shield' },
|
||||
ixPoolPeek: { kind: 'ixPoolPeek', 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 },
|
||||
@@ -87,6 +88,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
|
||||
<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="ixPoolPeek">Recorded Ixian inspected auction pool includes a 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>
|
||||
@@ -102,7 +104,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
|
||||
<FaceDancerFactFields value={fact} leaders={game.allLeaders} onChange={onChange} /> : fact.kind === 'noFieldValue' ?
|
||||
<NoFieldValueFields value={fact} onChange={onChange} /> : fact.kind === 'forceCount' ?
|
||||
<ForceCountFields value={fact} game={game} player={game.players.find(player => player.id === target)!} onChange={onChange} /> :
|
||||
fact.kind === 'prediction' || fact.kind === 'stormDial' || fact.kind === 'stormForecast' || fact.kind === 'spicePeek' || fact.kind === 'auctionPeek' ?
|
||||
fact.kind === 'prediction' || fact.kind === 'stormDial' || fact.kind === 'stormForecast' || fact.kind === 'spicePeek' || fact.kind === 'auctionPeek' || fact.kind === 'ixPoolPeek' ?
|
||||
<KnowledgeFactFields value={fact} onChange={onChange} /> : fact.kind === 'spice' ? <>
|
||||
<label>
|
||||
Compare current personal spice
|
||||
|
||||
@@ -7,9 +7,9 @@ import { knowledgeFactInputError, SPICE_PEEK_VALUES, type KnowledgeFact } from '
|
||||
import { TRUTH_CARD_NAMES } from '@/game/truthtrance-card-names';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
type NumericKnowledgeFact = Exclude<KnowledgeFact, { kind: 'prediction'; field: 'faction' } | { kind: 'spicePeek' } | { kind: 'auctionPeek' }>;
|
||||
type NumericKnowledgeFact = Exclude<KnowledgeFact, { kind: 'prediction'; field: 'faction' } | { kind: 'spicePeek' } | { kind: 'auctionPeek' } | { kind: 'ixPoolPeek' }>;
|
||||
function isNumericKnowledgeFact(fact: KnowledgeFact): fact is NumericKnowledgeFact {
|
||||
return fact.kind !== 'spicePeek' && fact.kind !== 'auctionPeek' && (fact.kind !== 'prediction' || fact.field === 'turn');
|
||||
return fact.kind !== 'spicePeek' && fact.kind !== 'auctionPeek' && fact.kind !== 'ixPoolPeek' && (fact.kind !== 'prediction' || fact.field === 'turn');
|
||||
}
|
||||
|
||||
export function KnowledgeFactFields({
|
||||
@@ -28,7 +28,17 @@ export function KnowledgeFactFields({
|
||||
const invalid = knowledgeFactInputError(value);
|
||||
return (
|
||||
<>
|
||||
{value.kind === 'auctionPeek' ? (
|
||||
{value.kind === 'ixPoolPeek' ? (
|
||||
<>
|
||||
<label>
|
||||
Recorded Ixian inspected auction pool includes
|
||||
<select value={value.name} onChange={(event) => onChange({ kind: 'ixPoolPeek', name: event.target.value })}>
|
||||
{TRUTH_CARD_NAMES.map((name) => <option key={name} value={name}>{name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<p className="fine">In Basic Bidding, this asks whether the named card is in the Ixian’s recorded inspected auction pool. If that knowledge is unavailable, the answer is I don’t know. Only the answer is published; the pool itself remains private.</p>
|
||||
</>
|
||||
) : value.kind === 'auctionPeek' ? (
|
||||
<>
|
||||
<label>
|
||||
Inspected auction card
|
||||
|
||||
@@ -16,6 +16,15 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Basic Truthtrance and the Ixian auction pool — 28 October. Prototyped /
|
||||
Partial.** A named card can be compared with the respondent's recorded inspected
|
||||
group in Basic Bidding; the question does not claim the card remains for sale.
|
||||
Current phase and turn, native Ixian authority and matching controller are
|
||||
required. Unavailable or canceled inspection yields unknown. Eight related unit
|
||||
files pass 27 cases, including genuine native inspection, questions, private
|
||||
answers and all four legal bot profiles. Advanced, borrowed inspection, broader
|
||||
questions and deployed acceptance remain open. [Scope](TRUTHTRANCE_KNOWLEDGE.md#basic-ixian-auction-knowledge--28-october-2026).
|
||||
|
||||
**Human-to-AI faction swaps — 28 October. Prototyped / Partial.** A human may
|
||||
exchange factions with an AI seat, which accepts immediately under a provisional
|
||||
policy. The action resets human readiness and preserves seats, controller mapping,
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 28 October 2026 — Basic Ixian auction-group Truthtrance
|
||||
|
||||
A player may ask whether a named card appears in the Basic Ixian's recorded
|
||||
inspected auction group. Single and compound controls use public card names;
|
||||
only the eligible respondent sees the verified answer before replying. Missing, canceled, stale and
|
||||
unauthorized knowledge returns unknown. Eight focused unit files pass 27 cases,
|
||||
including actual Basic inspection and legal responses across all bot profiles.
|
||||
Advanced, borrowed inspection, broader questions and deployed acceptance remain
|
||||
open. [Scope](TRUTHTRANCE_KNOWLEDGE.md#basic-ixian-auction-knowledge--28-october-2026).
|
||||
|
||||
## 28 October 2026 — Human-to-AI faction swaps
|
||||
|
||||
Humans can exchange with AI faction seats, which automatically accept under a
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Rule decision index
|
||||
|
||||
## Truthtrance and Basic Ixian auction knowledge — 28 October 2026
|
||||
|
||||
The publisher FAQ permits game-related yes/no and compound questions. A named
|
||||
card may be compared with the Basic Ixian's recorded inspected auction group
|
||||
during the same Bidding phase and turn. The group is not an assertion about
|
||||
remaining unsold lots. Missing, canceled or unauthorized inspection yields an
|
||||
unknown answer; Advanced and borrowed Homeworld inspection remain separate.
|
||||
This selector is a bounded software interpretation, not a printed restriction.
|
||||
[Scope](TRUTHTRANCE_KNOWLEDGE.md#basic-ixian-auction-knowledge--28-october-2026).
|
||||
|
||||
## Faction allocation before setup — 28 October 2026
|
||||
|
||||
The supplied revised rulebook, physical p. 4 (printed p. 2), describes random
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# Truthtrance: recorded predictions and storm knowledge
|
||||
|
||||
## Basic Ixian auction knowledge — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** The structured fact `{kind:'ixPoolPeek', name}`
|
||||
asks whether a named printed Treachery Card is in the Basic Ixian's recorded
|
||||
inspected auction group. The group records cards retained after Ixians returned
|
||||
one to the deck; it is not the current unsold lots, and retains cards sold
|
||||
afterward. The current Bidding phase and turn, native Ixian seat and matching
|
||||
recorded controller are required. A canceled, absent, stale or unauthorized
|
||||
inspection yields unknown. Other factions do not receive the group; the target
|
||||
alone sees the verified scalar, and the normal answer publishes the response.
|
||||
|
||||
The [GF9 FAQ p. 8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8)
|
||||
permits game-related yes/no and compound questions. This selector and its
|
||||
boundary are a software interpretation, not a printed restriction. Advanced
|
||||
Technology and borrowed Homeworld inspection are separate and remain open.
|
||||
Eight related unit files pass 27 cases, including a genuine Basic inspection,
|
||||
private questions and all-profile responses. Missing and aged observations are
|
||||
explicitly staged; broader combinations and deployed acceptance remain open.
|
||||
|
||||
## Atreides ordinary auction knowledge — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** The structured fact `{kind:'auctionPeek', name}` asks
|
||||
|
||||
+5
-4
@@ -3268,11 +3268,12 @@ 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 or auction peek, with a single fact or an AND/OR combination.',
|
||||
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.',
|
||||
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.',
|
||||
'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.',
|
||||
@@ -3281,11 +3282,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 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: '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: '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 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'] },
|
||||
{ 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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ export type KnowledgeFact =
|
||||
| { kind: 'prediction'; field: 'turn'; compare: Comparison; value: number }
|
||||
| { kind: 'spicePeek'; value: string }
|
||||
| { kind: 'auctionPeek'; name: string }
|
||||
| { kind: 'ixPoolPeek'; name: string }
|
||||
| { kind: 'stormDial' | 'stormForecast'; compare: Comparison; value: number };
|
||||
|
||||
/** Only values already chosen or actually known by this respondent. */
|
||||
@@ -24,17 +25,19 @@ export type TruthKnowledge = {
|
||||
stormForecast: number | null;
|
||||
spicePeek: string | null;
|
||||
auctionPeek: string | null;
|
||||
/** The authorized respondent's recorded Ixian group, not remaining lots. */
|
||||
ixPoolPeek?: readonly 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' || kind === 'auctionPeek';
|
||||
return kind === 'prediction' || kind === 'stormDial' || kind === 'stormForecast' || kind === 'spicePeek' || kind === 'auctionPeek' || kind === 'ixPoolPeek';
|
||||
}
|
||||
|
||||
export function knowledgeFactInputError(value: unknown): string | null {
|
||||
if (!isKnowledgeFact(value)) return 'Choose a stored prediction, storm dial or known forecast.';
|
||||
if (value.kind === 'auctionPeek')
|
||||
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.';
|
||||
if (value.kind === 'spicePeek')
|
||||
@@ -57,7 +60,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 === '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')
|
||||
return { kind: value.kind, field: value.field, faction: value.faction };
|
||||
@@ -67,7 +70,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'>,
|
||||
g: Pick<Game, 'phase' | 'turn' | 'advanced' | 'stormDials' | 'stormDialers' | 'stormCard' | 'stormCardKnown' | 'spicePeekKnown' | 'spiceDeck' | 'auction' | 'richeseAuction' | 'ixAuction' | 'ixAuctionKnown' | 'homeworldOccupationPreview'>,
|
||||
p: Pick<Player, 'id' | 'faction' | 'prediction'>,
|
||||
): TruthKnowledge {
|
||||
const dial = g.stormDials[p.id];
|
||||
@@ -77,6 +80,14 @@ export function truthKnowledgeOf(
|
||||
// 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;
|
||||
// Basic native Ixians may answer from their own successful inspection. The
|
||||
// record describes a known group, not the currently unsold auction cards.
|
||||
const ixKnown = g.phase === 3 && !g.advanced && p.faction === 'ixians'
|
||||
? g.ixAuctionKnown : null;
|
||||
const ixPool = ixKnown && ixKnown.turn === g.turn &&
|
||||
(ixKnown.controller === undefined || ixKnown.controller === p.id) &&
|
||||
(!g.homeworldOccupationPreview || ixKnown.controller === p.id)
|
||||
? ixKnown.cards.map(card => card.name) : null;
|
||||
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)
|
||||
@@ -92,6 +103,7 @@ export function truthKnowledgeOf(
|
||||
? g.stormCard : null,
|
||||
spicePeek: peekFace,
|
||||
auctionPeek: typeof lotName === 'string' && lotName.length > 0 ? lotName : null,
|
||||
ixPoolPeek: ixPool,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,6 +117,9 @@ export function knowledgeFactAnswer(
|
||||
if (fact.kind === 'auctionPeek')
|
||||
return knowledge.auctionPeek == null ? 'unknown'
|
||||
: knowledge.auctionPeek === fact.name ? 'yes' : 'no';
|
||||
if (fact.kind === 'ixPoolPeek')
|
||||
return knowledge.ixPoolPeek == null ? 'unknown'
|
||||
: knowledge.ixPoolPeek.includes(fact.name) ? 'yes' : 'no';
|
||||
if (fact.kind === 'spicePeek')
|
||||
return knowledge.spicePeek === null || knowledge.spicePeek === undefined ? 'unknown'
|
||||
: knowledge.spicePeek === fact.value ? 'yes' : 'no';
|
||||
@@ -121,6 +136,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 === 'ixPoolPeek')
|
||||
return `your recorded Ixian auction group includes ${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,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { createGame, newPlayer } 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('Ixian pool question accepts only canonical card names and describes the recorded group', () => {
|
||||
for (const name of TRUTH_CARD_NAMES)
|
||||
assert.deepEqual(parseKnowledgeFact({ kind: 'ixPoolPeek', name, extra: true }), { kind: 'ixPoolPeek', name });
|
||||
for (const name of ['', 'a hidden card', null, 7, {}, ['Shield']])
|
||||
assert.throws(() => parseKnowledgeFact({ kind: 'ixPoolPeek', name }));
|
||||
assert.match(knowledgeFactText({ kind: 'ixPoolPeek', name: 'Shield' }), /recorded Ixian auction group includes Shield/);
|
||||
});
|
||||
|
||||
void test('only native Basic Ixians with the current recorded inspection can answer', () => {
|
||||
const game = createGame('IXKNOW', newPlayer('ix', 'Ixians', 'ixians'), false, ['ix']);
|
||||
const ix = game.players[0];
|
||||
const other = newPlayer('other', 'Other', 'atreides');
|
||||
const shield = baseDeck().find(card => card.name === 'Shield')!;
|
||||
const absent = TRUTH_CARD_NAMES.find(name => name !== shield.name)!;
|
||||
const yes = { kind: 'ixPoolPeek', name: shield.name } as const;
|
||||
const no = { kind: 'ixPoolPeek', name: absent } as const;
|
||||
game.phase = 3;
|
||||
game.ixAuctionKnown = { turn: game.turn, cards: [shield] };
|
||||
assert.equal(knowledgeFactAnswer(yes, truthKnowledgeOf(game, ix)), 'yes');
|
||||
assert.equal(knowledgeFactAnswer(no, truthKnowledgeOf(game, ix)), 'no');
|
||||
assert.equal(knowledgeFactAnswer(yes, truthKnowledgeOf(game, other)), 'unknown');
|
||||
assert.equal(knowledgeFactAnswer(yes, truthKnowledgeOf({ ...game, phase: 4 }, ix)), 'unknown');
|
||||
assert.equal(knowledgeFactAnswer(yes, truthKnowledgeOf({ ...game, advanced: true }, ix)), 'unknown');
|
||||
assert.equal(knowledgeFactAnswer(yes, truthKnowledgeOf({ ...game, ixAuctionKnown: null }, ix)), 'unknown');
|
||||
assert.equal(knowledgeFactAnswer(yes, truthKnowledgeOf({ ...game, ixAuctionKnown: { turn: game.turn - 1, cards: [shield] } }, ix)), 'unknown');
|
||||
assert.equal(knowledgeFactAnswer(yes, truthKnowledgeOf({ ...game, ixAuctionKnown: { turn: game.turn, cards: [shield], controller: 'other' } }, ix)), 'unknown');
|
||||
assert.equal(knowledgeFactAnswer(yes, truthKnowledgeOf({ ...game, ixAuctionKnown: { turn: game.turn, cards: [shield], controller: ix.id } }, ix)), 'yes');
|
||||
assert.equal(knowledgeFactAnswer(yes, truthKnowledgeOf({ ...game, homeworldOccupationPreview: true }, ix)), 'unknown');
|
||||
assert.equal(knowledgeFactAnswer(yes, truthKnowledgeOf({ ...game, homeworldOccupationPreview: true, ixAuctionKnown: { turn: game.turn, cards: [shield], controller: ix.id } }, ix)), 'yes');
|
||||
assert.equal(knowledgeFactAnswer(yes, truthKnowledgeOf({ ...game, homeworldOccupationPreview: true, ixAuctionKnown: { turn: game.turn, cards: [shield], controller: other.id } }, other)), 'unknown');
|
||||
assert.equal(knowledgeFactAnswer(yes), 'unknown');
|
||||
});
|
||||
|
||||
void test('non-entitled targets do not inspect the private recorded group and compounds return only the answer', () => {
|
||||
const game = createGame('IXKNOW', newPlayer('ix', 'Ixians', 'ixians'), false, ['ix']);
|
||||
const ix = game.players[0];
|
||||
const other = newPlayer('other', 'Other', 'atreides');
|
||||
const shield = baseDeck().find(card => card.name === 'Shield')!;
|
||||
const hidden = { ...game, phase: 3, get ixAuctionKnown(): never { throw new Error('Unentitled pool access'); } };
|
||||
assert.equal(truthKnowledgeOf(hidden, other).ixPoolPeek, null);
|
||||
hidden.phase = 4;
|
||||
assert.equal(truthKnowledgeOf(hidden, ix).ixPoolPeek, null);
|
||||
game.phase = 3;
|
||||
game.ixAuctionKnown = { turn: game.turn, cards: [shield] };
|
||||
const fact = { kind: 'ixPoolPeek', name: shield.name } as const;
|
||||
const knowledge = truthKnowledgeOf(game, ix);
|
||||
assert.equal(truthFactAnswer(ix, { kind: 'and', terms: [fact, { kind: 'spice', compare: 'gte', value: 0 }] }, knowledge), 'yes');
|
||||
assert.equal(truthFactAnswer(ix, { kind: 'or', terms: [fact, { kind: 'spice', compare: 'gte', value: 999 }] }, knowledge), 'yes');
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { TRUTH_CARD_NAMES } from '../game/truthtrance-card-names';
|
||||
import { askKnowledge, rejectKnowledge } from './truthtrance-knowledge-fixture';
|
||||
import {
|
||||
advanceTruthMovementNativeEarly, createTruthMovementNativeEarlyFixture,
|
||||
truthMovementNativeEarlyInventory,
|
||||
} from './fixture-truth-movement-native-early';
|
||||
|
||||
/** Original Basic start and native Ixian inspection. The shared fixture selects
|
||||
* unopened starting positions so Atreides receives a real Truthtrance card. */
|
||||
function inspectedPool(): { game: Game; returned: string } {
|
||||
const fixture = createTruthMovementNativeEarlyFixture({ roster: 'ixians-tleilaxu' });
|
||||
let game = advanceTruthMovementNativeEarly(fixture.game, state =>
|
||||
state.phase === 3 && state.decision?.kind === 'ixAuction' && !state.response);
|
||||
const initial = game.ixAuction!.cards;
|
||||
assert.ok(initial.length > 1);
|
||||
const returned = initial[0].id;
|
||||
const retained = initial.slice(1).map(card => card.id).sort();
|
||||
game = applyAction(game, 'ixians', { type: 'decision', card: returned, position: 'bottom' });
|
||||
game = advanceTruthMovementNativeEarly(game, state =>
|
||||
state.phase === 3 && !!state.auction && !!state.ixAuctionKnown &&
|
||||
!state.response && !state.decision && !state.phaseOpening);
|
||||
assert.deepEqual(game.ixAuctionKnown!.cards.map(card => card.id).sort(), retained);
|
||||
assert.ok(!game.ixAuctionKnown!.cards.some(card => card.id === returned));
|
||||
assert.deepEqual(truthMovementNativeEarlyInventory(game), fixture.originalCards);
|
||||
return { game, returned };
|
||||
}
|
||||
|
||||
void test('native Basic Ixian pool knowledge answers privately and preserves the original cards', () => {
|
||||
const { game: initial, returned } = inspectedPool();
|
||||
const pool = structuredClone(initial.ixAuctionKnown!.cards);
|
||||
const lot = structuredClone(initial.auction);
|
||||
const owned = viewGame(initial, 'ixians').ixTechnology;
|
||||
assert.deepEqual(owned?.known.map(card => card.id).sort(), pool.map(card => card.id).sort());
|
||||
assert.equal(viewGame(initial, 'emperor').ixTechnology, null);
|
||||
const name = pool[0].name;
|
||||
const questioned = askKnowledge(initial, 'ixians', { kind: 'ixPoolPeek', name });
|
||||
assert.equal(viewGame(questioned, 'ixians').truthAnswer, 'yes');
|
||||
for (const id of ['atreides', 'emperor', 'tleilaxu'])
|
||||
assert.equal(viewGame(questioned, id).truthAnswer, null);
|
||||
rejectKnowledge(questioned, 'ixians', { type: 'truthAnswer', answer: 'no' });
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(questioned, 'ixians');
|
||||
view.players.find(player => player.id === 'ixians')!.bot = difficulty;
|
||||
const actions = botActions(view);
|
||||
assert.deepEqual(actions, [{ type: 'truthAnswer', answer: 'yes' }]);
|
||||
const done = applyAction(questioned, 'ixians', actions[0]);
|
||||
assert.equal(done.truthtrance, null);
|
||||
assert.equal(done.truthHistory!.at(-1)!.answer, 'yes');
|
||||
assert.deepEqual(done.ixAuctionKnown!.cards, pool);
|
||||
assert.deepEqual(done.auction, lot);
|
||||
assert.ok(done.deck.some(card => card.id === returned));
|
||||
assert.deepEqual(truthMovementNativeEarlyInventory(done), truthMovementNativeEarlyInventory(initial));
|
||||
}
|
||||
});
|
||||
|
||||
void test('the recorded group answers no for an absent face and unknown when no matching knowledge exists', () => {
|
||||
const { game: initial } = inspectedPool();
|
||||
const absent = TRUTH_CARD_NAMES.find(name => !initial.ixAuctionKnown!.cards.some(card => card.name === name));
|
||||
assert.ok(absent);
|
||||
const notInPool = askKnowledge(initial, 'ixians', { kind: 'ixPoolPeek', name: absent });
|
||||
assert.equal(viewGame(notInPool, 'ixians').truthAnswer, 'no');
|
||||
const done = applyAction(notInPool, 'ixians', { type: 'truthAnswer', answer: 'no' });
|
||||
assert.deepEqual(truthMovementNativeEarlyInventory(done), truthMovementNativeEarlyInventory(initial));
|
||||
|
||||
// These explicit saved-state fixtures remove or age the actual learned
|
||||
// observation; no simulated card is inserted into the physical auction.
|
||||
for (const missing of [null, { ...initial.ixAuctionKnown!, turn: initial.turn - 1 }]) {
|
||||
const staged = structuredClone(initial);
|
||||
staged.ixAuctionKnown = missing;
|
||||
const unknown = askKnowledge(staged, 'ixians', { kind: 'ixPoolPeek', name: absent });
|
||||
assert.equal(viewGame(unknown, 'ixians').truthAnswer, 'unknown');
|
||||
rejectKnowledge(unknown, 'ixians', { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.equal(applyAction(unknown, 'ixians', { type: 'truthAnswer', answer: 'unknown' }).truthtrance?.stage, 'unknown');
|
||||
}
|
||||
const wrong = askKnowledge(initial, 'emperor', { kind: 'ixPoolPeek', name: initial.ixAuctionKnown!.cards[0].name });
|
||||
assert.equal(viewGame(wrong, 'emperor').truthAnswer, 'unknown');
|
||||
});
|
||||
@@ -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' | 'auctionPeek' }>) {
|
||||
function markup(value: Extract<TruthFact, { kind: 'prediction' | 'stormDial' | 'stormForecast' | 'spicePeek' | 'auctionPeek' | 'ixPoolPeek' }>) {
|
||||
return renderToStaticMarkup(createElement(KnowledgeFactFields, { value, onChange() {} }));
|
||||
}
|
||||
|
||||
@@ -36,6 +36,24 @@ void test('knowledge controls expose full public faction registry and bounded st
|
||||
assert.match(forecast, /If no such value is available/);
|
||||
});
|
||||
|
||||
void test('recorded Ixian pool selector appears in standalone and compound facts', () => {
|
||||
const game = createGame('IXPOOLCONTROLS', newPlayer('a', 'Asker', 'emperor'), false, ['ix']);
|
||||
joinGame(game, newPlayer('b', 'Target', 'ixians'));
|
||||
const view = viewGame(game, 'a');
|
||||
const fact: TruthFact = { kind: 'ixPoolPeek', name: 'Shield' };
|
||||
const single = renderToStaticMarkup(createElement(TruthFactFields, { value: fact, game: view, target: 'b', onChange() {} }));
|
||||
assert.match(single, /value="ixPoolPeek" selected/);
|
||||
assert.match(single, /Recorded Ixian inspected auction pool includes/);
|
||||
assert.match(single, /value="Shield" selected/);
|
||||
assert.match(single, /pool itself remains private/);
|
||||
assert.match(single, /In Basic Bidding/);
|
||||
assert.match(single, /If that knowledge is unavailable/);
|
||||
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, /Recorded Ixian inspected auction pool includes/);
|
||||
assert.match(grouped, /value="Shield" selected/);
|
||||
});
|
||||
|
||||
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'));
|
||||
|
||||
Reference in new issue
Block a user