Support sector-specific Truthtrance shipments

This commit is contained in:
Quintine committed 2026-10-27 21:19:07 +11:00
1 parent 8006e53580
commit 536d334543
27 files changed
+1121 -71

No files matched your search

+29 -3
View File
@@ -2,9 +2,11 @@
import { TERRITORIES } from '@/game/board';
import { Input } from '@/components/ui/input';
export type ShipmentClauseInput = { territory: string; minimum: number };
export type ShipmentClauseInput = { territory: string; minimum: number; sector?: number };
export function invalidShipmentClause(claim: ShipmentClauseInput) {
return !Number.isSafeInteger(claim.minimum) || claim.minimum < 1 || claim.minimum > 20;
return !Number.isSafeInteger(claim.minimum) || claim.minimum < 1 || claim.minimum > 20 ||
claim.sector !== undefined && (!Number.isSafeInteger(claim.sector) ||
!TERRITORIES.find(destination => destination.id === claim.territory)?.sectors.includes(claim.sector));
}
/** Destination and physical minimum shared by flat and nested question builders. */
@@ -13,12 +15,36 @@ export function ShipmentClauseFields({ id, value, onChange }: {
value: ShipmentClauseInput;
onChange: (claim: ShipmentClauseInput) => void;
}) {
const printed = TERRITORIES.find(destination => destination.id === value.territory);
// Mixed-shipment leaves have their own `kind` and remain on their existing
// territory-only parser until that separate question family is integrated.
const allowSector = !('kind' in value);
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 })}>
onChange={event => {
const next = { ...value, territory: event.target.value };
delete next.sector;
onChange(next);
}}>
{TERRITORIES.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
{allowSector && <>
<label htmlFor={`${id}-sector`}>Shipment sector</label>
<select id={`${id}-sector`} 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 === '') {
const next = { ...value }; delete next.sector; onChange(next);
} else {
const sector = Number(selected);
if (printed?.sectors.includes(sector)) onChange({ ...value, sector });
}
}}>
<option value="">Any sector in this territory</option>
{printed?.sectors.map(sector => <option key={sector} value={sector}>Sector {sector}</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}
+26 -2
View File
@@ -14,6 +14,7 @@ const config: MixedActionConfig<TravelActionClaim> = {
actionLabel: 'shipment or ground-movement condition',
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.
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.
@@ -28,7 +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' || kind === 'move') onChange({ ...value, kind });
if (kind === 'ship') onChange({ ...value, kind });
else if (kind === 'move') onChange({ kind, territory: value.territory, minimum: value.minimum });
}}>
<option value="ship">Ship from reserves</option>
<option value="move">Make ground movement</option>
@@ -36,10 +38,32 @@ const config: MixedActionConfig<TravelActionClaim> = {
<label htmlFor={`${id}-territory`}>{value.kind === 'ship' ? 'Reserve-shipment destination' : '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 => {
const destination = event.currentTarget.value;
if (value.kind === 'ship') onChange({ kind: 'ship', territory: destination, minimum: value.minimum });
else onChange({ ...value, territory: destination });
}}>
<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' && (() => {
const printed = TERRITORIES.find(destination => destination.id === value.territory);
return <>
<label htmlFor={`${id}-sector`}>Shipment 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 });
else {
const sector = Number(selected);
if (printed?.sectors.includes(sector)) onChange({ ...value, sector });
}
}}>
<option value="">Any sector in this territory</option>
{printed?.sectors.map(sector => <option key={sector} value={sector}>Sector {sector}</option>)}
</select>
</>;
})()}
<label htmlFor={`${id}-minimum`}>Minimum physical forces in one {value.kind === 'ship' ? 'reserve shipment' : '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}
+27 -2
View File
@@ -117,7 +117,8 @@ const config: MixedActionConfig<TurnActionLeaf> = {
Amal conditions count an actual accepted future printed play in this original phase opening.
Earlier plays, disposal, other roles and recovery alone do not count. The condition closes with this opening.
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.
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.
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.
@@ -429,10 +430,34 @@ const config: MixedActionConfig<TurnActionLeaf> = {
<label htmlFor={`${id}-territory`}>{value.kind === 'ship' ? 'Reserve-shipment destination' : '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 => {
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 });
}}>
<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' && (() => {
const printed = TERRITORIES.find(destination => destination.id === value.territory);
return <>
<label htmlFor={`${id}-sector`}>Shipment 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 });
else {
const sector = Number(chosen);
if (printed?.sectors.includes(sector)) onChange({ ...value, sector });
}
}}>
<option value="">Any sector in this territory</option>
{printed?.sectors.map(sector => <option key={sector} value={sector}>Sector {sector}</option>)}
</select>
</>;
})()}
</>}
{'minimum' in value && <>
<label htmlFor={`${id}-minimum`}>{value.kind === 'payment' ? 'Minimum future ordinary payments in spice'
+22 -2
View File
@@ -1,6 +1,7 @@
'use client';
import type { Action, GameView, Plan } from '@/game/engine';
import { TERRITORIES } from '@/game/board';
import { bribeAction } from '@/game/bribe-options';
import { alliancePromiseModeSupported } from '@/game/alliance-promises';
import { nativePromiseScopeSupported } from '@/game/native-promise-scope';
@@ -39,6 +40,9 @@ const namesWindow = (mixed: MixedActionExpression<TurnActionLeaf>, effect: Windo
const namesStormDial = (mixed: MixedActionExpression<TurnActionLeaf>, source: string): boolean =>
mixed.kind === 'action' ? mixed.action.kind === 'stormDial' && mixed.action.source === source
: mixed.kind !== 'fact' && mixed.terms.some(term => namesStormDial(term, source));
const namesSectorShipment = (mixed: MixedActionExpression<TurnActionLeaf>): boolean =>
mixed.kind === 'action' ? mixed.action.kind === 'ship' && mixed.action.sector !== undefined
: mixed.kind !== 'fact' && mixed.terms.some(namesSectorShipment);
const namesBoxPlay = (mixed: MixedActionExpression<TurnActionLeaf>): boolean => mixed.kind === 'action'
? mixed.action.kind === 'cardPlay' && mixed.action.effect === 'nullentropyBox'
: mixed.kind !== 'fact' && mixed.terms.some(namesBoxPlay);
@@ -84,6 +88,14 @@ export function TurnActionGuideButton(props: TurnActionProps) {
const box = game.nullentropy, search = box?.search;
const windowPromise = game.turnActionPromises.find(promise => promise.event === event && promise.player === actor && promise.turn === turn);
const windowQuestion = windowPromise && JSON.stringify(windowPromise.mixed);
const sectorShipmentGuide = action.type === 'ship' && !!windowPromise && namesSectorShipment(windowPromise.mixed);
const shipmentSource = (current: GameView) => {
const owner = current.players.find(player => player.id === actor);
return JSON.stringify({ active: current.active, remaining: current.movementRemaining, storm: current.storm,
owner: owner && { reserves: owner.reserves, spice: owner.spice, shipped: owner.shipped },
occupants: current.players.map(player => ({ id: player.id, ally: player.ally, forces: player.forces })) });
};
const shipmentSignature = sectorShipmentGuide ? shipmentSource(game) : null;
const stormDialSource = action.type === 'stormDial' ? nativeStormDialOpportunity(game, actor) : null;
const semutaAction = action.type === 'semutaCommit' || action.type === 'semutaSelect' || action.type === 'semutaPass';
const semuta = semutaAction ? game.semutaReaction : null;
@@ -415,6 +427,13 @@ export function TurnActionGuideButton(props: TurnActionProps) {
!current.ready.includes(actor) && me.charityClaimed === false && current.charity === charity &&
charitySource(current) === charitySignature && current.charity.incomePending === false &&
Number.isSafeInteger(current.charity.amount) && current.charity.amount > 0;
const currentSectorShipment = sectorShipmentGuide && !!me && phase === 5 && current.active === actor &&
!!windowPromise && current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion &&
shipmentSource(current) === shipmentSignature && me.shipped !== true &&
typeof action.territory === 'string' && typeof action.sector === 'number' && Number.isSafeInteger(action.sector) &&
TERRITORIES.some(territory => territory.id === action.territory && territory.sectors.includes(action.sector as number)) &&
typeof action.amount === 'number' && Number.isSafeInteger(action.amount) && action.amount > 0 &&
typeof me.reserves === 'number' && action.amount <= me.reserves;
const currentStormDial = action.type === 'stormDial' && !!stormDialSource && !!windowPromise &&
current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion &&
namesStormDial(windowPromise.mixed, stormDialSource.source) &&
@@ -437,7 +456,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
Number.isSafeInteger(phase) && phase >= 0 && phase <= 8 && current.phase === phase && current.turn === turn && current.me === actor &&
current.turnActionGuide === guide && guide.event === event && guide.action === action &&
action.turnActionPromiseEvent === event && action.turnActionPromisePhase === phase && JSON.stringify(action) === signature && opportunity && movementPacket &&
(!printedBattleCancelAction || printedWindow) &&
(!printedBattleCancelAction || printedWindow) && (!sectorShipmentGuide || currentSectorShipment) &&
current.turnActionPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
!promise.fulfilled && !promise.released) &&
(!amalReadyAction || openingReady) &&
@@ -600,7 +619,8 @@ export function TurnActionPromises(props: TurnActionProps) {
Amal conditions count an actual accepted future printed play in this original phase opening.
Earlier plays, disposal, other roles and recovery alone do not count. The condition closes with this opening.
Amal halves available cash before ordinary payments become legal; preserve enough remaining cash for any payment condition.
Shipment conditions concern one future original reserve shipment. Ground-movement conditions count the maximum physical forces in one actual future move,
Shipment conditions concern one future original reserve shipment. An optional exact sector counts only that printed sector of the named territory.
Ground-movement conditions count the maximum physical forces in one actual future move,
never the sum of smaller moves. Earlier shipment and movement do not count.
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; ordinary payments remain possible until the actual turn ends.