Files
Dune/components/truthtrance.tsx
T
Quintine 9f4de647ce Connect compound Truthtrance shipment promises
Shipment-only AND/OR questions now evaluate one eventual reserve shipment or skip as a whole. Yes to AND requires every leaf, Yes to OR requires one, and No negates the complete expression. Separate answered questions remain jointly binding. The completion search considers all destination branches and owned preparation paths. Release and fulfillment apply once to the aggregate promise, and legacy flat records retain their shape.

The two-condition browser builder, grouped question/history and respondent guidance, all four AI answer policies and saved continuation are connected. Nested expressions have explicit software size limits. Existing active-unused-shipment, Basic/no-Guild Advanced and module gates remain; mixed present/future predicates and unresolved rule questions are unchanged. Independent source, privacy and persistence review found no blocker.

Types, lint, 4815 offline tests, build and 43 HTTP tests pass with stable source. The first typecheck caught an omitted flat-only guidance consumer and a legacy test requiring type narrowing; grouped guidance and an actual promised-state regression fix them. SQLite Basic/Advanced tests verify private reauthentication, second-OR-branch fulfillment, zero-write rejected actions, legal No skip and duplicate CAS with one result.

Two fixed genuine Basic/Advanced mixed-profile games complete in 1534 accepted actions, 41 periodic private JSON restores and 0 rejected candidates. Each uses one scenario-directed compound question from an actual held Truthtrance at a real legal opportunity; AI supplies all responses and other actions. Exact card, force, typed-elite and post-setup traitor custody are checked. Initial private harness runs needed correct immutable setup configuration and full-game auction/Fedaykin inventory accounting; original failed traces remain private, and the final same-seed runs pass. The full sample tree is reconstructed exactly by replacing only the three later UI/test edits; all 219 game source files match the samples. No game logic changed after those runs.

Browser QA used genuine base initialization before conserved turn-two staging. The human asked twenty to Carthag OR four to Arrakeen; the AI answered Yes and shipped four to the second destination. Public grouped history survived refresh. All 6 accepted actions replay exactly, with one discarded Truthtrance, one fulfilled aggregate promise, private JSON views and original seat custody intact. This remains bounded prototype evidence, not complete Truthtrance or mode certification.

All 1004 opening games and original seat/recovery records are preserved (1038 rooms after checks), including all 1005 rooms present immediately before checks. SQLite integrity and foreign-key checks pass. The healthy server was reused without restart, reset or recurring automation.

Verified source tree: 1b24372fbe34e24db56e8f095c25744eeefb27581f2bc6cced2fb752f5bdcb94
2026-09-14 05:43:00 +10:00

