Combine current facts with native bribe payments

This commit is contained in:
Quintine committed 2026-10-26 16:39:30 +11:00
1 parent b63a60da64
commit 6b2dfb84a2
23 files changed
+1445 -170

No files matched your search

+33 -162
View File
@@ -1,181 +1,52 @@
'use client';
import type { ReactElement } from 'react';
import type { GameView } from '@/game/engine';
import { TERRITORIES } from '@/game/board';
import { parseMixedActionExpression, type MixedActionExpression } from '@/game/mixed-action-question';
import type { MixedActionExpression } from '@/game/mixed-action-question';
import { MOVEMENT_EXPRESSION_MAX_DEPTH, MOVEMENT_EXPRESSION_MAX_LEAVES } from '@/game/movement-expression-promises';
import { parseMovementClaim, type MovementClaim } from '@/game/movement-promises';
import type { TruthFact } from '@/game/truthtrance';
import { truthFactExpressionError } from '@/game/truth-fact-expression';
import { TruthFactFields, invalidTruthFact } from './mixed-shipment-fields';
import { Button } from './ui/button';
import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields';
import { Input } from './ui/input';
type MixedMovementExpression = MixedActionExpression<MovementClaim>;
const newFact = (): MixedMovementExpression => ({ kind: 'fact', fact: { kind: 'hand', name: 'Shield' } });
const newMove = (): MixedMovementExpression => ({ kind: 'action', action: { territory: 'carthag', minimum: 1 } });
const newFactLeaf = (): TruthFact => ({ kind: 'hand', name: 'Shield' });
function factSize(fact: TruthFact): { leaves: number; depth: number } {
if (fact.kind !== 'and' && fact.kind !== 'or') return { leaves: 1, depth: 0 };
const sizes = fact.terms.map(factSize);
return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) };
}
function sizeOf(node: MixedMovementExpression): { leaves: number; depth: number } {
if (node.kind === 'fact') return factSize(node.fact);
if (node.kind === 'action') return { leaves: 1, depth: 0 };
const sizes = node.terms.map(sizeOf);
return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) };
}
function structuralError(node: MixedMovementExpression): string | null {
const size = sizeOf(node);
if (size.leaves > MOVEMENT_EXPRESSION_MAX_LEAVES || size.depth > MOVEMENT_EXPRESSION_MAX_DEPTH)
return 'Use at most sixteen conditions and four levels of grouping.';
if (node.kind === 'fact') return truthFactExpressionError(node.fact);
if (node.kind === 'action') return null;
if (node.terms.length < 2) return 'Join two or more conditions with AND or OR.';
for (const term of node.terms) {
const error = structuralError(term);
if (error) return error;
}
return null;
}
const config: MixedActionConfig<MovementClaim> = {
prefix: 'mixed-movement',
legend: 'Mixed current-fact / future ground-movement claim',
actionLabel: 'ground-movement condition',
actionTiming: 'Future ground move · this turn',
actionHelp: <>Each movement condition needs one actual future ground move after the answer, before your Movement turn ends this round.
Smaller moves never add together. Prior moves and shipments do not count. The claim grants no extra movement opportunity.</>,
fallbackError: 'Choose a current fact and a future ground-movement condition.',
maxLeaves: MOVEMENT_EXPRESSION_MAX_LEAVES,
maxDepth: MOVEMENT_EXPRESSION_MAX_DEPTH,
newAction: () => ({ territory: 'carthag', minimum: 1 }),
parseAction: parseMovementClaim,
renderAction: ({ value, id, disabled, onChange }) => <>
<label htmlFor={`${id}-territory`}>Ground-movement destination</label>
<select id={`${id}-territory`} value={TERRITORIES.some(destination => destination.id === value.territory) ? value.territory : ''}
disabled={disabled} aria-invalid={!TERRITORIES.some(destination => destination.id === value.territory)}
onChange={event => onChange({ ...value, territory: event.currentTarget.value })}>
<option value="">Choose a printed Arrakis territory</option>
{TERRITORIES.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
</select>
<label htmlFor={`${id}-minimum`}>Minimum physical forces in one ground move</label>
<Input id={`${id}-minimum`} type="number" min={1} max={20} step={1} required disabled={disabled}
value={Number.isNaN(value.minimum) ? '' : value.minimum}
aria-invalid={!Number.isSafeInteger(value.minimum) || value.minimum < 1 || value.minimum > 20}
onChange={event => onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} />
</>,
};
/** Draft feedback reuses the existing fact fields. The server remains the
* authority for current-fact eligibility and legal future movement routes. */
/** Keep the movement API and rendered control identifiers stable. */
export function mixedMovementInputError(value: unknown, game: GameView, target: string): string | null {
try {
parseMixedActionExpression(value, input => {
const fact = input as TruthFact;
const error = truthFactExpressionError(fact);
if (error) throw new Error(error);
if (invalidTruthFact(fact, game, target)) throw new Error('Resolve the marked current-fact fields.');
return fact;
}, parseMovementClaim);
return null;
} catch (error) {
return error instanceof Error ? error.message : 'Choose a current fact and a future ground-movement condition.';
}
return mixedActionInputError(value, game, target, config);
}
/** Preserve the complete original tree, including incomplete numeric drafts.
* Structural changes never evaluate current facts or select a future branch. */
export function MixedMovementFields({ value, game, target, disabled, onChange }: {
export function MixedMovementFields(props: {
value: MixedMovementExpression;
game: GameView;
target: string;
disabled: boolean;
onChange: (expression: MixedMovementExpression) => void;
}) {
const size = sizeOf(value), error = mixedMovementInputError(value, game, target);
const commit = (next: MixedMovementExpression) => {
if (!disabled && !structuralError(next)) onChange(next);
};
const renderFact = (fact: TruthFact, path: string, replace: (next: TruthFact) => MixedMovementExpression,
remove?: () => MixedMovementExpression): ReactElement => {
const allowed = (next: TruthFact) => !disabled && !structuralError(replace(next));
const change = (next: TruthFact) => commit(replace(next));
const wrap = (kind: 'and' | 'or'): TruthFact => ({ kind, terms: [fact, newFactLeaf()] });
return <fieldset key={path} data-mixed-movement-fact-path={path} className="grid min-w-0 gap-2 p-2">
<legend>{fact.kind === 'and' || fact.kind === 'or' ? `Current-fact group ${path}` : `Current fact ${path}`}</legend>
{fact.kind === 'and' || fact.kind === 'or' ? <>
<label htmlFor={`truth-mixed-movement-${path}-fact-meaning`}>Current-fact group {path} meaning</label>
<select id={`truth-mixed-movement-${path}-fact-meaning`} value={fact.kind} disabled={disabled} onChange={event => {
const kind = event.currentTarget.value;
if (kind === 'and' || kind === 'or') change({ ...fact, kind });
}}><option value="and">All conditions · AND</option><option value="or">At least one condition · OR</option></select>
{fact.terms.map((term, index) => renderFact(term, `${path}.${index + 1}`,
next => replace({ ...fact, terms: fact.terms.map((old, i) => i === index ? next : old) }),
() => {
const terms = fact.terms.filter((_, i) => i !== index);
return replace(terms.length === 1 ? terms[0] : { ...fact, terms });
}))}
<Button type="button" data-mixed-movement-control={`fact-add-${path}`}
disabled={!allowed({ ...fact, terms: [...fact.terms, newFactLeaf()] })}
onClick={() => change({ ...fact, terms: [...fact.terms, newFactLeaf()] })}>Add current fact</Button>
</> : <TruthFactFields value={fact} game={game} target={target} onChange={change} />}
<div className="flex flex-wrap gap-2">
{(['and', 'or'] as const).map(kind => <Button key={kind} type="button" data-mixed-movement-control={`fact-${kind}-${path}`}
disabled={!allowed(wrap(kind))} onClick={() => change(wrap(kind))}>Group current facts with {kind.toUpperCase()}</Button>)}
{remove && <Button type="button" data-mixed-movement-control={`fact-remove-${path}`} disabled={disabled}
onClick={() => commit(remove())}>Remove current fact</Button>}
</div>
</fieldset>;
};
const render = (node: MixedMovementExpression, path: string, replace: (next: MixedMovementExpression) => MixedMovementExpression,
remove?: () => MixedMovementExpression): ReactElement => {
const id = `truth-mixed-movement-${path}`;
const allowed = (next: MixedMovementExpression) => !disabled && !structuralError(replace(next));
const change = (next: MixedMovementExpression) => commit(replace(next));
const wrap = (kind: 'and' | 'or'): MixedMovementExpression => ({ kind, terms: [node, node.kind === 'fact' ? newMove() : newFact()] });
return <fieldset key={path} data-mixed-movement-path={path} className="grid min-w-0 gap-2 p-2">
<legend>{node.kind === 'and' || node.kind === 'or' ? `Mixed group ${path} · ${node.kind.toUpperCase()}` : `Mixed condition ${path}`}</legend>
{node.kind === 'and' || node.kind === 'or' ? <>
<label htmlFor={`${id}-meaning`}>Mixed group {path} meaning</label>
<select id={`${id}-meaning`} value={node.kind} disabled={disabled} onChange={event => {
const kind = event.currentTarget.value;
if (kind === 'and' || kind === 'or') change({ ...node, kind });
}}><option value="and">All conditions · AND</option><option value="or">At least one condition · OR</option></select>
{node.terms.map((term, index) => render(term, `${path}.${index + 1}`,
next => replace({ ...node, terms: node.terms.map((old, i) => i === index ? next : old) }),
() => {
const terms = node.terms.filter((_, i) => i !== index);
return replace(terms.length === 1 ? terms[0] : { ...node, terms });
}))}
<div className="flex flex-wrap gap-2">
<Button type="button" data-mixed-movement-control={`add-fact-${path}`}
disabled={!allowed({ ...node, terms: [...node.terms, newFact()] })}
onClick={() => change({ ...node, terms: [...node.terms, newFact()] })}>Add current fact</Button>
<Button type="button" data-mixed-movement-control={`add-action-${path}`}
disabled={!allowed({ ...node, terms: [...node.terms, newMove()] })}
onClick={() => change({ ...node, terms: [...node.terms, newMove()] })}>Add ground-movement condition</Button>
{(['and', 'or'] as const).map(kind => {
const next: MixedMovementExpression = { ...node, terms: [...node.terms, { kind, terms: [newFact(), newMove()] }] };
return <Button key={kind} type="button" data-mixed-movement-control={`nested-${kind}-${path}`}
disabled={!allowed(next)} onClick={() => change(next)}>Add nested {kind.toUpperCase()} group</Button>;
})}
</div>
</> : <>
<label htmlFor={`${id}-timing`}>Condition timing</label>
<select id={`${id}-timing`} value={node.kind} disabled={disabled} onChange={event => {
if (event.currentTarget.value === 'fact') change(newFact());
if (event.currentTarget.value === 'action') change(newMove());
}}><option value="fact">Current fact · at the answer</option><option value="action">Future ground move · this turn</option></select>
{node.kind === 'fact' ? renderFact(node.fact, path, fact => replace({ kind: 'fact', fact })) : node.kind === 'action' ? <>
<label htmlFor={`${id}-territory`}>Ground-movement destination</label>
<select id={`${id}-territory`} value={TERRITORIES.some(destination => destination.id === node.action.territory) ? node.action.territory : ''}
disabled={disabled} aria-invalid={!TERRITORIES.some(destination => destination.id === node.action.territory)}
onChange={event => change({ ...node, action: { ...node.action, territory: event.currentTarget.value } })}>
<option value="">Choose a printed Arrakis territory</option>
{TERRITORIES.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
</select>
<label htmlFor={`${id}-minimum`}>Minimum physical forces in one ground move</label>
<Input id={`${id}-minimum`} type="number" min={1} max={20} step={1} required disabled={disabled}
value={Number.isNaN(node.action.minimum) ? '' : node.action.minimum}
aria-invalid={!Number.isSafeInteger(node.action.minimum) || node.action.minimum < 1 || node.action.minimum > 20}
onChange={event => change({ ...node, action: { ...node.action, minimum: event.currentTarget.valueAsNumber } })} />
</> : null}
</>}
<div className="flex flex-wrap gap-2">
{(['and', 'or'] as const).map(kind => <Button key={kind} type="button" data-mixed-movement-control={`${kind}-${path}`}
disabled={!allowed(wrap(kind))} onClick={() => change(wrap(kind))}>Group with {kind.toUpperCase()}</Button>)}
{remove && <Button type="button" data-mixed-movement-control={`remove-${path}`} disabled={disabled}
onClick={() => commit(remove())}>Remove {node.kind === 'and' || node.kind === 'or' ? 'group' : 'condition'}</Button>}
</div>
</fieldset>;
};
return <fieldset disabled={disabled} data-testid="mixed-movement-expression"
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 / future ground-movement claim</legend>
<p className="fine">Current facts are evaluated when the player answers. They create no promise to keep cards, spice or forces afterward.
AND requires every condition; OR requires at least one. No makes the whole grouped claim false, not necessarily every condition.
Only the aggregate answer is shared; an unknown current fact can still allow a definite whole answer.</p>
<p className="fine">Each movement condition needs one actual future ground move after the answer, before your Movement turn ends this round.
Smaller moves never add together. Prior moves and shipments do not count. The claim grants no extra movement opportunity.</p>
<p className="fine">App input limits: {MOVEMENT_EXPRESSION_MAX_LEAVES} conditions and {MOVEMENT_EXPRESSION_MAX_DEPTH} levels of grouping.
These are input limits, not additional printed rules.</p>
<p className="fine">{size.leaves} / {MOVEMENT_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {MOVEMENT_EXPRESSION_MAX_DEPTH} levels of grouping</p>
{error && <p role="alert">{error}</p>}
{render(value, '1', next => next)}
</fieldset>;
return MixedActionFields({ ...props, config });
}