diff --git a/components/mixed-shipment-fields.tsx b/components/mixed-shipment-fields.tsx index b895b280..07fc6496 100644 --- a/components/mixed-shipment-fields.tsx +++ b/components/mixed-shipment-fields.tsx @@ -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 }: { + @@ -80,7 +94,8 @@ export function TruthFactFields({ value: fact, game, target, onChange }: { {TRUTH_CARD_NAMES.map(name => )} : fact.kind === 'handCount' || fact.kind === 'handInventory' ? - : fact.kind === 'noFieldValue' ? + : fact.kind === 'faceDancer' ? + : fact.kind === 'noFieldValue' ? : fact.kind === 'forceCount' ? player.id === target)!} onChange={onChange} /> : fact.kind === 'prediction' || fact.kind === 'stormDial' || fact.kind === 'stormForecast' ? diff --git a/components/truthtrance-face-dancer.tsx b/components/truthtrance-face-dancer.tsx new file mode 100644 index 00000000..56d7e55c --- /dev/null +++ b/components/truthtrance-face-dancer.tsx @@ -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 ( + <> + +

+ This asks whether the respondent currently holds an unrevealed Face + Dancer for this printed identity. A revealed, absent or returned card + means No. +

+

+ 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. +

+

+ 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. +

+ {error &&

{error}

} + + ); +} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 42b7c484..4947d0b8 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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; diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 83fc428d..cd2a163f 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -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 diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 0cb8bf60..7d63f46a 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -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 diff --git a/docs/TRUTHTRANCE_FACE_DANCERS.md b/docs/TRUTHTRANCE_FACE_DANCERS.md new file mode 100644 index 00000000..27459355 --- /dev/null +++ b/docs/TRUTHTRANCE_FACE_DANCERS.md @@ -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. diff --git a/game/reference.ts b/game/reference.ts index 5e877497..38a2266e 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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.', diff --git a/game/truthtrance-face-dancer.ts b/game/truthtrance-face-dancer.ts new file mode 100644 index 00000000..531ebb14 --- /dev/null +++ b/game/truthtrance-face-dancer.ts @@ -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; + 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`; +} diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 0d4c621d..1d311baa 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -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 & Partial, + p: Pick & Partial, 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 ')})`; diff --git a/tests/fixture-truthtrance-face-dancer.ts b/tests/fixture-truthtrance-face-dancer.ts new file mode 100644 index 00000000..835b0798 --- /dev/null +++ b/tests/fixture-truthtrance-face-dancer.ts @@ -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(run: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const original = crypto.getRandomValues.bind(crypto); + crypto.getRandomValues = (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; + +/** 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; diff --git a/tests/truthtrance-face-dancer-controls.test.tsx b/tests/truthtrance-face-dancer-controls.test.tsx new file mode 100644 index 00000000..bbae500a --- /dev/null +++ b/tests/truthtrance-face-dancer-controls.test.tsx @@ -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)]); +}); diff --git a/tests/truthtrance-face-dancer-engine.test.ts b/tests/truthtrance-face-dancer-engine.test.ts new file mode 100644 index 00000000..804242cf --- /dev/null +++ b/tests/truthtrance-face-dancer-engine.test.ts @@ -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); + } + } + }); +} diff --git a/tests/truthtrance-face-dancer-fact.test.ts b/tests/truthtrance-face-dancer-fact.test.ts new file mode 100644 index 00000000..a432f8da --- /dev/null +++ b/tests/truthtrance-face-dancer-fact.test.ts @@ -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[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'); +});