Add verified current Face Dancer questions to Truthtrance

Ask about a currently held unrevealed printed identity through shared facts
and AND/OR authoring. Keep ordinary Traitor meaning separate, candidate
identities public and only the aggregate answer published. Existing answer,
physical disposal and continuation flow need no new AI strategy.

Verified: 12 affected offline files86/86, types/lint and production build;
twelve actual field/question/gameplay/GameTable SSR journeys and sixteen legal
profile answers. Native Basic/Advanced sources and actual Mentat replacement
prove a current Yes creates no future reveal or retention promise.

Existing mixed-shipment admission stays unchanged. No saved-room, D1, reset,
restart or deployment operation; broader Truthtrance/mode assurance remains.
This commit is contained in:
Quintine committed 2026-10-24 19:15:46 +11:00
1 parent eff668b202
commit 6ea691255a
13 files changed
+1021 -3

No files matched your search

+16 -1
View File
@@ -8,6 +8,7 @@ import { TRUTH_CARD_NAMES } from '@/game/truthtrance-card-names';
import { SHIPMENT_EXPRESSION_MAX_DEPTH, SHIPMENT_EXPRESSION_MAX_LEAVES } from '@/game/shipment-promises';
import { isKnowledgeFact, knowledgeFactInputError } from '@/game/truthtrance-knowledge';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import { DUKE_VIDAL_ID } from '@/game/duke-vidal';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { CardCountFields } from './truthtrance-card-count';
@@ -16,6 +17,8 @@ import { ForceCountFields, forceCountInputError } from './truthtrance-force-coun
import { ShipmentClauseFields, invalidShipmentClause } from './shipment-claim-fields';
import { NoFieldValueFields } from './truthtrance-no-field-value';
import { parseNoFieldValueFact } from '@/game/truthtrance-no-field-value';
import { FaceDancerFactFields } from './truthtrance-face-dancer';
import { parseFaceDancerFact } from '@/game/truthtrance-face-dancer';
export function invalidTruthFact(fact: TruthFact, game: GameView, target: string): boolean {
if (fact.kind === 'and' || fact.kind === 'or') {
@@ -28,6 +31,15 @@ export function invalidTruthFact(fact: TruthFact, game: GameView, target: string
try { parseNoFieldValueFact(fact); return false; }
catch { return true; }
}
if (fact.kind === 'faceDancer') {
try {
parseFaceDancerFact(fact, [
...game.allLeaders.filter((leader) => leader.id !== DUKE_VIDAL_ID && leader.id !== CHEAP_HERO_TRAITOR).map((leader) => leader.id),
CHEAP_HERO_TRAITOR,
]);
return false;
} catch { return true; }
}
if (isKnowledgeFact(fact)) return !!knowledgeFactInputError(fact);
if (fact.kind === 'forceCount') {
return !!forceCountInputError(fact, game, game.players.find(player => player.id === target)!);
@@ -48,6 +60,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
handCount: { kind: 'handCount', name: 'Shield', compare: 'gte', value: 2 },
handInventory: { kind: 'handInventory', category: 'all', compare: 'gte', value: 2 },
traitor: { kind: 'traitor', leader: game.allLeaders[0].id },
faceDancer: { kind: 'faceDancer', leader: game.allLeaders.find((leader) => leader.id !== DUKE_VIDAL_ID)?.id ?? CHEAP_HERO_TRAITOR },
predictionFaction: { kind: 'prediction', field: 'faction', faction: 'atreides' },
predictionTurn: { kind: 'prediction', field: 'turn', compare: 'gte', value: 1 },
stormDial: { kind: 'stormDial', compare: 'gte', value: 0 },
@@ -65,6 +78,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
<option value="handCount">Current number of a named card</option>
<option value="handInventory">Current hand size or primary card role</option>
<option value="traitor">Selected a traitor</option>
<option value="faceDancer">Currently unrevealed Face Dancer</option>
<option value="predictionFaction">Stored faction prediction</option>
<option value="predictionTurn">Stored turn prediction</option>
<option value="stormDial">Known storm dial</option>
@@ -80,7 +94,8 @@ export function TruthFactFields({ value: fact, game, target, onChange }: {
{TRUTH_CARD_NAMES.map(name => <option key={name}>{name}</option>)}
</select>
</label> : fact.kind === 'handCount' || fact.kind === 'handInventory' ?
<CardCountFields value={fact} onChange={onChange} /> : fact.kind === 'noFieldValue' ?
<CardCountFields value={fact} onChange={onChange} /> : fact.kind === 'faceDancer' ?
<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' ?
+61
View File
@@ -0,0 +1,61 @@
'use client';
import { DUKE_VIDAL_ID } from '@/game/duke-vidal';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import { parseFaceDancerFact, type FaceDancerFact } from '@/game/truthtrance-face-dancer';
export function FaceDancerFactFields({
value,
leaders,
onChange,
}: {
value: FaceDancerFact;
leaders: readonly { id: string; name: string }[];
onChange: (fact: FaceDancerFact) => void;
}) {
const identities = [
...leaders.filter((leader) => leader.id !== DUKE_VIDAL_ID && leader.id !== CHEAP_HERO_TRAITOR),
{ id: CHEAP_HERO_TRAITOR, name: 'Cheap Hero / Heroine' },
];
const allowed = identities.map((identity) => identity.id);
let error: string | null = null;
try { parseFaceDancerFact(value, allowed); }
catch (cause) { error = cause instanceof Error ? cause.message : 'Choose a printed Face Dancer identity.'; }
return (
<>
<label>
Face Dancer identity
<select
aria-label="Face Dancer identity"
value={value.leader}
aria-invalid={!!error}
onChange={(event) => {
try {
onChange(parseFaceDancerFact({ kind: 'faceDancer', leader: event.currentTarget.value }, allowed));
} catch { return; }
}}
>
{identities.map((identity) => (
<option key={identity.id} value={identity.id}>{identity.name}</option>
))}
</select>
</label>
<p className="fine">
This asks whether the respondent currently holds an unrevealed Face
Dancer for this printed identity. A revealed, absent or returned card
means No.
</p>
<p className="fine">
This is a current fact at the answer, not a promise to reveal a Face
Dancer later. Ordinary traitor questions still ask only about ordinary
Traitor Cards.
</p>
<p className="fine">
Only the aggregate Truthtrance answer is published. This does not
physically reveal a Face Dancer or identify which part of an AND/OR
claim is true.
</p>
{error && <p className="notice" role="alert">{error}</p>}
</>
);
}
+26
View File
@@ -16,6 +16,32 @@ open mode gates.
## Current checkpoint and work
**Truthtrance unrevealed Face Dancer facts — 24 October. Prototyped / Partial.**
The shared current-fact author can now ask whether a respondent holds a named
identity as an unrevealed Face Dancer. Actual current cards determine the answer;
ordinary Traitors and revealed/returned cards stay distinct. Candidate names use
public printed identities, and only the target sees the verified aggregate
before its public reply. This neither turns up the card nor binds later retention
or Face Dance. Actual Mentat replacement remains legal after Yes.
[Source, predicate and bounded proof](TRUTHTRANCE_FACE_DANCERS.md).
Disjoint helper/control/native-fixture lanes and bounded review pass
**12 files86/86**, types/lint/build, twelve actual field/question/gameplay/
GameTable SSR journeys and sixteen existing-profile answer paths. Native
Basic26/Advanced29-action setup/Storm/Spice sources earn the actual cards;
replacement-first sources take60/63 actions. Narrow mixed-question feedback
preserves its existing expansion entry guard rather than changing a mode to
fit the fixture. No full-mode, broader mixed/future-question, comprehensive/
browser/deployed or AI calibration certificate; no saved-room/D1/reset/restart/
deployment operation.
Next concrete native content candidate: supplied physical38 H permits the
Tleilaxu special Karama during Revival, while native preflight currently waits
for a pending normal request. Its proactive window needs implementation;
Ghola/Emperor-special exceptions remain separately recorded provisional scope.
Inspect the existing [native special cases](../tests/tleilaxu-special.test.ts)
before changing that contract.
**Moritani captured and foreign-ghola targets — 24 October. Prototyped / Partial.**
Assassinate now accepts the opposing printed faction's valid captured or ordinary
foreign-ghola target. Pending choices show uniform printed potential bounty;
+28
View File
@@ -1,5 +1,33 @@
# Dune implementation status
## 24 October 2026 — Verified current Face Dancer questions
Add a distinct Truthtrance current unrevealed Face Dancer identity fact. Exact
shape/known-public-identity parsing, current strict unrevealed matching and
wording share the ordinary fact decoder/evaluator and compound answer flow.
Shared author fields use public printed names, exclude Duke and include the
single Hero/Heroine identity without reading the target's hidden cards.
Ordinary Traitor semantics and existing mixed-shipment mode gates remain.
Physical question disposal and parent continuation use the existing flow;
current Yes creates no future reveal/retention promise. No AI strategy changes.
Disjoint helper/control/fixture lanes and bounded review pass12 files86/86,
types/lint/build, twelve actual field-callback/question/answer/GameTable SSR
journeys and sixteen legal profile answers. Public Basic26/Advanced29-action
setup, actual native Face Dancer deal and Storm/Spice supply originals.
Native Mentat replacement-first takes60/63 actions; answer-then-replace branches
keep the earlier public fact while actual current custody changes. No played
hand, Face Dancer status, phase, wallet, answer or receipt is assigned.
Narrow feedback changes a proposed mixed-shipment expectation to preserve its
existing expansion guard; standalone current facts still work at that point.
The final frozen union and all required scoped checks pass on unchanged source.
Full Truthtrance/modes, broader future/mixed-question profiles, comprehensive/
browser/deployed acceptance and final AI remain unfinished.
[Source, exact proof and limits](TRUTHTRANCE_FACE_DANCERS.md).
No saved-room/D1/reset/restart/deployment operation.
Private proof:20261024-truthtrance-face-dancer/frozen-integrated.
## 24 October 2026 — Moritani native targets under capture or foreign Ghola
Replace the target-custody exclusion with actual native Harkonnen/Tleilaxu
+13
View File
@@ -1,5 +1,18 @@
# Rule decision index
## Truthtrance unrevealed Face Dancer fact — 24 October 2026
Compose the publisher FAQ's game-related Yes/No/AND/OR permission with native
Face Dancer cards (E1 printed6; supplied Advanced physical38 A/39). Ask only
whether a named identity is currently held unrevealed; revealed/absent/returned
cards are No and ordinary selected-Traitor semantics stay separate. Public
candidate IDs do not depend on hidden holdings; only the respondent sees the
verified aggregate before its public reply. This current fact creates no
future reveal/retention promise and does not turn up the physical card. Existing
mixed-shipment admission remains independent. No Face-Dancer-specific FAQ,
new roster/module permission or complete Truthtrance certification is claimed.
[Predicate, source and bounded proof](TRUTHTRANCE_FACE_DANCERS.md#source-and-predicate--24-october-2026).
## Moritani native targets under capture or foreign Ghola — 24 October 2026
Supplied physical35 D identifies the opposing printed faction and a different
+102
View File
@@ -0,0 +1,102 @@
# Truthtrance: current unrevealed Face Dancer identities
## Source and predicate — 24 October 2026
**Prototyped / Partial.** The [publisher November2020 FAQ, printed p8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
allows game-related Yes/No questions and AND/OR combinations. The
[publisher E1 rules, printed p6](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf)
establish native Tleilaxu's three Face Dancer cards separately from ordinary
Traitors. The authorized supplied Advanced revision, physical **p38 A** and
**p39 Truthtrance**, retains those cards and broad question permission.
Official indexed text was retrieved; direct FAQ access returned403. This is a
composition of those permissions and a precisely worded current fact, not a
new Face-Dancer-specific publisher FAQ or a new faction power.
The structured fact asks whether the respondent **currently holds the named
identity as an unrevealed Face Dancer**. One matching current card with
`revealed === false` answers Yes. Revealed, absent or returned cards answer No;
history and ordinary Traitor cards do not count. An absent undealt card is not
a prediction of the upcoming deal. Ordinary selected-Traitor questions keep
their existing meaning, including their original selection-time uncertainty.
The identity comes from public native printed rosters plus the shared Cheap
Hero/Heroine Traitor identity; shared Duke has no such card and stays invalid.
The selector never uses the respondent's private cards to populate candidates.
Leader life, captivity, Ghola control or fighting strength does not change this
identity predicate. Auditor is valid when its actual printed identity is in
the selected game; no card, module or roster is invented by asking a question.
## Connected behavior
The focused helper owns exact shape/identity parsing, current matching and
question wording. Shared Truthtrance parsing/evaluation/text uses it, including
existing nested AND/OR questions. The existing target-only verified answer,
storm-order priority, physical question-card disposal and parent continuation
remain. Only the question and whole answer become public; no underlying list
or per-clause match is added to other views. Shared mixed-shipment fact fields
also recognize the predicate, while existing mixed-question mode/turn gates
remain. Native Tleilaxu's current expansion profile does not gain a mixed-shipment
entry merely because this fact exists.
Persistent author controls explicitly distinguish currently unrevealed Face
Dancers from ordinary Traitors and future promises. A current Yes does not
reveal a physical Face Dancer, make it turn traitor, commit a later Face Dance,
or require retaining the card. Actual later native Mentat replacement is still
legal; the historical answer remains about the earlier current holdings.
All four profiles use the same existing owner-only verified-answer path;
there is no new question strategy or calibration.
## Native source and bounded proof
The fixture uses public Basic or explicit Advanced-preview Tleilaxu/Atreides/
Emperor setup with the original Ix family. Atreides is dealt its original
Truthtrance, other players choose actual Traitors, and native setup deals
Tleilaxu three actual Face Dancers. Native Storm/Spice reaches clean Charity.
Only original undealt card/Spice positions and scalar shuffle choices are
selected; no played hand, Face Dancer status, phase, wallet, answer or receipt
is assigned. Optional branches actually reach first Mentat and replace one
unrevealed Face Dancer before or after the answer. Basic and Advanced initial
sources take26/29 actions; replacement-first sources take60/63.
The frozen **12-file86/86** affected offline union, `npm run check:quick`,
production build and changed-control runtime smoke pass. Nine focused helper,
thirteen actual engine and six control cases cover held/unheld/revealed/returned
identity, exact parsing, Hero/Auditor/Duke boundaries, ordinary Traitor separation,
compound aggregate, rejected answers, original physical disposal and actual
Mentat replacement. The existing Truthtrance/knowledge/hand/count/No-Field/mixed
and shared-Duke file union retains its own bounded behavior.
Twelve actual identity-field callback/question/answer/full GameTable SSR
journeys cover Basic and Advanced held/unheld, OR aggregates, replacement-first
old/new and answer-then-replace. Their original parents resume; histories remain
correct after custody changes. Sixteen own-view answer paths across all four
existing profiles accept sixteen native Yes/No actions. Runtime source is
genuine native play; this is not browser/hydration/mobile or complete-game proof.
The one narrow proposed mixed-shipment test initially expected native Tleilaxu
to be admitted. That existing expansion profile is still excluded; the repaired
test proves rejection preserves the held card and permits the standalone fact
instead. No rule, mode or producer is loosened for the fixture. Basic native
fact1/1, actual replacement and the corrected gate case pass, followed by the
entire frozen union and required checks without further failures.
Evidence binds base `eff668b2029fe9935971b41e9f0597c1a014e78b`, tree
`db04e145756ca0eb4af846e62d3a8943d29804c294dffe306ce43e99d267df3b`
(2721 nonignored files). Private reports, logs, full-table/field renderings and
source hashes stay outside Git in `20261024-truthtrance-face-dancer`; four final
checkpoint prose/reference overlays are reviewed separately without changing
the verified application or tests.
Relevant paths: [predicate](../game/truthtrance-face-dancer.ts),
[shared Truthtrance flow](../game/truthtrance.ts),
[public identity field](../components/truthtrance-face-dancer.tsx),
[shared author](../components/mixed-shipment-fields.tsx),
[native fixture](../tests/fixture-truthtrance-face-dancer.ts),
[helper cases](../tests/truthtrance-face-dancer-fact.test.ts),
[engine cases](../tests/truthtrance-face-dancer-engine.test.ts) and
[control cases](../tests/truthtrance-face-dancer-controls.test.tsx).
This is a current-fact prototype, not complete arbitrary Truthtrance, all
future/mixed questions, full modes, comprehensive save/privacy/custody review,
browser/hydration/mobile or deployed acceptance. No saved-room/D1/reset/restart/
deployment operation is needed. Wider printed rules and final AI remain due.
+23 -1
View File
@@ -2888,6 +2888,28 @@ export const RULE_TOPICS: RuleTopic[] = [
},
],
},
{
id: 'truthtrance-face-dancers',
title: 'Truthtrance: currently unrevealed Face Dancers',
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Ask whether another player currently holds a named printed identity as an unrevealed Face Dancer.',
steps: [
'Declare Truthtrance and choose a verified current fact. Select Currently unrevealed Face Dancer, the respondent and a public printed identity. Cheap Hero / Heroine shares one identity; Duke has no Face Dancer card.',
'A matching currently held unrevealed card answers Yes. Revealed, absent and returned cards answer No. Ordinary selected-Traitor questions continue to ask about ordinary Traitor Cards.',
'Combine current facts with AND or OR. Only the respondent sees the verified whole answer before replying; other players receive the question and final aggregate answer, without the private card list or individual clause results.',
'This does not turn up a physical Face Dancer or promise to use or retain it later. A later legal replacement may change the current answer while the earlier historical answer remains correct. Existing mixed-shipment profile limits still apply.',
],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Exact public-identity parsing and current unrevealed matching share normal Truthtrance evaluation, aggregate text and physical disposal.', evidence: ['game/truthtrance-face-dancer.ts', 'game/truthtrance.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Public identity fields, current-only guidance and the common fact selector work standalone and in the existing compound builder.', evidence: ['components/truthtrance-face-dancer.tsx', 'components/mixed-shipment-fields.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Existing profiles use their own server-verified aggregate answer. Question strategy and final calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truthtrance-face-dancer-engine.test.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Native card and broad question permission, ordinary Traitor distinction and absence of any future promise are explicit.', evidence: ['docs/TRUTHTRANCE_FACE_DANCERS.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Frozen12-file86/86, types/lint/build, twelve actual identity-field/question/gameplay/table-SSR journeys and sixteen legal profile answers pass. Native26/29-action sources and actual60/63-action replacement sources preserve current cards, old answers and original parent; broader modes, future/mixed profiles and comprehensive/browser/deployed proof remain open.', evidence: ['tests/truthtrance-face-dancer-fact.test.ts', 'tests/truthtrance-face-dancer-engine.test.ts', 'tests/truthtrance-face-dancer-controls.test.tsx', 'tests/fixture-truthtrance-face-dancer.ts'] },
],
related: ['card-truthtrance', 'tleilaxu-face-dancers', 'truthtrance-hand-inventory'],
},
{
id: 'truthtrance-hand-inventory',
title: 'Truthtrance: hand size and primary card roles',
@@ -3089,7 +3111,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Ask another player a public yes/no question. Verified facts, supported shipment and ground-movement promises, current-battle plan commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
steps: [
'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.',
'Ask a game-related yes/no question. Named-card, selected-traitor and current personal-spice questions are checked by the server. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
'For current spice, choose Exactly, At least or At most and a nonnegative whole amount. This counts spice personally held when answering, excluding separately pledged aid and incoming payments. It does not ask whether a particular action is affordable or promise that the balance will stay unchanged. A true current-spice answer does not prevent later legal spending.',
'For a concealed No-Field, choose its current printed value0,3 or5, not the prospective force pool. A missing or already revealed marker answers No, including for0; a concealed5 with empty reserves still answers Yes to5. Only the respondent sees the expected aggregate before public reply. Neither question nor answer turns up the physical token or commits later use; current-force facts continue to exclude markers.',
'The questioned player answers publicly. A definite answer discards the card. If the answer cannot be known, the holder may ask a different question or save Truthtrance for later.',
+39
View File
@@ -0,0 +1,39 @@
import { DUKE_VIDAL_ID } from './duke-vidal';
import { CHEAP_HERO_TRAITOR } from './traitors';
/** A current unrevealed card identity, never a promise to retain or reveal it. */
export type FaceDancerFact = { kind: 'faceDancer'; leader: string };
/** The caller supplies this game's canonical physical Traitor identities. */
export function parseFaceDancerFact(
value: unknown,
allowedIdentities: readonly string[],
): FaceDancerFact {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new Error('Choose a current unrevealed Face Dancer identity fact.');
const input = value as Record<string, unknown>;
const keys = Reflect.ownKeys(input);
if (keys.length !== 2 || !keys.includes('kind') || !keys.includes('leader') ||
input.kind !== 'faceDancer')
throw new Error('Choose only a current unrevealed Face Dancer identity fact.');
if (typeof input.leader !== 'string' || !input.leader.trim() ||
input.leader === DUKE_VIDAL_ID || !allowedIdentities.includes(input.leader))
throw new Error('Choose a physical Traitor identity in this game for the Face Dancer fact.');
return { kind: 'faceDancer', leader: input.leader };
}
/** Ordinary Traitor cards and already revealed or returned Face Dancers do not count. */
export function faceDancerFactMatches(
holder: { faceDancers?: readonly { leader: string; revealed: boolean }[] },
fact: FaceDancerFact,
): boolean {
return holder.faceDancers?.some(card => card.leader === fact.leader && card.revealed === false) ?? false;
}
export function faceDancerFactText(
fact: FaceDancerFact,
leaderName: (id: string) => string,
): string {
const name = fact.leader === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : leaderName(fact.leader);
return `you currently hold ${name} as an unrevealed Face Dancer`;
}
+16 -1
View File
@@ -41,6 +41,7 @@ import { DUKE_VIDAL_ID } from './duke-vidal';
import { canUseAsTruthtranceRole } from './shrine';
import { isKnowledgeFact, parseKnowledgeFact, knowledgeFactAnswer, knowledgeFactText, truthKnowledgeOf, type KnowledgeFact, type TruthKnowledge } from './truthtrance-knowledge';
import { parseNoFieldValueFact, noFieldValueFactMatches, noFieldValueFactText, type NoFieldValueFact } from './truthtrance-no-field-value';
import { parseFaceDancerFact, faceDancerFactMatches, faceDancerFactText, type FaceDancerFact } from './truthtrance-face-dancer';
export type TruthFact =
| KnowledgeFact
@@ -48,6 +49,7 @@ export type TruthFact =
| HandInventoryFact
| ForceCountFact
| NoFieldValueFact
| FaceDancerFact
| { kind: 'hand'; name: string }
| { kind: 'traitor'; leader: string }
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
@@ -153,6 +155,16 @@ function parseFact(
);
return { kind: 'traitor', leader: v.leader };
}
if (v.kind === 'faceDancer') {
try {
// Candidate identities are public printed components, independent of the
// respondent's private cards or the status of any particular Face Dancer.
return parseFaceDancerFact(v, [CHEAP_HERO_TRAITOR,
...g.players.flatMap(player => player.leaders.map(leader => leader.id))]);
} catch (error) {
throw new TruthError(error instanceof Error ? error.message : String(error));
}
}
if (v.kind === 'spice') {
check(
v.compare === 'eq' || v.compare === 'gte' || v.compare === 'lte',
@@ -348,7 +360,7 @@ export function queuedTruthCardMatches(
}
/** A fact answer never returns which disjunct matched or any underlying private cards. */
export function truthFactAnswer(
p: Pick<Player, 'hand' | 'traitors' | 'traitorChoices' | 'spice' | 'noField'> & Partial<ForceCountPlayer>,
p: Pick<Player, 'hand' | 'traitors' | 'traitorChoices' | 'spice' | 'noField' | 'faceDancers'> & Partial<ForceCountPlayer>,
fact: TruthFact,
knowledge?: TruthKnowledge,
): TruthAnswer {
@@ -367,6 +379,8 @@ export function truthFactAnswer(
if (p.traitorChoices.length) return 'unknown';
return p.traitors.includes(fact.leader) ? 'yes' : 'no';
}
if (fact.kind === 'faceDancer')
return faceDancerFactMatches(p, fact) ? 'yes' : 'no';
if (fact.kind === 'spice') {
// A current fact reports personal custody, not transaction-specific spending power or a future promise.
const matches =
@@ -420,6 +434,7 @@ export function truthQuestionText(
if (f.kind === 'handCount') return cardCountFactText(f);
if (f.kind === 'hand') return `you hold ${f.name}`;
if (f.kind === 'traitor') return `${leaderName(f.leader)} is your traitor`;
if (f.kind === 'faceDancer') return faceDancerFactText(f, leaderName);
if (f.kind === 'spice')
return `you currently hold ${f.compare === 'eq' ? 'exactly' : f.compare === 'gte' ? 'at least' : 'at most'} ${f.value} spice`;
return `(${f.terms.map(clause).join(f.kind === 'and' ? ' AND ' : ' OR ')})`;
+229
View File
@@ -0,0 +1,229 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export type TruthFaceDancerStep = { actor: string; action: Action };
export type TruthFaceDancerTransition = { before: Game; steps: TruthFaceDancerStep[]; after: Game };
type TruthFaceDancerSource = {
respondent: string; target: string; originalInventory: string[]; originalTraitors: string[];
};
export const truthFaceDancerPlayer = (game: Game, id: string) => {
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
};
export const truthFaceDancerClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
export const truthFaceDancerInventory = (game: Game) => {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Original physical Treachery Cards retain distinct current slots');
return ids;
};
export const truthFaceDancerTraitors = (game: Game) => {
const ids = [...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
[...player.traitors, ...player.traitorChoices, ...(player.faceDancers ?? []).map(card => card.leader)])];
assert.equal(new Set(ids).size, ids.length, 'Each original physical Traitor/Face Dancer card has one current slot');
return ids.sort();
};
/** Original scalar shuffle choices only. UUIDs and every other native byte
* entropy source stay unchanged; no live hand or Face Dancer status is set. */
function originalScalarOrder<T>(run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
else Reflect.apply(original, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function actTruthFaceDancer(game: Game, actor: string, action: Action,
actions: TruthFaceDancerStep[] = []): Game {
const next = originalScalarOrder(() => applyAction(game, actor, action));
actions.push({ actor, action: structuredClone(action) }); return next;
}
/** Native phase/setup/response controls only. The original question remains
* caller-owned, and no optional Face Dancer replacement is silently selected. */
export function nextTruthFaceDancer(game: Game): TruthFaceDancerStep | null {
if (game.truthtrance) return null;
return nextRicheseStrongholdsNativeStep(game);
}
export function advanceTruthFaceDancer(game: Game, until: (state: Game) => boolean,
actions: TruthFaceDancerStep[] = []): Game {
for (let count = 0; count < 1400; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine Face Dancer question chronology ended before its requested boundary');
const next = nextTruthFaceDancer(game);
assert.ok(next, `Choose the original question or private plan at ${game.turn}/${game.phase}/${game.truthtrance?.stage ?? game.decision?.kind}`);
game = actTruthFaceDancer(game, next.actor, next.action, actions);
}
throw Error('The native Face Dancer question chronology did not reach its finite boundary');
}
export function passTruthFaceDancerResponses(game: Game, actions: TruthFaceDancerStep[] = []): Game {
for (let count = 0; game.response && count < 60; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actTruthFaceDancer(game, actor.id, { type: 'passResponse' }, actions);
}
assert.ok(!game.response); return game;
}
export function passTruthFaceDancerPriority(game: Game, actions: TruthFaceDancerStep[] = []): Game {
for (let count = 0; game.truthtrance?.stage === 'priority' && count < 60; count++) {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthFaceDancer(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask'); return game;
}
/** Public three-seat E1 Basic/Advanced setup, actual private Traitor choices,
* original native Face Dancer deal and first Storm/Spice. Stop at clean Charity
* with the original starting Truthtrance still physically held by Atreides. */
export function createTruthtranceFaceDancerFixture(options: { advanced?: boolean; replaced?: boolean } = {}) {
const advanced = options.advanced ?? false, respondent = 'tleilaxu', asker = 'atreides', observer = 'emperor';
let game = createGame('TRUTHTRANCEFACEDANCER', newPlayer(respondent, 'Tleilaxu', 'tleilaxu'), advanced, ['ix']);
joinGame(game, newPlayer(asker, 'Atreides', 'atreides'));
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: TruthFaceDancerStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthFaceDancer(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthFaceDancer(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => player.hand.length === 0));
assert.ok(!truthFaceDancerPlayer(game, respondent).faceDancers);
const originalInventory = truthFaceDancerInventory(game), originalTraitors = truthFaceDancerTraitors(game);
const truth = game.deck.find(card => card.effect === 'truthtrance'); assert.ok(truth);
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 2);
assert.equal(ordinary.length, 2);
const selected = [ordinary[0], truth, ordinary[1]];
const dealOrder = selected.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
const beforeStartingDeal = structuredClone(game);
let faceDancerDeal: TruthFaceDancerTransition | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const next = nextTruthFaceDancer(game); assert.ok(next);
const before = structuredClone(game), hadDancers = !!truthFaceDancerPlayer(game, respondent).faceDancers;
act(next.actor, next.action);
if (!hadDancers && truthFaceDancerPlayer(game, respondent).faceDancers)
faceDancerDeal = { before, steps: [structuredClone(next)], after: structuredClone(game) };
}
assert.equal(game.status, 'playing'); assert.ok(faceDancerDeal);
const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
assert.deepEqual(truthFaceDancerPlayer(game, respondent).traitors, []);
const cards = structuredClone(truthFaceDancerPlayer(game, respondent).faceDancers!);
assert.equal(cards.length, 3); assert.ok(cards.every(card => !card.revealed));
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
reach(state => state.turn === 1 && state.phase === 2 && truthFaceDancerClean(state));
assert.ok(truthFaceDancerPlayer(game, asker).hand.some(card => card.id === truth.id));
assert.deepEqual(truthFaceDancerPlayer(game, respondent).faceDancers, cards);
assert.deepEqual(truthFaceDancerInventory(game), originalInventory);
assert.deepEqual(truthFaceDancerTraitors(game), originalTraitors);
const heldIDs = cards.map(card => card.leader), canonical = [...game.players.flatMap(player => player.leaders.map(leader => leader.id)), CHEAP_HERO_TRAITOR];
const notHeld = canonical.find(leader => !heldIDs.includes(leader)); assert.ok(notHeld);
const target = heldIDs[0], fact = { kind: 'faceDancer', leader: target };
const declaration: Action = { type: 'card', card: truth.id };
const questionAction: Action = { type: 'truthAsk', question: { kind: 'fact', target: respondent, fact } };
const fixture = { game, source: structuredClone(game), initial, lobby, setup, beforeStartingDeal, afterSetup, faceDancerDeal,
owner: asker, asker, respondent, observer, truth: truth.id, card: truth.id, cards, held: target, heldIDs,
notHeld, unheld: notHeld, canonical, hero: CHEAP_HERO_TRAITOR,
cheapHero: CHEAP_HERO_TRAITOR, cheapHeroHeld: heldIDs.includes(CHEAP_HERO_TRAITOR), target,
fact, declaration, questionAction, action: questionAction, advanced,
actions, sourceActionCount: actions.length, inventory: originalInventory,
originalInventory, originalTraitors, originalSpice, dealOrder, spiceOrder,
staging: [
'Actual public native Tleilaxu/Atreides/Emperor ready/start and private Traitor choices. Advanced uses the original explicit preview action; no played Basic state is converted.',
'Select only original scalar shuffle order and three original unopened Treachery positions. Atreides is physically dealt its original starting Truthtrance. Tleilaxu receives its original three Face Dancer cards through actual finishSetup after the other seats choose their Traitors; no Face Dancer card/status or played hand is assigned.',
'Order two original unopened ordinary Spice positions and actually complete first Storm and native Spice controls to clean Charity. Basic uses one blow and Advanced uses both; neither original question card nor Face Dancer has been used.',
'Stop before declaration/question. Held, canonical not-held and Cheap Hero identities are read from original current custody. No army, wallet, phase, forecast, revealed flag, answer, receipt or ownership is installed.',
],
};
const prefixActionCount = actions.length;
const replacement = options.replaced ? replaceTruthtranceFaceDancer(game, fixture, actions) : null;
const current = replacement?.game ?? game;
const currentCards = replacement?.cardsAfter ?? cards;
const currentTarget = replacement?.new ?? target;
const currentFact = { kind: 'faceDancer', leader: currentTarget };
const currentQuestion: Action = { type: 'truthAsk', question: { kind: 'fact', target: respondent, fact: currentFact } };
return { ...fixture, game: current, beforeQuestion: structuredClone(current),
cards: currentCards, held: currentTarget, heldIDs: currentCards.map(card => card.leader),
target: currentTarget, fact: currentFact, questionAction: currentQuestion, action: currentQuestion,
unheld: replacement?.old ?? notHeld, notHeld: replacement?.old ?? notHeld,
cheapHeroHeld: currentCards.some(card => card.leader === CHEAP_HERO_TRAITOR),
replaced: !!options.replaced, replacement,
old: replacement?.old ?? null, new: replacement?.new ?? null,
beforeReplacement: replacement?.before ?? null, afterReplacement: replacement?.after ?? null,
prefixActionCount, sourceActionCount: actions.length,
staging: [...fixture.staging, ...(replacement ? [
'Before using the original starting Truthtrance, actually advance to first Mentat and use native replaceFaceDancer plus its original response. The old/new identities are actual current custody; no card/status or replacement receipt is assigned.',
] : [])],
};
}
export type TruthtranceFaceDancerFixture = ReturnType<typeof createTruthtranceFaceDancerFixture>;
/** Declare the actual held card and finish only its real storm-order priority. */
export function openTruthFaceDancer(source: Game, fixture: TruthtranceFaceDancerFixture,
actions: TruthFaceDancerStep[] = []): Game {
assert.ok(truthFaceDancerClean(source));
let game = actTruthFaceDancer(source, fixture.asker, fixture.declaration, actions);
game = passTruthFaceDancerPriority(game, actions);
assert.equal(game.truthtrance?.queue[0].player, fixture.asker); return game;
}
export const openTruthtranceFaceDancer = openTruthFaceDancer;
export function askTruthFaceDancer(source: Game, fixture: TruthtranceFaceDancerFixture,
leader = fixture.target, actions: TruthFaceDancerStep[] = []): Game {
const game = source.truthtrance?.stage === 'ask' ? source : openTruthFaceDancer(source, fixture, actions);
return actTruthFaceDancer(game, fixture.asker, { type: 'truthAsk', question: {
kind: 'fact', target: fixture.respondent, fact: { kind: 'faceDancer', leader },
} }, actions);
}
/** A definite actual response disposes of this card once and resumes its parent. */
export function answerTruthFaceDancer(source: Game, fixture: TruthtranceFaceDancerFixture,
answer: 'yes' | 'no', actions: TruthFaceDancerStep[] = []): Game {
let game = actTruthFaceDancer(source, fixture.respondent, { type: 'truthAnswer', answer }, actions);
game = advanceTruthFaceDancer(game, truthFaceDancerClean, actions);
assert.ok(!game.truthtrance); return game;
}
export function finishTruthtranceFaceDancer(source: Game, actions: TruthFaceDancerStep[] = []): Game {
return advanceTruthFaceDancer(source, truthFaceDancerClean, actions);
}
/** Optional short native lifecycle branch: leave the original Truthtrance held,
* reach actual first Mentat and replace one unrevealed Face Dancer normally.
* The returned old/new cards are facts of that real transfer, not assignments. */
export function replaceTruthtranceFaceDancer(source: Game, fixture: TruthFaceDancerSource,
actions: TruthFaceDancerStep[] = []) {
let game = advanceTruthFaceDancer(source, state => state.turn === 1 && state.phase === 8 && truthFaceDancerClean(state), actions);
const before = structuredClone(game), start = actions.length, old = fixture.target;
const cardsBefore = structuredClone(truthFaceDancerPlayer(game, fixture.respondent).faceDancers!);
const slot = cardsBefore.findIndex(card => card.leader === old && !card.revealed); assert.ok(slot >= 0);
const step: TruthFaceDancerStep = { actor: fixture.respondent, action: { type: 'replaceFaceDancer', leader: old } };
game = actTruthFaceDancer(game, step.actor, step.action, actions);
game = passTruthFaceDancerResponses(game, actions);
const cardsAfter = structuredClone(truthFaceDancerPlayer(game, fixture.respondent).faceDancers!);
const drawn = cardsAfter[slot].leader;
assert.equal(truthFaceDancerPlayer(game, fixture.respondent).faceDancerReplacedTurn, 1);
assert.equal(cardsAfter[slot].revealed, false); assert.notEqual(drawn, old);
assert.deepEqual(truthFaceDancerInventory(game), fixture.originalInventory);
assert.deepEqual(truthFaceDancerTraitors(game), fixture.originalTraitors);
return { game, before, step, after: structuredClone(game), old, new: drawn, drawn, cardsBefore, cardsAfter,
beforeActionCount: start, steps: structuredClone(actions.slice(start)), sourceActionCount: actions.length };
}
export const replaceTruthFaceDancer = replaceTruthtranceFaceDancer;
@@ -0,0 +1,164 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Children, createElement, isValidElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { FaceDancerFactFields } from '../components/truthtrance-face-dancer';
import {
invalidTruthFact,
mixedShipmentInputError,
MixedShipmentFields,
TruthFactFields,
} from '../components/mixed-shipment-fields';
import { createDukeVidal, DUKE_VIDAL_ID } from '../game/duke-vidal';
import { createGame, newPlayer, viewGame, type GameView } from '../game/engine';
import type { MixedShipmentExpression } from '../game/mixed-shipment-question';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
import type { FaceDancerFact } from '../game/truthtrance-face-dancer';
import type { TruthFact } from '../game/truthtrance';
function projected(): GameView {
const game = createGame('TRUTHFACEDANCERCONTROLS', newPlayer('a', 'Asker', 'atreides'), false, ['ix']);
game.players.push(newPlayer('t', 'Respondent', 'tleilaxu'));
Object.assign(game, { status: 'playing', phase: 3, turn: 3, order: ['a', 't'] });
const view = viewGame(game, 'a');
// The public registry can contain Duke, who has no physical identity card.
view.allLeaders = [createDukeVidal().leader, ...view.allLeaders];
return view;
}
const fact = (leader = 'atreides-0'): FaceDancerFact => ({ kind: 'faceDancer', leader });
function markup(game = projected(), value = fact()) {
return renderToStaticMarkup(createElement(FaceDancerFactFields, {
value, leaders: game.allLeaders, onChange() {},
}));
}
function select(node: ReactNode, identity: string): boolean {
for (const child of Children.toArray(node)) {
if (!isValidElement<{
children?: ReactNode;
onChange?: (event: { currentTarget: { value: string } }) => void;
'aria-label'?: string;
}>(child)) continue;
if (child.props['aria-label'] === 'Face Dancer identity' && child.props.onChange) {
child.props.onChange({ currentTarget: { value: identity } });
return true;
}
if (select(child.props.children, identity)) return true;
}
return false;
}
function choose(game: GameView, identity: string): FaceDancerFact[] {
const selected: FaceDancerFact[] = [];
const tree = FaceDancerFactFields({
value: fact(), leaders: game.allLeaders, onChange(value) { selected.push(value); },
});
select(tree, identity);
return selected;
}
void test('the actual identity selector uses public printed names, excludes Duke and shares one Hero/Heroine option', () => {
const game = projected();
const html = markup(game);
for (const leader of game.allLeaders.filter((leader) => leader.id !== DUKE_VIDAL_ID)) {
assert.ok(html.includes(`value="${leader.id}"`));
assert.ok(html.includes(leader.name));
}
assert.doesNotMatch(html, /value="duke-vidal"/);
assert.equal((html.match(new RegExp(`value="${CHEAP_HERO_TRAITOR}"`, 'g')) ?? []).length, 1);
assert.match(html, /Cheap Hero \/ Heroine/);
assert.deepEqual(choose(game, 'atreides-0'), [fact('atreides-0')]);
assert.deepEqual(choose(game, 'tleilaxu-0'), [fact('tleilaxu-0')]);
assert.deepEqual(choose(game, CHEAP_HERO_TRAITOR), [fact(CHEAP_HERO_TRAITOR)]);
assert.deepEqual(choose(game, DUKE_VIDAL_ID), []);
assert.deepEqual(choose(game, 'not-a-printed-identity'), []);
});
void test('persistent guidance asks about current unrevealed custody and publishes only the aggregate answer', () => {
const html = markup();
assert.match(html, /respondent currently holds an unrevealed Face Dancer for this printed identity/);
assert.match(html, /A revealed, absent or returned card means No/);
assert.match(html, /current fact at the answer, not a promise to reveal a Face Dancer later/);
assert.match(html, /Ordinary traitor questions still ask only about ordinary Traitor Cards/);
assert.match(html, /Only the aggregate Truthtrance answer is published/);
assert.match(html, /does not physically reveal a Face Dancer/);
assert.match(html, /or identify which part of an AND\/OR claim is true/);
});
void test('TruthFactFields exposes the standalone Face Dancer option without changing ordinary traitor fields', () => {
const game = projected();
const html = renderToStaticMarkup(createElement(TruthFactFields, {
value: fact(), game, target: 't', onChange() {},
}));
assert.match(html, /value="faceDancer" selected="">Currently unrevealed Face Dancer/);
assert.match(html, /aria-label="Face Dancer identity"/);
assert.doesNotMatch(html, /value="duke-vidal"/);
const ordinary = renderToStaticMarkup(createElement(TruthFactFields, {
value: { kind: 'traitor', leader: 'atreides-0' }, game, target: 't', onChange() {},
}));
assert.match(ordinary, /value="traitor" selected="">Selected a traitor/);
assert.doesNotMatch(ordinary, /aria-label="Face Dancer identity"/);
});
void test('standalone AND/OR and mixed shipment validation accept the same current fact and reject unavailable identities', () => {
const game = projected();
const grouped: TruthFact = {
kind: 'and', terms: [fact(), { kind: 'or', terms: [fact(CHEAP_HERO_TRAITOR), { kind: 'spice', compare: 'gte', value: 2 }] }],
};
assert.equal(invalidTruthFact(grouped, game, 't'), false);
assert.equal(invalidTruthFact(fact(DUKE_VIDAL_ID), game, 't'), true);
assert.equal(invalidTruthFact(fact('not-a-printed-identity'), game, 't'), true);
const mixed: MixedShipmentExpression = {
kind: 'and', terms: [
{ kind: 'fact', fact: fact() },
{ kind: 'or', terms: [
{ kind: 'fact', fact: fact(CHEAP_HERO_TRAITOR) },
{ kind: 'shipment', territory: 'carthag', minimum: 1 },
] },
],
};
assert.equal(mixedShipmentInputError(mixed, game, 't'), null);
const html = renderToStaticMarkup(createElement(MixedShipmentFields, {
id: 'face-dancer-mixed', value: mixed, game, target: 't', disabled: false, onChange() {},
}));
assert.equal((html.match(/aria-label="Face Dancer identity"/g) ?? []).length, 2);
assert.match(html, /Shipment from reserves · this turn/);
assert.match(html, /Current fact · at the answer/);
const invalid: MixedShipmentExpression = {
kind: 'and', terms: [{ kind: 'fact', fact: fact(DUKE_VIDAL_ID) }, { kind: 'shipment', territory: 'carthag', minimum: 1 }],
};
assert.match(mixedShipmentInputError(invalid, game, 't')!, /Resolve the marked fields/);
});
void test('invalid identity input stays visibly marked while the shared Hero/Heroine choice remains available', () => {
const game = projected();
const invalid = markup(game, fact(DUKE_VIDAL_ID));
assert.match(invalid, /aria-invalid="true"/);
assert.match(invalid, /role="alert"/);
game.allLeaders = [createDukeVidal().leader];
const heroOnly = markup(game, fact(CHEAP_HERO_TRAITOR));
assert.match(heroOnly, /Cheap Hero \/ Heroine/);
assert.match(heroOnly, /aria-invalid="false"/);
assert.doesNotMatch(heroOnly, /value="duke-vidal"/);
assert.deepEqual(choose(game, CHEAP_HERO_TRAITOR), [fact(CHEAP_HERO_TRAITOR)]);
});
void test('selector and mixed validation never inspect target Face Dancers, private hands or wallets', () => {
const game = projected();
for (const player of game.players) {
for (const field of ['faceDancers', 'hand', 'traitors', 'spice']) {
Object.defineProperty(player, field, {
get() { throw new Error(`Current fact UI read ${player.id} private ${field}`); },
});
}
}
const html = renderToStaticMarkup(createElement(TruthFactFields, {
value: fact(), game, target: 't', onChange() {},
}));
assert.match(html, /Currently unrevealed Face Dancer/);
assert.match(html, /value="atreides-0"/);
assert.equal(invalidTruthFact(fact(), game, 't'), false);
assert.deepEqual(choose(game, CHEAP_HERO_TRAITOR), [fact(CHEAP_HERO_TRAITOR)]);
});
@@ -0,0 +1,180 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { truthFactAnswer, truthQuestionText, type TruthFact, type TruthQuestion } from '../game/truthtrance';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import {
createTruthtranceFaceDancerFixture as fixture,
actTruthFaceDancer as act,
truthFaceDancerPlayer as player,
truthFaceDancerInventory as inventory,
truthFaceDancerTraitors as traitors,
truthFaceDancerClean as clean,
advanceTruthFaceDancer as advance,
openTruthtranceFaceDancer as open,
askTruthFaceDancer as ask,
answerTruthFaceDancer as answer,
replaceTruthtranceFaceDancer as replace,
} from './fixture-truthtrance-face-dancer';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
const frame = (g: Game) => structuredClone({ phase: g.phase, turn: g.turn, active: g.active,
ready: g.ready, order: g.order, decision: g.decision, response: g.response,
phaseOpening: g.phaseOpening, movementRemaining: g.movementRemaining,
shipmentPromises: g.shipmentPromises, movementPromises: g.movementPromises });
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} native Face Dancer fact answers from the actual deal and resumes the same parent`, () => {
const f = fixture({ advanced });
const original = frame(f.game), cards = structuredClone(player(f.game, f.respondent).faceDancers);
assert.equal(cards?.length, 3);
assert.ok(cards?.some(card => card.leader === f.held && !card.revealed));
let g = ask(f.game, f);
assert.equal(g.truthtrance?.stage, 'answer');
assert.equal(viewGame(g, f.respondent).truthAnswer, 'yes');
for (const actor of [f.asker, f.observer]) {
const view = viewGame(g, actor);
assert.equal(view.truthAnswer, null);
assert.equal(view.players.find(p => p.id === f.respondent)!.faceDancers, undefined);
assert.deepEqual(view.truthHistory, []);
}
rejected(g, f.respondent, { type: 'truthAnswer', answer: 'no' });
rejected(g, f.respondent, { type: 'truthAnswer', answer: 'unknown' });
rejected(g, f.observer, { type: 'truthAnswer', answer: 'yes' });
g = answer(g, f, 'yes');
assert.deepEqual(frame(g), original);
assert.deepEqual(player(g, f.respondent).faceDancers, cards);
assert.equal(g.discard.filter(card => card.id === f.card).length, 1);
assert.equal(player(g, f.asker).hand.some(card => card.id === f.card), false);
assert.deepEqual(inventory(g), f.originalInventory);
assert.deepEqual(traitors(g), f.originalTraitors);
for (const actor of [f.asker, f.respondent, f.observer]) {
const history = viewGame(g, actor).truthHistory;
assert.equal(history.length, 1);
assert.equal(history[0].answer, 'yes');
assert.deepEqual(history[0].question, f.action.question);
}
rejected(g, f.respondent, { type: 'truthAnswer', answer: 'yes' });
});
}
void test('a current canonical unheld identity is a definite No, while an ordinary Traitor question stays distinct', () => {
const f = fixture();
const g = ask(f.game, f, f.unheld);
assert.equal(viewGame(g, f.respondent).truthAnswer, 'no');
rejected(g, f.respondent, { type: 'truthAnswer', answer: 'yes' });
assert.equal(viewGame(answer(g, f, 'no'), f.observer).truthHistory[0].answer, 'no');
const ordinary: TruthQuestion = { kind: 'fact', target: f.respondent,
fact: { kind: 'traitor', leader: f.held } };
const different = act(open(f.game, f), f.asker, { type: 'truthAsk', question: ordinary });
assert.equal(viewGame(different, f.respondent).truthAnswer, 'no');
assert.equal(truthFactAnswer(player(f.game, f.respondent), { kind: 'faceDancer', leader: f.held }), 'yes');
const hero = ask(f.game, f, f.hero);
assert.equal(viewGame(hero, f.respondent).truthAnswer, f.cheapHeroHeld ? 'yes' : 'no');
});
void test('nested AND/OR publishes only its aggregate answer without exposing individual Face Dancer matches', () => {
const f = fixture({ advanced: true });
const fact: TruthFact = { kind: 'and', terms: [
{ kind: 'or', terms: [{ kind: 'faceDancer', leader: f.unheld }, { kind: 'faceDancer', leader: f.held }] },
{ kind: 'spice', compare: 'gte', value: 0 },
] };
const question: TruthQuestion = { kind: 'fact', target: f.respondent, fact };
const g = act(open(f.game, f), f.asker, { type: 'truthAsk', question });
assert.equal(viewGame(g, f.respondent).truthAnswer, 'yes');
const done = answer(g, f, 'yes');
for (const actor of [f.asker, f.observer]) {
const view = viewGame(done, actor);
assert.deepEqual(view.truthHistory[0].question, question);
assert.equal(view.truthHistory[0].answer, 'yes');
assert.equal(view.truthAnswer, null);
assert.equal(view.players.find(p => p.id === f.respondent)!.faceDancers, undefined);
assert.deepEqual(Object.keys(view.truthHistory[0]).sort(), ['answer', 'asker', 'phase', 'question', 'turn']);
}
const text = truthQuestionText(question, id => viewGame(g, f.asker).allLeaders.find(l => l.id === id)!.name, id => id);
assert.match(text, /unrevealed Face Dancer/);
assert.match(text, / OR /);
assert.match(text, / AND /);
});
void test('an actual Mentat replacement is allowed after Yes and leaves its historical fact unchanged', () => {
const f = fixture();
const answered = answer(ask(f.game, f), f, 'yes');
const history = structuredClone(viewGame(answered, f.observer).truthHistory);
const replacement = replace(answered, f);
assert.equal(replacement.old, f.held);
assert.notEqual(replacement.new, replacement.old);
assert.equal(truthFactAnswer(player(replacement.game, f.respondent), { kind: 'faceDancer', leader: f.held }), 'no');
assert.equal(truthFactAnswer(player(replacement.game, f.respondent), { kind: 'faceDancer', leader: replacement.new }), 'yes');
assert.deepEqual(viewGame(replacement.game, f.observer).truthHistory, history);
assert.equal(replacement.game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(traitors(replacement.game), f.originalTraitors);
});
void test('the fact does not bypass existing mixed-shipment mode limits and remains usable as a standalone question', () => {
const f = fixture();
const source = advance(f.game, g => g.phase === 5 && g.active === f.respondent && clean(g));
assert.equal(viewGame(source, f.asker).truthShipmentTargets.includes(f.respondent), false);
const question: TruthQuestion = { kind: 'mixedShipment', target: f.respondent, mixed: {
kind: 'or', terms: [
{ kind: 'fact', fact: { kind: 'faceDancer', leader: f.held } },
{ kind: 'shipment', territory: 'arrakeen', minimum: 1 },
],
} };
const ready = open(source, f);
rejected(ready, f.asker, { type: 'truthAsk', question });
const pending = ask(ready, f);
assert.equal(viewGame(pending, f.respondent).truthAnswer, 'yes');
const done = answer(pending, f, 'yes');
assert.equal(done.shipmentPromises, undefined);
assert.equal(done.active, source.active);
});
for (const leader of ['old', 'new'] as const) {
void test(`a genuine replaced-first ${leader} identity uses current custody at a fresh question`, () => {
const f = fixture({ advanced: true, replaced: true });
const identity = f[leader]; assert.ok(identity);
const g = ask(f.game, f, identity);
const expected = leader === 'old' ? 'no' : 'yes';
assert.equal(viewGame(g, f.respondent).truthAnswer, expected);
const done = answer(g, f, expected);
assert.equal(viewGame(done, f.asker).truthHistory[0].answer, expected);
assert.equal(done.phase, 8);
});
}
void test('invalid or shared Duke identities and extra status fields reject before changing the physical question', () => {
const f = fixture();
const ready = open(f.game, f);
for (const fact of [
{ kind: 'faceDancer', leader: DUKE_VIDAL_ID },
{ kind: 'faceDancer', leader: 'missing-disc' },
{ kind: 'faceDancer', leader: f.held, revealed: false },
]) rejected(ready, f.asker, { type: 'truthAsk', question: { kind: 'fact', target: f.respondent, fact } });
assert.ok(player(ready, f.asker).hand.some(card => card.id === f.card));
assert.equal(ready.discard.some(card => card.id === f.card), false);
});
for (const profile of DIFFICULTIES) {
void test(`${profile} answers the same actual current Face Dancer facts without strategy or hidden rival access`, () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced });
for (const [identity, expected] of [[f.held, 'yes'], [f.unheld, 'no']] as const) {
const g = ask(f.game, f, identity);
const view = viewGame(g, f.respondent);
view.players.find(p => p.id === f.respondent)!.bot = profile;
const proposed = botActions(view);
assert.deepEqual(proposed, [{ type: 'truthAnswer', answer: expected }]);
const done = act(g, f.respondent, proposed[0]);
assert.equal(done.truthtrance, null);
assert.equal(viewGame(done, f.observer).truthHistory[0].answer, expected);
}
}
});
}
+124
View File
@@ -0,0 +1,124 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createAuditorLeader, leaders } from '../game/cards';
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
import {
faceDancerFactMatches,
faceDancerFactText,
parseFaceDancerFact,
type FaceDancerFact,
} from '../game/truthtrance-face-dancer';
const guild = leaders('guild');
const auditor = createAuditorLeader();
const allowed = [...guild.map(leader => leader.id), auditor.id, CHEAP_HERO_TRAITOR];
const fact = (leader = guild[0].id): FaceDancerFact => ({ kind: 'faceDancer', leader });
void test('the current exact held unrevealed Face Dancer answers Yes without changing its holder or fact', () => {
const holder = { faceDancers: [
{ leader: guild[0].id, revealed: false },
{ leader: guild[1].id, revealed: false },
{ leader: guild[2].id, revealed: true },
] };
const question = parseFaceDancerFact(fact(), allowed);
const before = structuredClone({ holder, question });
assert.equal(faceDancerFactMatches(holder, question), true);
assert.equal(faceDancerFactMatches(holder, fact(guild[1].id)), true);
assert.equal(faceDancerFactMatches(holder, fact(guild[2].id)), false);
assert.equal(faceDancerFactMatches(holder, fact(guild[3].id)), false);
assert.deepEqual({ holder, question }, before);
});
void test('absent and empty Face Dancer hands are No even if the same ordinary Traitor is held', () => {
const holder = { traitors: [guild[0].id], faceDancers: undefined };
assert.equal(faceDancerFactMatches(holder, fact()), false);
assert.equal(faceDancerFactMatches({ faceDancers: [] }, fact()), false);
assert.equal(faceDancerFactMatches({ faceDancers: [
{ leader: guild[1].id, revealed: false },
] }, fact()), false);
});
void test('revealing or returning the matching card changes the current fact to No without future binding', () => {
const held = { faceDancers: [{ leader: guild[0].id, revealed: false }] };
const revealed = { faceDancers: [{ leader: guild[0].id, revealed: true }] };
const returned = { faceDancers: [{ leader: guild[1].id, revealed: false }] };
const question = fact();
const before = structuredClone({ held, revealed, returned, question });
assert.equal(faceDancerFactMatches(held, question), true);
assert.equal(faceDancerFactMatches(revealed, question), false);
assert.equal(faceDancerFactMatches(returned, question), false);
assert.deepEqual({ held, revealed, returned, question }, before);
});
void test('only literal unrevealed false qualifies; a missing or nonboolean flag is not a hidden card', () => {
for (const revealed of [undefined, null, 0, '', 'false']) {
const holder = { faceDancers: [{ leader: guild[0].id, revealed }] };
assert.equal(faceDancerFactMatches(
holder as unknown as Parameters<typeof faceDancerFactMatches>[0], fact(),
), false);
}
});
void test('known Auditor and shared Cheap Hero identities are valid when included in the physical game list', () => {
for (const identity of [auditor.id, CHEAP_HERO_TRAITOR]) {
const input = fact(identity);
const parsed = parseFaceDancerFact(input, allowed);
assert.deepEqual(parsed, input);
assert.notEqual(parsed, input);
assert.equal(faceDancerFactMatches({ faceDancers: [
{ leader: identity, revealed: false },
] }, parsed), true);
assert.equal(faceDancerFactMatches({ faceDancers: [
{ leader: identity, revealed: true },
] }, parsed), false);
}
});
void test('Duke is never a physical Face Dancer identity, including an accidentally permissive allowed list', () => {
assert.throws(() => parseFaceDancerFact(fact(DUKE_VIDAL_ID), [...allowed, DUKE_VIDAL_ID]),
/physical Traitor identity/);
});
void test('unknown, stale and unavailable identities reject instead of becoming an unheld No fact', () => {
for (const identity of ['unknown-leader', 'atreides-0', 'cheap-hero', '', ' guild-0'])
assert.throws(() => parseFaceDancerFact(fact(identity), allowed), /physical Traitor identity/);
assert.throws(() => parseFaceDancerFact(fact(), []), /physical Traitor identity/);
assert.throws(() => parseFaceDancerFact(fact(auditor.id), guild.map(leader => leader.id)),
/physical Traitor identity/);
assert.throws(() => parseFaceDancerFact(fact(CHEAP_HERO_TRAITOR), guild.map(leader => leader.id)),
/physical Traitor identity/);
});
void test('the parser requires exactly the named current fact without mutating rejected input or the whitelist', () => {
const identities = [...allowed];
const before = [...identities];
for (const value of [
null, undefined, false, 1, guild[0].id, [], [fact()], {},
{ kind: 'faceDancer' },
{ leader: guild[0].id },
{ kind: 'traitor', leader: guild[0].id },
{ kind: 'faceDancer', leader: 0 },
{ kind: 'faceDancer', leader: null },
{ ...fact(), revealed: false },
{ ...fact(), future: true },
{ ...fact(), faction: 'tleilaxu' },
]) {
const original = structuredClone(value);
assert.throws(() => parseFaceDancerFact(value, identities));
assert.deepEqual(value, original);
}
assert.deepEqual(identities, before);
assert.throws(() => parseFaceDancerFact({ ...fact(), [Symbol('extra')]: true }, identities));
assert.throws(() => parseFaceDancerFact(Object.create(fact()) as unknown, identities));
});
void test('wording names current unrevealed Face Dancer custody and leaves ordinary Traitor meaning distinct', () => {
assert.equal(faceDancerFactText(fact(), identity => guild.find(leader => leader.id === identity)!.name),
'you currently hold Staban Tuek as an unrevealed Face Dancer');
assert.equal(faceDancerFactText(fact(auditor.id), () => auditor.name),
'you currently hold Auditor as an unrevealed Face Dancer');
assert.equal(faceDancerFactText(fact(CHEAP_HERO_TRAITOR), () => {
assert.fail('The shared hero identity uses its canonical name without a physical leader lookup.');
}), 'you currently hold Cheap Hero / Heroine as an unrevealed Face Dancer');
});