717 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { ShipmentClaimFields, invalidShipmentClause, type ShipmentClauseInput } from './shipment-claim-fields';
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';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import { shipmentPromiseModeSupported } from '@/game/shipment-promises';
import {
TRUTH_CARD_NAMES,
truthQuestionText,
type TruthFact,
type TruthQuestion,
} from '@/game/truthtrance';
import type { Action, GameView } from '@/game/engine';
import { canUseAsTruthtranceRole } from '@/game/shrine';
export function Truthtrance({
game: g,
act,
busy,
}: {
game: GameView;
act: (a: Action) => void;
busy: boolean;
}) {
const w = g.truthtrance!;
const me = g.players.find((p) => p.id === g.me)!;
const current = w.queue[0];
const truthCards =
me.hand?.filter((card) => canUseAsTruthtranceRole(g, me, card)) ?? [];
const declaration = (cards: typeof truthCards): Action => ({
type: 'card',
card: cards[0].id,
...(cards.length > 1 ? { cards: cards.slice(1).map((card) => card.id) } : {}),
...(cards.some((card) => card.effect === 'karama')
? {
shrineTruthtrance: cards
.filter((card) => card.effect === 'karama')
.map((card) => card.id),
}
: {}),
});
const [target, setTarget] = useState(
g.players.find((p) => p.id !== me.id)!.id,
);
const [kind, setKind] = useState<
'fact' | 'freeform' | 'battlePlan' | 'shipment'
>('fact');
const [shipmentJoin, setShipmentJoin] = useState<'single' | 'and' | 'or'>('single');
const [shipmentClauses, setShipmentClauses] = useState<ShipmentClauseInput[]>([
{ territory: 'carthag', minimum: 6 },
{ territory: 'arrakeen', minimum: 4 },
]);
const invalidShipment = shipmentClauses.slice(0, shipmentJoin === 'single' ? 1 : 2).some(invalidShipmentClause);
const shipmentAvailable =
shipmentPromiseModeSupported(g) &&
g.phase === 5 &&
g.active === target &&
!g.players.find((p) => p.id === target)?.shipped &&
!g.response &&
!g.decision &&
!g.phaseOpening;
const [planClauses, setPlanClauses] = useState<PlanClaim[]>([
{ kind: 'dial', compare: 'gte', value: 1 },
{ kind: 'weapon', name: null },
]);
const [scope, setScope] = useState<'fact' | 'currentTurn'>('fact');
const [text, setText] = useState('');
const [join, setJoin] = useState<'single' | 'and' | 'or'>('single');
const [clauses, setClauses] = useState<TruthFact[]>([
{ kind: 'hand', name: 'Shield' },
{ kind: 'hand', name: 'Snooper' },
]);
const factId = useId();
const invalidSpice = (fact: TruthFact) =>
(fact.kind === 'spice' ||
fact.kind === 'handCount' ||
fact.kind === 'handInventory') &&
(!Number.isSafeInteger(fact.value) || fact.value < 0);
const invalidFact = clauses
.slice(0, join === 'single' ? 1 : 2)
.some((fact) => invalidSpice(fact) || (isKnowledgeFact(fact) && !!knowledgeFactInputError(fact)));
const leaderName = (id: string) =>
id === CHEAP_HERO_TRAITOR
? 'Cheap Hero / Heroine'
: (g.allLeaders.find((l) => l.id === id)?.name ?? id);
const name = (id: string) => g.players.find((p) => p.id === id)!.name;
const question: TruthQuestion =
kind === 'fact'
? {
kind,
target,
fact: join === 'single' ? clauses[0] : { kind: join, terms: clauses },
}
: kind === 'battlePlan'
? {
kind,
target,
territory: g.battle?.territory ?? '',
claim:
join === 'single'
? planClauses[0]
: { kind: join, terms: planClauses },
}
: kind === 'shipment'
? {
kind,
target,
...(shipmentJoin === 'single' ? shipmentClauses[0] : { claim: { op: shipmentJoin, terms: shipmentClauses } }),
}
: { kind, target, text, scope };
const button = (label: string, action: Action, disabled = false) => (
<Button
className="game-action"
disabled={busy || disabled}
onClick={() => act(action)}
>
{label}
</Button>
);
return (
<section className="truthtrance-panel" aria-label="Truthtrance question">
<h2>Truthtrance</h2>
<p className="fine">
<Link href="/rules#card-truthtrance">Question rules</Link> · The table
resumes its previous decision after these questions.
</p>
{w.stage === 'priority' ? (
<>
<p>
A Truthtrance has been declared. Declare yours now or pass;
questions then follow storm order.
</p>
<ul>
{g.players.map((p) => (
<li key={p.id}>
{p.name} ·{' '}
{w.queue.some((q) => q.player === p.id)
? 'Declared'
: w.passed.includes(p.id)
? 'Passed'
: 'Choosing'}
</li>
))}
</ul>
{!w.passed.includes(me.id) && (
<>
{truthCards.map((c) => (
<div key={c.id}>
{button(
c.effect === 'karama'
? 'Use my Karama as Truthtrance'
: 'Declare my Truthtrance',
declaration([c]),
)}
</div>
))}
{truthCards.length > 1 &&
button(
`Declare all ${truthCards.length} as Truthtrance`,
declaration(truthCards),
)}
{button('Pass Truthtrance priority', { type: 'truthPass' })}
</>
)}
</>
) : (
<>
<p className="eyebrow">
{name(current.player)} holds the question · {w.queue.length} pending
</p>
{w.question && (
<blockquote>
<p>{truthQuestionText(w.question, leaderName)}</p>
<p className="fine">Asked of {name(w.question.target)}</p>
</blockquote>
)}
{w.stage === 'answer' ? (
w.question!.target === me.id ? (
<>
<p>
Your answer is public. Answer truthfully about the game; a
promise can only bind decisions in this turn.
</p>
{w.question!.kind === 'shipment' ? (
<>
<p className="notice">
A definite answer binds your shipment from reserves this
turn. Available preparation includes your Ghola, Karama
and recoverable allied funding. Yes leaves the exact
count, sector and payment yours to choose within the
complete statement. No requires that statement to be false;
for AND, at least one condition must be false; for OR,
every condition must be false.
</p>
{(g.truthShipmentAnswers ?? []).map((answer) => (
<div key={answer}>
{button(
answer === 'unknown'
? 'I don’t know yet'
: `Answer ${answer === 'yes' ? 'Yes' : 'No'}`,
{ type: 'truthAnswer', answer },
)}
</div>
))}
</>
) : w.question!.kind === 'battlePlan' ? (
<>
<p className="notice">
Choose an answer you can honor in a legal plan, including
available Ghola preparation. Earlier Truthtrance answers,
Voice and revealed prescience elements apply. An answer
about a sealed plan is already fixed.
</p>
{(g.truthBattleAnswers ?? []).map((answer) => (
<div key={answer}>
{button(
answer === 'unknown'
? 'I don’t know yet'
: `Answer ${answer === 'yes' ? 'Yes' : 'No'}`,
{ type: 'truthAnswer', answer },
)}
</div>
))}
<p className="fine">
A definite answer constrains your plan. Other plan
elements remain yours to choose.
</p>
</>
) : w.question!.kind === 'fact' ? (
<>
<p className="notice">
Your current game information establishes:{' '}
<strong>
{g.truthAnswer === 'unknown'
? 'I don’t know yet'
: g.truthAnswer === 'yes'
? 'Yes'
: 'No'}
</strong>
. Only you see this until you answer.
</p>
{button('Publish my answer', {
type: 'truthAnswer',
answer: g.truthAnswer,
})}
</>
) : (
<>
{button('Answer Yes', {
type: 'truthAnswer',
answer: 'yes',
})}
{button('Answer No', { type: 'truthAnswer', answer: 'no' })}
{button('I don’t know', {
type: 'truthAnswer',
answer: 'unknown',
})}
<p className="fine">
Use “I don’t know” for something you cannot know or
decide. The holder can ask a different question or save
the card.
</p>
</>
)}
</>
) : (
<output>Waiting for {name(w.question!.target)} to answer.</output>
)
) : current.player !== me.id ? (
<output>
Waiting for {name(current.player)} to{' '}
{w.stage === 'unknown'
? 'ask again or save the card'
: 'ask a question'}
.
</output>
) : (
<>
{w.stage === 'unknown' && (
<>
<p>
The answer could not be known. Ask a different question or
keep Truthtrance for later.
</p>
{button('Save card for later', { type: 'truthSave' })}
</>
)}
<label>
Ask which player?
<select
value={target}
onChange={(e) => setTarget(e.target.value)}
>
{g.players
.filter((p) => p.id !== me.id)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.bot ? ' · AI' : ''}
</option>
))}
</select>
</label>
<label>
Question type
<select
value={kind}
onChange={(e) => setKind(e.target.value as typeof kind)}
>
<option value="fact">
Verified current facts
</option>
<option value="shipment">
Bind a shipment from reserves
</option>
<option value="freeform">Write a game question</option>
{g.battle && (
<option value="battlePlan">
Bind a battle-plan choice
</option>
)}
</select>
</label>
{kind === 'shipment' ? (
<>
<ShipmentClaimFields id={`${factId}-shipment`} clauses={shipmentClauses}
join={shipmentJoin} onClauses={setShipmentClauses} onJoin={setShipmentJoin} />
<p className="fine">
Includes Fremen reinforcements and Guild transport from
southern reserves. Ground movement and transport of forces
already on the board do not count.
</p>
{!shipmentAvailable && (
<p className="notice">
Automatic enforcement currently supports the active
player’s unused shipment in base Basic games and base
Advanced games without Guild or optional modules. Select
that player and finish pending decisions first. Earlier
questions and other rules configurations remain
unfinished.
</p>
)}
<p className="notice">
{invalidShipment
? 'Enter a whole minimum of one to twenty forces.'
: truthQuestionText(question, leaderName)}
</p>
</>
) : kind === 'battlePlan' ? (
<>
<label>
Combine conditions
<select
value={join}
onChange={(e) => setJoin(e.target.value as typeof join)}
>
<option value="single">One condition</option>
<option value="and">Both · AND</option>
<option value="or">Either · OR</option>
</select>
</label>
{planClauses
.slice(0, join === 'single' ? 1 : 2)
.map((c, index) => (
<fieldset key={index}>
<legend>Condition {index + 1}</legend>
<BattleClaimFields
game={g}
value={c}
onChange={(next) =>
setPlanClauses(
planClauses.map((old, n) =>
n === index ? next : old,
),
)
}
/>
</fieldset>
))}
<p className="notice">
{truthQuestionText(question, leaderName)}
</p>
{g.battle &&
![g.battle.attacker, g.battle.defender].includes(
target,
) && <p>Select one of the combatants for this question.</p>}
</>
) : kind === 'fact' ? (
<>
<label>
Combine facts
<select
value={join}
onChange={(e) => setJoin(e.target.value as typeof join)}
>
<option value="single">One fact</option>
<option value="and">Both facts · AND</option>
<option value="or">Either fact · OR</option>
</select>
</label>
{clauses
.slice(0, join === 'single' ? 1 : 2)
.map((c, index) => (
<fieldset key={index}>
<legend>Fact {index + 1}</legend>
<label>
Fact type
<select
value={c.kind === 'prediction' ? c.field === 'faction' ? 'predictionFaction' : 'predictionTurn' : c.kind}
onChange={(e) =>
setClauses(
clauses.map((old, n) =>
n === index
? e.target.value === 'hand'
? { kind: 'hand', name: 'Shield' }
: e.target.value === 'handCount'
? {
kind: 'handCount',
name: 'Shield',
compare: 'gte',
value: 2,
}
: e.target.value === 'handInventory'
? {
kind: 'handInventory',
category: 'all',
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',
compare: 'gte',
value: 6,
}
: {
kind: 'traitor',
leader: g.allLeaders[0].id,
}
: old,
),
)
}
>
<option value="hand">Holds a named card</option>
<option value="handCount">
Number of a named card
</option>
<option value="handInventory">
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>
</select>
</label>
{c.kind === 'hand' ? (
<label>
Card name
<select
value={c.name}
onChange={(e) =>
setClauses(
clauses.map((old, n) =>
n === index
? { kind: 'hand', name: e.target.value }
: old,
),
)
}
>
{TRUTH_CARD_NAMES.map((name) => (
<option key={name}>{name}</option>
))}
</select>
</label>
) : c.kind === 'handCount' ||
c.kind === 'handInventory' ? (
<CardCountFields
value={c}
onChange={(next) =>
setClauses(
clauses.map((old, n) =>
n === index ? next : old,
),
)
}
/>
) : 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>
Compare personal spice
<select
value={c.compare}
onChange={(e) =>
setClauses(
clauses.map((old, n) =>
n === index
? {
...c,
compare: e.target
.value as typeof c.compare,
}
: old,
),
)
}
>
<option value="eq">Exactly</option>
<option value="gte">At least</option>
<option value="lte">At most</option>
</select>
</label>
<label htmlFor={`${factId}-${index}-spice-amount`}>
Spice amount
<Input
id={`${factId}-${index}-spice-amount`}
type="number"
min={0}
max={Number.MAX_SAFE_INTEGER}
step={1}
required
value={Number.isNaN(c.value) ? '' : c.value}
aria-invalid={invalidSpice(c)}
aria-describedby={`${factId}-${index}-spice-help${invalidSpice(c) ? ` ${factId}-${index}-spice-error` : ''}`}
onChange={(e) =>
setClauses(
clauses.map((old, n) =>
n === index
? {
...c,
value:
e.currentTarget.valueAsNumber,
}
: old,
),
)
}
/>
</label>
<p
className="fine"
id={`${factId}-${index}-spice-help`}
>
Counts spice the player personally holds now.
Excludes pledged aid and incoming payments. This
asks about the current balance; it does not
promise future holdings or spending.
</p>
{invalidSpice(c) && (
<p
className="notice"
role="alert"
id={`${factId}-${index}-spice-error`}
>
{Number.isInteger(c.value) &&
c.value > Number.MAX_SAFE_INTEGER
? 'That spice amount is too large.'
: 'Enter a whole spice amount of zero or more.'}
</p>
)}
</>
) : (
c.kind === 'traitor' && (
<label>
Leader
<select
value={c.leader}
onChange={(e) =>
setClauses(
clauses.map((old, n) =>
n === index
? {
kind: 'traitor',
leader: e.target.value,
}
: old,
),
)
}
>
{g.allLeaders.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
<option value={CHEAP_HERO_TRAITOR}>
Cheap Hero / Heroine
</option>
</select>
</label>
)
)}
</fieldset>
))}
<p className="notice">
{invalidFact
? 'Enter a valid whole amount before asking this question.'
: truthQuestionText(question, leaderName)}
</p>
</>
) : (
<>
<label>
Question concerns
<select
value={scope}
onChange={(e) => setScope(e.target.value as typeof scope)}
>
<option value="fact">A game fact</option>
<option value="currentTurn">
A promise during turn {g.turn}
</option>
</select>
</label>
<label>
One yes/no question
<textarea
rows={4}
maxLength={500}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Will you ship at least six forces to Carthag this turn?"
/>
</label>
<p className="notice">
Freeform answers rely on the players. Automatic checks for
arbitrary promises are unfinished. AI can answer structured
fact, supported shipment and battle-plan questions; it
cannot yet interpret freeform questions.
</p>
</>
)}
{button(
'Ask publicly',
{ type: 'truthAsk', question },
(kind === 'shipment' &&
(invalidShipment || !shipmentAvailable)) ||
(kind === 'freeform' && !text.trim()) ||
(kind === 'fact' && invalidFact) ||
(kind === 'battlePlan' &&
(!g.battle ||
![g.battle.attacker, g.battle.defender].includes(
target,
))),
)}
</>
)}
</>
)}
</section>
);
}
export function TruthHistory({ game: g }: { game: GameView }) {
const records = g.truthHistory.filter((r) => r.turn === g.turn);
if (!records.length) return null;
const name = (id: string) => g.players.find((p) => p.id === id)?.name ?? id;
return (
<details className="notice truthtrance-history">
<summary>Truthtrance answers · Turn {g.turn}</summary>
<p className="fine">
Structured battle-plan and supported reserve-shipment answers are
enforced. Current-turn freeform promises remain binding when possible,
but their automatic enforcement is unfinished.
</p>
<ol>
{records.map((r, i) => (
<li key={i}>
<p>
{name(r.asker)} → {name(r.question.target)}:{' '}
{truthQuestionText(r.question, (id) =>
id === CHEAP_HERO_TRAITOR
? 'Cheap Hero / Heroine'
: (g.allLeaders.find((l) => l.id === id)?.name ?? id),
)}
</p>
<strong>
{r.answer === 'unknown'
? 'I don’t know'
: r.answer === 'yes'
? 'Yes'
: 'No'}
</strong>
</li>
))}
</ol>
</details>
);
}