Connect Truthtrance questions about recorded predictions and storm knowledge

Add typed questions about the respondent's stored Bene Gesserit prediction faction/turn, active submitted storm dial and actually known native Advanced Fremen storm card. Use the same eligible private facts for authoritative validation and target-only answer projection. Preserve unknown/rephrase/save continuation, compound aggregate privacy and physical card custody. Future promises, freeform interpretation and strategic AI question selection remain incomplete; no mode or publication gate opens.

Connect four question controls with all numeric comparisons, canonical faction choices and shared input guards. All four AI profiles answer the owner-only verified scalar. Independent source/privacy review and integration checks fixed missing comparisons, parent selector mapping and invalid submission guards. Thirteen new cases and the 44-case focused union cover normal/unknown/dishonest answers, private views, JSON and authenticated SQLite restoration, interrupted storm-peek continuation, stale/duplicate/competing requests and corrupt saved predicates without writes.

Validation: types, lint, 4662 offline tests, production build and 43 HTTP tests passed with unchanged source. Two genuine-setup four-profile games completed without rejected actions: Basic 192 actions/five periodic JSON rounds and Advanced 518/fourteen. Exact Treachery/force/elite custody and all eight final restored seat views pass. Three explicitly scripted legal questions use naturally held cards, covering prediction faction, prediction turn and submitted storm dial. Forecast questioning is not exercised by these complete games; the samples do not measure autonomous AI asking strategy or strength.

The separate Advanced browser exercise uses real prediction and forecast progression, staging only physical Truthtrance acquisition by relocation. Invalid dial input is blocked; an unknown answer can be rephrased; both compound prediction clauses and the forecast comparison work. Refresh after submission and final resolution restores history unknown/Yes/Yes and the remaining Shield. Both physical Truthtrances are discarded once, unrelated resources remain unchanged and private projection passes. SQLite tests establish pending-answer restoration; the browser observation does not claim a paused pending answer after reconnect.

After the reported outage, all 767 opening games and original seat/recovery records were preserved (801 after HTTP checks). All 768 rooms and original records immediately before verification also remained unchanged. Database integrity and foreign keys passed. Reused the healthy server without restart, reset or recurring automation.

