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

+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 ')})`;