Support mixed current-fact and shipment Truthtrance questions

This commit is contained in:
Quintine committed 2026-10-03 07:11:28 +10:00
1 parent 0ceb28bd31
commit cd985c7c34
20 files changed
+982 -326

No files matched your search

+2 -2
View File
@@ -4523,7 +4523,7 @@ export function GameTable({
g.shipmentPromises,
me.id,
g.turn,
).some((promise) => promise.answer)
).some(promise => matchesShipment(promise,null) !== promise.answer)
? 'Complete the promised shipment before moving.'
: null
}
@@ -4589,7 +4589,7 @@ export function GameTable({
g.shipmentPromises,
me.id,
g.turn,
).some((promise) => promise.answer)
).some(promise => matchesShipment(promise,null) !== promise.answer)
? 'Complete the promised shipment before moving.'
: null
}
+217
View File
@@ -0,0 +1,217 @@
'use client';
import { useId, type ReactElement } from 'react';
import type { GameView } from '@/game/engine';
import type { MixedShipmentExpression } from '@/game/mixed-shipment-question';
import { TRUTH_CARD_NAMES, type TruthFact } from '@/game/truthtrance';
import { SHIPMENT_EXPRESSION_MAX_DEPTH, SHIPMENT_EXPRESSION_MAX_LEAVES } from '@/game/shipment-promises';
import { isKnowledgeFact, knowledgeFactInputError } from '@/game/truthtrance-knowledge';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { CardCountFields } from './truthtrance-card-count';
import { KnowledgeFactFields } from './truthtrance-knowledge';
import { ForceCountFields, forceCountInputError } from './truthtrance-force-count';
import { ShipmentClauseFields, invalidShipmentClause } from './shipment-claim-fields';
export function invalidTruthFact(fact: TruthFact, game: GameView, target: string): boolean {
if (fact.kind === 'and' || fact.kind === 'or') {
return fact.terms.some(term => invalidTruthFact(term, game, target));
}
if (fact.kind === 'spice' || fact.kind === 'handCount' || fact.kind === 'handInventory') {
return !Number.isSafeInteger(fact.value) || fact.value < 0;
}
if (isKnowledgeFact(fact)) return !!knowledgeFactInputError(fact);
if (fact.kind === 'forceCount') {
return !!forceCountInputError(fact, game, game.players.find(player => player.id === target)!);
}
return false;
}
/** The same current-fact fields serve standalone and mixed questions. */
export function TruthFactFields({ value: fact, game, target, onChange }: {
value: TruthFact;
game: GameView;
target: string;
onChange: (fact: TruthFact) => void;
}) {
const id = useId();
const defaults: Record<string, TruthFact> = {
hand: { kind: 'hand', name: 'Shield' },
handCount: { kind: 'handCount', name: 'Shield', compare: 'gte', value: 2 },
handInventory: { kind: 'handInventory', category: 'all', compare: 'gte', value: 2 },
traitor: { kind: 'traitor', leader: game.allLeaders[0].id },
predictionFaction: { kind: 'prediction', field: 'faction', faction: 'atreides' },
predictionTurn: { kind: 'prediction', field: 'turn', compare: 'gte', value: 1 },
stormDial: { kind: 'stormDial', compare: 'gte', value: 0 },
stormForecast: { kind: 'stormForecast', compare: 'gte', value: 1 },
spice: { kind: 'spice', compare: 'gte', value: 6 },
forceCount: { kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 6 },
};
return <>
<label>
Current fact type
<select value={fact.kind === 'prediction' ? fact.field === 'faction' ? 'predictionFaction' : 'predictionTurn' : fact.kind}
onChange={event => onChange(defaults[event.target.value])}>
<option value="hand">Holds a named card now</option>
<option value="handCount">Current number of a named card</option>
<option value="handInventory">Current 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>
{fact.kind === 'hand' ? <label>
Card name
<select value={fact.name} onChange={event => onChange({ kind: 'hand', name: event.target.value })}>
{TRUTH_CARD_NAMES.map(name => <option key={name}>{name}</option>)}
</select>
</label> : fact.kind === 'handCount' || fact.kind === 'handInventory' ?
<CardCountFields value={fact} onChange={onChange} /> : fact.kind === 'forceCount' ?
<ForceCountFields value={fact} game={game} player={game.players.find(player => player.id === target)!} onChange={onChange} /> :
fact.kind === 'prediction' || fact.kind === 'stormDial' || fact.kind === 'stormForecast' ?
<KnowledgeFactFields value={fact} onChange={onChange} /> : fact.kind === 'spice' ? <>
<label>
Compare current personal spice
<select value={fact.compare} onChange={event => onChange({ ...fact, compare: event.target.value as typeof fact.compare })}>
<option value="eq">Exactly</option>
<option value="gte">At least</option>
<option value="lte">At most</option>
</select>
</label>
<label htmlFor={`${id}-spice-amount`}>Spice amount</label>
<Input id={`${id}-spice-amount`} type="number" min={0} max={Number.MAX_SAFE_INTEGER} step={1} required
value={Number.isNaN(fact.value) ? '' : fact.value}
aria-invalid={invalidTruthFact(fact, game, target)}
aria-describedby={`${id}-spice-help${invalidTruthFact(fact, game, target) ? ` ${id}-spice-error` : ''}`}
onChange={event => onChange({ ...fact, value: event.currentTarget.valueAsNumber })} />
<p className="fine" id={`${id}-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>
{invalidTruthFact(fact, game, target) && <p className="notice" role="alert" id={`${id}-spice-error`}>
{Number.isInteger(fact.value) && fact.value > Number.MAX_SAFE_INTEGER
? 'That spice amount is too large.' : 'Enter a whole spice amount of zero or more.'}
</p>}
</> : fact.kind === 'traitor' ? <label>
Leader
<select value={fact.leader} onChange={event => onChange({ kind: 'traitor', leader: event.target.value })}>
{game.allLeaders.map(leader => <option key={leader.id} value={leader.id}>{leader.name}</option>)}
<option value={CHEAP_HERO_TRAITOR}>Cheap Hero / Heroine</option>
</select>
</label> : null}
</>;
}
const newFact = (): MixedShipmentExpression => ({ kind: 'fact', fact: { kind: 'hand', name: 'Shield' } });
const newShipment = (): MixedShipmentExpression => ({ kind: 'shipment', territory: 'carthag', minimum: 6 });
function factSize(fact: TruthFact): { leaves: number; depth: number } {
if (fact.kind !== 'and' && fact.kind !== 'or') return { leaves: 1, depth: 0 };
const children = fact.terms.map(factSize);
return { leaves: children.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(...children.map(child => child.depth)) };
}
function expressionSize(expression: MixedShipmentExpression): { leaves: number; depth: number } {
if (expression.kind === 'fact') return factSize(expression.fact);
if (expression.kind === 'shipment') return { leaves: 1, depth: 0 };
const children = expression.terms.map(expressionSize);
return { leaves: children.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(...children.map(child => child.depth)) };
}
export function mixedShipmentInputError(expression: MixedShipmentExpression, game: GameView, target: string): string | null {
const size = expressionSize(expression);
if (size.leaves > SHIPMENT_EXPRESSION_MAX_LEAVES || size.depth > SHIPMENT_EXPRESSION_MAX_DEPTH) {
return 'Use at most sixteen conditions and four nested groups.';
}
let facts = 0;
let shipments = 0;
let invalid = false;
const visit = (node: MixedShipmentExpression) => {
if (node.kind === 'fact') {
facts++;
invalid ||= invalidTruthFact(node.fact, game, target);
} else if (node.kind === 'shipment') {
shipments++;
invalid ||= invalidShipmentClause(node);
} else {
invalid ||= node.terms.length < 2;
node.terms.forEach(visit);
}
};
visit(expression);
if (!facts || !shipments) return 'Include both a current fact and a shipment-from-reserves condition.';
return invalid ? 'Resolve the marked fields; shipment minima must be whole numbers from one to twenty.' : null;
}
export function MixedShipmentFields({ id, value, game, target, disabled, onChange }: {
id: string;
value: MixedShipmentExpression;
game: GameView;
target: string;
disabled: boolean;
onChange: (expression: MixedShipmentExpression) => void;
}) {
const size = expressionSize(value);
const render = (node: MixedShipmentExpression, path: string, depth: number,
update: (next: MixedShipmentExpression) => void, remove?: () => void): ReactElement => {
const group = node.kind === 'and' || node.kind === 'or';
const wrapAllowed = size.leaves < SHIPMENT_EXPRESSION_MAX_LEAVES &&
depth + expressionSize(node).depth < SHIPMENT_EXPRESSION_MAX_DEPTH;
return <fieldset key={path} className="grid min-w-0 gap-2" style={{ padding: depth ? 6 : 10 }}>
<legend>{group ? `Group ${path} · ${node.kind.toUpperCase()}` : `Condition ${path}`}</legend>
{group ? <>
<label>
Group meaning
<select value={node.kind} onChange={event => update({ ...node, kind: event.target.value as 'and' | 'or' })}>
<option value="and">All conditions · AND</option>
<option value="or">At least one condition · OR</option>
</select>
</label>
{node.terms.map((term, index) => render(term, `${path}.${index + 1}`, depth + 1,
next => update({ ...node, terms: node.terms.map((old, i) => i === index ? next : old) }),
() => {
const terms = node.terms.filter((_, i) => i !== index);
update(terms.length === 1 ? terms[0] : { ...node, terms });
}))}
<div className="flex flex-wrap gap-2">
<Button disabled={disabled || size.leaves >= SHIPMENT_EXPRESSION_MAX_LEAVES}
onClick={() => update({ ...node, terms: [...node.terms, newFact()] })}>Add current fact</Button>
<Button disabled={disabled || size.leaves >= SHIPMENT_EXPRESSION_MAX_LEAVES}
onClick={() => update({ ...node, terms: [...node.terms, newShipment()] })}>Add shipment condition</Button>
<Button disabled={disabled || size.leaves + 2 > SHIPMENT_EXPRESSION_MAX_LEAVES || depth + 2 > SHIPMENT_EXPRESSION_MAX_DEPTH}
onClick={() => update({ ...node, terms: [...node.terms, { kind: 'and', terms: [newFact(), newShipment()] }] })}>Add nested group</Button>
</div>
</> : <>
<label>
Condition timing
<select value={node.kind} onChange={event => update(event.target.value === 'fact' ? newFact() : newShipment())}>
<option value="fact">Current fact · at the answer</option>
<option value="shipment">Shipment from reserves · this turn</option>
</select>
</label>
{node.kind === 'fact' ? <TruthFactFields value={node.fact} game={game} target={target}
onChange={fact => update({ kind: 'fact', fact })} /> : node.kind === 'shipment' ?
<ShipmentClauseFields id={`${id}-${path}`} value={node}
onChange={clause => update({ kind: 'shipment', ...clause })} /> : null}
</>}
<div className="flex flex-wrap gap-2">
<Button disabled={disabled || !wrapAllowed} onClick={() => update({ kind: 'and', terms: [node, newShipment()] })}>Group with AND</Button>
<Button disabled={disabled || !wrapAllowed} onClick={() => update({ kind: 'or', terms: [node, newShipment()] })}>Group with OR</Button>
{remove && <Button disabled={disabled} onClick={remove}>Remove {group ? 'group' : 'condition'}</Button>}
</div>
</fieldset>;
};
return <fieldset disabled={disabled} className="grid min-w-0 gap-2 [&_select]:min-w-0 [&_select]:max-w-full [&_button]:max-w-full [&_button]:min-h-11 [&_button]:h-auto [&_button]:py-2 [&_button]:whitespace-normal">
<legend>Mixed current-fact / this-turn shipment claim</legend>
<p className="fine">
Current facts are evaluated when the player answers, not after shipment. They do not require keeping
a card, spice or forces later. Every shipment condition refers to the same shipment from reserves this turn.
No makes the whole grouped claim false, not necessarily every condition.
</p>
<p className="fine">{size.leaves} / {SHIPMENT_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {SHIPMENT_EXPRESSION_MAX_DEPTH} nested groups</p>
{render(value, '1', 0, onChange)}
</fieldset>;
}
+25 -9
View File
@@ -7,6 +7,30 @@ export function invalidShipmentClause(claim: ShipmentClauseInput) {
return !Number.isSafeInteger(claim.minimum) || claim.minimum < 1 || claim.minimum > 20;
}
/** Destination and physical minimum shared by flat and nested question builders. */
export function ShipmentClauseFields({ id, value, onChange }: {
id: string;
value: ShipmentClauseInput;
onChange: (claim: ShipmentClauseInput) => void;
}) {
return <>
<label htmlFor={`${id}-territory`}>This-turn shipment destination</label>
<select id={`${id}-territory`} value={value.territory}
onChange={event => onChange({ ...value, territory: event.target.value })}>
{TERRITORIES.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<label htmlFor={`${id}-minimum`}>Minimum physical forces from reserves</label>
<Input id={`${id}-minimum`} type="number" min={1} max={20} step={1} required
value={Number.isNaN(value.minimum) ? '' : value.minimum}
aria-invalid={invalidShipmentClause(value)} aria-describedby={`${id}-help`}
onChange={event => onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} />
<p className="fine" id={`${id}-help`}>
A whole minimum of one to twenty physical forces in the same shipment from reserves this turn.
Skipping makes this shipment condition false.
</p>
</>;
}
export function ShipmentClaimFields({ id, clauses, join, onClauses, onJoin }: {
id: string;
clauses: ShipmentClauseInput[];
@@ -25,15 +49,7 @@ export function ShipmentClaimFields({ id, clauses, join, onClauses, onJoin }: {
const update = (next: ShipmentClauseInput) => onClauses(clauses.map((previous, i) => i === index ? next : previous));
return <fieldset key={index} className="grid gap-2">
<legend>Shipment condition {index + 1}</legend>
<label htmlFor={`${id}-territory-${index}`}>Shipment destination</label>
<select id={`${id}-territory-${index}`} value={claim.territory} onChange={event => update({ ...claim, territory: event.target.value })}>
{TERRITORIES.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<label htmlFor={`${id}-minimum-${index}`}>Minimum physical forces</label>
<Input id={`${id}-minimum-${index}`} type="number" min={1} max={20} step={1} required
value={Number.isNaN(claim.minimum) ? '' : claim.minimum}
aria-invalid={invalidShipmentClause(claim)} aria-describedby={`${id}-help`}
onChange={event => update({ ...claim, minimum: event.currentTarget.valueAsNumber })} />
<ShipmentClauseFields id={`${id}-${index}`} value={claim} onChange={update} />
</fieldset>;
})}
<p className="fine" id={`${id}-help`}>
+22 -6
View File
@@ -1,6 +1,8 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { liveShipmentPromises, shipmentClaimText } from '@/game/shipment-promises';
import { liveShipmentPromises, matchesShipment, shipmentClaimText } from '@/game/shipment-promises';
import { truthQuestionText } from '@/game/truthtrance';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import { territory } from '@/game/board';
import { reserveShipmentCost, guildShipmentCost } from '@/game/shipment-price';
import { Button } from './ui/button';
@@ -63,16 +65,30 @@ export function ShipmentPromises({
<ul>
{promises.map((p, index) => (
<li key={index}>
You answered {p.answer ? 'Yes' : 'No'} to: Will you{' '}
{shipmentClaimText(p)} this turn?
You answered {p.answer ? 'Yes' : 'No'} to:{' '}
{p.mixed
? truthQuestionText(
{ kind: 'mixedShipment', target: p.player, mixed: p.mixed },
id => id === CHEAP_HERO_TRAITOR
? 'Cheap Hero / Heroine'
: g.allLeaders.find(leader => leader.id === id)?.name ?? id,
)
: `Will you ${shipmentClaimText(p)} this turn?`}
</li>
))}
</ul>
{promises.some((p) => p.answer) && (
{promises.some((p) => matchesShipment(p, null) !== p.answer) ? (
<p>
Complete a qualifying shipment before ground movement or finishing
your turn. You can choose any legal count, sector and funding that
honor your answers.
your turn: skipping would not honor the whole claim you answered.
You can choose any legal count, sector and funding that honor all
your answers. Current facts remain fixed at the time of your answer.
</p>
) : (
<p>
Skipping shipment honors your whole claims. If you ship, choose a
legal shipment that honors every answer. Current facts are historical
and create no later card, spice or force holding obligation.
</p>
)}
{next && (
+54 -245
View File
@@ -1,18 +1,20 @@
'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 {
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 { Input } from '@/components/ui/input';
import { shipmentAvailable as targetShipmentAvailable } from '@/game/shipment-opportunity';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import { shipmentPromiseModeSupported } from '@/game/shipment-promises';
import {
TRUTH_CARD_NAMES,
truthQuestionText,
type TruthFact,
type TruthQuestion,
@@ -50,19 +52,27 @@ export function Truthtrance({
g.players.find((p) => p.id !== me.id)!.id,
);
const [kind, setKind] = useState<
'fact' | 'freeform' | 'battlePlan' | 'shipment'
'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 =
shipmentPromiseModeSupported(g) &&
g.phase === 5 &&
g.active === target &&
!g.players.find((p) => p.id === target)?.shipped &&
targetShipmentAvailable(g, {id:target,shipped:g.players.find(p => p.id === target)?.shipped ?? false}) &&
!g.response &&
!g.decision &&
!g.phaseOpening;
@@ -78,15 +88,9 @@ export function Truthtrance({
{ 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)!)));
.some((fact) => invalidTruthFact(fact, g, target));
const leaderName = (id: string) =>
id === CHEAP_HERO_TRAITOR
? 'Cheap Hero / Heroine'
@@ -115,7 +119,9 @@ export function Truthtrance({
target,
...(shipmentJoin === 'single' ? shipmentClauses[0] : { claim: { op: shipmentJoin, terms: shipmentClauses } }),
}
: { kind, target, text, scope };
: kind === 'mixedShipment'
? { kind, target, mixed }
: { kind, target, text, scope };
const button = (label: string, action: Action, disabled = false) => (
<Button
className="game-action"
@@ -189,16 +195,19 @@ export function Truthtrance({
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 === 'shipment' || w.question!.kind === 'mixedShipment' ? (
<>
<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.
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}>
@@ -321,6 +330,9 @@ export function Truthtrance({
<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">
@@ -329,10 +341,15 @@ export function Truthtrance({
)}
</select>
</label>
{kind === 'shipment' ? (
{kind === 'shipment' || kind === 'mixedShipment' ? (
<>
<ShipmentClaimFields id={`${factId}-shipment`} clauses={shipmentClauses}
join={shipmentJoin} onClauses={setShipmentClauses} onJoin={setShipmentJoin} />
{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
@@ -348,10 +365,12 @@ export function Truthtrance({
unfinished.
</p>
)}
<p className="notice">
{invalidShipment
? 'Enter a whole minimum of one to twenty forces.'
: truthQuestionText(question, leaderName)}
<p className="notice" aria-label="Public grouped question preview">
{kind === 'mixedShipment'
? invalidMixed ?? truthQuestionText(question, leaderName)
: invalidShipment
? 'Enter a whole minimum of one to twenty forces.'
: truthQuestionText(question, leaderName)}
</p>
</>
) : kind === 'battlePlan' ? (
@@ -411,220 +430,8 @@ export function Truthtrance({
.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>
)
)}
<TruthFactFields value={c} game={g} target={target}
onChange={next => setClauses(clauses.map((old, n) => n === index ? next : old))} />
</fieldset>
))}
<p className="notice">
@@ -670,6 +477,8 @@ export function Truthtrance({
{ type: 'truthAsk', question },
(kind === 'shipment' &&
(invalidShipment || !shipmentAvailable)) ||
(kind === 'mixedShipment' &&
(!!invalidMixed || !shipmentAvailable)) ||
(kind === 'freeform' && !text.trim()) ||
(kind === 'fact' && invalidFact) ||
(kind === 'battlePlan' &&