Combine current-turn payments with shipment and movement promises

This commit is contained in:
Quintine committed 2026-10-26 21:09:49 +11:00
1 parent 8dbec05844
commit 0d72a33ee5
18 files changed
+2591 -7

No files matched your search

+2
View File
@@ -1,5 +1,6 @@
'use client';
import { TravelPromises } from './travel-promises';
import { TurnActionPromises } from './turn-action-promises';
import { MixedTraitorPromises } from './mixed-traitor-promises';
import { MixedBattlePromises } from './mixed-battle-promises';
import { TraitorDecisionPromises } from './traitor-decision-promises';
@@ -2066,6 +2067,7 @@ export function GameTable({
{g.status === 'playing' && <MixedBattlePromises game={g} />}
{g.status === 'playing' && <MixedTraitorPromises game={g} />}
{g.status === 'playing' && <TravelPromises game={g} busy={busy} act={act} />}
{g.status === 'playing' && <TurnActionPromises game={g} busy={busy} act={act} />}
{g.guildBetrayalReaction ? (
<NexusGuildBetrayal game={g} act={act} busy={reactionBusy} />
) : g.richeseBetrayalReaction ? (
+24 -2
View File
@@ -3,6 +3,9 @@
import { TravelClaimFields, travelClaimInputError } from './travel-claim-fields';
import { TravelAnswers } from './travel-promises';
import type { TravelActionClaim } from '@/game/travel-expression-promises';
import { TurnActionFields, turnActionInputError } from './turn-action-fields';
import { TurnActionAnswers } from './turn-action-promises';
import type { TurnActionLeaf } from '@/game/turn-action-promises';
import { MixedTraitorFields, mixedTraitorInputError } from './mixed-traitor-fields';
import type { TraitorActionClaim } from '@/game/mixed-traitor-question';
@@ -93,7 +96,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' | 'travel'
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
>('fact');
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
@@ -175,6 +178,12 @@ export function Truthtrance({
{ kind: 'action', action: { kind: 'move', territory: 'arrakeen', minimum: 1 } },
] });
const travelError = travelClaimInputError(travel, g, target);
const [turnActions, setTurnActions] = useState<MixedActionExpression<TurnActionLeaf>>({ kind: 'and', terms: [
{ kind: 'action', action: { kind: 'payment', recipient: g.me, minimum: 1 } },
{ kind: 'action', action: { kind: 'ship', territory: 'carthag', minimum: 1 } },
{ kind: 'action', action: { kind: 'move', territory: 'arrakeen', minimum: 1 } },
] });
const turnActionsError = turnActionInputError(turnActions, g, target);
const [shipment, setShipment] = useState<ShipmentExpression>({ territory: 'carthag', minimum: 6 });
const [mixed, setMixed] = useState<MixedShipmentExpression>({
kind: 'and',
@@ -211,6 +220,7 @@ export function Truthtrance({
claim: planClaim,
}
: kind === 'mixedBattlePlan' ? { kind, target, battle: g.battle?.event ?? null, territory: g.battle?.territory ?? '', mixed: mixedBattle }
: kind === 'turnActions' ? { kind, target, turn: g.turn, mixed: turnActions }
: 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 ?? '' }
@@ -441,6 +451,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 === 'turnActions' ? (
<TurnActionAnswers game={g} act={act} busy={busy} />
) : w.question!.kind === 'travel' ? (
<TravelAnswers game={g} act={act} busy={busy} />
) : w.question!.kind === 'traitorCall' || w.question!.kind === 'mixedTraitorCall' ? (
@@ -569,6 +581,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="turnActions">Combine this turn’s payments, shipment and ground movements</option>
<option value="travel">Combine this turn’s shipment and ground movements</option>
<option value="shipment">
Bind a shipment from reserves
@@ -585,7 +598,15 @@ export function Truthtrance({
)}
</select>
</label>
{kind === 'travel' ? (
{kind === 'turnActions' ? (
<>
<TurnActionFields value={turnActions} game={g} target={target}
disabled={busy || !g.truthTurnActionTargets.includes(target)} onChange={setTurnActions} />
{!g.truthTurnActionTargets.includes(target) && <p className="notice">Choose a player with a supported payment, shipment or ground-movement opportunity this turn.
Ordinary payments may remain available after their Shipment and Movement opportunity ends.</p>}
<p className="notice" aria-label="Public turn-action question preview">{turnActionsError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'travel' ? (
<>
<TravelClaimFields value={travel} game={g} target={target}
disabled={busy || !g.truthTravelTargets.includes(target)} onChange={setTravel} />
@@ -833,6 +854,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 === 'turnActions' && (!!turnActionsError || !g.truthTurnActionTargets.includes(target))) ||
(kind === 'travel' && (!!travelError || !g.truthTravelTargets.includes(target))) ||
(kind === 'mixedTraitorCall' && (!!mixedTraitorError || !g.truthMixedTraitorTargets.includes(target))) ||
(kind === 'traitorCall' && !g.truthTraitorTargets.includes(target)) ||
+97
View File
@@ -0,0 +1,97 @@
'use client';
import { TERRITORIES } from '@/game/board';
import type { GameView } from '@/game/engine';
import type { MixedActionExpression } from '@/game/mixed-action-question';
import { parseTurnActionLeaf, TURN_ACTION_MAX_DEPTH, TURN_ACTION_MAX_LEAVES, type TurnActionLeaf } from '@/game/turn-action-promises';
import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields';
import { Input } from './ui/input';
type TurnActionExpression = MixedActionExpression<TurnActionLeaf>;
const recipients = (game: GameView, target: string) => {
const player = game.players.find(player => player.id === target);
return player ? game.players.filter(recipient => recipient.id !== target && recipient.id !== player.ally) : [];
};
const config: MixedActionConfig<TurnActionLeaf> = {
prefix: 'turn-action',
legend: 'Future payments, shipment and ground movements, with optional current facts',
actionLabel: 'payment, shipment or ground-movement condition',
actionTiming: 'Future ordinary payment, reserve shipment or ground move · this turn',
actionHelp: <>Ordinary payment conditions sum future real bribes to the same recipient after the answer this turn.
Earlier payments, gifts and ally aid do not count. The sender pays immediately; the recipient collects at the next Mentat opening.
Payments made during Mentat Pause wait until next turn&apos;s Mentat opening; collection never counts twice.
Shipment conditions concern the single future original reserve shipment after the answer.
Ground-movement conditions use the maximum physical forces in one actual future move to that destination; smaller moves never add together.
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.
Original costs, timing, cards, preparation and action rights still apply; this claim grants no extra action.</>,
fallbackError: 'Choose a future ordinary payment, reserve shipment or ground-movement condition, with optional current facts.',
maxLeaves: TURN_ACTION_MAX_LEAVES,
maxDepth: TURN_ACTION_MAX_DEPTH,
requireFact: false,
newAction: () => ({ kind: 'ship', territory: 'carthag', minimum: 1 }),
parseAction: (input, game, target) => {
const action = parseTurnActionLeaf(input);
if (action.kind === 'payment' && !recipients(game, target).some(recipient => recipient.id === action.recipient))
throw new Error('Choose a public non-allied recipient for the future ordinary payment.');
return action;
},
renderAction: ({ value, id, game, target, disabled, onChange }) => {
const choices = recipients(game, target);
return <>
<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 === value.kind) return;
if (kind === 'payment') onChange({ kind, recipient: '', minimum: value.minimum });
if (kind === 'ship' || kind === 'move') onChange({ kind,
territory: value.kind === 'payment' ? 'carthag' : value.territory, minimum: value.minimum });
}}>
<option value="payment">Make an ordinary payment</option>
<option value="ship">Ship from reserves</option>
<option value="move">Make ground movement</option>
</select>
{value.kind === 'payment' ? <>
<label htmlFor={`${id}-recipient`}>Ordinary payment recipient</label>
<select id={`${id}-recipient`} value={choices.some(recipient => recipient.id === value.recipient) ? value.recipient : ''}
disabled={disabled} aria-invalid={!choices.some(recipient => recipient.id === value.recipient)}
onChange={event => {
const recipient = event.currentTarget.value;
if (recipient === '' || choices.some(choice => choice.id === recipient)) onChange({ ...value, recipient });
}}>
<option value="">Choose a recipient</option>
{choices.map(recipient => <option key={recipient.id} value={recipient.id}>{recipient.name}</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`}>{value.kind === 'payment' ? 'Minimum future ordinary payments in spice'
: `Minimum physical forces in one ${value.kind === 'ship' ? 'reserve shipment' : 'ground move'}`}</label>
<Input id={`${id}-minimum`} type="number" min={1} max={value.kind === 'payment' ? Number.MAX_SAFE_INTEGER : 20}
step={1} required disabled={disabled} value={Number.isFinite(value.minimum) ? value.minimum : ''}
aria-invalid={!Number.isSafeInteger(value.minimum) || value.minimum < 1 || value.kind !== 'payment' && value.minimum > 20}
onChange={event => onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} />
</>;
},
};
/** Public schema choices only; the native engine owns eligibility and funding. */
export function turnActionInputError(value: unknown, game: GameView, target: string): string | null {
return mixedActionInputError(value, game, target, config);
}
export function TurnActionFields(props: {
value: TurnActionExpression;
game: GameView;
target: string;
disabled: boolean;
onChange: (expression: TurnActionExpression) => void;
}) {
return MixedActionFields({ ...props, config });
}
+180
View File
@@ -0,0 +1,180 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { bribeAction } from '@/game/bribe-options';
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 TurnActionProps = { game: GameView; busy: boolean; act: (action: Action) => void };
/** One projected native packet; no client branch selection or completion solver. */
export function TurnActionGuideButton(props: TurnActionProps) {
const game = props.game, guide = game.turnActionGuide;
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), auction = game.auction, decision = game.decision;
const native = game.richeseAuction, order = game.revivalOrder, revival = game.revival;
const auctionSource = (current: GameView) => current.auction && JSON.stringify({
bid: current.auction.bid, bidder: current.auction.bidder, active: current.auction.active,
remaining: current.auction.remaining, allyPayment: current.auction.allyPayment,
});
const nativeSource = (current: GameView) => current.richeseAuction && JSON.stringify({
event: current.richeseAuction.event, method: current.richeseAuction.method,
eligible: current.richeseAuction.eligible, submitted: current.richeseAuction.submitted,
active: current.richeseAuction.active, outcome: current.richeseAuction.outcome,
bid: current.richeseAuction.bid, bidder: current.richeseAuction.bidder,
passed: current.richeseAuction.passed, acted: current.richeseAuction.acted,
});
const auctionSignature = auctionSource(game), nativeSignature = nativeSource(game), orderSignature = JSON.stringify(order);
const box = game.nullentropy, search = box?.search;
const canAct = () => {
const current = props.game, me = current.players.find(player => player.id === actor);
const options = current.bribeOptions;
const target = action.type === 'bribe' && typeof action.target === 'string'
? current.players.find(player => player.id === action.target) : undefined;
const limit = options.targets.find(recipient => recipient.id === action.target);
// Ordinary bribes can remain legal during another player's native turn or
// after this payer has readied. The projected native timing stays decisive.
const payment = action.type === 'bribe' && !!me && !!target && target.id !== actor && target.id !== me.ally &&
options.blocked === null && Number.isSafeInteger(options.available) &&
typeof action.amount === 'number' && action.amount <= options.available &&
!!limit && Number.isSafeInteger(limit.maximum) && action.amount <= limit.maximum &&
!!bribeAction(options, target.id, action.amount);
const paidBox = action.type === 'decision' && current.decision === decision &&
current.decision?.kind === 'nullentropy' && current.nullentropy === box && current.nullentropy?.search === search &&
current.decision.player === actor && !!current.nullentropy?.search &&
current.nullentropy.search.event === action.event && !current.nullentropy.blocked;
const currentAuction = current.auction === auction && auctionSource(current) === auctionSignature && !!auction;
const invoice = phase === 3 && action.type === 'decision' && typeof action.karama === 'boolean' &&
(!action.karama || typeof action.card === 'string') &&
current.decision === decision && current.decision?.kind === 'auctionPayment' && current.decision.player === actor &&
!current.richeseAuction && currentAuction && current.auction?.bidder === actor;
const nativePass = phase === 3 && action.type === 'richeseBid' && current.richeseAuction === native &&
nativeSource(current) === nativeSignature && native && native.event === action.event && !native.outcome &&
native.eligible.includes(actor) && (native.method === 'silent'
? !native.submitted.includes(actor) && action.amount === 0
: native.active === actor && action.amount === null) && action.allyPayment === 0;
const requiredBid = phase === 3 && action.type === 'bid' && !current.richeseAuction && currentAuction &&
current.auction?.active === actor && typeof action.amount === 'number' && Number.isSafeInteger(action.amount) && action.amount > 0;
const revivalTurn = current.revival === revival && !current.revival.pending && !current.revival.orderBlocked &&
current.revivalOrder === order && JSON.stringify(current.revivalOrder) === orderSignature &&
(!order || order.turn === turn && order.remaining[0] === actor && current.active === actor);
const opportunity = payment || paidBox || (phase === 2
? !current.ready.includes(actor) && ['charity', 'ready', 'card'].includes(action.type)
: phase === 3
? invoice || nativePass || requiredBid || action.type === 'passBid' && !current.richeseAuction && currentAuction && current.auction?.active === actor
: phase === 4
? !current.ready.includes(actor) && revivalTurn && ['revive', 'reviveLeader', 'reviveKwisatz', 'acceptLeaderRevival', 'ready', 'card'].includes(action.type)
: phase === 5
? current.active === actor && (action.type === 'guildShip' && action.from === 'reserves' ||
['ship', 'move', 'endMovement', 'card', 'prepareAdvisors', 'revealNoField'].includes(action.type))
: (phase === 1 && current.nexus && !current.spiceWindow || phase === 7 || phase === 8) &&
action.type === 'ready' && !current.ready.includes(actor));
return !props.busy && current === game && nativePromiseScopeSupported(current) && current.status === 'playing' &&
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 &&
current.turnActionPromises.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 || invoice) &&
!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 recipient = game.players.find(player => player.id === action.target)?.name ?? 'the quoted recipient';
const sector = typeof action.sector === 'number' ? `, sector ${action.sector}` : '';
const label = action.type === 'bribe' ? `Pay ${amount} spice to ${recipient} while honoring your whole answer`
: action.type === 'charity' ? 'Claim Charity while preserving your whole answer'
: action.type === 'ready' ? phase === 8 ? 'Ready to end this turn while honoring your whole answer'
: 'Finish this preparation phase while preserving your whole answer'
: action.type === 'passBid' ? 'Pass this bid while preserving your whole answer'
: action.type === 'bid' ? `Place the required bid of ${amount} while preserving your whole answer`
: action.type === 'richeseBid' ? action.amount === 0 ? 'Submit zero in this Silent auction while preserving your whole answer'
: 'Pass this Richese bid while preserving your whole answer'
: action.type === 'revive' ? `Revive ${amount} forces while preserving your whole answer`
: action.type === 'reviveLeader' ? 'Revive the quoted leader while preserving your whole answer'
: action.type === 'acceptLeaderRevival' ? 'Accept the quoted leader revival while preserving your whole answer'
: action.type === 'reviveKwisatz' ? 'Revive your Kwisatz Haderach while preserving your whole answer'
: 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 preserving any later payment conditions'
: action.type === 'decision' ? game.decision?.kind === 'auctionPayment'
? action.karama === true ? 'Use the quoted Karama for the current auction payment while preserving your whole answer'
: 'Pay the current auction price while preserving your whole answer'
: 'Complete your paid Box search while preserving your whole answer'
: action.type === 'prepareAdvisors' ? `Prepare advisors in ${destination} while preserving your whole answer`
: action.type === 'revealNoField' ? 'Reveal your No-Field while preserving your whole answer'
: 'Use the offered native card preparation while preserving your whole answer';
return <Button className="game-action min-h-11 whitespace-normal" data-testid="turn-action-promise-guide"
disabled={!canAct()} onClick={() => { if (canAct()) props.act(action); }}>{label}</Button>;
}
/** Own original whole questions and aggregate status only. */
export function TurnActionPromises(props: TurnActionProps) {
const game = props.game, own = game.turnActionPromises.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 turn-action promises" id="turn-action-promise-guidance">
<h3>Your payment, 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: 'turnActions', 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 its remaining conditions close this turn.'}
</li>)}</ul>
<p>AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.
Separate No answers each create their own restriction. Legal alternatives remain available; no fixed branch is selected for you.
Current facts freeze at the answer and 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">Count the sum of future real ordinary payments to each named recipient after the answer.
Earlier payments, gifts and ally aid do not count. The sender pays immediately; the recipient collects at the next Mentat opening.
Payments made during Mentat Pause wait until next turn&apos;s Mentat opening; collection never counts twice.
Shipment conditions concern one future original reserve shipment. 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.
Shipment and movement close at your own Movement turn end; ordinary payments remain possible until the actual turn ends.
Original costs, timing, cards, preparation and action rights still apply. This claim grants no extra action.</p>
{live && <p>{game.phase === 8 ? 'Complete any remaining ordinary payment before the actual turn ends. Late recipients collect next turn.'
: game.phase < 5 ? 'Your Movement turn is still ahead. Preserve the needed forces, spice and cards and complete any offered native preparation.'
: 'Use the current native controls to honor your whole answer. Payment conditions can remain after your Movement turn ends.'}</p>}
{live && <TurnActionGuideButton {...props} />}
</section>;
}
/** Render only the current respondent's private native whole-answer choices. */
export function TurnActionAnswers(props: TurnActionProps) {
const game = props.game, window = game.truthtrance, question = window?.question;
if (window?.stage !== 'answer' || question?.kind !== 'turnActions' || question.target !== game.me || !game.truthTurnActionAnswers) return null;
const actor = game.me, turn = game.turn, phase = game.phase, source = window.queue[0], answers = game.truthTurnActionAnswers;
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' &&
Number.isSafeInteger(phase) && phase >= 0 && phase <= 8 && 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.truthTurnActionAnswers === 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 payment, shipment and movement question" className="space-y-3">
<p className="notice">Answer the whole expression. Current facts freeze now; OR keeps legal alternatives and No keeps the whole expression false.
Future real ordinary payments add together per recipient. Only one future original reserve shipment and actual future ground-movement groups count.
Shipment and movement close at your own Movement turn end; payment conditions last until the actual turn ends.
Original rights, preparation, timing 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-turn-action-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>;
}