Files
Dune/components/mixed-shipment-fields.tsx
T

247 lines
14 KiB
TypeScript

'use client';
import { useId, type ReactElement } from 'react';
import type { GameView } from '@/game/engine';
import type { MixedShipmentExpression } from '@/game/mixed-shipment-question';
import type { TruthFact } from '@/game/truthtrance';
import { TRUTH_CARD_NAMES } from '@/game/truthtrance-card-names';
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 { DUKE_VIDAL_ID } from '@/game/duke-vidal';
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';
import { NoFieldValueFields } from './truthtrance-no-field-value';
import { parseNoFieldValueFact } from '@/game/truthtrance-no-field-value';
import { FaceDancerFactFields } from './truthtrance-face-dancer';
import { parseFaceDancerFact } from '@/game/truthtrance-face-dancer';
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 (fact.kind === 'noFieldValue') {
try { parseNoFieldValueFact(fact); return false; }
catch { return true; }
}
if (fact.kind === 'faceDancer') {
try {
parseFaceDancerFact(fact, [
...game.allLeaders.filter((leader) => leader.id !== DUKE_VIDAL_ID && leader.id !== CHEAP_HERO_TRAITOR).map((leader) => leader.id),
CHEAP_HERO_TRAITOR,
]);
return false;
} catch { return true; }
}
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 },
faceDancer: { kind: 'faceDancer', leader: game.allLeaders.find((leader) => leader.id !== DUKE_VIDAL_ID)?.id ?? CHEAP_HERO_TRAITOR },
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 },
spicePeek: { kind: 'spicePeek', value: 'worm' },
auctionPeek: { kind: 'auctionPeek', name: 'Shield' },
spice: { kind: 'spice', compare: 'gte', value: 6 },
forceCount: { kind: 'forceCount', zone: { kind: 'reserves' }, counter: 'total', compare: 'gte', value: 6 },
noFieldValue: { kind: 'noFieldValue', value: 3 },
};
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="faceDancer">Currently unrevealed Face Dancer</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="spicePeek">Known next Spice Blow card</option>
<option value="auctionPeek">Inspected ordinary auction card</option>
<option value="spice">Current personal spice</option>
<option value="forceCount">Current physical forces</option>
<option value="noFieldValue">Current concealed No-Field printed value</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 === 'faceDancer' ?
<FaceDancerFactFields value={fact} leaders={game.allLeaders} onChange={onChange} /> : fact.kind === 'noFieldValue' ?
<NoFieldValueFields 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' || fact.kind === 'spicePeek' || fact.kind === 'auctionPeek' ?
<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>;
}