Support mixed current-fact and shipment Truthtrance questions
This commit is contained in:
1 parent
0ceb28bd31
commit
cd985c7c34
20 files changed
+982
-326
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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
@@ -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' &&
|
||||
|
||||
Reference in new issue
Block a user