diff --git a/components/mixed-shipment-fields.tsx b/components/mixed-shipment-fields.tsx index 5796c798..c5c39820 100644 --- a/components/mixed-shipment-fields.tsx +++ b/components/mixed-shipment-fields.tsx @@ -13,6 +13,8 @@ import { CardCountFields } from './truthtrance-card-count'; import { KnowledgeFactFields } from './truthtrance-knowledge'; import { ForceCountFields, forceCountInputError } from './truthtrance-force-count'; import { ShipmentClauseFields, invalidShipmentClause } from './shipment-claim-fields'; +import { NoFieldValueFields } from './truthtrance-no-field-value'; +import { parseNoFieldValueFact } from '@/game/truthtrance-no-field-value'; export function invalidTruthFact(fact: TruthFact, game: GameView, target: string): boolean { if (fact.kind === 'and' || fact.kind === 'or') { @@ -21,6 +23,10 @@ export function invalidTruthFact(fact: TruthFact, game: GameView, target: string if (fact.kind === 'spice' || fact.kind === 'handCount' || fact.kind === 'handInventory') { return !Number.isSafeInteger(fact.value) || fact.value < 0; } + if (fact.kind === 'noFieldValue') { + try { parseNoFieldValueFact(fact); 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)!); @@ -47,6 +53,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: { stormForecast: { kind: 'stormForecast', compare: 'gte', value: 1 }, spice: { kind: 'spice', compare: 'gte', value: 6 }, forceCount: { kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 6 }, + noFieldValue: { kind: 'noFieldValue', value: 3 }, }; return <> @@ -63,6 +70,7 @@ export function TruthFactFields({ value: fact, game, target, onChange }: { Known storm forecast Current personal spice Current physical forces + Current concealed No-Field printed value {fact.kind === 'hand' ? @@ -71,8 +79,9 @@ export function TruthFactFields({ value: fact, game, target, onChange }: { {TRUTH_CARD_NAMES.map(name => {name})} : fact.kind === 'handCount' || fact.kind === 'handInventory' ? - : fact.kind === 'forceCount' ? - player.id === target)!} onChange={onChange} /> : + : fact.kind === 'noFieldValue' ? + : fact.kind === 'forceCount' ? + player.id === target)!} onChange={onChange} /> : fact.kind === 'prediction' || fact.kind === 'stormDial' || fact.kind === 'stormForecast' ? : fact.kind === 'spice' ? <> diff --git a/components/truthtrance-no-field-value.tsx b/components/truthtrance-no-field-value.tsx new file mode 100644 index 00000000..1417daab --- /dev/null +++ b/components/truthtrance-no-field-value.tsx @@ -0,0 +1,17 @@ +'use client'; + +import type { NoFieldValueFact } from '@/game/truthtrance-no-field-value'; + +export function NoFieldValueFields({ value, onChange }: { + value: NoFieldValueFact; + onChange: (value: NoFieldValueFact) => void; +}) { + return <> + Printed No-Field value onChange({ kind: 'noFieldValue', value: Number(event.target.value) as NoFieldValueFact['value'] })}> + 0 + 3 + 5 + + This asks whether the respondent currently has a concealed No-Field with this printed value. No deployed token, or an already revealed token, means No, including for value 0. This is not a prospective force count or a commitment to use a future token. The public answer does not physically reveal the token. + >; +} diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index f94f6cb5..11796870 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,29 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Current concealed No-Field value facts — 16 October.** Truthtrance can ask +whether the respondent currently has a concealed printed0/3/5. Absent or +revealed is No even for0; concealed5 with exhausted reserves remains5. Read +only that player's original model, never allied pools, prospective counters +or shipment intent. The private expected/public aggregate path leaves token, +event, forces, reserves and history untouched; original card disposal resumes +the parent. This broad game-fact/source composition is explicit, not a dedicated +No-Field FAQ. Physical-force facts remain separate; no new saved root or mode. + +Twenty-three new cases and23-file228/228 related union pass; types/lint/build +pass with existing warnings. One Advanced fixture now completes original Guild +interception before expecting deployment; no product guard changes. The allied +negative case uses actual native Ecaz/Richese Nexus, not staged ally metadata. +Four actual390px queries publish one reply/card and return to original phase5/ +Richese activity: concealed0 Yes, exhausted5 AND physical0 Yes, undeployed0 No, +revealed5 No. No overflow/application exception; four initial Vite dependency +requests were aborted by startup optimization reload before completed journeys. +Sixteen fresh programs/four policies accept48 priority/reply actions, then legal +shipment/reveal or full-plan/battle continuation. Guide import resolves42 paths/ +source anchor; five No-Field facets and all three coverages stay Partial. +Owned host/tab/scaffolds removed; no human game, D1 or deployment changed. +[Exact question semantics and scoped proof](RICHESE_NO_FIELD_RULES.md#current-concealed-value-truthtrance--16-october-2026). + **Distinct Advanced special Karama / No-Field — 16 October.** Supplied p27 C and p36 B bar ordinary dial Prescience; distinct p27 F grants an entire-plan special. Standing choose-recommended provisionally keeps them separate. diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 5c6aba2e..32860d73 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,44 @@ # Dune implementation status +## 16 October 2026 — Current concealed No-Field Truthtrance values + +Native before-state rejected a real current-value question during an actual +Advanced full-plan offer as an unsupported AND/OR clause. Add the strict +two-key `{kind:'noFieldValue',value:0|3|5}` leaf, shared static selector and +same-owner truthful answer/text integration. No marker/revealed history is No +even for0; concealed5 with no reserves remains printed5, not prospective0. +Physical-force facts and native token custody/event/costs remain separate. +No new saved root field, synthetic alliance, module or public gate. + +Broad publisher game-fact authority and supplied physical p39 ground this +explicit current-observation/information-versus-physical-revelation composition, +not a retrieved dedicated No-Field FAQ. Ordinary Prescience's exception is not +this card. Original Truthtrance priority/aggregate/private answer/disposal and +native shipment/power/battle parent continue; historical observations do not +become future token-use promises. + +Twenty-three new cases and23-file228/228 related union pass, with types/lint +and build; existing module-register/chunk-size/route warnings remain. One +fixture initially asserted an Advanced shipment before its original Guild +interception finished; native continuation now precedes the deployment check. +The allied no-borrow case uses genuine Ecaz/Richese Nexus/coalition, not copied +alliance metadata. Bot tests assert accepted native replies/public outcomes, +not exact forwarding-array/default pins. + +Four actual390px journeys publish one reply/card and resume original phase5/ +Richese activity: concealed0 Yes; concealed5/empty reserves AND board physical0 +Yes; undeployed0 No; revealed5 No. Token model/event, force arrays, reserves +and spice remain unchanged. No overflow/application exceptions. Initial Vite +optimization reload aborted four old dependency requests; the rendered flows +completed afterward. Owned host/tab/scaffolds removed. + +Sixteen fresh native programs/all four profiles accept48 priority/reply actions, +then original shipment, actual voluntary reveal or full-plan/battle/Collection +continues. Guide import resolves42 evidence paths/source anchor; five No-Field +facets and all three topic coverages remain Partial. No human game, D1, +deployment or new broad/HTTP/recovery/privacy/custody assurance changed. +[Canonical semantics/source](RICHESE_NO_FIELD_RULES.md#current-concealed-value-truthtrance--16-october-2026). + ## 16 October 2026 — Distinct Advanced special inspection against No-Fields Read the supplied authorized revision's physical p27 C/F and p36 B. Ordinary diff --git a/docs/RICHESE_NO_FIELD_RULES.md b/docs/RICHESE_NO_FIELD_RULES.md index 05950034..f672f021 100644 --- a/docs/RICHESE_NO_FIELD_RULES.md +++ b/docs/RICHESE_NO_FIELD_RULES.md @@ -2,6 +2,57 @@ Audit date: 2026-09-06. This document distinguishes the printed token lifecycle from engine integration and unresolved combinations. It does not certify Richese, Homeworlds, or any other gated game mode. +## Current concealed-value Truthtrance — 16 October 2026 + +The separate current-fact question asks whether the respondent **currently has +a concealed No-Field with printed value 0,3 or5**. It reads that player's actual +deployed physical token, not an ally's variable army, a pending declaration, +last-used history or prospective reserve-capped counters. No deployed token, +including one already revealed, answers No even for value0. A concealed5 with +zero reserves still has printed value5, not0. + +Basic uses the existing GF9 November2020 p8 game-related yes/no-fact authority; +Advanced also uses the supplied physical p39 Truthtrance text. The p27 C/ +p36 B ordinary Prescience exception does not name this distinct card. This +current-observation/information-versus-component-revelation composition is +explicit, not a newly retrieved No-Field-specific FAQ. + +Only the respondent sees the expected answer before replying. The final +public Yes/No can convey information but does not turn up a marker, materialize +counters, debit reserves, rotate its event or reset shipment history. Existing +AND/OR publishes only the aggregate. Native physical Truthtrance disposal +resumes the same shipment/power/battle; a historical fact does not become a +future token-use promise. Physical-force count questions still exclude markers +and denomination. No new Game root field, module gate or generic natural- +language/future-No-Field promise enforcement is introduced. + +### Scoped current-fact proof + +The23 new focused/native cases and frozen23-file228/228 related union pass. +Actual current-fact declaration, priority, truthful/compound answer, physical +discard and original opportunity continue without marker/model/event changes. +The allied negative case uses the original native Ecaz/Richese Nexus alliance, +not a copied alliance. An Advanced fixture now finishes its real Guild +interception before expecting the declared shipment to deploy; no guard changes. + +Four actual390px question journeys publish one original reply/card each: +concealed0 → Yes; concealed5 with empty reserves AND physical0 at its sector +→ Yes; undeployed0 → No; revealed5 → No. Each returns to original phase5/ +Richese activity without changing token model, event, forces, reserves or spice. +No overflow/application exception. Initial Vite optimization reload aborted +four old dependency requests; the rendered journeys completed afterward. +Owned host/tab/scaffolds were removed. + +Sixteen fresh programs across all four legal policies accept48 priority/reply +actions, then continue original shipment, actual voluntary reveal or full-plan +offer/battle into Collection. Historical replies remain observations through +those later actions. Actual generated guide import resolves42 evidence paths/ +source anchor; five No-Field facets and all three topic coverages stay Partial. +These are genuine setup/shipment plus labelled conserved controls, not full +unstaged games, new recovery/privacy/custody or deployed acceptance. +Types/lint and application build also pass, retaining the existing module- +register/chunk-size/route warnings. Complete mode/combination assurance remains. + ## Atreides special inspection — 16 October 2026 The supplied p27 C ordinary Prescience dial exception remains. Its distinct diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 954772c0..153dba44 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -661,6 +661,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | No-Field battle cards | Under standing choose-recommended, preserve native materialization before card effects: actual reserve-limited token reveal, original Reinforcements cost, then successful Harass return. Privately forecast physical counters/sector and require three own reserves after reveal before sealing the fee card; future returns or ally reserves cannot fund it. Zero, original token/history, support and fixed/variable owners remain native. This is explicitly provisional composition, not a new publisher FAQ or user answer. Other profile/module and inspection gates stay. | [Harass contract](HARASS_WITHDRAW_RUNTIME.md#own-no-field-composition--16-october-2026) · [Native lifecycle](RICHESE_NO_FIELD_RULES.md#concealed-battle-card-composition--16-october-2026) | | Mixed No-Field Occupy | Extend the existing provisional additive ordinary-plus-reserve-capped-marker pool into selected native Advanced coalition battles, as ally or opponent. Original fixed Ecaz strength, support payer/counter owners, Traitor/actual reveal and history stay; prevention restores selected-lead-only pool. Selected Ecaz plan controls the ally's private variable quote, not its own ordinary maximum; rivals get no concealed increment. The FAQ mixed-cap ambiguity remains unresolved, not newly adjudicated. Basic mixed-co-side and other profile/module gates remain. | [Exact composition](RICHESE_NO_FIELD_RULES.md#mixed-native-occupy-pool--16-october-2026) | | No-Field / Atreides special plan | Supplied physical p27 C bars ordinary dial Prescience; distinct p27 F grants an entire-plan special Karama and p36 B names Prescience, not separately F. Under standing choose-recommended, provisionally allow F's actual complete private plan read, retaining C's dial bar. Original target-first commitment is not public No-Field revelation; both seals still materialize once, including zero/caps, then original costs/losses/history run. No token inventory or automatic allied inspection entitlement is granted. This is explicit textual/timing composition, not a new combined FAQ/user answer or mode opening. | [Exact source and scope](ATREIDES_FULL_PLAN_TIMING.md#supplied-source-no-field-composition--16-october-2026) | +| No-Field / current Truthtrance fact | Broad game-related yes/no-fact authority supports the distinct current concealed printed-value predicate0/3/5. No marker/revealed history is No even for0; capped/exhausted5 remains printed5. Read the respondent's own model, never an ally or planned shipment. Publish only aggregate Yes/No after the private expected answer; information disclosure is not physical revelation or future-use obligation. This is explicit composition, not a dedicated No-Field FAQ. Existing physical-counter exclusion, native disposal/parent and mode gates remain. | [Source and current-fact scope](RICHESE_NO_FIELD_RULES.md#current-concealed-value-truthtrance--16-october-2026) | | Clean Ix Stone / Tooth / Mirror | Reuse the existing CHOAM prototype order: original Stone first, then uncopied Tooth. The earlier priority question is unanswered; standing choose-recommended extends that order provisionally, not as a publisher ruling or user answer. Printed Mirror copy-first and user-selected physical retention stay independent. Clean selected Ix/Richese supports native Ixians/Tleilaxu in both modes; Ecaz/Moritani stays Advanced-only. Typed winner ambiguity, new Ix Tech/Stronghold and other module bands remain guarded. | [Source and profile distinction](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026) | | Expansion-faction setup | Selecting Ecaz/Moritani factions does not select their independent three-card variant. Ordinary decks contain 33/35/47 cards; Richese's ten-card cache stays separate. Ecaz allocates six total forces within Imperial Basin sectors 9–11, retaining fourteen reserves. Flexible sector allocation is the literal printed territory constraint; Fremen→Ecaz→Advanced BG order is an implementation inference, while Moritani-last is printed. | [Source and profile contract](EXPANSION_FACTIONS_PROTOTYPE.md) | | Ixian Technology on Richese lots | Superseded 8 October by the authorized supplied Advanced physical p34, which expressly excludes Richese auctions from Ixian Technology. Richese cache/Black Market lots now open directly; the once-per-round Technology use remains for ordinary lots. The temporary per-lot decline scaffolding and its tests were deleted. | [Auction source boundary](RICHESE_AUCTION_RULES.md#13-september-saved-per-lot-technology-decline--superseded-8-october) | diff --git a/docs/TRUTHTRANCE_FORCE_FACTS.md b/docs/TRUTHTRANCE_FORCE_FACTS.md index 32ebb2d9..b6fd423f 100644 --- a/docs/TRUTHTRANCE_FORCE_FACTS.md +++ b/docs/TRUTHTRANCE_FORCE_FACTS.md @@ -33,6 +33,22 @@ aggregate total is answerable; typed comparisons are unavailable rather than inventing a distribution. Ordinary factions without special counters have zero elite counters. The same public guard feeds parsing and controls. +## Separate concealed-marker value fact — 16 October 2026 + +Choose **Current concealed No-Field printed value** to ask the distinct +existence/value predicate for0/3/5. This never changes physical-force counts: +a marker contributes no physical counters, and its printed5 remains5 even +when reserves can materialize only0 or2. No current concealed token is No, +including for value0; last-used faceup history is not current custody. + +The private expected-answer and public aggregate/one-card-disposal path is the +same as other facts. Asking/answering reveals information, not the physical +component, and does not commit later use. Original root state and native +opportunities remain. This uses the existing broad game-fact authority and, +for Advanced, supplied physical p39; no dedicated No-Field FAQ is claimed. +[Exact scope and source composition](RICHESE_NO_FIELD_RULES.md#current-concealed-value-truthtrance--16-october-2026). + + ## Connected flow and saved behavior Choose **Current physical forces**, then the pool, optional board location, diff --git a/game/reference.ts b/game/reference.ts index f02e7cc7..210109ff 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1034,6 +1034,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'The map shows a concealed marker without its denomination to everyone. Richese privately sees its token values, last-use restriction and reveal control. Movement selection keeps the marker separate from physical forces. No hidden denomination or physical token identity is included in another player’s projected board state.', 'Marker-only battles use the owner’s private reserve-limited force pool. Both plans must be sealed before the token materializes. A zero-token battle still resolves with its leader, cards and traitor decisions. Ordinary Atreides prescience cannot request this opponent’s dial; other eligible plan elements remain available.', 'Advanced Atreides special Karama is provisionally distinct from ordinary Prescience: it may privately inspect the complete committed No-Field plan, including dial/support and actual played faces. The target seals first; only Atreides receives that inspection, not automatic allies or observers. This is not a token inventory/maximum disclosure or early physical reveal. Both combatants still seal before native materialization. Ordinary dial Prescience remains prohibited.', + 'Truthtrance may separately ask whether you currently have a concealed No-Field with printed value0,3 or5. No deployed token, including a revealed last-used token, is No even for value0; concealed5 remains5 with empty reserves. Only the respondent sees the expected answer before public reply. AND/OR publishes one aggregate. Asking/answering does not reveal the physical marker, move counters or bind future use. Physical-force facts still exclude markers.', 'Basic and Advanced ordinary mixed battles have a provisional private pool: physical forces already here plus the token value capped by current reserves. The Battle Wheel labels that interpretation explicitly. Both plans seal before materialization, then native support, casualties and aftermath use actual forces. This first version does not adjudicate the FAQ mixed dial cap.', 'In supported card profiles, provisional materialization-first order forecasts the own marker privately without moving forces early. Both plans seal, the original token reveals and materializes its reserve-limited ordinary counters at its sector, then Harass selects/returns actual undialed counters. Reinforcements must leave three original reserves after that reveal and before any future Harass return; an unfunded card cannot seal a plan. Marker0, last-used identity and original reveal-event history remain native.', 'Concealed collection, occupancy and control use one-force presence. Exposed storm sectors and unprotected worm destruction reveal and remove the resulting actual forces. Storm shelter and other sectors remain separate. Trip to Gamont at a location with a marker reveals it, even if ordinary forces also occupy that sector, then returns one actual force if present. It is still used if zero forces remain. Canceling the CHOAM power leaves the marker concealed.', @@ -1043,6 +1044,7 @@ export const RULE_TOPICS: RuleTopic[] = [ related: [ 'richese-cards', 'richese-acquisition', + 'card-truthtrance', 'movement', 'battle', 'implementation-checklist', @@ -1052,35 +1054,35 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Native lifecycle, allied pricing, private additive pools and card funding remain. The distinct Advanced full-plan special now reads the complete sealed plan while ordinary dial Prescience stays barred; private reading never materializes the token early. Original both-seals reveal, fixed/variable owners, fees, returns and history stay.', - evidence: ['game/shipment-price.ts', 'game/engine.ts'], + 'Native lifecycle, additive pools, card funding and distinct special inspection remain. Current printed-value Truthtrance now reads only the respondent’s original concealed token, never reserves, allied variable armies or last-used history. Existing physical-force facts remain separate; card disposal resumes the original parent without model/event mutation.', + evidence: ['game/shipment-price.ts', 'game/engine.ts', 'game/truthtrance.ts', 'game/truthtrance-no-field-value.ts'], }, { area: 'Player controls', status: 'Partial', detail: - 'Original inventory/shipment/movement/card controls remain. Before spending and during the entitled private read, the provisional special-power distinction is explicit. Four actual390px journeys show zero/card legality, fractional dial versus support, capped Ecaz-variable plan and actual H/R faces, then original card cleanup/market into Collection.', - evidence: ['components/richese-allied-no-field.tsx', 'components/richese-no-field.tsx', 'components/harass-withdraw.tsx', 'components/game-table.tsx', 'components/private-battle-plan.tsx'], + 'Original inventory/card/inspection controls remain. The shared current-fact selector adds static0/3/5 printed-value controls with explicit absent-versus-zero and printed-versus-materialized explanation. Four actual390px query journeys include concealed0, exhausted5 AND physical0, undeployed0 and revealed5; native public reply/disposal returns to the same Shipment turn.', + evidence: ['components/richese-allied-no-field.tsx', 'components/richese-no-field.tsx', 'components/harass-withdraw.tsx', 'components/game-table.tsx', 'components/private-battle-plan.tsx', 'components/mixed-shipment-fields.tsx', 'components/truthtrance-no-field-value.tsx'], }, { area: 'AI', status: 'Partial', detail: - 'Own/allied policies remain. All four profiles spend the actual special once, privately inspect the original first seal and complete native reveal/declared outcomes. Sixteen current ordinary/active/prevented coalition programs accept92 policy actions through Collection. Strategy and calibration stay deferred.', + 'Existing own/allied and special-inspection policies remain. All four profiles pass native Truthtrance priority and use the owner-only aggregate truthAnswer. Sixteen current programs accept48 policy actions, then continue original shipment, voluntary reveal or full-plan/battle into Collection. Question strategy and calibration remain deferred.', evidence: ['game/bots.ts'], }, { area: 'Documentation', status: 'Partial', detail: - 'Supplied p27 C/F and p36 B ground the explicitly provisional ordinary-versus-special distinction and private-read-versus-public-token-reveal timing. Original additive caps, cost-before-return and remaining Truthtrance/module boundaries stay separately qualified.', - evidence: ['docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/RICHESE_NO_FIELD_RULES.md', 'docs/ATREIDES_FULL_PLAN_TIMING.md'], + 'The broad game-related yes/no authority and explicit current-observation/information-versus-component-revelation composition are recorded. Printed value, reserve cap, physical counter exclusion, ordinary Prescience, distinct special inspection and remaining future/module boundaries stay separate.', + evidence: ['docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/RICHESE_NO_FIELD_RULES.md', 'docs/ATREIDES_FULL_PLAN_TIMING.md', 'docs/TRUTHTRANCE_FORCE_FACTS.md'], }, { area: 'Verification', status: 'Partial', detail: - 'Eighteen new actual-shipment inspection cases and the29-file393/393 union cover zero/caps, mixed sectors, native target-first read, ordinary commitments, real Traitors and original costs/history. Four mobile and16 policy continuations are scoped current proof; earlier recovery/allied evidence remains historical, not new assurance or complete-mode/deployed acceptance.', + 'Twenty-three new focused/native fact cases and the23-file228/228 union cover0/3/5, empty/capped reserves, absence/history, actual allied owner isolation, truthful/compound replies and original continuation. Four mobile and16 policy programs are scoped current proof. Earlier inspection/allied/recovery evidence remains historical; no new complete-mode/deployed assurance.', evidence: [ 'tests/richese-no-field.test.ts', 'tests/richese-no-field-engine.test.ts', @@ -1104,6 +1106,9 @@ export const RULE_TOPICS: RuleTopic[] = [ 'tests/fixture-no-field-full-plan.ts', 'tests/no-field-full-plan-occupy.test.ts', 'tests/fixture-no-field-full-plan-occupy.ts', + 'tests/truthtrance-no-field-value.test.ts', + 'tests/truthtrance-no-field-engine.test.ts', + 'tests/fixture-truthtrance-no-field.ts', ], }, ], @@ -2631,10 +2636,11 @@ export const RULE_TOPICS: RuleTopic[] = [ steps: [ 'Choose Current physical forces, then the pool, counter type and an Exactly, At least or At most comparison. A board question names both territory and sector. Available HMS interiors and revealed Discovery locations are separate from their outside territories.', 'Every physical counter counts once, including advisors. The total includes elites, and the normal count excludes them. Combat bonuses, concealed No-Field markers and their hidden force values do not enter the count.', + 'Use the separate Current concealed No-Field printed value question for a token denomination. Concealed5 can coexist with exactly zero physical counters at its sector and zero reserves; this still is not five materialized forces. Public Yes/No does not itself reveal or materialize the marker.', 'Reserves includes the native Homeworld reserve breakdown. These controls do not query individual Homeworld populations. A Basic faction whose starred counters are not tracked separately supports only the aggregate total.', 'Combine the question with another supported fact using AND or OR. Only the respondent sees the expected answer; the published result does not identify which clause matched. Later legal movement or revival does not change this historical answer or break a promise.', ], - related: ['card-truthtrance', 'force-counters', 'truthtrance-spice', 'privacy'], + related: ['card-truthtrance', 'force-counters', 'truthtrance-spice', 'richese-no-field', 'privacy'], checklist: [ { area: 'Implementation', status: 'Implemented', detail: 'Physical pool comparisons, exact live locations, typed-counter availability and saved fact revalidation are connected.', evidence: ['game/truthtrance-force-count.ts', 'game/truthtrance.ts'] }, { area: 'Player controls', status: 'Implemented', detail: 'Pool, territory/sector, counter type and numeric comparison share validation with the server.', evidence: ['components/truthtrance-force-count.tsx', 'components/truthtrance.tsx'] }, @@ -2905,6 +2911,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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.', '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.', 'A saved definite answer resumes automatically: its public answer, binding promise and consumed card are recorded once, then the next queued question or interrupted action resumes. Private answer and preparation guidance is unavailable during this automatic recovery.', 'Freeform questions also accept public Yes, No, or I don’t know answers. Their truthfulness relies on the players. AI answers structured facts and battle-plan questions; it cannot yet interpret freeform questions.', @@ -2921,6 +2928,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'truthtrance-card-count', 'truthtrance-hand-inventory', 'truthtrance-shipment', + 'richese-no-field', ], }, ...FACTIONS.map( diff --git a/game/truthtrance-no-field-value.ts b/game/truthtrance-no-field-value.ts new file mode 100644 index 00000000..8c2855d2 --- /dev/null +++ b/game/truthtrance-no-field-value.ts @@ -0,0 +1,45 @@ +import { + validateRicheseNoField, + type NoFieldValue, + type RicheseNoField, +} from './richese-no-field'; + +export type NoFieldValueFact = { + kind: 'noFieldValue'; + value: NoFieldValue; +}; + +export function parseNoFieldValueFact(value: unknown): NoFieldValueFact { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new Error('Choose a current concealed No-Field printed-value fact.'); + const input = value as Record; + const keys = Object.keys(input); + if ( + keys.length !== 2 || + !keys.includes('kind') || + !keys.includes('value') || + input.kind !== 'noFieldValue' + ) + throw new Error('Choose only a current concealed No-Field printed-value fact.'); + if (input.value !== 0 && input.value !== 3 && input.value !== 5) + throw new Error('Choose the printed No-Field value zero, three, or five.'); + return { kind: 'noFieldValue', value: input.value }; +} + +/** Observe current concealed custody, never shipment history or materialized forces. */ +export function noFieldValueFactMatches( + state: RicheseNoField | undefined, + fact: NoFieldValueFact, +): boolean { + if (state === undefined) return false; + validateRicheseNoField(state); + if (state.deployed === null) return false; + const token = state.tokens.find( + (candidate) => candidate.id === state.deployed!.tokenId, + )!; + return token.value === fact.value; +} + +export function noFieldValueFactText(fact: NoFieldValueFact): string { + return `you currently have a concealed No-Field with printed value ${fact.value}`; +} diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 2cebfdbe..8283118a 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -38,12 +38,14 @@ import { CHEAP_HERO_TRAITOR } from './traitors'; 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'; export type TruthFact = | KnowledgeFact | CardCountFact | HandInventoryFact | ForceCountFact + | NoFieldValueFact | { kind: 'hand'; name: string } | { kind: 'traitor'; leader: string } | { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number } @@ -116,6 +118,10 @@ function parseFact( throw new TruthError(error instanceof Error ? error.message : String(error)); } } + if (v.kind === 'noFieldValue') { + try { return parseNoFieldValueFact(v); } + catch (error) { throw new TruthError(error instanceof Error ? error.message : String(error)); } + } if (isKnowledgeFact(v)) { try { return parseKnowledgeFact(v); } catch (error) { throw new TruthError(error instanceof Error ? error.message : String(error)); } @@ -338,11 +344,13 @@ 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 { if (isKnowledgeFact(fact)) return knowledgeFactAnswer(fact, knowledge); + if (fact.kind === 'noFieldValue') + return noFieldValueFactMatches(p.noField, fact) ? 'yes' : 'no'; if (fact.kind === 'forceCount') return forceCountFactMatches(p as ForceCountPlayer, fact) ? 'yes' : 'no'; if (fact.kind === 'handInventory') @@ -400,6 +408,7 @@ export function truthQuestionText( return `In this battle in ${territory(q.territory).name}, will it be true that ${planClaimText(q.claim, leaderName)}?`; const clause = (f: TruthFact): string => { if (isKnowledgeFact(f)) return knowledgeFactText(f); + if (f.kind === 'noFieldValue') return noFieldValueFactText(f); if (f.kind === 'forceCount') return forceCountFactText(f); if (f.kind === 'handInventory') return handInventoryFactText(f); if (f.kind === 'handCount') return cardCountFactText(f); diff --git a/tests/fixture-truthtrance-no-field.ts b/tests/fixture-truthtrance-no-field.ts new file mode 100644 index 00000000..c10cad6f --- /dev/null +++ b/tests/fixture-truthtrance-no-field.ts @@ -0,0 +1,175 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import type { Card } from '../game/cards'; +import type { NoFieldValue } from '../game/richese-no-field'; +import type { TruthFact } from '../game/truthtrance'; +import { + createHarassNoFieldFixture, type HarassNoFieldFixture, +} from './fixture-harass-no-field'; +import { + createNoFieldFullPlanFixture, type NoFieldFullPlanFixture, +} from './fixture-no-field-full-plan'; + +export type TruthtranceNoFieldOptions = { + advanced?: boolean; + marker?: NoFieldValue; + reserves?: number; + boundary?: 'beforeShipment' | 'afterShipment' | 'battle' | 'fullPlanOffer'; +}; +export type TruthtranceNoFieldFixture = { + game: Game; + owner: string; + asker: string; + opponent: string; + observer: string; + token: { id: string; value: NoFieldValue }; + truthCard: Card; + staging: string[]; + harass: HarassNoFieldFixture | null; + fullPlan: NoFieldFullPlanFixture | null; +}; +export function truthtranceNoFieldPlayer(game: Game, actor: string) { + const player = game.players.find(candidate => candidate.id === actor); + assert.ok(player, `Missing original No-Field seat ${actor}`); + return player; +} + +/** Physical inventory at these settled native boundaries; no catalog is redealt. */ +export function truthtranceNoFieldCards(game: Game) { + return [ + ...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), + ...game.players.flatMap(player => player.hand), + ].map(card => card.id).sort(); +} + +/** Conserved hand staging after genuine setup, never a replacement or invented card. */ +export function stageNoFieldTruthtrance(game: Game, actor: string, staging: string[]): Card { + const inventory = truthtranceNoFieldCards(game); + const player = truthtranceNoFieldPlayer(game, actor); + const held = player.hand.find(card => card.effect === 'truthtrance'); + if (held) return structuredClone(held); + const source = [game.deck, ...game.players.filter(candidate => candidate.id !== actor).map(candidate => candidate.hand)] + .find(cards => cards.some(card => card.effect === 'truthtrance')); + assert.ok(source, 'Relocate an originally selected, unused physical Truthtrance'); + if (player.hand.length === 4) { + const returned = player.hand.pop()!; + game.deck.push(returned); + staging.push(`After genuine setup: conserve ${returned.id} by returning it from ${actor}'s full hand to the original deck.`); + } + assert.ok(player.hand.length < 4); + const card = source.splice(source.findIndex(candidate => candidate.effect === 'truthtrance'), 1)[0]; + player.hand.push(card); + staging.push(`After genuine setup: conserve original physical Truthtrance ${card.id} by relocating it into ${actor}'s hand; no hand exceeds four.`); + assert.deepEqual(truthtranceNoFieldCards(game), inventory); + return structuredClone(card); +} + +/** All windows and marker custody come from the original setup/shipment/battle actions. */ +export function createTruthtranceNoFieldFixture(options: TruthtranceNoFieldOptions = {}): TruthtranceNoFieldFixture { + const boundary = options.boundary ?? 'afterShipment'; + const harass = boundary === 'fullPlanOffer' ? null : createHarassNoFieldFixture({ + advanced: options.advanced ?? false, marker: options.marker, reserves: options.reserves, + }); + if (boundary === 'fullPlanOffer') assert.notEqual(options.advanced, false, 'The native full-plan offer is Advanced'); + const fullPlan = boundary === 'fullPlanOffer' ? createNoFieldFullPlanFixture({ + marker: options.marker, reserves: options.reserves, + }) : null; + const native = harass ?? fullPlan!; + const source = boundary === 'beforeShipment' ? native.beforeShipment + : boundary === 'afterShipment' ? native.afterShipment : native.game; + const game = structuredClone(source); + const asker = fullPlan ? fullPlan.atreides : harass!.observer; + const staging = [...native.staging]; + const truthCard = stageNoFieldTruthtrance(game, asker, staging); + const inventory = truthtranceNoFieldCards(game); + assert.equal(new Set(inventory).size, inventory.length); + assert.deepEqual(inventory, truthtranceNoFieldCards(native.afterSetup)); + for (const player of game.players) { + assert.ok(player.hand.length <= 4); + assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, count) => sum + count, 0), 20); + } + return { + game, owner: native.owner, asker, opponent: native.opponent, observer: native.observer, + token: structuredClone(native.token), truthCard, staging, harass, fullPlan, + }; +} + +export function noFieldTruthFact(value: NoFieldValue): TruthFact { + return { kind: 'noFieldValue', value }; +} +export function declareNoFieldTruthtrance(game: Game, asker: string): Game { + const card = truthtranceNoFieldPlayer(game, asker).hand.find(candidate => candidate.effect === 'truthtrance'); + assert.ok(card); + return applyAction(game, asker, { type: 'card', card: card.id }); +} +export function passNoFieldTruthPriority(game: Game): Game { + for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) { + const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); + assert.ok(actor); + game = applyAction(game, actor.id, { type: 'truthPass' }); + } + assert.equal(game.truthtrance?.stage, 'ask'); + return game; +} +export function askNoFieldTruth(game: Game, asker: string, target: string, fact: TruthFact): Game { + if (!game.truthtrance) game = passNoFieldTruthPriority(declareNoFieldTruthtrance(game, asker)); + assert.equal(game.truthtrance?.stage, 'ask'); + return applyAction(game, asker, { type: 'truthAsk', question: { kind: 'fact', target, fact } }); +} +export function answerNoFieldTruth(game: Game, expected: 'yes' | 'no'): Game { + const question = game.truthtrance?.question; + assert.equal(game.truthtrance?.stage, 'answer'); + assert.ok(question?.kind === 'fact'); + assert.equal(viewGame(game, question.target).truthAnswer, expected); + return applyAction(game, question.target, { type: 'truthAnswer', answer: expected }); +} +export function rejectNoFieldTruthAction(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} + +/** Everything observed must stay put, including live opportunities and prior shipment history. */ +export function noFieldTruthObservation(game: Game) { + return structuredClone({ + turn: game.turn, phase: game.phase, active: game.active, ready: game.ready, + movementRemaining: game.movementRemaining, response: game.response, decision: game.decision, + phaseOpening: game.phaseOpening, pendingShipment: game.pendingShipment, battle: game.battle, + shipmentPromises: game.shipmentPromises, richeseAllyOpportunity: game.richeseAllyOpportunity, + richeseAllyOffer: game.richeseAllyOffer, + players: game.players.map(player => ({ + id: player.id, noField: player.noField, noFieldEvent: player.noFieldEvent, + reserves: player.reserves, tanks: player.tanks, forces: player.forces, elites: player.elites, + spice: player.spice, shipped: player.shipped, moved: player.moved, revived: player.revived, + })), + }); +} + +/** Finish the real public answer and its original physical discard, without driving the parent. */ +export function finishNoFieldTruth(game: Game, expected: 'yes' | 'no'): Game { + const before = structuredClone(game); + const window = before.truthtrance!; + assert.equal(window.queue.length, 1); + assert.ok(window.question?.kind === 'fact'); + const consumed = window.queue[0]; + const card = truthtranceNoFieldPlayer(before, consumed.player).hand.find(candidate => candidate.id === consumed.card); + assert.ok(card?.effect === 'truthtrance'); + const done = answerNoFieldTruth(game, expected); + assert.equal(done.truthtrance, null); + assert.ok(!done.pendingTreacheryDiscard); + assert.deepEqual(noFieldTruthObservation(done), noFieldTruthObservation(before)); + assert.deepEqual(done.truthHistory, [...(before.truthHistory ?? []), { + turn: before.turn, phase: before.phase, asker: consumed.player, + question: window.question, answer: expected, + }]); + assert.deepEqual(done.deck, before.deck); + assert.deepEqual(done.richeseCache, before.richeseCache); + assert.deepEqual(done.richeseRemoved, before.richeseRemoved); + assert.deepEqual(done.discard, [...before.discard, card]); + for (const player of done.players) { + const original = truthtranceNoFieldPlayer(before, player.id); + assert.deepEqual(player.hand, original.hand.filter(candidate => candidate.id !== consumed.card)); + } + assert.deepEqual(truthtranceNoFieldCards(done), truthtranceNoFieldCards(before)); + return done; +} diff --git a/tests/truthtrance-no-field-engine.test.ts b/tests/truthtrance-no-field-engine.test.ts new file mode 100644 index 00000000..4dfa9d15 --- /dev/null +++ b/tests/truthtrance-no-field-engine.test.ts @@ -0,0 +1,269 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import type { NoFieldValue } from '../game/richese-no-field'; +import type { TruthFact } from '../game/truthtrance'; +import { + applyNoFieldFullPlanStep, finishNoFieldFullPlanBattle, +} from './fixture-no-field-full-plan'; +import { noFieldFullPlanOccupyFixture } from './fixture-no-field-full-plan-occupy'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; +import { + askNoFieldTruth, createTruthtranceNoFieldFixture, declareNoFieldTruthtrance, + finishNoFieldTruth, noFieldTruthFact, noFieldTruthObservation, passNoFieldTruthPriority, + rejectNoFieldTruthAction, stageNoFieldTruthtrance, truthtranceNoFieldCards, + truthtranceNoFieldPlayer, +} from './fixture-truthtrance-no-field'; + +const VALUES: NoFieldValue[] = [0, 3, 5]; + +for (const advanced of [false, true]) { + for (const marker of VALUES) { + void test(`${advanced ? 'Advanced' : 'Basic'} native Truthtrance reads deployed printed ${marker} without touching its token or forces`, () => { + const fixture = createTruthtranceNoFieldFixture({ advanced, marker }); + const { game, owner, asker, token } = fixture; + const original = truthtranceNoFieldPlayer(game, owner); + assert.equal(original.noField!.deployed!.tokenId, token.id); + for (const value of VALUES) { + const waiting = askNoFieldTruth(game, asker, owner, noFieldTruthFact(value)); + const expected = marker === value ? 'yes' : 'no'; + assert.deepEqual(noFieldTruthObservation(waiting), noFieldTruthObservation(game)); + for (const actor of game.players.map(player => player.id)) { + const view = viewGame(waiting, actor); + assert.equal(view.truthAnswer, actor === owner ? expected : null); + if (actor !== owner) { + assert.equal(view.richeseNoField!.private, null); + assert.deepEqual(view.richeseNoField!.public, viewGame(game, actor).richeseNoField!.public); + assert.equal(view.players.find(player => player.id === owner)!.hand, undefined); + } + } + const done = finishNoFieldTruth(waiting, expected); + assert.equal(done.discard.filter(card => card.id === fixture.truthCard.id).length, 1); + for (const actor of done.players.map(player => player.id)) { + assert.deepEqual(viewGame(done, actor).truthHistory.at(-1), done.truthHistory!.at(-1)); + assert.equal(viewGame(done, actor).truthAnswer, null); + } + } + }); + } + + for (const reserves of [2, 0]) { + void test(`${advanced ? 'Advanced' : 'Basic'} printed five remains five with ${reserves} physical reserves`, () => { + const fixture = createTruthtranceNoFieldFixture({ advanced, marker: 5, reserves }); + const { game, asker, owner } = fixture; + assert.equal(truthtranceNoFieldPlayer(game, owner).reserves, reserves); + for (const value of [5, 0] as const) { + const waiting = askNoFieldTruth(game, asker, owner, noFieldTruthFact(value)); + finishNoFieldTruth(waiting, value === 5 ? 'yes' : 'no'); + } + const observed = finishNoFieldTruth(askNoFieldTruth(game, asker, owner, noFieldTruthFact(5)), 'yes'); + const history = structuredClone(observed.truthHistory); + const before = truthtranceNoFieldPlayer(observed, owner); + const exposed = applyAction(observed, owner, { + type: 'revealNoField', event: before.noFieldEvent, token: fixture.token.id, + }); + const after = truthtranceNoFieldPlayer(exposed, owner); + assert.equal(after.noField!.deployed, null); + assert.equal(after.noField!.lastShipped, fixture.token.id); + assert.deepEqual(after.noField!.tokens, before.noField!.tokens); + assert.notEqual(after.noFieldEvent, before.noFieldEvent); + assert.equal(after.reserves, 0); + assert.equal(after.forces[fixture.harass!.location] ?? 0, reserves); + assert.deepEqual(exposed.truthHistory, history); + assert.equal(viewGame(exposed, asker).richeseNoField!.public.lastUsed, 5); + assert.deepEqual(truthtranceNoFieldCards(exposed), truthtranceNoFieldCards(observed)); + }); + } + + void test(`${advanced ? 'Advanced' : 'Basic'} absent, never-shipped and already-revealed markers answer No, not zero or Unknown`, () => { + const fixture = createTruthtranceNoFieldFixture({ advanced, marker: 3, boundary: 'beforeShipment' }); + const { game, asker, owner, opponent } = fixture; + assert.equal(truthtranceNoFieldPlayer(game, owner).noField!.deployed, null); + assert.equal(truthtranceNoFieldPlayer(game, owner).noField!.lastShipped, null); + assert.equal(truthtranceNoFieldPlayer(game, opponent).noField, undefined); + for (const target of [owner, opponent]) + for (const value of VALUES) + finishNoFieldTruth(askNoFieldTruth(game, asker, target, noFieldTruthFact(value)), 'no'); + + // The original planned shipment remains executable, with its original token/event. + const done = finishNoFieldTruth(askNoFieldTruth(game, asker, owner, noFieldTruthFact(3)), 'no'); + const shipment = fixture.harass!.shipmentStep; + let shipped = applyAction(done, shipment.actor, shipment.action); + // Advanced still owns its original Guild interception before physical deployment. + for (let count = 0; (shipped.response || shipped.phaseOpening || shipped.decision) && count < 40; count++) { + const step = nextRicheseStrongholdsNativeStep(shipped); + assert.ok(step); + shipped = applyAction(shipped, step.actor, step.action); + } + assert.equal(shipped.decision, null); + assert.equal(truthtranceNoFieldPlayer(shipped, owner).noField!.deployed!.tokenId, fixture.token.id); + assert.equal(shipped.response, null); + const concealed = truthtranceNoFieldPlayer(shipped, owner); + const revealed = applyAction(shipped, owner, { + type: 'revealNoField', event: concealed.noFieldEvent, token: fixture.token.id, + }); + assert.equal(truthtranceNoFieldPlayer(revealed, owner).noField!.deployed, null); + assert.equal(truthtranceNoFieldPlayer(revealed, owner).noField!.lastShipped, fixture.token.id); + assert.equal(viewGame(revealed, asker).richeseNoField!.public.lastUsed, 3); + stageNoFieldTruthtrance(revealed, asker, fixture.staging); + for (const value of VALUES) + finishNoFieldTruth(askNoFieldTruth(revealed, asker, owner, noFieldTruthFact(value)), 'no'); + }); +} + +void test('a nonowner respondent cannot borrow the concealed token from their Richese ally', () => { + const fixture = noFieldFullPlanOccupyFixture({ lead: 'ecaz', marker: 5, richeseReserves: 2 }); + const { game, richese: owner, ecaz: ally, atreides: asker } = fixture; + assert.equal(truthtranceNoFieldPlayer(game, owner).ally, ally); + assert.equal(truthtranceNoFieldPlayer(game, ally).ally, owner); + stageNoFieldTruthtrance(game, asker, fixture.staging); + assert.equal(truthtranceNoFieldPlayer(game, ally).noField, undefined); + for (const value of [0, 5] as const) { + const waiting = askNoFieldTruth(game, asker, ally, noFieldTruthFact(value)); + assert.equal(viewGame(waiting, ally).truthAnswer, 'no'); + finishNoFieldTruth(waiting, 'no'); + } + finishNoFieldTruth(askNoFieldTruth(game, asker, owner, noFieldTruthFact(5)), 'yes'); +}); + +void test('dishonest and Unknown answers retain the original current fact and parent opportunity until the truthful public answer', () => { + for (const boundary of ['beforeShipment', 'afterShipment'] as const) { + const fixture = createTruthtranceNoFieldFixture({ marker: 0, boundary }); + const { game, owner, asker } = fixture; + const waiting = askNoFieldTruth(game, asker, owner, noFieldTruthFact(0)); + const expected = boundary === 'beforeShipment' ? 'no' : 'yes'; + rejectNoFieldTruthAction(waiting, owner, { type: 'truthAnswer', answer: expected === 'yes' ? 'no' : 'yes' }); + rejectNoFieldTruthAction(waiting, owner, { type: 'truthAnswer', answer: 'unknown' }); + rejectNoFieldTruthAction(waiting, owner, { type: 'truthSave' }); + const native = fixture.harass!; + rejectNoFieldTruthAction(waiting, native.shipmentStep.actor, native.shipmentStep.action); + assert.equal(waiting.truthtrance!.stage, 'answer'); + assert.equal(waiting.truthHistory?.length ?? 0, 0); + assert.ok(truthtranceNoFieldPlayer(waiting, asker).hand.some(card => card.id === fixture.truthCard.id)); + const done = finishNoFieldTruth(waiting, expected); + if (boundary === 'beforeShipment') { + const shipped = applyAction(done, native.shipmentStep.actor, native.shipmentStep.action); + assert.equal(truthtranceNoFieldPlayer(shipped, owner).noField!.deployed!.tokenId, fixture.token.id); + } else { + const player = truthtranceNoFieldPlayer(done, owner); + const revealed = applyAction(done, owner, { type: 'revealNoField', event: player.noFieldEvent, token: fixture.token.id }); + assert.equal(truthtranceNoFieldPlayer(revealed, owner).noField!.deployed, null); + } + } +}); + +void test('invalid denomination and extra private selection fields cannot replace the live asking opportunity', () => { + const fixture = createTruthtranceNoFieldFixture({ marker: 3 }); + const waiting = passNoFieldTruthPriority(declareNoFieldTruthtrance(fixture.game, fixture.asker)); + for (const fact of [ + { kind: 'noFieldValue', value: '3' }, + { kind: 'noFieldValue', value: 1 }, + { kind: 'noFieldValue', value: 3, token: fixture.token.id }, + ]) { + rejectNoFieldTruthAction(waiting, fixture.asker, { + type: 'truthAsk', question: { kind: 'fact', target: fixture.owner, fact }, + }); + } + finishNoFieldTruth(askNoFieldTruth(waiting, fixture.asker, fixture.owner, noFieldTruthFact(3)), 'yes'); +}); + +void test('AND/OR publish only the aggregate answer, never matching leaves or physical No-Field identity', () => { + const fixture = createTruthtranceNoFieldFixture({ advanced: true, marker: 5, reserves: 0 }); + const { game, owner, asker } = fixture; + const rows: { fact: TruthFact; answer: 'yes' | 'no' }[] = [ + { fact: { kind: 'or', terms: [noFieldTruthFact(0), noFieldTruthFact(3)] }, answer: 'no' }, + { fact: { kind: 'or', terms: [noFieldTruthFact(3), noFieldTruthFact(5)] }, answer: 'yes' }, + { fact: { kind: 'and', terms: [noFieldTruthFact(5), { kind: 'spice', compare: 'gte', value: 0 }] }, answer: 'yes' }, + { fact: { kind: 'and', terms: [noFieldTruthFact(5), noFieldTruthFact(0)] }, answer: 'no' }, + ]; + for (const { fact, answer } of rows) { + const waiting = askNoFieldTruth(game, asker, owner, fact); + assert.equal(viewGame(waiting, owner).truthAnswer, answer); + for (const other of game.players.filter(player => player.id !== owner)) { + const view = viewGame(waiting, other.id); + assert.equal(view.truthAnswer, null); + assert.deepEqual(view.truthHistory, []); + assert.equal(view.richeseNoField!.private, null); + assert.deepEqual(view.richeseNoField!.public, viewGame(game, other.id).richeseNoField!.public); + assert.equal(view.players.find(player => player.id === owner)!.hand, undefined); + } + const done = finishNoFieldTruth(waiting, answer); + const receipt = done.truthHistory!.at(-1)!; + assert.deepEqual(receipt.question, { kind: 'fact', target: owner, fact }); + assert.equal(JSON.stringify(receipt).includes(fixture.token.id), false); + for (const actor of done.players.map(player => player.id)) { + assert.deepEqual(viewGame(done, actor).truthHistory, [receipt]); + if (actor !== owner) { + assert.equal(viewGame(done, actor).richeseNoField!.private, null); + assert.deepEqual(viewGame(done, actor).richeseNoField!.public, viewGame(game, actor).richeseNoField!.public); + } + } + } +}); + +void test('a native full-plan offer survives the fact interrupt and later genuine battle changes custody without rewriting the answer', () => { + const fixture = createTruthtranceNoFieldFixture({ advanced: true, marker: 5, reserves: 2, boundary: 'fullPlanOffer' }); + const native = fixture.fullPlan!; + const { game, owner, asker } = fixture; + assert.equal(game.decision?.kind, 'fullPlanOffer'); + assert.equal(truthtranceNoFieldPlayer(game, asker).hand.length, 4); + const waiting = askNoFieldTruth(game, asker, owner, noFieldTruthFact(5)); + rejectNoFieldTruthAction(waiting, native.declineStep.actor, native.declineStep.action); + const answered = finishNoFieldTruth(waiting, 'yes'); + assert.deepEqual(answered.decision, game.decision); + assert.deepEqual(answered.battle, game.battle); + const history = structuredClone(answered.truthHistory); + let continued = applyNoFieldFullPlanStep(answered, native.declineStep); + assert.equal(continued.decision, null); + for (const step of native.planActions) continued = applyNoFieldFullPlanStep(continued, step); + assert.equal(continued.battle!.revealed, true); + const exposed = truthtranceNoFieldPlayer(continued, owner); + const original = truthtranceNoFieldPlayer(game, owner); + assert.equal(exposed.noField!.deployed, null); + assert.equal(exposed.noField!.lastShipped, fixture.token.id); + assert.deepEqual(exposed.noField!.tokens, original.noField!.tokens); + assert.notEqual(exposed.noFieldEvent, original.noFieldEvent); + assert.equal(exposed.reserves, 0); + assert.equal(exposed.forces[native.location], 2); + assert.deepEqual(continued.truthHistory, history); + const done = finishNoFieldFullPlanBattle(continued); + assert.equal(done.battle, null); + assert.deepEqual(done.truthHistory, history); + assert.equal(truthtranceNoFieldPlayer(done, owner).noField!.deployed, null); + assert.deepEqual(truthtranceNoFieldCards(done), truthtranceNoFieldCards(game)); + assert.equal(done.discard.filter(card => card.id === fixture.truthCard.id).length, 1); +}); + +void test('all four bot profiles pass real priority and reply legally using only their private aggregate truthAnswer', () => { + const fixture = createTruthtranceNoFieldFixture({ advanced: true, marker: 5, reserves: 0 }); + const { game, owner, asker, opponent } = fixture; + for (const difficulty of DIFFICULTIES) { + let current = declareNoFieldTruthtrance(game, asker); + const priorityView = viewGame(current, owner); + priorityView.players.find(player => player.id === owner)!.bot = difficulty; + const priority = botActions(priorityView).find(action => action.type === 'truthPass'); + assert.ok(priority); + current = applyAction(current, owner, priority); + current = passNoFieldTruthPriority(current); + for (const value of [5, 0] as const) { + const pending = askNoFieldTruth(current, asker, owner, noFieldTruthFact(value)); + const view = viewGame(pending, owner); + view.players.find(player => player.id === owner)!.bot = difficulty; + const replies = botActions(view); + assert.equal(view.truthAnswer, value === 5 ? 'yes' : 'no'); + const reply = replies.find(action => action.type === 'truthAnswer'); + assert.ok(reply); + const otherView = viewGame(pending, opponent); + otherView.players.find(player => player.id === opponent)!.bot = difficulty; + assert.ok(botActions(otherView).every(action => action.type !== 'truthAnswer')); + const replied = applyAction(pending, owner, reply); + assert.equal(replied.truthHistory?.at(-1)?.answer, value === 5 ? 'yes' : 'no'); + assert.equal(replied.truthtrance, null); + assert.deepEqual(noFieldTruthObservation(replied), noFieldTruthObservation(game)); + assert.deepEqual(truthtranceNoFieldCards(replied), truthtranceNoFieldCards(game)); + assert.equal(replied.discard.filter(card => card.id === fixture.truthCard.id).length, 1); + } + } +}); diff --git a/tests/truthtrance-no-field-value.test.ts b/tests/truthtrance-no-field-value.test.ts new file mode 100644 index 00000000..743e4c1b --- /dev/null +++ b/tests/truthtrance-no-field-value.test.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createRicheseNoField, + deployRicheseNoField, + projectRicheseNoField, + revealRicheseNoField, + type NoFieldValue, + type RicheseNoField, +} from '../game/richese-no-field'; +import { + noFieldValueFactMatches, + parseNoFieldValueFact, + type NoFieldValueFact, +} from '../game/truthtrance-no-field-value'; + +function fact(value: NoFieldValue): NoFieldValueFact { + return { kind: 'noFieldValue', value }; +} + +function inventory() { + return createRicheseNoField(['token-zero', 'token-three', 'token-five']); +} + +function deployed(value: NoFieldValue) { + const state = inventory(); + return deployRicheseNoField(state, { + tokenId: state.tokens.find((token) => token.value === value)!.id, + controller: 'richese-player', + location: { territory: 'arrakeen', sector: 10 }, + }); +} + +void test('only a deployed physical token answers Yes for its exact printed denomination without altering custody', () => { + for (const printedValue of [0, 3, 5] as const) { + const state = deployed(printedValue); + const before = structuredClone(state); + assert.equal( + noFieldValueFactMatches( + state, + parseNoFieldValueFact({ kind: 'noFieldValue', value: printedValue }), + ), + true, + ); + assert.equal( + noFieldValueFactMatches(state, fact(printedValue === 5 ? 3 : 5)), + false, + ); + assert.deepEqual(state, before); + } +}); + +void test('no inventory or undeployed inventory is No, not a concealed zero', () => { + const state = inventory(); + assert.equal(noFieldValueFactMatches(undefined, fact(0)), false); + assert.equal(noFieldValueFactMatches(undefined, fact(5)), false); + assert.equal(noFieldValueFactMatches(state, fact(0)), false); + assert.equal(noFieldValueFactMatches(state, fact(3)), false); + assert.equal(noFieldValueFactMatches(deployed(0), fact(0)), true); +}); + +void test('printed five is independent of capped or exhausted reserves and revelation ends the fact despite last-use history', () => { + const state = deployed(5); + const before = structuredClone(state); + for (const reserves of [2, 0]) { + assert.equal(noFieldValueFactMatches(state, fact(5)), true); + const revealed = revealRicheseNoField(state, { + tokenId: state.deployed!.tokenId, + reserves, + cause: 'voluntary', + }); + assert.equal(revealed.forces, reserves); + assert.equal(revealed.value, 5); + assert.equal(projectRicheseNoField(revealed.state, false).lastUsed, 5); + assert.equal(noFieldValueFactMatches(revealed.state, fact(5)), false); + assert.equal(noFieldValueFactMatches(revealed.state, fact(0)), false); + assert.deepEqual(state, before); + } +}); + +void test('observation rejects malformed physical custody instead of treating it as absence', () => { + const state = deployed(3); + const malformed: RicheseNoField[] = [ + { ...state, tokens: [] }, + { + ...state, + tokens: state.tokens.map((token) => ({ ...token, value: 3 as const })), + }, + { + ...state, + deployed: { ...state.deployed!, tokenId: 'not-a-physical-token' }, + }, + { ...state, lastShipped: 'token-five' }, + { + ...state, + deployed: { + ...state.deployed!, + location: { territory: 'arrakeen', sector: -1 }, + }, + }, + ]; + for (const invalid of malformed) + assert.throws(() => noFieldValueFactMatches(invalid, fact(3))); + assert.throws(() => + noFieldValueFactMatches({ ...inventory(), tokens: [] }, fact(0)), + ); +}); + +void test('question input requires exactly the fact kind and a numeric printed denomination', () => { + for (const invalid of [ + null, + [], + 'noFieldValue', + { kind: 'noFieldValue' }, + { value: 3 }, + { kind: 'forceCount', value: 3 }, + { kind: 'noFieldValue', value: 3, tokenId: 'token-three' }, + { kind: 'noFieldValue', value: 3, reserves: 0 }, + ]) + assert.throws(() => parseNoFieldValueFact(invalid)); + for (const value of ['0', '3', '5', true, false, null, undefined, NaN, Infinity, -1, 1, 3.5]) + assert.throws(() => parseNoFieldValueFact({ kind: 'noFieldValue', value })); +});
This asks whether the respondent currently has a concealed No-Field with this printed value. No deployed token, or an already revealed token, means No, including for value 0. This is not a prospective force count or a commitment to use a future token. The public answer does not physically reveal the token.