Support whole AND/OR future ground moves
This commit is contained in:
1 parent
0e3c155876
commit
d524642b65
20 files changed
+1803
-63
No files matched your search
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import { TraitorDecisionPromises } from './traitor-decision-promises';
|
||||
import { MovementPromises } from './movement-promises';
|
||||
import { MovementExpressionPromises } from './movement-expression-promises';
|
||||
import { HomeworldEcazDuke } from './homeworld-ecaz-duke';
|
||||
import { integerInputValue } from '../lib/integer-input';
|
||||
import { normalAuctionHandBlock } from '@/game/normal-auction';
|
||||
@@ -1987,6 +1988,7 @@ export function GameTable({
|
||||
<NexusFremenRideBetrayal game={g} act={act} busy={reactionBusy} />
|
||||
<HomeworldEcazDuke game={g} act={act} busy={reactionBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||||
<MovementPromises game={g} act={act} busy={reactionBusy} />
|
||||
<MovementExpressionPromises game={g} send={act} busy={reactionBusy} />
|
||||
{!reactionOwnsControls && g.semutaReaction && <SemutaReaction game={g} act={act} busy={reactionBusy} />}
|
||||
{g.karamaBlocked && <p className="notice" role="status">{g.karamaBlocked}</p>}
|
||||
{g.karamaPurposeBlocks?.map(({purpose, message}) => <p key={purpose} className="notice" role="status">{message}</p>)}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import {
|
||||
parseMovementExpression, MOVEMENT_EXPRESSION_MAX_DEPTH, MOVEMENT_EXPRESSION_MAX_LEAVES,
|
||||
type MovementExpression,
|
||||
} from '@/game/movement-expression-promises';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
type TerritoryChoice = { id: string; name: string };
|
||||
export function movementExpressionInputError(value: unknown): string | null {
|
||||
try { parseMovementExpression(value); return null; }
|
||||
catch (error) { return error instanceof Error ? error.message : 'Choose a valid ground-movement expression.'; }
|
||||
}
|
||||
|
||||
// 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 }
|
||||
: { ...node, terms: node.terms.map(structuralDraft) };
|
||||
const inputSize = (node: MovementExpression): { leaves: number; depth: number } => {
|
||||
if (node.kind === 'move') return { leaves: 1, depth: 0 };
|
||||
const sizes = node.terms.map(inputSize);
|
||||
return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) };
|
||||
};
|
||||
|
||||
/** Hook-free controlled tree. The server decides future route feasibility. */
|
||||
export function MovementExpressionFields({ value, territories, disabled, onChange }: {
|
||||
value: MovementExpression;
|
||||
territories: readonly TerritoryChoice[];
|
||||
disabled: boolean;
|
||||
onChange: (expression: MovementExpression) => void;
|
||||
}) {
|
||||
const newLeaf = (): MovementExpression => ({ kind: 'move', territory: territories[0]?.id ?? 'carthag', minimum: 1 });
|
||||
const size = inputSize(value), error = movementExpressionInputError(value);
|
||||
const commit = (next: MovementExpression) => {
|
||||
if (!disabled && !movementExpressionInputError(structuralDraft(next))) onChange(next);
|
||||
};
|
||||
const render = (node: MovementExpression, path: string, replace: (next: MovementExpression) => MovementExpression,
|
||||
remove?: () => MovementExpression): ReactElement => {
|
||||
const id = `truth-movement-expression-${path}`;
|
||||
const allowed = (next: MovementExpression) => !disabled && !movementExpressionInputError(structuralDraft(replace(next)));
|
||||
const change = (next: MovementExpression) => commit(replace(next));
|
||||
const wrap = (kind: 'and' | 'or'): MovementExpression => ({ kind, terms: [node, newLeaf()] });
|
||||
return <fieldset key={path} data-movement-expression-path={path} className="grid min-w-0 gap-2 p-2">
|
||||
<legend>{node.kind === 'move' ? `Movement condition ${path}` : `Movement group ${path} · ${node.kind.toUpperCase()}`}</legend>
|
||||
{node.kind === 'move' ? <>
|
||||
<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 })}>
|
||||
<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 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)}
|
||||
onChange={event => change({ ...node, minimum: event.currentTarget.valueAsNumber })} />
|
||||
</> : <>
|
||||
<label htmlFor={`${id}-meaning`}>Movement group {path} meaning</label>
|
||||
<select id={`${id}-meaning`} value={node.kind} disabled={disabled} onChange={event => {
|
||||
const kind = event.currentTarget.value;
|
||||
if (kind === 'and' || kind === 'or') change({ ...node, kind });
|
||||
}}>
|
||||
<option value="and">All conditions · AND</option>
|
||||
<option value="or">At least one condition · OR</option>
|
||||
</select>
|
||||
{node.terms.map((term, index) => render(term, `${path}.${index + 1}`,
|
||||
next => replace({ ...node, terms: node.terms.map((old, i) => i === index ? next : old) }),
|
||||
() => {
|
||||
const terms = node.terms.filter((_, i) => i !== index);
|
||||
return replace(terms.length === 1 ? terms[0] : { ...node, terms });
|
||||
}))}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" data-movement-expression-control={`add-${path}`}
|
||||
disabled={!allowed({ ...node, terms: [...node.terms, newLeaf()] })}
|
||||
onClick={() => change({ ...node, terms: [...node.terms, newLeaf()] })}>Add movement condition</Button>
|
||||
{(['and', 'or'] as const).map(kind => {
|
||||
const next: MovementExpression = { ...node, terms: [...node.terms, { kind, terms: [newLeaf(), newLeaf()] }] };
|
||||
return <Button key={kind} type="button" data-movement-expression-control={`nested-${kind}-${path}`}
|
||||
disabled={!allowed(next)} onClick={() => change(next)}>Add nested {kind.toUpperCase()} group</Button>;
|
||||
})}
|
||||
</div>
|
||||
</>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['and', 'or'] as const).map(kind => <Button key={kind} type="button" data-movement-expression-control={`${kind}-${path}`}
|
||||
disabled={!allowed(wrap(kind))} onClick={() => change(wrap(kind))}>Group with {kind.toUpperCase()}</Button>)}
|
||||
{remove && <Button type="button" data-movement-expression-control={`remove-${path}`} disabled={disabled}
|
||||
onClick={() => commit(remove())}>Remove {node.kind === 'move' ? 'condition' : 'group'}</Button>}
|
||||
</div>
|
||||
</fieldset>;
|
||||
};
|
||||
return <fieldset disabled={disabled} data-testid="movement-expression"
|
||||
className="grid min-w-0 gap-2 [&_select]:min-w-0 [&_select]:max-w-full [&_button]:max-w-full [&_button]:min-h-11 [&_button]:h-auto [&_button]:py-2 [&_button]:whitespace-normal">
|
||||
<legend>Ground-movement expression</legend>
|
||||
<p className="fine">
|
||||
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.
|
||||
</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>
|
||||
<p className="fine">{size.leaves} / {MOVEMENT_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {MOVEMENT_EXPRESSION_MAX_DEPTH} levels of grouping</p>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{render(value, '1', next => next)}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { liveMovementExpressionPromises, movementExpressionTerritories, movementExpressionText } from '@/game/movement-expression-promises';
|
||||
import { liveMovementPromises } from '@/game/movement-promises';
|
||||
import { nativePromiseScopeSupported } from '@/game/native-promise-scope';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type MovementExpressionPromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
|
||||
const destinationName = (game: GameView, id: string) => game.combatLocations.find(location => location.id === id)?.name ?? id;
|
||||
|
||||
/** Forward one exact native quote. This guide does not choose a future branch
|
||||
* or reconstruct physical forces, card entitlement or payment legality. */
|
||||
export function MovementExpressionPromiseGuideButton(props: MovementExpressionPromiseProps) {
|
||||
const game = props.game, guide = game.movementExpressionPromiseGuide;
|
||||
if (!guide) return null;
|
||||
const actor = game.me, turn = game.turn, phase = guide.phase, action = guide.action;
|
||||
const event = action.movementExpressionPromiseEvent, signature = JSON.stringify(action);
|
||||
const canAct = () => {
|
||||
const current = props.game, me = current.players.find(player => player.id === current.me);
|
||||
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 payment = phase === 3 && action.type === 'decision' &&
|
||||
current.decision?.kind === 'auctionPayment' && current.decision.player === actor;
|
||||
const native = current.richeseAuction;
|
||||
const nativePass = phase === 3 && action.type === 'richeseBid' && 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 revivalOrder = current.revivalOrder;
|
||||
const opportunity = paidBox || (phase === 2
|
||||
? !current.ready.includes(actor) && (action.type === 'charity' || action.type === 'ready')
|
||||
: phase === 3
|
||||
? payment || nativePass || action.type === 'passBid' && !native && current.auction?.active === actor
|
||||
: phase === 4
|
||||
? !current.ready.includes(actor) && (action.type === 'revive' || action.type === 'ready') &&
|
||||
(!revivalOrder || revivalOrder.turn === turn && revivalOrder.remaining[0] === actor && current.active === actor)
|
||||
: phase === 5 && current.active === actor &&
|
||||
['move', 'ship', 'endMovement', 'card', 'prepareAdvisors', 'revealNoField'].includes(action.type));
|
||||
return !props.busy && current === game && nativePromiseScopeSupported(current) && current.status === 'playing' &&
|
||||
Number.isSafeInteger(phase) && phase >= 0 && phase <= 5 && current.phase === phase && current.turn === turn && current.me === actor &&
|
||||
current.movementExpressionPromiseGuide === guide && current.movementExpressionPromiseGuide.phase === phase &&
|
||||
current.movementExpressionPromiseGuide.action === action && JSON.stringify(action) === signature && opportunity &&
|
||||
typeof event === 'string' && liveMovementExpressionPromises(current.movementExpressionPromises, actor, turn).some(promise => promise.event === event) &&
|
||||
!!me && !me.autopilot && !current.truthtrance && !current.response && (!current.decision || paidBox || payment) &&
|
||||
!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 = typeof action.territory === 'string' ? destinationName(game, action.territory) : 'the chosen destination';
|
||||
const sector = typeof action.sector === 'number' ? action.sector : 0;
|
||||
// These already-projected own faces supply labels only, never eligibility.
|
||||
const card = (action.type === 'card' || action.type === 'decision')
|
||||
? game.players.find(player => player.id === actor)?.hand?.find(card => card.id === action.card) : undefined;
|
||||
const recovered = action.type === 'decision' && game.decision?.kind === 'nullentropy'
|
||||
? game.nullentropy?.search?.cards.find(card => card.id === action.card) : undefined;
|
||||
const payment = action.type === 'decision' && game.decision?.kind === 'auctionPayment';
|
||||
const label = payment ? action.karama === true ? `Use ${card?.name ?? 'your card'} for the current auction payment` : 'Pay the current auction price'
|
||||
: action.type === 'decision' ? `Choose ${recovered?.name ?? 'the required card'} in your paid Box search`
|
||||
: action.type === 'charity' ? 'Claim Charity for your later movement'
|
||||
: action.type === 'revive' ? `Revive ${amount} forces for your later movement`
|
||||
: action.type === 'ready' ? 'Finish this preparation phase while preserving your promises'
|
||||
: action.type === 'passBid' ? 'Pass this bid and preserve later movement'
|
||||
: action.type === 'richeseBid' ? action.amount === 0 ? 'Submit zero in this Silent auction and preserve later movement' : 'Pass this Richese bid and preserve later movement'
|
||||
: action.type === 'card' && action.mode === 'shipment' ? `Use ${card?.name ?? 'your card'} for Guild shipment rates`
|
||||
: action.type === 'card' && card?.effect === 'nullentropyBox' ? 'Pay 2 spice and search with Nullentropy Box'
|
||||
: action.type === 'card' && card?.effect === 'ghola' ? `Play Ghola to return ${amount} ${amount === 1 ? 'force' : 'forces'} to reserves for the required movement`
|
||||
: action.type === 'card' && action.mode === 'choam' ? 'Play Kulon to extend the required movement'
|
||||
: action.type === 'prepareAdvisors' ? `Prepare advisors in ${destination} for the required movement`
|
||||
: action.type === 'revealNoField' ? 'Reveal your No-Field to prepare physical forces for the required movement'
|
||||
: action.type === 'card' ? 'Play Hajr to prepare the required movement'
|
||||
: action.type === 'ship' && action.noField ? `Ship a concealed No-Field to ${destination}, sector ${sector}, before the required movement`
|
||||
: action.type === 'ship' ? `Ship ${amount} forces to ${destination}, sector ${sector}, before the required movement`
|
||||
: action.type === 'endMovement' ? 'Finish movement while honoring the whole expression'
|
||||
: action.noField && !amount ? `Move your concealed No-Field to ${destination}, sector ${sector}, to prepare the required movement`
|
||||
: action.noField ? `Move ${amount} physical forces with your No-Field to ${destination}, sector ${sector}`
|
||||
: `${action.movementCard ? 'Use Ornithopter and move' : 'Move'} ${amount} forces to ${destination}, sector ${sector}`;
|
||||
return <Button className="game-action min-h-11 whitespace-normal" data-testid="movement-expression-promise-guide"
|
||||
disabled={!canAct()} onClick={() => { if (canAct()) props.send(action); }}>{label}</Button>;
|
||||
}
|
||||
|
||||
export function MovementExpressionPromises(props: MovementExpressionPromiseProps) {
|
||||
const game = props.game, own = game.movementExpressionPromises.filter(promise => promise.player === game.me);
|
||||
if (!own.length) return null;
|
||||
const live = liveMovementExpressionPromises(own, game.me, game.turn).length > 0;
|
||||
const waiting = live && (game.phase < 5 || game.phase === 5 && game.active !== game.me);
|
||||
return <section className="notice space-y-3" aria-label="Your Truthtrance movement expression promises" id="movement-expression-promise-guidance">
|
||||
<h3>Your movement expression promises</h3>
|
||||
<ul>{own.map(promise => <li key={promise.event}>
|
||||
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {movementExpressionText(promise.claim)}.
|
||||
{' '}Largest future ground move to each destination: {movementExpressionTerritories(promise.claim).map(territory =>
|
||||
`${destinationName(game, territory)}: ${promise.progress.find(progress => progress.territory === territory)?.maximum ?? 0} physical forces`).join('; ')}.
|
||||
{promise.released ? ' Released: this commitment is no longer possible.'
|
||||
: promise.fulfilled ? ' Fulfilled: this 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.
|
||||
Each condition requires one actual future ground move of the named minimum after the answer.
|
||||
Smaller moves never add together; progress records the maximum in one move to each destination.
|
||||
Prior moves and shipments do not count. A shipment can prepare a move but does not fulfill a condition.
|
||||
</p>
|
||||
<p className="fine">
|
||||
Yes must be completed before your Movement turn ends this round. No remains binding through every later Hajr or Ornithopter move.
|
||||
Original movement, shipment, preparation, payment and turn rules still apply.
|
||||
A paid Box search must finish before moving; its fee is paid only once.
|
||||
Opposing effects release an answer only if it becomes impossible.
|
||||
{' '}<a href="/rules#truthtrance-movement-expression">Movement expression rules</a>.
|
||||
</p>
|
||||
{waiting && <p>Your Movement turn is still ahead. Keep the needed forces, spice and cards available; movement controls appear on your turn.
|
||||
{game.phase < 5 && ' Complete any current preparation below before confirming readiness.'}
|
||||
{game.decision?.kind === 'nullentropy' && game.decision.player === game.me && ' Your already-paid Box search can finish now.'}
|
||||
</p>}
|
||||
{!liveMovementPromises(game.movementPromises ?? [], game.me, game.turn).length && <MovementExpressionPromiseGuideButton {...props} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { MovementExpressionFields, movementExpressionInputError } from './movement-expression-fields';
|
||||
import type { MovementExpression } from '@/game/movement-expression-promises';
|
||||
import { TERRITORIES } from '@/game/board';
|
||||
import { BribeExpressionFields, bribeExpressionInputError } from './bribe-expression-fields';
|
||||
import { bribeExpressionRecipients, type BribeExpression } from '@/game/bribe-expression-promises';
|
||||
import { RevivalExpressionFields, revivalExpressionInputError } from './revival-expression-fields';
|
||||
@@ -68,7 +71,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'
|
||||
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression'
|
||||
>('fact');
|
||||
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
|
||||
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
|
||||
@@ -102,6 +105,10 @@ export function Truthtrance({
|
||||
const bidClaim: BidClaim = { turn: g.bidPromiseLot?.turn ?? g.turn,
|
||||
lot: g.bidPromiseLot?.lot ?? 1, minimum: bidMinimum };
|
||||
const [movement, setMovement] = useState<MovementClaim>({ territory: 'carthag', minimum: 6 });
|
||||
const [movementExpression, setMovementExpression] = useState<MovementExpression>({ kind: 'and', terms: [
|
||||
{ kind: 'move', territory: 'imperial_basin', minimum: 1 }, { kind: 'move', territory: 'carthag', minimum: 1 },
|
||||
] });
|
||||
const movementExpressionError = movementExpressionInputError(movementExpression);
|
||||
const [shipment, setShipment] = useState<ShipmentExpression>({ territory: 'carthag', minimum: 6 });
|
||||
const [mixed, setMixed] = useState<MixedShipmentExpression>({
|
||||
kind: 'and',
|
||||
@@ -138,6 +145,7 @@ export function Truthtrance({
|
||||
claim: planClaim,
|
||||
}
|
||||
: kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' }
|
||||
: kind === 'movementExpression' ? { kind, target, turn: g.turn, claim: movementExpression }
|
||||
: kind === 'movement' ? { kind, target, ...movement }
|
||||
: kind === 'revivalExpression' ? { kind, target, turn: g.turn, claim: revivalExpression }
|
||||
: kind === 'leaderRevival' ? { kind, target, ...leaderRevivalClaim }
|
||||
@@ -302,6 +310,13 @@ export function Truthtrance({
|
||||
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
|
||||
</div>)}
|
||||
</>
|
||||
) : w.question!.kind === 'movementExpression' ? (
|
||||
<>
|
||||
<p className="notice">Yes requires the whole AND/OR question to become true through your future physical ground moves. No keeps the whole expression false and may permit some moves. Each condition needs one qualifying group; smaller moves never add together. Original movement allowances, cards and responses still apply.</p>
|
||||
{(g.truthMovementExpressionAnswers ?? []).map(answer => <div key={answer}>
|
||||
{button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}
|
||||
</div>)}
|
||||
</>
|
||||
) : w.question!.kind === 'movement' ? (
|
||||
<>
|
||||
<p className="notice">Yes requires one matching ground movement before your turn ends, while possible. No forbids every matching move through later Hajr or Ornithopter opportunities. Separate smaller groups do not add together. The No-Field marker itself is not a physical force counter.</p>
|
||||
@@ -419,6 +434,7 @@ export function Truthtrance({
|
||||
Verified current facts
|
||||
</option>
|
||||
<option value="movement">Bind ground movement this turn</option>
|
||||
<option value="movementExpression">Combine ground moves with AND/OR</option>
|
||||
<option value="alliance">Bind alliance membership at this Nexus close</option>
|
||||
<option value="bribe">Bind ordinary bribe payments this turn</option>
|
||||
<option value="bribeExpression">Combine ordinary bribe payments with AND/OR</option>
|
||||
@@ -507,6 +523,16 @@ export function Truthtrance({
|
||||
{!g.truthTraitorTargets.includes(target) && <p>Reveal both plans and choose a voter who has not submitted their Traitor decision.</p>}
|
||||
{g.battle?.revealed && <p className="notice" aria-label="Public Traitor question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>}
|
||||
</>
|
||||
) : kind === 'movementExpression' ? (
|
||||
<>
|
||||
<MovementExpressionFields value={movementExpression} territories={TERRITORIES}
|
||||
disabled={busy || !g.truthMovementExpressionTargets.includes(target)} onChange={setMovementExpression} />
|
||||
<p className="fine">Each condition requires one future physical group of its minimum. AND may require several moves and owned extra-movement cards; OR keeps alternatives. No negates the whole question. Shipment, the concealed No-Field marker, HMS relocation and Ambassador relocation do not fulfill a ground-move condition.</p>
|
||||
{g.phase < 5 && <p className="notice">Movement comes later. Preserve its forces, spice and cards; any current owned preparation appears when available.</p>}
|
||||
{g.advanced && <p className="fine">Advanced Hajr remains immediately after a completed movement and its required arrival decisions.</p>}
|
||||
{!g.truthMovementExpressionTargets.includes(target) && <p className="notice">Choose a supported respondent with an unused movement opportunity and finish pending decisions. Optional modules remain unfinished.</p>}
|
||||
<p className="notice" aria-label="Public combined movement question preview">{movementExpressionError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||
</>
|
||||
) : kind === 'movement' ? (
|
||||
<>
|
||||
<MovementClaimFields id={`${factId}-movement`} value={movement} onChange={setMovement} />
|
||||
@@ -597,6 +623,7 @@ export function Truthtrance({
|
||||
{!['fact', 'battlePlan', 'shipment'].includes(kind) && button(
|
||||
'Ask publicly',
|
||||
{ type: 'truthAsk', question },
|
||||
(kind === 'movementExpression' && (!!movementExpressionError || !g.truthMovementExpressionTargets.includes(target))) ||
|
||||
(kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) ||
|
||||
(kind === 'revivalExpression' && (!!revivalExpressionInputError(revivalExpression) || !validRevivalExpressionNames || !g.truthRevivalExpressionTargets.includes(target))) ||
|
||||
(kind === 'leaderRevival' && (!!leaderRevivalClaimInputError(leaderRevivalClaim, leaderRevivalChoices) || !g.truthLeaderRevivalTargets.includes(target))) ||
|
||||
|
||||
Reference in new issue
Block a user