Let players compare normal, elite or total counters in reserves, the Tanks or one live board territory and sector. Reuse compound facts and private answers. Exclude concealed No-Field values and combat-strength modifiers; guard Basic factions without a tracked starred-counter split. Reparse saved fact questions before actions and projection, including nested leaves and dynamic locations. Preserve interrupted decisions, physical card disposal and historical answers after later legal movement. Connect controls, contextual guidance and the capability checklist while leaving full mode, combination and publication gates unchanged. Validation: types, lint, 5122 offline tests and production build passed. Independent review and 21 focused pure, engine, control and authenticated concurrent recovery tests passed. Six complete-game samples finished with 1551 legal actions, 39 JSON restores and no rejected actions. These are continuation checks, not AI strength calibration. Browser room 86WE6LXV exercised genuine setup, compound physical counts, AI response, mobile controls, human private-answer refresh and publication. Two existing Truthtrance Cards retired once each and forces stayed intact. All 1,584 prior rooms, 3,352 seats and auxiliary records remain unchanged; one QA room and human seat were added. No server restart or database reset. Existing screenshot capture failure leaves full visual acceptance open.
725 lines
31 KiB
TypeScript
725 lines
31 KiB
TypeScript
'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 { ForceCountFields, forceCountInputError } from './truthtrance-force-count';
|
||
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)) ||
|
||
(fact.kind === 'forceCount' && !!forceCountInputError(fact, g, g.players.find(p => p.id === target)!)));
|
||
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 === 'forceCount'
|
||
? { kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 6 }
|
||
: 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>
|
||
<option value="forceCount">Current physical forces</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 === 'forceCount' ? (
|
||
<ForceCountFields value={c} game={g} player={g.players.find(p => p.id === target)!}
|
||
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
|
||
? 'Resolve the marked fields 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>
|
||
);
|
||
}
|