Support sector-specific Truthtrance movement

This commit is contained in:
Quintine committed 2026-10-27 21:27:07 +11:00
1 parent 536d334543
commit 1059cc1682
24 files changed
+1016 -100

No files matched your search

+13 -2
View File
@@ -15,7 +15,7 @@ const config: MixedActionConfig<MovementClaim> = {
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.</>,
You may name an exact sector. 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,
@@ -25,10 +25,21 @@ const config: MixedActionConfig<MovementClaim> = {
<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 })}>
onChange={event => onChange({ territory: event.currentTarget.value, minimum: value.minimum })}>
<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}-sector`}>Ground-movement sector</label>
<select id={`${id}-sector`} disabled={disabled} value={value.sector === undefined ? '' : String(value.sector)}
aria-invalid={value.sector !== undefined && !TERRITORIES.find(row => row.id === value.territory)?.sectors.includes(value.sector)}
onChange={event => {
const selected = event.currentTarget.value, printed = TERRITORIES.find(row => row.id === value.territory);
if (selected === '') onChange({ territory: value.territory, minimum: value.minimum });
else { const sector = Number(selected); if (printed?.sectors.includes(sector)) onChange({ ...value, sector }); }
}}>
<option value="">Any sector in this territory</option>
{TERRITORIES.find(row => row.id === value.territory)?.sectors.map(sector => <option key={sector} value={sector}>Sector {sector}</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}
+12 -1
View File
@@ -10,9 +10,20 @@ export function MovementClaimFields({ id, value, onChange }: {
<legend>Ground-movement condition</legend>
<label htmlFor={`${id}-territory`}>Ground-movement destination</label>
<select id={`${id}-territory`} value={value.territory}
onChange={event => onChange({ ...value, territory: event.target.value })}>
onChange={event => onChange({ territory: event.currentTarget.value, minimum: value.minimum })}>
{TERRITORIES.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
</select>
<label htmlFor={`${id}-sector`}>Ground-movement sector</label>
<select id={`${id}-sector`} value={value.sector === undefined ? '' : String(value.sector)}
aria-invalid={value.sector !== undefined && !TERRITORIES.find(row => row.id === value.territory)?.sectors.includes(value.sector)}
onChange={event => {
const selected = event.currentTarget.value, printed = TERRITORIES.find(row => row.id === value.territory);
if (selected === '') onChange({ territory: value.territory, minimum: value.minimum });
else { const sector = Number(selected); if (printed?.sectors.includes(sector)) onChange({ ...value, sector }); }
}}>
<option value="">Any sector in this territory</option>
{TERRITORIES.find(row => row.id === value.territory)?.sectors.map(sector => <option key={sector} value={sector}>Sector {sector}</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
value={Number.isNaN(value.minimum) ? '' : value.minimum} aria-invalid={!validMovementClaim(value)}
+15 -3
View File
@@ -1,6 +1,7 @@
'use client';
import type { ReactElement } from 'react';
import { TERRITORIES } from '@/game/board';
import {
parseMovementExpression, MOVEMENT_EXPRESSION_MAX_DEPTH, MOVEMENT_EXPRESSION_MAX_LEAVES,
type MovementExpression,
@@ -17,7 +18,7 @@ export function movementExpressionInputError(value: unknown): string | null {
// Structural edits preserve repairable destination and numeric drafts. The
// detached valid placeholders are used only to check tree shape and limits.
const structuralDraft = (node: MovementExpression): MovementExpression => node.kind === 'move'
? { ...node, territory: 'carthag', minimum: 1 }
? { kind: 'move', territory: 'carthag', minimum: 1 }
: { ...node, terms: node.terms.map(structuralDraft) };
const inputSize = (node: MovementExpression): { leaves: number; depth: number } => {
if (node.kind === 'move') return { leaves: 1, depth: 0 };
@@ -49,10 +50,21 @@ export function MovementExpressionFields({ value, territories, disabled, onChang
<label htmlFor={`${id}-territory`}>Ground-movement destination</label>
<select id={`${id}-territory`} value={territories.some(destination => destination.id === node.territory) ? node.territory : ''}
disabled={disabled} aria-invalid={!territories.some(destination => destination.id === node.territory)}
onChange={event => change({ ...node, territory: event.currentTarget.value })}>
onChange={event => change({ kind: 'move', territory: event.currentTarget.value, minimum: node.minimum })}>
<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}-sector`}>Ground-movement sector</label>
<select id={`${id}-sector`} disabled={disabled} value={node.sector === undefined ? '' : String(node.sector)}
aria-invalid={node.sector !== undefined && !TERRITORIES.find(row => row.id === node.territory)?.sectors.includes(node.sector)}
onChange={event => {
const selected = event.currentTarget.value, printed = TERRITORIES.find(row => row.id === node.territory);
if (selected === '') change({ kind: 'move', territory: node.territory, minimum: node.minimum });
else { const sector = Number(selected); if (printed?.sectors.includes(sector)) change({ ...node, sector }); }
}}>
<option value="">Any sector in this territory</option>
{TERRITORIES.find(row => row.id === node.territory)?.sectors.map(sector => <option key={sector} value={sector}>Sector {sector}</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.minimum) ? '' : node.minimum} aria-invalid={!!movementExpressionInputError(node)}
@@ -98,7 +110,7 @@ export function MovementExpressionFields({ value, territories, disabled, onChang
AND requires every condition; OR requires at least one. No makes the whole expression false,
not necessarily every condition. Legal alternatives remain available; the question does not pin a fixed branch.
Each condition needs one future actual ground move after the answer, before your Movement turn ends this round.
Smaller moves never add together; a shipment can prepare a move but does not fulfill a condition.
Name an exact sector when needed. Smaller moves never add together; a shipment can prepare a move but does not fulfill a condition.
</p>
<p className="fine">App input limits: {MOVEMENT_EXPRESSION_MAX_LEAVES} conditions and {MOVEMENT_EXPRESSION_MAX_DEPTH} levels of grouping.
Groups do not count as conditions. These are input limits, not additional printed rules.</p>
+7 -8
View File
@@ -15,7 +15,7 @@ const config: MixedActionConfig<TravelActionClaim> = {
actionTiming: 'Future shipment or ground move · this turn',
actionHelp: <>Shipment conditions concern your single future original reserve shipment after the answer.
Choose any sector or one exact printed sector in that territory; only that sector satisfies an exact condition.
Ground-movement conditions concern one actual future ground move before your Movement turn ends this round.
Ground-movement conditions concern one actual future ground move before your Movement turn ends this round; its sector may also be named.
Count physical forces in one action; smaller moves never add together. Earlier actions do not count.
OR keeps legal shipment and movement alternatives available. No makes the whole expression false.
Original costs, movement rights, cards and preparation still apply; this claim grants no extra shipment or movement.</>,
@@ -29,8 +29,8 @@ const config: MixedActionConfig<TravelActionClaim> = {
<label htmlFor={`${id}-action`}>Future action</label>
<select id={`${id}-action`} value={value.kind} disabled={disabled} onChange={event => {
const kind = event.currentTarget.value;
if (kind === 'ship') onChange({ ...value, kind });
else if (kind === 'move') onChange({ kind, territory: value.territory, minimum: value.minimum });
if ((kind === 'ship' || kind === 'move') && kind !== value.kind)
onChange({ kind, territory: value.territory, minimum: value.minimum });
}}>
<option value="ship">Ship from reserves</option>
<option value="move">Make ground movement</option>
@@ -40,20 +40,19 @@ const config: MixedActionConfig<TravelActionClaim> = {
disabled={disabled} aria-invalid={!TERRITORIES.some(destination => destination.id === value.territory)}
onChange={event => {
const destination = event.currentTarget.value;
if (value.kind === 'ship') onChange({ kind: 'ship', territory: destination, minimum: value.minimum });
else onChange({ ...value, territory: destination });
onChange({ kind: value.kind, territory: destination, minimum: value.minimum });
}}>
<option value="">Choose a printed Arrakis territory</option>
{TERRITORIES.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
</select>
{value.kind === 'ship' && (() => {
{(value.kind === 'ship' || value.kind === 'move') && (() => {
const printed = TERRITORIES.find(destination => destination.id === value.territory);
return <>
<label htmlFor={`${id}-sector`}>Shipment sector</label>
<label htmlFor={`${id}-sector`}>{value.kind === 'ship' ? 'Shipment sector' : 'Ground-movement sector'}</label>
<select id={`${id}-sector`} disabled={disabled} value={value.sector === undefined ? '' : String(value.sector)}
aria-invalid={value.sector !== undefined && !printed?.sectors.includes(value.sector)} onChange={event => {
const selected = event.currentTarget.value;
if (selected === '') onChange({ kind: 'ship', territory: value.territory, minimum: value.minimum });
if (selected === '') onChange({ kind: value.kind, territory: value.territory, minimum: value.minimum });
else {
const sector = Number(selected);
if (printed?.sectors.includes(sector)) onChange({ ...value, sector });
+5 -6
View File
@@ -119,7 +119,7 @@ const config: MixedActionConfig<TurnActionLeaf> = {
Amal halves available cash before ordinary payments become legal; preserve enough remaining cash for any payment condition.
Shipment conditions concern the single future original reserve shipment after the answer. Choose any sector or one exact printed sector;
a different sector in the same territory does not satisfy an exact-sector condition.
Ground-movement conditions use the maximum physical forces in one actual future move to that destination; smaller moves never add together.
Ground-movement conditions use the maximum physical forces in one actual future move to that destination and optional exact sector; smaller moves never add together.
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
Shipment and movement close at your own Movement turn end, while payment conditions last until the actual turn ends.
AND requires every condition; OR keeps legal alternatives. No makes the whole expression false, not every condition separately.
@@ -434,20 +434,19 @@ const config: MixedActionConfig<TurnActionLeaf> = {
const destination = event.currentTarget.value;
// An exact sector belongs to its printed territory. Selecting a
// different territory returns to the explicit any-sector choice.
if (value.kind === 'ship') onChange({ kind: 'ship', territory: destination, minimum: value.minimum });
else onChange({ ...value, territory: destination });
onChange({ kind: value.kind, territory: destination, minimum: value.minimum });
}}>
<option value="">Choose a printed Arrakis territory</option>
{TERRITORIES.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
</select>
{value.kind === 'ship' && (() => {
{(value.kind === 'ship' || value.kind === 'move') && (() => {
const printed = TERRITORIES.find(destination => destination.id === value.territory);
return <>
<label htmlFor={`${id}-sector`}>Shipment sector</label>
<label htmlFor={`${id}-sector`}>{value.kind === 'ship' ? 'Shipment sector' : 'Ground-movement sector'}</label>
<select id={`${id}-sector`} disabled={disabled} value={value.sector === undefined ? '' : String(value.sector)}
aria-invalid={value.sector !== undefined && !printed?.sectors.includes(value.sector)} onChange={event => {
const chosen = event.currentTarget.value;
if (chosen === '') onChange({ kind: 'ship', territory: value.territory, minimum: value.minimum });
if (chosen === '') onChange({ kind: value.kind, territory: value.territory, minimum: value.minimum });
else {
const sector = Number(chosen);
if (printed?.sectors.includes(sector)) onChange({ ...value, sector });