Support Truthtrance No-Field shipment promises
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 10:07:00 +11:00
1 parent 9ab9b9a613
commit 7fccd62814
16 files changed
+658 -32

No files matched your search

+23 -2
View File
@@ -8,15 +8,18 @@ import {
} from '@/game/shipment-promises';
import { shipmentExpressionInputError, shipmentExpressionInputSize } from '@/game/shipment-expression-input';
import { ShipmentClauseFields } from './shipment-claim-fields';
import { TruthNoFieldShipmentFields } from './truth-no-field-shipment-fields';
import { Button } from './ui/button';
const newLeaf = (): ShipmentExpression => ({ territory: 'carthag', minimum: 1 });
/** Hook-free controlled tree. Stateful leaf descendants are rendered by React. */
export function ShipmentExpressionFields({ value, id = 'truth-shipment', disabled, onChange }: {
export function ShipmentExpressionFields({ value, id = 'truth-shipment', disabled, allowNoField = false, onChange }: {
value: ShipmentExpression;
id?: string;
disabled: boolean;
/** Expose the public marker question only for an eligible Richese target. */
allowNoField?: boolean;
onChange: (expression: ShipmentExpression) => void;
}) {
const size = shipmentExpressionInputSize(value);
@@ -30,6 +33,7 @@ export function ShipmentExpressionFields({ value, id = 'truth-shipment', disable
remove?: () => ShipmentExpression,
): ReactElement => {
const group = 'op' in node;
const marker = !group && 'kind' in node && node.kind === 'noField';
const allowed = (next: ShipmentExpression) => !disabled && !shipmentExpressionInputError(replace(next));
const change = (next: ShipmentExpression) => commit(replace(next));
const wrap = (op: 'and' | 'or'): ShipmentExpression => ({ op, terms: [node, newLeaf()] });
@@ -63,7 +67,24 @@ export function ShipmentExpressionFields({ value, id = 'truth-shipment', disable
</div>
</> : 'constant' in node ? <p className="notice" role="alert">
Frozen current facts are unavailable in a pure shipment question. Remove this condition.
</p> : <ShipmentClauseFields id={`${id}-${path}`} value={node} onChange={change} />}
</p> : <>
{(allowNoField || marker) && <>
<label htmlFor={`${id}-${path}-condition-type`}>Shipment condition type</label>
<select id={`${id}-${path}-condition-type`} value={marker ? 'noField' : 'physical'}
disabled={disabled} onChange={event => {
const kind = event.currentTarget.value;
if (kind === 'physical') change(newLeaf());
else if (kind === 'noField' && allowNoField) change({ kind: 'noField' });
}}>
<option value="physical">Physical forces from reserves</option>
<option value="noField" disabled={!allowNoField}>Owned No-Field from reserves</option>
</select>
</>}
{marker ? <>
{!allowNoField && <p className="notice">Choose Richese for a No-Field shipment condition, or change this condition to physical forces.</p>}
<TruthNoFieldShipmentFields id={`${id}-${path}`} value={node} disabled={disabled || !allowNoField} onChange={change} />
</> : 'minimum' in node ? <ShipmentClauseFields id={`${id}-${path}`} value={node} onChange={change} /> : null}
</>}
<div className="flex flex-wrap gap-2">
<Button type="button" data-shipment-expression-control={`and-${path}`} disabled={!allowed(wrap('and'))}
onClick={() => change(wrap('and'))}>Group with AND</Button>
@@ -0,0 +1,53 @@
'use client';
import { TERRITORIES } from '@/game/board';
/** This public question never includes a concealed token identity or value. */
export type NoFieldShipmentInput = {
kind: 'noField';
territory?: string;
sector?: number;
};
/** Author one No-Field leaf inside the ordinary shipment expression. */
export function TruthNoFieldShipmentFields({ id, value, disabled, onChange }: {
id: string;
value: NoFieldShipmentInput;
disabled: boolean;
onChange: (value: NoFieldShipmentInput) => void;
}) {
const destination = TERRITORIES.find(territory => territory.id === value.territory);
return <>
<label htmlFor={`${id}-no-field-territory`}>No-Field shipment destination</label>
<select id={`${id}-no-field-territory`} value={value.territory ?? ''} disabled={disabled}
aria-invalid={value.territory !== undefined && !destination} onChange={event => {
if (disabled) return;
const territory = event.currentTarget.value;
if (territory === '') onChange({ kind: 'noField' });
else if (TERRITORIES.some(candidate => candidate.id === territory))
onChange({ kind: 'noField', territory });
}}>
<option value="">Any destination</option>
{TERRITORIES.map(territory => <option key={territory.id} value={territory.id}>{territory.name}</option>)}
</select>
{destination && <>
<label htmlFor={`${id}-no-field-sector`}>No-Field shipment sector</label>
<select id={`${id}-no-field-sector`} value={value.sector === undefined ? '' : String(value.sector)}
disabled={disabled} aria-invalid={value.sector !== undefined && !destination.sectors.includes(value.sector)}
onChange={event => {
if (disabled) return;
const selected = event.currentTarget.value;
if (selected === '') onChange({ kind: 'noField', territory: destination.id });
else {
const sector = Number(selected);
if (destination.sectors.includes(sector)) onChange({ kind: 'noField', territory: destination.id, sector });
}
}}>
<option value="">Any sector in this territory</option>
{destination.sectors.map(sector => <option key={sector} value={sector}>Sector {sector}</option>)}
</select>
</>}
<p className="fine">Ask whether the player ships an owned No-Field from reserves this turn.
The question does not identify its token or value. A physical-force minimum is a separate condition.</p>
</>;
}
+6 -3
View File
@@ -5,6 +5,7 @@ import {
parseShipmentExpression,
ShipmentClaimError,
shipmentExpressionOf,
shipmentClaimHasNoField,
type ShipmentExpression,
} from '@/game/shipment-promises';
import { shipmentExpressionInputError } from '@/game/shipment-expression-input';
@@ -23,7 +24,8 @@ export function TruthShipmentQuestion({ game, question, busy, act, onChange }: {
const ownsQuestion = () => !!game.me && game.players.some(player => player.id === game.me) &&
game.truthtrance?.stage === 'ask' && game.truthtrance.queue[0]?.player === game.me;
const hasTarget = () => question.target !== game.me && game.players.some(player => player.id === question.target);
const supported = () => hasTarget() && game.truthShipmentTargets.includes(question.target);
const supported = () => hasTarget() && game.truthShipmentTargets.includes(question.target) &&
(!shipmentClaimHasNoField(question) || game.players.some(player => player.id === question.target && player.faction === 'richese'));
const inputError = () => {
try {
parseShipmentExpression(shipmentExpressionOf(question), { allowConstants: false });
@@ -37,8 +39,9 @@ export function TruthShipmentQuestion({ game, question, busy, act, onChange }: {
const error = inputError();
return <>
{hasTarget() ? <ShipmentExpressionFields value={shipmentExpressionOf(question)}
disabled={busy || !ownsQuestion() || !supported()} onChange={next => {
if (!busy && ownsQuestion() && supported() && !shipmentExpressionInputError(next)) onChange(next);
allowNoField={game.players.some(player => player.id === question.target && player.faction === 'richese')}
disabled={busy || !ownsQuestion() || !game.truthShipmentTargets.includes(question.target)} onChange={next => {
if (!busy && ownsQuestion() && hasTarget() && game.truthShipmentTargets.includes(question.target) && !shipmentExpressionInputError(next)) onChange(next);
}} /> : <p className="notice">Choose another seated player for this question.</p>}
{!supported() && <p className="notice">Choose an eligible respondent with an unused shipment this turn and finish pending decisions.</p>}
<blockquote data-testid="truth-shipment-preview">