Combine current-turn payments with shipment and movement promises
This commit is contained in:
1 parent
8dbec05844
commit
0d72a33ee5
18 files changed
+2591
-7
No files matched your search
@@ -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 ? (
|
||||
|
||||
@@ -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)) ||
|
||||
|
||||
@@ -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'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 });
|
||||
}
|
||||
@@ -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'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>;
|
||||
}
|
||||
Reference in new issue
Block a user