Combine native shipment and ground movement promises

This commit is contained in:
Quintine committed 2026-10-26 19:25:07 +11:00
1 parent 13398aa76e
commit d4e82c74b1
26 files changed
+2341 -10

No files matched your search

+2
View File
@@ -1,4 +1,5 @@
'use client';
import { TravelPromises } from './travel-promises';
import { MixedTraitorPromises } from './mixed-traitor-promises';
import { MixedBattlePromises } from './mixed-battle-promises';
import { TraitorDecisionPromises } from './traitor-decision-promises';
@@ -2064,6 +2065,7 @@ export function GameTable({
{g.status === 'playing' && <MixedBidPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <MixedBattlePromises game={g} />}
{g.status === 'playing' && <MixedTraitorPromises game={g} />}
{g.status === 'playing' && <TravelPromises game={g} busy={busy} act={act} />}
{g.guildBetrayalReaction ? (
<NexusGuildBetrayal game={g} act={act} busy={reactionBusy} />
) : g.richeseBetrayalReaction ? (
+3 -1
View File
@@ -19,6 +19,7 @@ export type MixedActionConfig<A> = {
maxDepth: number;
maxNodes?: number;
maxTerms?: number;
requireFact?: boolean;
newAction: () => A;
parseAction: (value: unknown, game: GameView, target: string) => A;
renderAction: (props: { value: A; id: string; game: GameView; target: string;
@@ -57,7 +58,8 @@ export function mixedActionInputError<A>(value: unknown, game: GameView, target:
return fact;
}, input => config.parseAction(input, game, target), { maxDepth: config.maxDepth, maxLeaves: config.maxLeaves,
...(config.maxNodes === undefined ? {} : { maxNodes: config.maxNodes }),
...(config.maxTerms === undefined ? {} : { maxTerms: config.maxTerms }) });
...(config.maxTerms === undefined ? {} : { maxTerms: config.maxTerms }),
...(config.requireFact === undefined ? {} : { requireFact: config.requireFact }) });
return null;
} catch (error) {
return error instanceof Error ? error.message : config.fallbackError;
+64
View File
@@ -0,0 +1,64 @@
'use client';
import { TERRITORIES } from '@/game/board';
import type { GameView } from '@/game/engine';
import type { MixedActionExpression } from '@/game/mixed-action-question';
import { parseTravelActionClaim, TRAVEL_MAX_DEPTH, TRAVEL_MAX_LEAVES, type TravelActionClaim } from '@/game/travel-expression-promises';
import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields';
import { Input } from './ui/input';
type TravelExpression = MixedActionExpression<TravelActionClaim>;
const config: MixedActionConfig<TravelActionClaim> = {
prefix: 'travel',
legend: 'Future shipment / ground-movement claim, with optional current facts',
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.
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.
Original costs, movement rights, cards and preparation still apply; this claim grants no extra shipment or movement.</>,
fallbackError: 'Choose a future shipment or ground-movement condition, with optional current facts.',
maxLeaves: TRAVEL_MAX_LEAVES,
maxDepth: TRAVEL_MAX_DEPTH,
requireFact: false,
newAction: () => ({ kind: 'ship', territory: 'carthag', minimum: 1 }),
parseAction: parseTravelActionClaim,
renderAction: ({ value, id, disabled, onChange }) => <>
<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 });
}}>
<option value="ship">Ship from reserves</option>
<option value="move">Make ground movement</option>
</select>
<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 })}>
<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 {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}
aria-invalid={!Number.isSafeInteger(value.minimum) || value.minimum < 1 || value.minimum > 20}
onChange={event => onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} />
</>,
};
/** Only public printed choices belong here; native eligibility stays on the server. */
export function travelClaimInputError(value: unknown, game: GameView, target: string): string | null {
return mixedActionInputError(value, game, target, config);
}
export function TravelClaimFields(props: {
value: TravelExpression;
game: GameView;
target: string;
disabled: boolean;
onChange: (expression: TravelExpression) => void;
}) {
return MixedActionFields({ ...props, config });
}
+109
View File
@@ -0,0 +1,109 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { nativePromiseScopeSupported } from '@/game/native-promise-scope';
import { truthQuestionText, type TruthAnswer } from '@/game/truthtrance';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import { Button } from './ui/button';
type TravelProps = { game: GameView; busy: boolean; act: (action: Action) => void };
/** Forward the exact current native packet without choosing a branch or route. */
export function TravelPromiseGuideButton(props: TravelProps) {
const game = props.game, guide = game.travelPromiseGuide;
if (!guide) return null;
const actor = game.me, turn = game.turn, phase = game.phase, event = guide.event, action = guide.action;
const signature = JSON.stringify(action);
const canAct = () => {
const current = props.game, me = current.players.find(player => player.id === actor);
const paidBox = action.type === 'decision' && current.decision?.kind === 'nullentropy' &&
current.decision.player === actor && !!current.nullentropy?.search &&
current.nullentropy.search.event === action.event && !current.nullentropy.blocked;
const opportunity = paidBox || action.type === 'guildShip' && action.from === 'reserves' ||
['ship', 'move', 'endMovement', 'card', 'prepareAdvisors', 'revealNoField'].includes(action.type);
return !props.busy && current === game && nativePromiseScopeSupported(current) && current.status === 'playing' &&
phase === 5 && current.phase === phase && current.turn === turn && current.me === actor && current.active === actor &&
current.travelPromiseGuide === guide && guide.event === event && guide.action === action &&
action.travelPromiseEvent === event && JSON.stringify(action) === signature && opportunity &&
current.travelPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
!promise.fulfilled && !promise.released) &&
!!me && !me.autopilot && !current.truthtrance && !current.response && (!current.decision || paidBox) &&
!current.phaseOpening && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
};
const amount = action.forces && typeof action.forces === 'object'
? Object.values(action.forces as Record<string, number>).reduce((sum, count) => sum + count, 0)
: typeof action.amount === 'number' ? action.amount : 0;
const destination = game.combatLocations.find(location => location.id === action.territory)?.name ?? 'the quoted destination';
const sector = typeof action.sector === 'number' ? `, sector ${action.sector}` : '';
const label = action.type === 'ship' || action.type === 'guildShip' ? `Ship ${amount} physical forces from reserves to ${destination}${sector}${action.type === 'guildShip' ? ' using Guild transport' : ''} while honoring your whole answer`
: action.type === 'move' ? `Move ${amount} physical forces to ${destination}${sector} while honoring your whole answer`
: action.type === 'endMovement' ? 'Finish movement while honoring your whole answer'
: action.type === 'decision' ? 'Complete your paid Box search for your whole answer'
: action.type === 'prepareAdvisors' ? `Prepare advisors in ${destination} for your whole answer`
: action.type === 'revealNoField' ? 'Reveal your No-Field to prepare physical forces for your whole answer'
: 'Use the offered native card preparation for your whole answer';
return <Button className="game-action min-h-11 whitespace-normal" data-testid="travel-promise-guide"
disabled={!canAct()} onClick={() => { if (canAct()) props.act(action); }}>{label}</Button>;
}
/** Retain own raw history, never a compiled branch or individual factual answer. */
export function TravelPromises(props: TravelProps) {
const game = props.game, own = game.travelPromises.filter(promise => promise.player === game.me);
if (!own.length) return null;
const live = own.some(promise => promise.turn === game.turn && !promise.fulfilled && !promise.released);
return <section className="notice space-y-3" aria-label="Your Truthtrance shipment and movement promises" id="travel-promise-guidance">
<h3>Your shipment and movement promises</h3>
<ul>{own.map(promise => <li key={promise.event}>
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'travel', target: promise.player,
turn: promise.turn, mixed: promise.mixed }, id => id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine'
: game.allLeaders.find(leader => leader.id === id)?.name ?? id,
id => game.combatLocations.find(location => location.id === id)?.name ?? id,
id => game.players.find(player => player.id === id)?.name ?? id)}.
{promise.released ? ' Released: this whole commitment is no longer possible.'
: promise.fulfilled ? ' Fulfilled: this whole commitment is complete.' : ' This whole answer is binding until your Movement turn ends this round.'}
</li>)}</ul>
<p>AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.
Legal alternatives remain available; no fixed branch is selected for you.
Current facts are evaluated at the answer. They create no promise to keep cards, spice or forces afterward.
Only the aggregate answer is shared. Individual fact answers and any reduced future branch remain private.</p>
<p className="fine">Shipment conditions concern one future original reserve shipment after the answer.
Ground-movement conditions count the maximum physical forces in one actual future move, never the sum of smaller moves.
Earlier actions do not count. A shipment can prepare a move; each action counts only for its own conditions.
Original shipment rights, movement opportunities, cards, preparation and costs still apply.
This claim grants no extra shipment or movement.</p>
{live && game.active !== game.me && <p>Your Movement turn is still ahead. Keep the needed forces, spice and cards available;
native controls appear on your turn.</p>}
{live && <TravelPromiseGuideButton {...props} />}
</section>;
}
/** One whole answer, using only the current target's offered native choices. */
export function TravelAnswers(props: TravelProps) {
const game = props.game, window = game.truthtrance, question = window?.question;
if (window?.stage !== 'answer' || question?.kind !== 'travel' || question.target !== game.me || !game.truthTravelAnswers) return null;
const actor = game.me, turn = game.turn, phase = game.phase, source = window.queue[0], answers = game.truthTravelAnswers;
const signature = JSON.stringify(question), sourceSignature = JSON.stringify(source);
const canAnswer = (answer: TruthAnswer) => {
const current = props.game, me = current.players.find(player => player.id === actor);
return !props.busy && current === game && current.status === 'playing' && phase === 5 && current.phase === phase &&
current.turn === turn && current.me === actor && question.turn === turn &&
current.truthtrance === window && window.stage === 'answer' && window.question === question && question.target === actor &&
JSON.stringify(question) === signature && !!source && window.queue[0] === source && JSON.stringify(source) === sourceSignature &&
current.truthTravelAnswers === answers && answers.includes(answer) &&
!!me && !me.autopilot && !current.response && !current.decision && !current.phaseOpening &&
!current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
};
return <section aria-label="Answer the whole shipment and movement question" className="space-y-3">
<p className="notice">Answer the whole shipment and ground-movement expression. Current facts freeze now.
OR preserves legal alternatives; No keeps the whole expression false.
Only a future original reserve shipment and actual future ground moves count, with physical forces in one action.
Original rights, preparation and costs still apply. Only your whole answer is shared.</p>
{answers.map(answer => {
const action: Action = { type: 'truthAnswer', answer };
return <Button key={answer} className="game-action min-h-11 whitespace-normal" data-travel-answer={answer}
disabled={!canAnswer(answer)} onClick={() => { if (canAnswer(answer)) props.act(action); }}>
{answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`}
</Button>;
})}
</section>;
}
+24 -2
View File
@@ -1,5 +1,9 @@
'use client';
import { TravelClaimFields, travelClaimInputError } from './travel-claim-fields';
import { TravelAnswers } from './travel-promises';
import type { TravelActionClaim } from '@/game/travel-expression-promises';
import { MixedTraitorFields, mixedTraitorInputError } from './mixed-traitor-fields';
import type { TraitorActionClaim } from '@/game/mixed-traitor-question';
@@ -89,7 +93,7 @@ export function Truthtrance({
g.players.find((p) => p.id !== me.id)!.id,
);
const [kind, setKind] = useState<
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall'
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel'
>('fact');
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
@@ -166,6 +170,11 @@ export function Truthtrance({
{ kind: 'action', action: { call: true } },
] });
const mixedTraitorError = mixedTraitorInputError(mixedTraitor, g, target);
const [travel, setTravel] = useState<MixedActionExpression<TravelActionClaim>>({ kind: 'and', terms: [
{ kind: 'action', action: { kind: 'ship', territory: 'carthag', minimum: 1 } },
{ kind: 'action', action: { kind: 'move', territory: 'arrakeen', minimum: 1 } },
] });
const travelError = travelClaimInputError(travel, g, target);
const [shipment, setShipment] = useState<ShipmentExpression>({ territory: 'carthag', minimum: 6 });
const [mixed, setMixed] = useState<MixedShipmentExpression>({
kind: 'and',
@@ -202,6 +211,7 @@ export function Truthtrance({
claim: planClaim,
}
: kind === 'mixedBattlePlan' ? { kind, target, battle: g.battle?.event ?? null, territory: g.battle?.territory ?? '', mixed: mixedBattle }
: kind === 'travel' ? { kind, target, turn: g.turn, mixed: travel }
: kind === 'mixedTraitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '', mixed: mixedTraitor }
: kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' }
: kind === 'mixedMovement' ? { kind, target, turn: g.turn, mixed: mixedMovement }
@@ -431,6 +441,8 @@ export function Truthtrance({
<div key={answer}>{button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}</div>
))}
</>
) : w.question!.kind === 'travel' ? (
<TravelAnswers game={g} act={act} busy={busy} />
) : w.question!.kind === 'traitorCall' || w.question!.kind === 'mixedTraitorCall' ? (
<TraitorDecisionAnswers game={g} act={act} busy={busy} />
) : w.question!.kind === 'battlePlan' ? (
@@ -557,6 +569,7 @@ export function Truthtrance({
<option value="mixedBid">Combine current facts and ordinary bids</option>
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
<option value="mixedTraitorCall">Combine current facts and this Traitor declaration</option>
<option value="travel">Combine this turn’s shipment and ground movements</option>
<option value="shipment">
Bind a shipment from reserves
</option>
@@ -572,7 +585,15 @@ export function Truthtrance({
)}
</select>
</label>
{kind === 'mixedTraitorCall' ? (
{kind === 'travel' ? (
<>
<TravelClaimFields value={travel} game={g} target={target}
disabled={busy || !g.truthTravelTargets.includes(target)} onChange={setTravel} />
{!g.truthTravelTargets.includes(target) && <p className="notice">Choose an active or unused native Shipment and Movement opportunity in this phase.</p>}
<p className="notice" aria-label="Public travel question preview">{travelError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'mixedTraitorCall' ? (
<>
<MixedTraitorFields value={mixedTraitor} game={g} target={target}
disabled={busy || !g.truthMixedTraitorTargets.includes(target)} onChange={setMixedTraitor} />
@@ -812,6 +833,7 @@ export function Truthtrance({
(kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) ||
(kind === 'mixedBid' && (!!mixedBidError || !g.truthMixedBidTargets.includes(target))) ||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
(kind === 'travel' && (!!travelError || !g.truthTravelTargets.includes(target))) ||
(kind === 'mixedTraitorCall' && (!!mixedTraitorError || !g.truthMixedTraitorTargets.includes(target))) ||
(kind === 'traitorCall' && !g.truthTraitorTargets.includes(target)) ||
(kind === 'mixedShipment' &&