Verified source tree: 09daaac288cb91ea407f4573f853a59d307e12ed30d2eccd39e671a47336f40d
This commit is contained in:
Quintine committed 2026-09-14 01:34:47 +10:00
1 parent 8e1cd7b75b
commit 8906b41bda
14 files changed
+989 -13

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
'use client';
import { useId } from 'react';
import { FACTIONS } from '@/game/catalog';
import type { TruthFact } from '@/game/truthtrance';
import { knowledgeFactInputError } from '@/game/truthtrance-knowledge';
import { Input } from '@/components/ui/input';
type KnowledgeFact = Extract<TruthFact, { kind: 'prediction' | 'stormDial' | 'stormForecast' }>;
type NumericKnowledgeFact = Exclude<KnowledgeFact, { kind: 'prediction'; field: 'faction' }>;
function isNumericKnowledgeFact(fact: KnowledgeFact): fact is NumericKnowledgeFact {
return fact.kind !== 'prediction' || fact.field === 'turn';
}
export function KnowledgeFactFields({
value,
onChange,
}: {
value: KnowledgeFact;
onChange: (fact: KnowledgeFact) => void;
}) {
const id = useId();
const isFaction = !isNumericKnowledgeFact(value);
const numericValue = isNumericKnowledgeFact(value) ? value : null;
const isTurn = numericValue?.kind === 'prediction' && numericValue.field === 'turn';
const min = isTurn ? 1 : numericValue?.kind === 'stormForecast' ? 1 : 0;
const max = isTurn ? 10 : numericValue?.kind === 'stormForecast' ? 6 : 20;
const invalid = knowledgeFactInputError(value);
return (
<>
{isFaction ? (
<label>
Predicted faction
<select
value={value.faction}
onChange={(event) => onChange({ ...value, faction: event.target.value as typeof value.faction })}
>
{FACTIONS.map((faction) => <option key={faction.id} value={faction.id}>{faction.name}</option>)}
</select>
</label>
) : numericValue && (
<>
{(isTurn || numericValue.kind === 'stormDial' || numericValue.kind === 'stormForecast') && (
<label>
Compare stored value
<select
value={numericValue.compare}
onChange={(event) => onChange({ ...numericValue, compare: event.target.value as typeof numericValue.compare })}
>
<option value="eq">Exactly</option><option value="gte">At least</option><option value="lte">At most</option>
</select>
</label>
)}
<label htmlFor={`${id}-value`}>
{numericValue.kind === 'stormDial' ? 'Storm dial value' : numericValue.kind === 'stormForecast' ? 'Storm forecast sector' : 'Turn number'}
<Input
id={`${id}-value`}
type="number"
min={min}
max={max}
step={1}
required
value={Number.isNaN(numericValue.value) ? '' : numericValue.value}
aria-invalid={!!invalid}
aria-describedby={`${id}-help${invalid ? ` ${id}-error` : ''}`}
onChange={(event) => onChange({ ...numericValue, value: event.currentTarget.valueAsNumber })}
/>
</label>
<p id={`${id}-help`} className="fine">
This asks about an already recorded choice or a forecast the target currently knows. If no such value is available, the answer is I don’t know. Only the answer is published.
</p>
</>
)}
{isFaction && <p className="fine">This asks about an already recorded faction choice. If no such value is available, the answer is I don’t know. Only the answer is published.</p>}
{invalid && <p id={`${id}-error`} className="notice" role="alert">{invalid}</p>}
</>
);
}
+24 -3
View File
@@ -2,6 +2,8 @@
import { TERRITORIES } from '@/game/board';
import { BattleClaimFields } from './battle-promises';
import { CardCountFields } from './truthtrance-card-count';
import { KnowledgeFactFields } from './truthtrance-knowledge';
import { isKnowledgeFact, knowledgeFactInputError } from '@/game/truthtrance-knowledge';
import type { PlanClaim } from '@/game/battle-promises';
import { useId, useState } from 'react';
import Link from 'next/link';
@@ -83,7 +85,7 @@ export function Truthtrance({
(!Number.isSafeInteger(fact.value) || fact.value < 0);
const invalidFact = clauses
.slice(0, join === 'single' ? 1 : 2)
.some(invalidSpice);
.some((fact) => invalidSpice(fact) || (isKnowledgeFact(fact) && !!knowledgeFactInputError(fact)));
const leaderName = (id: string) =>
id === CHEAP_HERO_TRAITOR
? 'Cheap Hero / Heroine'
@@ -312,7 +314,7 @@ export function Truthtrance({
onChange={(e) => setKind(e.target.value as typeof kind)}
>
<option value="fact">
Verified cards, traitors or spice
Verified current facts
</option>
<option value="shipment">
Bind a shipment from reserves
@@ -440,7 +442,7 @@ export function Truthtrance({
<label>
Fact type
<select
value={c.kind}
value={c.kind === 'prediction' ? c.field === 'faction' ? 'predictionFaction' : 'predictionTurn' : c.kind}
onChange={(e) =>
setClauses(
clauses.map((old, n) =>
@@ -461,6 +463,14 @@ export function Truthtrance({
compare: 'gte',
value: 2,
}
: e.target.value === 'predictionFaction'
? { kind: 'prediction', field: 'faction', faction: 'atreides' }
: e.target.value === 'predictionTurn'
? { kind: 'prediction', field: 'turn', compare: 'gte', value: 1 }
: e.target.value === 'stormDial'
? { kind: 'stormDial', compare: 'gte', value: 0 }
: e.target.value === 'stormForecast'
? { kind: 'stormForecast', compare: 'gte', value: 1 }
: e.target.value === 'spice'
? {
kind: 'spice',
@@ -484,6 +494,10 @@ export function Truthtrance({
Hand size or primary card role
</option>
<option value="traitor">Selected a traitor</option>
<option value="predictionFaction">Stored faction prediction</option>
<option value="predictionTurn">Stored turn prediction</option>
<option value="stormDial">Known storm dial</option>
<option value="stormForecast">Known storm forecast</option>
<option value="spice">
Current personal spice
</option>
@@ -521,6 +535,13 @@ export function Truthtrance({
)
}
/>
) : c.kind === 'prediction' || c.kind === 'stormDial' || c.kind === 'stormForecast' ? (
<KnowledgeFactFields
value={c}
onChange={(next) =>
setClauses(clauses.map((old, n) => n === index ? next : old))
}
/>
) : c.kind === 'spice' ? (
<>
<label>
+20 -7
View File
@@ -6,11 +6,11 @@ gated.** Prototypes do not open completion or publication gates.
## Current checkpoint and work
Previous verified checkpoint: `d0dc700`, Bureaucrat payment redirection.
Types, lint, **4,631 offline tests**, build and **43 HTTP tests** passed.
All 699 opening rooms and original seat/recovery records were preserved
(733 after HTTP checks). All 700 rooms present immediately before verification
also remained unchanged. The healthy server was reused after the outage.
Latest verified checkpoint: `8e1cd7b`, Emperor Nexus extra revival.
Types, lint, **4,649 offline tests**, build and **43 HTTP tests** passed.
All 733 opening rooms and original seat/recovery records were preserved
(767 after HTTP checks); all 734 present immediately before verification also
remained unchanged. The healthy server was reused.
[Mentat](MENTAT_QUESTION.md) connects private naming/disclosure before posture
and faction powers, all four AI profiles and saved continuation in explicitly
@@ -28,7 +28,7 @@ All 699 opening rooms and original seat/recovery records survived the reported
outage. The healthy server was reused. The completed checkpoint's Git record
contains final broad verification and post-HTTP preservation totals.
Current checkpoint: [Emperor Nexus Secret Ally revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md).
Completed prototype: [Emperor Nexus Secret Ally revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md).
Exactly three free returns, independent ordinary allowances, shared Fedaykin caps,
private controls, four AI profiles and saved continuation are connected. Independent
review and 17 focused effect tests pass. Two genuine-start and two explicitly staged
@@ -43,6 +43,19 @@ forced spice payments. No answer or purchase policy is assumed. All 733 opening
rooms and seat/recovery records survived the latest outage unchanged; a new snapshot
protects all 734 rooms before final verification. The healthy server is reused.
Current checkpoint: [Truthtrance recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), connecting
stored BG prediction faction/turn, submitted current Storm dials and actually known
Fremen forecasts. The same private verified answer drives controls, all four bots
and saved continuation. Independent source/privacy review and 44 focused cases pass,
including five authenticated SQLite recovery cases. Two genuine-setup games finish
in 192/518 actions with zero rejections and three scripted, naturally held-card
questions; forecast coverage comes from the separate conserved browser exercise.
Browser controls, unknown/rephrasing, compound answers and resolved refresh pass.
All 767 opening rooms and seat/recovery records survived the latest reported outage;
the healthy server is reused. A new snapshot protects all 768 rooms before broad
verification. Final checks, preservation totals and Git delivery are recorded in
the checkpoint commit and private report; full mode/publication gates remain.
## Authoritative navigation
- [Rules checklist](../game/reference.ts), topic `implementation-checklist` and
@@ -71,7 +84,7 @@ These links define bounded working behavior, not complete module certification.
| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md) and [No-Field](SMUGGLER_NO_FIELD.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md). Remaining bands and combinations are explicit in those contracts. |
| Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Jacurutu income, Testing Station and Shrine. Orgiz and contested/mixed cases remain pending. |
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Collection trade](NEXUS_CHOAM_SECRET_ALLY.md) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected; CHOAM inspection and Emperor purchase remain pending. A source audit or helper alone is not a completed effect. |
| Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), and [Basic/no-Guild Advanced reserve-shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. |
| Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), [recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), and [Basic/no-Guild Advanced reserve-shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. |
| Other cards | [Sapho runtime](JUICE_OF_SAPHO_RUNTIME.md) includes first among remaining unstarted movement turns after Advanced Guild has finished. Richese card contracts and the checklist identify the other connected effects and explicit gaps. |
| Multiplayer | Saved rooms/seats, [recovery](../README.md#saved-seats-and-reconnecting), uncertain-request retry, [own-seat AI](AUTOPILOT.md), and [voluntary seat handover](SEAT_HANDOVER.md) have connected controls and recovery evidence. |
+32
View File
@@ -1,5 +1,37 @@
# Dune implementation status
## 14 September 2026 — Truthtrance recorded knowledge and outage preservation
[Truthtrance knowledge](TRUTHTRANCE_KNOWLEDGE.md) connects stored Bene Gesserit
prediction faction/turn, a respondent's active submitted storm dial and the card
actually known to native Advanced Fremen. Server validation, target-only answer
projection, compound controls, all four AI profiles and saved continuation share
the same eligible facts. Missing knowledge stays unknown; only the aggregate
answer becomes public and a definite answer consumes the physical card once.
This does not implement general future commitments or AI question strategy.
Independent source/privacy review and 13 new cases pass; the focused union passes
44, including five authenticated SQLite recovery cases. Review fixed missing
comparison controls, parent selector mapping and the invalid-input submission
guard. Recovery covers pending questions and interrupted storm peeks, private
seat views, dishonest/stale/duplicate requests, competing writes and malformed
saved predicates without state or physical-custody corruption.
Two genuine-setup games using all four profiles finish with zero rejections:
Basic 192 actions/five periodic JSON rounds; Advanced 518/fourteen. Exact card and
force custody holds, including all eight restored final seat views. Three scripted
questions use naturally held cards and cover prediction faction, prediction turn
and submitted dial. Forecast coverage comes from the separate browser exercise,
whose only staged acquisition relocates two physical Truthtrances. That exercise
verifies invalid input, unknown/rephrasing, both compound clauses, a forecast
comparison and refresh following submission and final resolution.
After the reported power failure, all 767 opening rooms and original seat/recovery
records match the backup. Integrity and foreign keys pass; the healthy server is
reused. A fresh snapshot protects all 768 rooms before broad checks. Final broad
results, source fingerprint, post-HTTP preservation and Git delivery are recorded
in the verified commit and private report. Completion/publication gates remain.
## 14 September 2026 — Emperor Nexus revival prototype and outage recovery
+1
View File
@@ -20,6 +20,7 @@ Updated 14 September 2026. This is the authoritative navigation index for existi
| Guild payment | Rate, transport permission and income are distinct; power owner, shipper, contributors and independent rate-card beneficiary remain distinct. | [Rate/cancellation sources](GUILD_RATE_KARAMA_RULES.md), [implemented payment routing](GUILD_SHIPMENT_PAYMENTS.md) |
| Truthtrance | Current facts are boolean disclosures, not promises to preserve resources. Structured Basic and base Advanced active-opportunity shipment promises bind within their documented scope (Advanced excludes Guild and optional modules); prose is not automatically enforced. | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card facts](TRUTHTRANCE_CARD_COUNT.md), [shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md), [remaining audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) |
| Truthtrance hand categories | Count current physical hand custody by explicitly named primary role; alternate battle slots and faction powers do not reclassify cards. Only an aggregate answer is disclosed. | [Hand inventory](TRUTHTRANCE_HAND_INVENTORY.md) |
| Truthtrance recorded knowledge | Questions compare an already recorded BG prediction, active submitted storm dial or actually known native Fremen forecast. Missing knowledge is unknown; only the aggregate answer is published. No future promise or unknown deck peek is created. | [Connected predicates and evidence](TRUTHTRANCE_KNOWLEDGE.md) |
| Nexus lifecycle | Twelve physical cards, phase draw/replacement and alliance forfeiture; native Cunning, absent-faction Secret Ally and Betrayal have separate effects. No blanket Karama immunity. | [Common source contract](NEXUS_CARD_RULES.md), [lifecycle runtime](NEXUS_CARD_RUNTIME.md) |
| Nexus faction effects | Preserve physical custody, original faction identity and durable effect receipts; use each printed family instead of borrowing another faction's rules by analogy. | [Atreides](NEXUS_ATREIDES_RULES.md), [Harkonnen](NEXUS_HARKONNEN_RULES.md), [BG](NEXUS_BENE_GESSERIT_RULES.md), [Emperor](NEXUS_EMPEROR_RULES.md), [Fremen](NEXUS_FREMEN_RULES.md), [Guild](NEXUS_GUILD_RULES.md) |
| Expansion Nexus families | Source audit coverage does not imply every panel is playable; consult later runtime records in the status log. | [Ixian Cunning](NEXUS_IXIAN_CUNNING_RULES.md), [Ixian replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Tleilaxu](NEXUS_TLEILAXU_RULES.md), [CHOAM](NEXUS_CHOAM_RULES.md), [Richese](NEXUS_RICHESE_RULES.md), [Ecaz](NEXUS_ECAZ_RULES.md), [Moritani](NEXUS_MORITANI_RULES.md) |
+94
View File
@@ -0,0 +1,94 @@
# Truthtrance: recorded predictions and storm knowledge
14 September 2026. **Prototyped**, with **Partial** coverage. This extends the
existing question flow with four typed predicates; freeform interpretation,
general future commitments and complete mode acceptance remain unfinished.
## Authority and semantics
The [GF9 November 2020 FAQ, p. 8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8)
permits game-related Yes/No questions and AND/OR combinations. Its unknown-answer
flow allows another question or retaining the card. Publisher-indexed text was
retrieved again for this batch. These precise predicate definitions implement
part of that broad permission; they are not additional printed restrictions.
- **Prediction faction:** compare the respondent's already-recorded native Bene
Gesserit prediction with a named faction.
- **Prediction turn:** compare the turn in that stored prediction using exactly,
at least or at most. This asks what was selected, not whether that future win
will happen or whether the player promises to make it happen.
- **Submitted storm dial:** compare this respondent's locked value while the
current Storm phase and its submission remain active. A not-yet-submitted or
expired dial is unknown; zero is a real first-turn value, not missing data.
- **Known forecast:** compare the current storm card only for native Advanced
Fremen when the existing knowledge flag is true. Canceled peeks, unknown cards
and other factions receive unknown; no new deck lookup or borrowed knowledge
is granted. The question concerns the known card, not a promise about eventual
storm movement after other effects.
The shared parser accepts canonical faction identities, prediction turns 1–10,
dials 0–20 and forecasts 1–6, and rejects malformed comparisons and values.
Stored dial entitlement also checks the turn's actual legal dialing range.
Missing knowledge yields unknown rather than inventing a choice or an answer.
## Connected behavior and privacy
`truthKnowledgeOf` constructs only the target's eligible facts. Both server answer
validation and target-only answer projection use that same snapshot. AND/OR uses
the existing three-valued evaluation: a false AND term or true OR term can settle
the aggregate despite an unknown sibling. No matching-clause list is published.
The public question and response controls are independent of private values. The
target sees the verified scalar, and its answer becomes public through the normal
history flow. Neither prediction fields nor an unrevealed dial/forecast are added
to rivals' views. Equality answers can naturally reveal the queried value, as the
chosen question intends. A definite answer discards the actual Truthtrance once;
unknown retains its retry/save continuation. The original storm, auction, battle
or other interaction remains in place while the question is open.
The four UI choices share faction/value fields and numeric comparison controls.
Both compound clauses support them, with invalid-input and busy submission guards.
All four AI profiles answer the existing owner-only scalar. They do not receive
opponent knowledge, and this does not claim new strategic question selection or
freeform language understanding.
## Verification boundary
Focused tests use real Basic/Advanced setup, a real locked prediction and ordinary
storm/forecast progression. They stage only the asker's physical Truthtrance
acquisition by relocation, without duplicating cards. They cover private Yes/No
and unknown answers, changed/expired entitlement, invalid queries, dishonest and
duplicate answers, compound indistinguishability, all profiles and JSON views.
The isolated component tests check all numeric comparisons and ranges.
Thirteen new cases pass (six rules, two controls and five production SQLite
recovery cases); the focused union with existing Truthtrance/spice coverage passes
44 cases. Authenticated saved-seat tests restore pending questions and the original
storm-peek response, preserve all private views and physical custody, reject stale,
dishonest and duplicate answers, and allow only one competing version write.
Malformed saved predicates fail without a database write. Independent source and
privacy review found missing numeric comparison controls; integration review also
fixed the parent selector mapping and invalid-input submission guard.
Two genuine-setup, four-profile games finish without rejected actions: Basic in
192 actions with five periodic JSON rounds, Advanced in 518 with fourteen. Exact
card/force custody holds and all eight final seat views restore. Explicitly
scripted legal questions use naturally held cards: the Basic sample asks about a
prediction faction and a submitted storm dial; Advanced asks about a prediction
turn. Neither sample exercises a forecast question. These are bounded continuation
checks, not evidence of autonomous AI question strategy or strength calibration.
The separate browser exercise uses real Advanced setup, a recorded prediction and
ordinary forecast progression, relocating only two physical Truthtrances to the
asker. It verifies invalid dial input, an unknown answer and rephrasing, a compound
faction/turn question, a forecast comparison and refresh after submission and
resolution. The final history is unknown/Yes/Yes, both physical cards are discarded,
and unrelated resources and private projections remain unchanged. Pending-answer
restoration is established by SQLite tests, not a paused browser observation.
All 767 opening games and original seat/recovery records match the backup after
the reported outage. Integrity and foreign keys pass; the healthy server is reused.
The new browser room brings the preservation baseline to 768 before broad checks.
Required broad results, source fingerprint, final preservation totals and Git
delivery are recorded in the checkpoint commit and private report. These results
do not certify all Truthtrance questions, complete expansions or publication readiness.
+2 -1
View File
@@ -27,6 +27,7 @@ import { quoteJacurutuBattleIncome } from './discovery-battle';
import { greatMakerSignature, greatMakerMajority, validateGreatMaker, type GreatMaker } from './great-maker';
import { quoteNexusChoamTrade, validateNexusChoamTrade, nexusChoamTradeSignature, type NexusChoamTrade } from './nexus-choam-trade';
import { EMPEROR_NEXUS_REVIVALS, emperorNexusEvent, emperorNexusSignature, emperorNexusModeSupported, emperorNexusPools, emperorNexusRevivalElites, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
import { truthKnowledgeOf } from './truthtrance-knowledge';
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
import { shipmentAvailable } from './shipment-opportunity';
import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning';
@@ -23482,7 +23483,7 @@ export function viewGame(state: Game, id: string) {
g.truthtrance?.stage === 'answer' &&
g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'fact'
? truthFactAnswer(me, g.truthtrance.question.fact)
? truthFactAnswer(me, g.truthtrance.question.fact, truthKnowledgeOf(g, me))
: null,
schema: g.schema,
botsPending: g.botsPending ?? false,
+23
View File
@@ -2213,6 +2213,29 @@ export const RULE_TOPICS: RuleTopic[] = [
},
],
},
{
id: 'truthtrance-knowledge',
title: 'Truthtrance: recorded predictions and storm knowledge',
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Ask about the target’s recorded Bene Gesserit prediction, submitted storm dial or currently known Fremen storm card, with a single fact or an AND/OR combination.',
steps: [
'Choose a recorded prediction faction, compare its turn, or compare the target’s current submitted storm dial or known Fremen forecast. Exactly, at least and at most are available for numeric facts.',
'These questions describe a choice already recorded or information currently known. They do not require anyone to win on a future turn, change a choice, or make the storm move a promised distance.',
'A missing prediction, an unsubmitted or expired storm dial, or an unknown forecast yields I don’t know. A forecast question never gives another faction the Fremen’s private knowledge.',
'Only the target sees the verified answer before replying. The published response gives the combined result, without identifying which clause matched or separately displaying the stored values.',
'As with other Truthtrance questions, an unknown answer lets the holder ask something else or save the card. A definite answer spends the physical card once and restores the interrupted interaction.',
],
related: ['card-truthtrance', 'truthtrance-spice', 'privacy'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Stored prediction and entitled current storm knowledge use shared three-valued fact evaluation; no future commitment or unknown deck lookup.', evidence: ['game/truthtrance-knowledge.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Four fact choices, numeric comparisons, public faction names, bounded inputs and compound question previews.', evidence: ['components/truthtrance-knowledge.tsx', 'components/truthtrance.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles answer the owner-only verified scalar through the existing Truthtrance flow. Strategic question selection and freeform understanding remain incomplete.', evidence: ['game/bots.ts', 'tests/truthtrance-knowledge.test.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Current-knowledge semantics, unknown cases and original FAQ authority are explicit.', evidence: ['docs/TRUTHTRANCE_KNOWLEDGE.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused rules, UI, compound privacy, bots, JSON and production SQLite restoration; broader Truthtrance acceptance remains open.', evidence: ['tests/truthtrance-knowledge.test.ts', 'tests/truthtrance-knowledge-controls.test.tsx', 'tests/truthtrance-knowledge-recovery.test.ts'] },
],
},
{
id: 'truthtrance-shipment',
title: 'Truthtrance: shipment promises',
+92
View File
@@ -0,0 +1,92 @@
import { FACTIONS, type FactionId } from './catalog';
import type { Game, Player } from './engine';
type Comparison = 'eq' | 'gte' | 'lte';
export type KnowledgeFact =
| { kind: 'prediction'; field: 'faction'; faction: FactionId }
| { kind: 'prediction'; field: 'turn'; compare: Comparison; value: number }
| { kind: 'stormDial' | 'stormForecast'; compare: Comparison; value: number };
/** Only values already chosen or actually known by this respondent. */
export type TruthKnowledge = {
prediction: { faction: FactionId; turn: number } | null;
stormDial: number | null;
stormForecast: number | null;
};
export function isKnowledgeFact(value: unknown): value is KnowledgeFact {
if (!value || typeof value !== 'object') return false;
const kind = (value as { kind?: unknown }).kind;
return kind === 'prediction' || kind === 'stormDial' || kind === 'stormForecast';
}
export function knowledgeFactInputError(value: unknown): string | null {
if (!isKnowledgeFact(value)) return 'Choose a stored prediction, storm dial or known forecast.';
if (value.kind === 'prediction') {
if (value.field === 'faction')
return FACTIONS.some((faction) => faction.id === value.faction)
? null : 'Choose a known faction for the recorded prediction.';
if (value.field !== 'turn') return 'Choose the prediction faction or turn.';
}
if (value.compare !== 'eq' && value.compare !== 'gte' && value.compare !== 'lte')
return 'Compare the stored value using exactly, at least, or at most.';
const minimum = value.kind === 'stormDial' ? 0 : 1;
const maximum = value.kind === 'prediction' ? 10 : value.kind === 'stormDial' ? 20 : 6;
return Number.isSafeInteger(value.value) && value.value >= minimum && value.value <= maximum
? null : `Choose a whole ${value.kind === 'prediction' ? 'prediction turn' : value.kind === 'stormDial' ? 'storm dial' : 'forecast distance'} from ${minimum} to ${maximum}.`;
}
export function parseKnowledgeFact(value: unknown): KnowledgeFact {
const error = knowledgeFactInputError(value);
if (error || !isKnowledgeFact(value)) throw new Error(error ?? 'Choose a valid private-knowledge question.');
if (value.kind === 'prediction' && value.field === 'faction')
return { kind: value.kind, field: value.field, faction: value.faction };
if (value.kind === 'prediction')
return { kind: value.kind, field: value.field, compare: value.compare, value: value.value };
return { kind: value.kind, compare: value.compare, value: value.value };
}
export function truthKnowledgeOf(
g: Pick<Game, 'phase' | 'turn' | 'advanced' | 'stormDials' | 'stormDialers' | 'stormCard' | 'stormCardKnown'>,
p: Pick<Player, 'id' | 'faction' | 'prediction'>,
): TruthKnowledge {
const dial = g.stormDials[p.id];
return {
prediction: p.faction === 'beneGesserit' && p.prediction
? { ...p.prediction } : null,
stormDial: g.phase === 0 && g.stormDialers.includes(p.id) &&
Number.isSafeInteger(dial) && dial >= (g.turn === 1 ? 0 : 1) && dial <= (g.turn === 1 ? 20 : 3)
? dial : null,
stormForecast: p.faction === 'fremen' && g.advanced && g.stormCardKnown &&
Number.isSafeInteger(g.stormCard) && g.stormCard! >= 1 && g.stormCard! <= 6
? g.stormCard : null,
};
}
export function knowledgeFactAnswer(
fact: KnowledgeFact,
knowledge?: TruthKnowledge,
): 'yes' | 'no' | 'unknown' {
const error = knowledgeFactInputError(fact);
if (error) throw new Error(error);
if (!knowledge) return 'unknown';
if (fact.kind === 'prediction' && fact.field === 'faction')
return knowledge.prediction === null ? 'unknown'
: knowledge.prediction.faction === fact.faction ? 'yes' : 'no';
const actual = fact.kind === 'prediction' ? knowledge.prediction?.turn
: fact.kind === 'stormDial' ? knowledge.stormDial : knowledge.stormForecast;
if (actual === null || actual === undefined) return 'unknown';
return (fact.compare === 'eq' ? actual === fact.value
: fact.compare === 'gte' ? actual >= fact.value : actual <= fact.value) ? 'yes' : 'no';
}
export function knowledgeFactText(fact: KnowledgeFact): string {
if (fact.kind === 'prediction' && fact.field === 'faction')
return `your recorded Bene Gesserit prediction names ${FACTIONS.find((f) => f.id === fact.faction)!.name}`;
const comparison = fact.compare === 'eq' ? 'exactly' : fact.compare === 'gte' ? 'at least' : 'at most';
return fact.kind === 'prediction'
? `the turn in your recorded Bene Gesserit prediction is ${comparison} ${fact.value}`
: fact.kind === 'stormDial'
? `your submitted storm dial for this turn is ${comparison} ${fact.value}`
: `the storm card currently known to you as Fremen shows ${comparison} ${fact.value} sectors`;
}
+11 -2
View File
@@ -26,8 +26,10 @@ import { treacheryDeck } from './cards';
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';
export type TruthFact =
| KnowledgeFact
| CardCountFact
| HandInventoryFact
| { kind: 'hand'; name: string }
@@ -89,6 +91,10 @@ function parseFact(
'Truthtrance supports at most 16 clauses and four levels of grouping.',
);
const v = record(value);
if (isKnowledgeFact(v)) {
try { return parseKnowledgeFact(v); }
catch (error) { throw new TruthError(error instanceof Error ? error.message : String(error)); }
}
if (v.kind === 'handCount' || v.kind === 'handInventory') {
try {
return v.kind === 'handCount'
@@ -306,7 +312,9 @@ export function queuedTruthCardMatches(
export function truthFactAnswer(
p: Pick<Player, 'hand' | 'traitors' | 'traitorChoices' | 'spice'>,
fact: TruthFact,
knowledge?: TruthKnowledge,
): TruthAnswer {
if (isKnowledgeFact(fact)) return knowledgeFactAnswer(fact, knowledge);
if (fact.kind === 'handInventory')
return handInventoryFactMatches(p.hand, fact) ? 'yes' : 'no';
if (fact.kind === 'handCount')
@@ -327,7 +335,7 @@ export function truthFactAnswer(
: p.spice <= fact.value;
return matches ? 'yes' : 'no';
}
const answers = fact.terms.map((term) => truthFactAnswer(p, term));
const answers = fact.terms.map((term) => truthFactAnswer(p, term, knowledge));
if (fact.kind === 'and')
return answers.includes('no')
? 'no'
@@ -350,6 +358,7 @@ export function truthQuestionText(
if (q.kind === 'battlePlan')
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 === 'handInventory') return handInventoryFactText(f);
if (f.kind === 'handCount') return cardCountFactText(f);
if (f.kind === 'hand') return `you hold ${f.name}`;
@@ -525,7 +534,7 @@ export function resolveTruthAction(
);
if (q.kind === 'fact')
check(
a.answer === truthFactAnswer(p, q.fact),
a.answer === truthFactAnswer(p, q.fact, truthKnowledgeOf(g, p)),
'Answer this fact question truthfully.',
);
if (q.kind === 'shipment') {
@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { KnowledgeFactFields } from '../components/truthtrance-knowledge';
import { knowledgeFactInputError } from '../game/truthtrance-knowledge';
import type { TruthFact } from '../game/truthtrance';
function markup(value: Extract<TruthFact, { kind: 'prediction' | 'stormDial' | 'stormForecast' }>) {
return renderToStaticMarkup(createElement(KnowledgeFactFields, { value, onChange() {} }));
}
void test('knowledge controls expose full public faction registry and bounded stored predicates', () => {
const faction = markup({ kind: 'prediction', field: 'faction', faction: 'atreides' });
assert.match(faction, /value="atreides"/);
assert.match(faction, /value="emperor"/);
assert.match(faction, /already recorded faction choice/);
const turn = markup({ kind: 'prediction', field: 'turn', compare: 'gte', value: 1 });
assert.match(turn, /min="1"/);
assert.match(turn, /max="10"/);
const dial = markup({ kind: 'stormDial', compare: 'lte', value: 20 });
assert.match(dial, /min="0"/);
assert.match(dial, /max="20"/);
assert.match(dial, /value="eq"/);
assert.match(dial, /value="gte"/);
assert.match(dial, /value="lte"/);
const forecast = markup({ kind: 'stormForecast', compare: 'eq', value: 6 });
assert.match(forecast, /min="1"/);
assert.match(forecast, /max="6"/);
assert.match(forecast, /value="eq"/);
assert.match(forecast, /value="gte"/);
assert.match(forecast, /value="lte"/);
assert.match(forecast, /If no such value is available/);
});
void test('knowledge helper rejects invalid stored bounds', () => {
assert.ok(knowledgeFactInputError({ kind: 'prediction', field: 'turn', compare: 'eq', value: 0 }));
assert.ok(knowledgeFactInputError({ kind: 'prediction', field: 'turn', compare: 'eq', value: 11 }));
assert.ok(knowledgeFactInputError({ kind: 'stormDial', compare: 'gte', value: 21 }));
assert.ok(knowledgeFactInputError({ kind: 'stormForecast', compare: 'lte', value: 0 }));
assert.equal(knowledgeFactInputError({ kind: 'stormDial', compare: 'eq', value: 20 }), null);
});
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeBaseGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import type { TruthFact } from '../game/truthtrance';
export function knowledgeBotActions(g:Game,id:string) {
const view=viewGame(g,id);view.players.find(p=>p.id===id)!.bot='Medium';
return botActions(view);
}
/** Real Basic/Advanced setup and locked BG prediction. Only the asker's physical
* Truthtrance acquisition is staged; all other opening cards remain in custody. */
export function knowledgeGame(advanced = false, ids = ['a', 'b', 'f', 'o']) {
let g = createGame('KNOWTEST', newPlayer(ids[0], 'Question holder', 'guild'), advanced, []);
for (const [index, faction] of ['beneGesserit', 'fremen', 'atreides'].entries())
joinGame(g, newPlayer(ids[index + 1], faction, faction as 'beneGesserit' | 'fremen' | 'atreides'));
for (const p of g.players) p.ready = true;
g = initializeBaseGameForAudit(g);
g = applyAction(g, ids[1], { type: 'predict', faction: 'guild', turn: 7 });
for (let n = 0; g.status !== 'playing' && n < 40; n++) {
const candidate = g.players.flatMap(p => knowledgeBotActions(g,p.id).map(action => ({id:p.id,action})))[0];
assert.ok(candidate,'A legal setup choice must be available');
g = applyAction(g,candidate.id,candidate.action);
}
assert.equal(g.status,'playing');assert.equal(g.phase,0);
const cards = [...g.deck,...g.players.flatMap(p=>p.hand)].filter(c=>c.effect==='truthtrance');
assert.equal(cards.length,2);
for (const card of cards) {
g.deck = g.deck.filter(c=>c.id!==card.id);
for (const p of g.players) p.hand = p.hand.filter(c=>c.id!==card.id);
g.players[0].hand.push(card);
}
return g;
}
export function knownForecastGame(ids = ['a','b','f','o']) {
let g = knowledgeGame(true,ids);
for (const id of g.stormDialers) g=applyAction(g,id,{type:'stormDial',amount:0});
for (let n=0;(g.phase===0||g.response?.kind==='stormPeek')&&n<40;n++) {
const candidates=g.players.flatMap(p=>knowledgeBotActions(g,p.id).map(action=>({id:p.id,action})));
const candidate=candidates.find(c=>c.action.type==='passResponse'||c.action.type==='ready');
assert.ok(candidate,'Storm progression must have a pass/ready action');
g=applyAction(g,candidate.id,candidate.action);
}
assert.equal(g.phase,1);assert.equal(g.stormCardKnown,true);
return g;
}
export function askKnowledge(state:Game,target:string,fact:TruthFact) {
const asker=state.players[0],card=asker.hand.find(c=>c.effect==='truthtrance')!;
let g=applyAction(state,asker.id,{type:'card',card:card.id});
while(g.truthtrance?.stage==='priority') {
const p=g.players.find(p=>!g.truthtrance!.passed.includes(p.id))!;
g=applyAction(g,p.id,{type:'truthPass'});
}
return applyAction(g,asker.id,{type:'truthAsk',question:{kind:'fact',target,fact}});
}
export function rejectKnowledge(g:Game,id:string,action:Action) {
const before=structuredClone(g);assert.throws(()=>applyAction(g,id,action));assert.deepEqual(g,before);
}
export function knowledgePhysical(g:Game) {
return {cards:[...g.deck,...g.discard,...g.players.flatMap(p=>p.hand)].map(c=>c.id).sort(),
forces:g.players.map(p=>({id:p.id,total:p.reserves+p.tanks+Object.values(p.forces).reduce((a,b)=>a+b,0),elite:(p.elites?.reserves??0)+(p.elites?.tanks??0)+Object.values(p.elites?.forces??{}).reduce((a,b)=>a+b,0)}))};
}
@@ -0,0 +1,403 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import type { TruthFact } from '../game/truthtrance';
import type { RoomsClock } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import {
knowledgeGame,
knownForecastGame,
askKnowledge,
knowledgePhysical,
} from './truthtrance-knowledge-fixture';
const clock: RoomsClock = { now: () => 23000, sleep: async () => {} };
const rows = (db: DatabaseSync) =>
db.prepare('SELECT state,version FROM rooms ORDER BY code').all();
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
const secrets = (g: Game) => ({
predictions: g.players.map((p) => ({
id: p.id,
prediction: p.prediction ?? null,
})),
stormDials: g.stormDials,
stormDialers: g.stormDialers,
stormCard: g.stormCard,
stormCardKnown: g.stormCardKnown,
response: g.response,
phase: g.phase,
turn: g.turn,
});
async function fixture(
t: test.TestContext,
advanced: boolean,
prepare: (ids: string[]) => Game,
) {
const store = unitStore();
t.after(() => store.sqlite.close());
const made = await store.rooms.createRoom(
'Private knowledge recovery',
'guild',
advanced,
[],
);
const code = made.view.code,
tokens = [made.token];
for (const faction of ['beneGesserit', 'fremen', 'atreides'] as const)
tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
);
const ids = auths.map((auth) => auth.playerId),
g = prepare(ids);
g.code = code;
g.version = (await store.rooms.readRoom(code)).version;
store.sqlite
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
.run(JSON.stringify(g), g.version, code);
store.writes.length = 0;
return { ...store, code, tokens, auths, ids, g };
}
type Fixture = Awaited<ReturnType<typeof fixture>>;
async function restored(f: Fixture, expected: Game) {
const before = rows(f.sqlite);
for (const [index, token] of f.tokens.entries()) {
const rooms = f.restart(),
auth = await rooms.authenticate(f.code, token);
const view = await rooms.readSeatView(f.code, auth);
assert.deepEqual(view, viewGame(expected, f.ids[index]));
for (const rival of view.players.filter((p) => p.id !== f.ids[index])) {
assert.equal('hand' in rival, false);
assert.equal('spice' in rival, false);
assert.equal('prediction' in rival, false);
}
if (expected.truthtrance?.question?.target !== f.ids[index])
assert.equal(view.truthAnswer, null);
if (index !== 2) assert.equal(view.stormForecast, null);
}
await f.restart().continueRoomAutomatic(f.code, clock);
assert.deepEqual(rows(f.sqlite), before);
}
async function act(
f: Fixture,
index: number,
action: Action,
version?: number,
) {
const rooms = f.restart(),
auth = await rooms.authenticate(f.code, f.tokens[index]);
const current = await rooms.readRoom(f.code);
await rooms.act(f.code, auth, version ?? current.version, action, clock);
return f.restart().readRoom(f.code);
}
function oneCardSpent(initial: Game, done: Game) {
const card = initial.truthtrance!.queue[0].card,
owner = initial.truthtrance!.queue[0].player;
assert.equal(done.discard.filter((c) => c.id === card).length, 1);
for (const p of done.players)
assert.equal(
p.hand.some((c) => c.id === card),
false,
);
assert.deepEqual(
done.players.find((p) => p.id === owner)!.hand,
initial.players
.find((p) => p.id === owner)!
.hand.filter((c) => c.id !== card),
);
assert.deepEqual(knowledgePhysical(done), knowledgePhysical(initial));
assert.equal(done.truthtrance, null);
}
void test('saved BG AND/OR prediction questions restore privately and consume one physical Truthtrance only after an authenticated truthful answer', async (t) => {
for (const advanced of [false, true])
for (const kind of ['and', 'or'] as const) {
const fact: TruthFact = {
kind,
terms: [
{ kind: 'prediction', field: 'faction', faction: 'guild' },
{ kind: 'prediction', field: 'turn', compare: 'eq', value: 3 },
],
};
const expected = kind === 'and' ? 'no' : 'yes';
const f = await fixture(t, advanced, (ids) =>
askKnowledge(knowledgeGame(advanced, ids), ids[1], fact),
);
await restored(f, f.g);
const immutable = rows(f.sqlite),
seats = f.sqlite
.prepare('SELECT * FROM seats ORDER BY player_id')
.all();
await assert.rejects(
act(f, 3, { type: 'truthAnswer', answer: expected }),
);
await assert.rejects(
act(f, 1, {
type: 'truthAnswer',
answer: expected === 'yes' ? 'no' : 'yes',
}),
/truthfully/,
);
await assert.rejects(
act(f, 1, { type: 'truthAnswer', answer: 'unknown' }),
/truthfully/,
);
await assert.rejects(
act(f, 1, { type: 'truthAnswer', answer: expected }, f.g.version - 1),
/table changed/,
);
assert.deepEqual(rows(f.sqlite), immutable);
assert.equal(f.writes.length, 0);
const done = await act(
f,
1,
{ type: 'truthAnswer', answer: expected },
f.g.version,
);
assert.equal(done.version, f.g.version + 1);
assert.equal(done.truthHistory!.at(-1)!.answer, expected);
assert.deepEqual(secrets(done), secrets(f.g));
oneCardSpent(f.g, done);
await restored(f, done);
const saved = rows(f.sqlite);
await assert.rejects(
act(f, 1, { type: 'truthAnswer', answer: expected }, f.g.version),
/table changed/,
);
await assert.rejects(
act(f, 1, { type: 'truthAnswer', answer: expected }, done.version),
);
assert.deepEqual(rows(f.sqlite), saved);
assert.deepEqual(
f.writes.map((w) => w.changes),
[1],
);
assert.deepEqual(
f.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(),
seats,
);
}
});
void test('saved current storm-dial and known Fremen forecast facts answer without changing the committed value or exposing it to other seats', async (t) => {
for (const forecast of [false, true]) {
const f = await fixture(t, forecast, (ids) => {
let g = forecast ? knownForecastGame(ids) : knowledgeGame(false, ids);
const target = forecast
? ids[2]
: g.stormDialers.find((id) => id !== ids[0])!;
if (!forecast)
g = applyAction(g, target, { type: 'stormDial', amount: 0 });
return askKnowledge(g, target, {
kind: forecast ? 'stormForecast' : 'stormDial',
compare: 'eq',
value: forecast ? g.stormCard! : 0,
});
});
const target = f.g.truthtrance!.question!.target,
index = f.ids.indexOf(target);
assert.equal(viewGame(f.g, target).truthAnswer, 'yes');
await restored(f, f.g);
const before = rows(f.sqlite);
await assert.rejects(
act(f, index, { type: 'truthAnswer', answer: 'no' }),
/truthfully/,
);
assert.deepEqual(rows(f.sqlite), before);
assert.equal(f.writes.length, 0);
const done = await act(f, index, { type: 'truthAnswer', answer: 'yes' });
assert.deepEqual(secrets(done), secrets(f.g));
oneCardSpent(f.g, done);
await restored(f, done);
if (!forecast) {
const other = done.stormDialers.find((id) => id !== target)!;
const continued = await act(f, f.ids.indexOf(other), {
type: 'stormDial',
amount: 0,
});
assert.equal(continued.stormDials[target], 0);
assert.equal(continued.stormDials[other], 0);
await restored(f, continued);
}
}
});
/** A conserved real Karama creates the native response; no response object is invented. */
function stormResponseGame(ids: string[]) {
let g = knowledgeGame(true, ids);
const karama = [...g.deck, ...g.players.flatMap((p) => p.hand)].find(
(c) => c.effect === 'karama',
)!;
g.deck = g.deck.filter((c) => c.id !== karama.id);
for (const p of g.players) p.hand = p.hand.filter((c) => c.id !== karama.id);
g.players[0].hand.push(karama);
for (const id of g.stormDialers)
g = applyAction(g, id, { type: 'stormDial', amount: 0 });
for (const p of g.players)
if (!g.ready.includes(p.id)) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.response?.kind, 'stormPeek');
assert.equal(g.stormCardKnown, false);
return g;
}
void test('unknown forecast survives restart and retains the card for rephrasing or saving while the original storm response resumes once', async (t) => {
for (const save of [false, true]) {
const f = await fixture(t, true, (ids) =>
askKnowledge(stormResponseGame(ids), ids[2], {
kind: 'stormForecast',
compare: 'eq',
value: 1,
}),
);
const initial = clone(f.g),
card = initial.truthtrance!.queue[0].card;
await restored(f, initial);
await assert.rejects(
act(f, 2, { type: 'truthAnswer', answer: 'no' }),
/truthfully/,
);
let g = await act(f, 2, { type: 'truthAnswer', answer: 'unknown' });
assert.equal(g.truthtrance!.stage, 'unknown');
assert.deepEqual(g.players[0].hand, initial.players[0].hand);
assert.equal(
g.discard.some((c) => c.id === card),
false,
);
assert.deepEqual(secrets(g), secrets(initial));
await restored(f, g);
const before = rows(f.sqlite);
await assert.rejects(act(f, 1, { type: 'truthSave' }));
await assert.rejects(act(f, 2, { type: 'truthAnswer', answer: 'unknown' }));
assert.deepEqual(rows(f.sqlite), before);
if (save) {
g = await act(f, 0, { type: 'truthSave' });
assert.equal(g.truthtrance, null);
assert.deepEqual(g.players[0].hand, initial.players[0].hand);
assert.equal(
g.discard.some((c) => c.id === card),
false,
);
} else {
g = await act(f, 0, {
type: 'truthAsk',
question: {
kind: 'fact',
target: f.ids[1],
fact: { kind: 'prediction', field: 'turn', compare: 'eq', value: 7 },
},
});
await restored(f, g);
g = await act(f, 1, { type: 'truthAnswer', answer: 'yes' });
oneCardSpent(initial, g);
}
assert.deepEqual(g.response, initial.response);
assert.deepEqual(secrets(g), secrets(initial));
await restored(f, g);
for (
let count = 0;
g.response?.kind === 'stormPeek' && count < 5;
count++
) {
const responder = g.players.find(
(p) => !g.response!.passed.includes(p.id),
)!;
g = await act(f, f.ids.indexOf(responder.id), { type: 'passResponse' });
}
assert.equal(g.response, null);
assert.equal(g.stormCardKnown, true);
assert.equal(g.discard.filter((c) => c.id === card).length, save ? 0 : 1);
assert.deepEqual(knowledgePhysical(g), knowledgePhysical(initial));
await restored(f, g);
}
});
void test('concurrent identical truthful prediction answers commit one physical card and one history entry', async (t) => {
const f = await fixture(t, true, (ids) =>
askKnowledge(knowledgeGame(true, ids), ids[1], {
kind: 'prediction',
field: 'faction',
faction: 'guild',
}),
);
let arrivals = 0,
release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
f.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
const timer = setTimeout(release, 2000);
let results: PromiseSettledResult<unknown>[];
try {
results = await Promise.allSettled(
[0, 1].map(() =>
act(f, 1, { type: 'truthAnswer', answer: 'yes' }, f.g.version),
),
);
} finally {
clearTimeout(timer);
delete f.hooks.beforeWrite;
}
assert.equal(arrivals, 2);
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
assert.equal(results.filter((r) => r.status === 'rejected').length, 1);
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, f.g.version + 1);
assert.equal(done.truthHistory!.length, 1);
assert.deepEqual(secrets(done), secrets(f.g));
oneCardSpent(f.g, done);
assert.deepEqual(
f.writes.map((w) => w.changes).sort((a, b) => a - b),
[0, 1],
);
await restored(f, done);
});
void test('malformed saved knowledge bounds or fields cannot produce an authenticated answer or write a repaired state', async (t) => {
const f = await fixture(t, false, (ids) =>
askKnowledge(knowledgeGame(false, ids), ids[1], {
kind: 'prediction',
field: 'turn',
compare: 'eq',
value: 7,
}),
);
for (const malformed of [
{ kind: 'prediction', field: 'turn', compare: 'eq', value: 11 },
{ kind: 'prediction', field: 'winner', compare: 'eq', value: 7 },
{ kind: 'stormForecast', compare: 'eq', value: 7 },
]) {
const damaged = clone(f.g),
question = damaged.truthtrance!.question!;
assert.equal(question.kind, 'fact');
if (question.kind !== 'fact')
throw new Error('Expected the stored structured question');
question.fact = malformed as TruthFact;
f.sqlite
.prepare('UPDATE rooms SET state=? WHERE code=?')
.run(JSON.stringify(damaged), f.code);
const before = rows(f.sqlite),
writes = f.writes.length;
const rooms = f.restart(),
auth = await rooms.authenticate(f.code, f.tokens[1]);
await assert.rejects(
rooms.readSeatView(f.code, auth),
/prediction|forecast/,
);
await assert.rejects(
act(f, 1, { type: 'truthAnswer', answer: 'yes' }),
/prediction|forecast/,
);
await assert.rejects(
act(f, 1, { type: 'truthAnswer', answer: 'unknown' }),
/prediction|forecast/,
);
assert.deepEqual(rows(f.sqlite), before);
assert.equal(f.writes.length, writes);
}
});
+103
View File
@@ -0,0 +1,103 @@
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 { FACTIONS } from '../game/catalog';
import { truthFactAnswer, truthQuestionText, type TruthFact } from '../game/truthtrance';
import { knowledgeFactAnswer, parseKnowledgeFact, truthKnowledgeOf, type KnowledgeFact } from '../game/truthtrance-knowledge';
import { knowledgeGame, knownForecastGame, askKnowledge, rejectKnowledge, knowledgePhysical, knowledgeBotActions } from './truthtrance-knowledge-fixture';
void test('knowledge predicates normalize typed inputs and reject invalid factions, comparisons and printed ranges',()=>{
for(const faction of FACTIONS) assert.deepEqual(parseKnowledgeFact({kind:'prediction',field:'faction',faction:faction.id,extra:'ignored'}),{kind:'prediction',field:'faction',faction:faction.id});
for(const [kind,min,max] of [['prediction',1,10],['stormDial',0,20],['stormForecast',1,6]] as const)
for(const compare of ['eq','gte','lte'] as const) {
const template={kind,...(kind==='prediction'?{field:'turn'}:{}),compare};
for(const value of [min,max]) assert.equal((parseKnowledgeFact({...template,value}) as {value:number}).value,value);
for(const value of [min-1,max+1,1.5,NaN,Infinity,'3',null]) assert.throws(()=>parseKnowledgeFact({...template,value}));
assert.throws(()=>parseKnowledgeFact({...template,value:min,compare:'gt'}));
}
for(const value of [null,[],{}, {kind:'prediction',field:'winner'}, {kind:'prediction',field:'faction',faction:'unknown'}]) assert.throws(()=>parseKnowledgeFact(value));
});
void test('recorded BG prediction answers faction and turn questions without exposing either underlying value',()=>{
for(const advanced of [false,true])for(const [fact,expected] of [
[{kind:'prediction',field:'faction',faction:'guild'},'yes'],
[{kind:'prediction',field:'faction',faction:'atreides'},'no'],
[{kind:'prediction',field:'turn',compare:'gte',value:7},'yes'],
[{kind:'prediction',field:'turn',compare:'lte',value:6},'no'],
] as const){
const initial=knowledgeGame(advanced),g=askKnowledge(initial,'b',fact);
const physical=knowledgePhysical(initial),saved=JSON.parse(JSON.stringify(g));
for(const p of g.players) assert.deepEqual(viewGame(saved,p.id),viewGame(g,p.id));
assert.equal(viewGame(g,'b').truthAnswer,expected);assert.equal(viewGame(g,'o').truthAnswer,null);
const rival=viewGame(g,'a').players.find(p=>p.id==='b')!;assert.equal('prediction' in rival,false);
rejectKnowledge(g,'b',{type:'truthAnswer',answer:expected==='yes'?'no':'yes'});
rejectKnowledge(g,'o',{type:'truthAnswer',answer:expected});
const done=applyAction(saved,'b',{type:'truthAnswer',answer:expected});
assert.equal(done.truthtrance,null);assert.equal(done.truthHistory!.at(-1)!.answer,expected);
assert.deepEqual(done.players[1].prediction,initial.players[1].prediction);assert.deepEqual(knowledgePhysical(done),physical);
assert.equal(done.discard.filter(c=>c.effect==='truthtrance').length,1);
rejectKnowledge(done,'b',{type:'truthAnswer',answer:expected});
}
});
void test('a submitted storm dial answers while locked and unpublished, but absent or expired submissions remain unknown',()=>{
let initial=knowledgeGame();const target=initial.stormDialers.find(id=>id!=='a')!;
const fact:KnowledgeFact={kind:'stormDial',compare:'eq',value:0};
const missing=askKnowledge(initial,target,fact);assert.equal(viewGame(missing,target).truthAnswer,'unknown');
initial=applyAction(initial,target,{type:'stormDial',amount:0});
const g=askKnowledge(initial,target,fact);assert.equal(viewGame(g,target).truthAnswer,'yes');
assert.equal(viewGame(g,'a').stormRevealed,null);
const done=applyAction(g,target,{type:'truthAnswer',answer:'yes'});assert.equal(done.stormDials[target],0);
assert.deepEqual(knowledgePhysical(done),knowledgePhysical(initial));
const other=done.stormDialers.find(id=>id!==target)!;
let progressed=applyAction(done,other,{type:'stormDial',amount:0});
for(let n=0;progressed.phase===0&&n<20;n++) {
const candidate=progressed.players.flatMap(p=>knowledgeBotActions(progressed,p.id).map(action=>({id:p.id,action}))).find(c=>c.action.type==='ready'||c.action.type==='passResponse')!;
assert.ok(candidate);progressed=applyAction(progressed,candidate.id,candidate.action);
}
assert.equal(progressed.phase,1);
assert.equal(truthKnowledgeOf(progressed,progressed.players.find(p=>p.id===target)!).stormDial,null);
});
void test('forecast predicates use only actual Fremen knowledge and never reveal a canceled, unknown or other-faction storm card',()=>{
const initial=knownForecastGame(),value=initial.stormCard!;
const fact:KnowledgeFact={kind:'stormForecast',compare:'eq',value};
const g=askKnowledge(initial,'f',fact);assert.equal(viewGame(g,'f').truthAnswer,'yes');
for(const id of ['a','b','o']) {const v=viewGame(g,id);assert.equal(v.stormForecast,null);assert.equal(v.truthAnswer,null);}
const done=applyAction(g,'f',{type:'truthAnswer',answer:'yes'});assert.equal(done.stormCard,value);assert.deepEqual(knowledgePhysical(done),knowledgePhysical(initial));
for(const id of ['a','b','o']) assert.equal(truthKnowledgeOf(initial,initial.players.find(p=>p.id===id)!).stormForecast,null);
const unknown={...initial,stormCardKnown:false};
assert.equal(knowledgeFactAnswer(fact,truthKnowledgeOf(unknown,unknown.players[2])),'unknown');
assert.equal(viewGame(askKnowledge(unknown,'f',fact),'f').truthAnswer,'unknown');
assert.equal(truthKnowledgeOf({...initial,advanced:false},initial.players[2]).stormForecast,null);
});
void test('compound answers publish only the aggregate and all four bots use the same owner-only truth',()=>{
const fact:TruthFact={kind:'or',terms:[{kind:'prediction',field:'faction',faction:'guild'},{kind:'prediction',field:'turn',compare:'eq',value:3}]};
const a=knowledgeGame(),b=structuredClone(a);b.players[1].prediction={faction:'atreides',turn:3};
const left=askKnowledge(a,'b',fact),right=askKnowledge(b,'b',fact);
for(const id of ['a','f','o']) assert.deepEqual(viewGame(left,id),viewGame(right,id));
assert.equal(viewGame(left,'b').truthAnswer,'yes');assert.equal(viewGame(right,'b').truthAnswer,'yes');
for(const difficulty of DIFFICULTIES) {
const g=structuredClone(left);g.players[1].bot=difficulty;
const actions=botActions(viewGame(g,'b'));assert.deepEqual(actions,[{type:'truthAnswer',answer:'yes'}]);
const done=applyAction(g,'b',actions[0]);assert.equal(done.truthHistory!.at(-1)!.answer,'yes');
}
const knowledge=truthKnowledgeOf(a,a.players[1]);
assert.equal(truthFactAnswer(a.players[1],{kind:'and',terms:[{kind:'stormForecast',compare:'eq',value:1},{kind:'prediction',field:'faction',faction:'atreides'}]},knowledge),'no');
assert.equal(truthFactAnswer(a.players[1],{kind:'or',terms:[{kind:'stormForecast',compare:'eq',value:1},{kind:'prediction',field:'faction',faction:'atreides'}]},knowledge),'unknown');
});
void test('unknown stored knowledge retains the real card and permits a different question without fabricating a future promise',()=>{
const initial=knowledgeGame(),g=askKnowledge(initial,'o',{kind:'prediction',field:'turn',compare:'eq',value:7});
assert.equal(viewGame(g,'o').truthAnswer,'unknown');rejectKnowledge(g,'o',{type:'truthAnswer',answer:'no'});
const unknown=applyAction(g,'o',{type:'truthAnswer',answer:'unknown'});
assert.equal(unknown.truthtrance!.stage,'unknown');assert.deepEqual(unknown.players[0].hand,initial.players[0].hand);
const retry=applyAction(unknown,'a',{type:'truthAsk',question:{kind:'fact',target:'b',fact:{kind:'prediction',field:'turn',compare:'eq',value:7}}});
const done=applyAction(retry,'b',{type:'truthAnswer',answer:'yes'});assert.equal(done.truthtrance,null);
assert.deepEqual(knowledgePhysical(done),knowledgePhysical(initial));
const wording=truthQuestionText({kind:'fact',target:'b',fact:{kind:'prediction',field:'faction',faction:'guild'}},id=>id);
assert.match(wording,/recorded.*prediction/);assert.doesNotMatch(wording,/will you win|must win/i);
});