Admit supported active or originally queued unused shipments during the current Shipment & Movement phase. Answering preserves the real active player and queue; private future reachability uses owned resources, joint whole promises and the actual respondent profile. Hide executable guidance until that owner is active. Keep native Guild WAIT/TAKE, frozen mixed facts, interception, costs and actual delivery. Do not release a waiting promise while another declaration is unresolved or count its approved ally contribution as freely recoverable. Before response declaration, reject Emperor gifts or optional BG accompaniment that remove the last legal completion; affordable options remain legal. Minimal AI declines optional accompaniment while bound rather than attempting to spend required reserves. No strategy tuning or new profile/module/earlier-phase opening. Validation and exact frozen-source evidence are recorded in the canonical Truthtrance shipment contract. Three real public Basic/Advanced programmes use original unopened starting deals, paid intervening turns and a later Emperor or native out-of-order Guild fulfillment. Four minimal policies answer while waiting and ship only on their actual turn. Controlled funding, temporary price, gift and advisor probes remain separate. Existing stale scope fixtures and two type declarations were repaired during feedback. Earlier phases, broader modules/actions, full Truthtrance/modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
531 lines
21 KiB
TypeScript
531 lines
21 KiB
TypeScript
'use client';
|
||
import { ShipmentClaimFields, invalidShipmentClause, type ShipmentClauseInput } from './shipment-claim-fields';
|
||
import { BattleClaimFields } from './battle-promises';
|
||
import {
|
||
MixedShipmentFields,
|
||
TruthFactFields,
|
||
invalidTruthFact,
|
||
mixedShipmentInputError,
|
||
} from './mixed-shipment-fields';
|
||
import type { MixedShipmentExpression } from '@/game/mixed-shipment-question';
|
||
import type { PlanClaim } from '@/game/battle-promises';
|
||
import { useId, useState } from 'react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||
import {
|
||
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' | 'mixedShipment'
|
||
>('fact');
|
||
const [shipmentJoin, setShipmentJoin] = useState<'single' | 'and' | 'or'>('single');
|
||
const [shipmentClauses, setShipmentClauses] = useState<ShipmentClauseInput[]>([
|
||
{ territory: 'carthag', minimum: 6 },
|
||
{ territory: 'arrakeen', minimum: 4 },
|
||
]);
|
||
const [mixed, setMixed] = useState<MixedShipmentExpression>({
|
||
kind: 'and',
|
||
terms: [
|
||
{ kind: 'fact', fact: { kind: 'hand', name: 'Shield' } },
|
||
{ kind: 'shipment', territory: 'carthag', minimum: 6 },
|
||
],
|
||
});
|
||
const invalidMixed = mixedShipmentInputError(mixed, g, target);
|
||
const invalidShipment = shipmentClauses.slice(0, shipmentJoin === 'single' ? 1 : 2).some(invalidShipmentClause);
|
||
const shipmentAvailable = g.truthShipmentTargets.includes(target);
|
||
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 invalidFact = clauses
|
||
.slice(0, join === 'single' ? 1 : 2)
|
||
.some((fact) => invalidTruthFact(fact, g, 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 locationName = (id: string) => g.combatLocations.find(place => place.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 === 'mixedShipment'
|
||
? { kind, target, mixed }
|
||
: { 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">
|
||
<a href="/rules#card-truthtrance">Question rules</a> · 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, locationName)}</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' || w.question!.kind === 'mixedShipment' ? (
|
||
<>
|
||
<p className="notice">
|
||
A definite answer binds the whole claim about your
|
||
shipment from reserves this turn. Any current facts are
|
||
fixed privately when you answer; they create no later
|
||
card, spice or force holding obligation. Available
|
||
preparation includes your Ghola, Karama and recoverable
|
||
allied funding. Count, sector and payment remain yours
|
||
to choose while honoring the whole claim. AND requires
|
||
all conditions; OR requires at least one. No makes the
|
||
whole claim false. A Yes answer may allow skipping
|
||
shipment if a current-fact branch already satisfies it.
|
||
</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="mixedShipment">
|
||
Combine current facts and this-turn shipment
|
||
</option>
|
||
<option value="freeform">Write a game question</option>
|
||
{g.battle && (
|
||
<option value="battlePlan">
|
||
Bind a battle-plan choice
|
||
</option>
|
||
)}
|
||
</select>
|
||
</label>
|
||
{kind === 'shipment' || kind === 'mixedShipment' ? (
|
||
<>
|
||
{kind === 'mixedShipment' ? (
|
||
<MixedShipmentFields id={`${factId}-mixed`} value={mixed}
|
||
game={g} target={target} disabled={busy} onChange={setMixed} />
|
||
) : (
|
||
<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.
|
||
{' '}Guild interception and Karama rate responses resolve
|
||
before delivery. A stopped shipment can release an
|
||
impossible answer; an unfinished response does not fulfill it.
|
||
</p>
|
||
{!shipmentAvailable && (
|
||
<p className="notice">
|
||
Automatic enforcement supports an unused shipment this
|
||
phase, including a player still waiting for their turn,
|
||
in Basic or Advanced
|
||
base rules, including the Guild, or a
|
||
classic payer in the Richese Semuta profile. Select
|
||
an eligible player and finish pending decisions first.
|
||
Native No-Field shipments and other rules configurations
|
||
remain unfinished.
|
||
</p>
|
||
)}
|
||
<p className="notice" aria-label="Public grouped question preview">
|
||
{kind === 'mixedShipment'
|
||
? invalidMixed ?? truthQuestionText(question, leaderName, locationName)
|
||
: invalidShipment
|
||
? 'Enter a whole minimum of one to twenty forces.'
|
||
: truthQuestionText(question, leaderName, locationName)}
|
||
</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, locationName)}
|
||
</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>
|
||
<TruthFactFields value={c} game={g} target={target}
|
||
onChange={next => setClauses(clauses.map((old, n) => n === index ? next : old))} />
|
||
</fieldset>
|
||
))}
|
||
<p className="notice">
|
||
{invalidFact
|
||
? 'Resolve the marked fields before asking this question.'
|
||
: truthQuestionText(question, leaderName, locationName)}
|
||
</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 === 'mixedShipment' &&
|
||
(!!invalidMixed || !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),
|
||
id => g.combatLocations.find(place => place.id === id)!.name,
|
||
)}
|
||
</p>
|
||
<strong>
|
||
{r.answer === 'unknown'
|
||
? 'I don’t know'
|
||
: r.answer === 'yes'
|
||
? 'Yes'
|
||
: 'No'}
|
||
</strong>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</details>
|
||
);
|
||
}
|