Combine current facts with future ground movement
This commit is contained in:
1 parent
d524642b65
commit
b63a60da64
19 files changed
+1590
-7
No files matched your search
@@ -2,6 +2,7 @@
|
||||
import { TraitorDecisionPromises } from './traitor-decision-promises';
|
||||
import { MovementPromises } from './movement-promises';
|
||||
import { MovementExpressionPromises } from './movement-expression-promises';
|
||||
import { MixedMovementPromises } from './mixed-movement-promises';
|
||||
import { HomeworldEcazDuke } from './homeworld-ecaz-duke';
|
||||
import { integerInputValue } from '../lib/integer-input';
|
||||
import { normalAuctionHandBlock } from '@/game/normal-auction';
|
||||
@@ -1989,6 +1990,7 @@ export function GameTable({
|
||||
<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} />
|
||||
<MixedMovementPromises 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,181 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import type { GameView } from '@/game/engine';
|
||||
import { TERRITORIES } from '@/game/board';
|
||||
import { parseMixedActionExpression, type MixedActionExpression } from '@/game/mixed-action-question';
|
||||
import { MOVEMENT_EXPRESSION_MAX_DEPTH, MOVEMENT_EXPRESSION_MAX_LEAVES } from '@/game/movement-expression-promises';
|
||||
import { parseMovementClaim, type MovementClaim } from '@/game/movement-promises';
|
||||
import type { TruthFact } from '@/game/truthtrance';
|
||||
import { truthFactExpressionError } from '@/game/truth-fact-expression';
|
||||
import { TruthFactFields, invalidTruthFact } from './mixed-shipment-fields';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
type MixedMovementExpression = MixedActionExpression<MovementClaim>;
|
||||
const newFact = (): MixedMovementExpression => ({ kind: 'fact', fact: { kind: 'hand', name: 'Shield' } });
|
||||
const newMove = (): MixedMovementExpression => ({ kind: 'action', action: { territory: 'carthag', minimum: 1 } });
|
||||
const newFactLeaf = (): TruthFact => ({ kind: 'hand', name: 'Shield' });
|
||||
function factSize(fact: TruthFact): { leaves: number; depth: number } {
|
||||
if (fact.kind !== 'and' && fact.kind !== 'or') return { leaves: 1, depth: 0 };
|
||||
const sizes = fact.terms.map(factSize);
|
||||
return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) };
|
||||
}
|
||||
function sizeOf(node: MixedMovementExpression): { leaves: number; depth: number } {
|
||||
if (node.kind === 'fact') return factSize(node.fact);
|
||||
if (node.kind === 'action') return { leaves: 1, depth: 0 };
|
||||
const sizes = node.terms.map(sizeOf);
|
||||
return { leaves: sizes.reduce((sum, child) => sum + child.leaves, 0), depth: 1 + Math.max(0, ...sizes.map(child => child.depth)) };
|
||||
}
|
||||
function structuralError(node: MixedMovementExpression): string | null {
|
||||
const size = sizeOf(node);
|
||||
if (size.leaves > MOVEMENT_EXPRESSION_MAX_LEAVES || size.depth > MOVEMENT_EXPRESSION_MAX_DEPTH)
|
||||
return 'Use at most sixteen conditions and four levels of grouping.';
|
||||
if (node.kind === 'fact') return truthFactExpressionError(node.fact);
|
||||
if (node.kind === 'action') return null;
|
||||
if (node.terms.length < 2) return 'Join two or more conditions with AND or OR.';
|
||||
for (const term of node.terms) {
|
||||
const error = structuralError(term);
|
||||
if (error) return error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Draft feedback reuses the existing fact fields. The server remains the
|
||||
* authority for current-fact eligibility and legal future movement routes. */
|
||||
export function mixedMovementInputError(value: unknown, game: GameView, target: string): string | null {
|
||||
try {
|
||||
parseMixedActionExpression(value, input => {
|
||||
const fact = input as TruthFact;
|
||||
const error = truthFactExpressionError(fact);
|
||||
if (error) throw new Error(error);
|
||||
if (invalidTruthFact(fact, game, target)) throw new Error('Resolve the marked current-fact fields.');
|
||||
return fact;
|
||||
}, parseMovementClaim);
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : 'Choose a current fact and a future ground-movement condition.';
|
||||
}
|
||||
}
|
||||
|
||||
/** Preserve the complete original tree, including incomplete numeric drafts.
|
||||
* Structural changes never evaluate current facts or select a future branch. */
|
||||
export function MixedMovementFields({ value, game, target, disabled, onChange }: {
|
||||
value: MixedMovementExpression;
|
||||
game: GameView;
|
||||
target: string;
|
||||
disabled: boolean;
|
||||
onChange: (expression: MixedMovementExpression) => void;
|
||||
}) {
|
||||
const size = sizeOf(value), error = mixedMovementInputError(value, game, target);
|
||||
const commit = (next: MixedMovementExpression) => {
|
||||
if (!disabled && !structuralError(next)) onChange(next);
|
||||
};
|
||||
const renderFact = (fact: TruthFact, path: string, replace: (next: TruthFact) => MixedMovementExpression,
|
||||
remove?: () => MixedMovementExpression): ReactElement => {
|
||||
const allowed = (next: TruthFact) => !disabled && !structuralError(replace(next));
|
||||
const change = (next: TruthFact) => commit(replace(next));
|
||||
const wrap = (kind: 'and' | 'or'): TruthFact => ({ kind, terms: [fact, newFactLeaf()] });
|
||||
return <fieldset key={path} data-mixed-movement-fact-path={path} className="grid min-w-0 gap-2 p-2">
|
||||
<legend>{fact.kind === 'and' || fact.kind === 'or' ? `Current-fact group ${path}` : `Current fact ${path}`}</legend>
|
||||
{fact.kind === 'and' || fact.kind === 'or' ? <>
|
||||
<label htmlFor={`truth-mixed-movement-${path}-fact-meaning`}>Current-fact group {path} meaning</label>
|
||||
<select id={`truth-mixed-movement-${path}-fact-meaning`} value={fact.kind} disabled={disabled} onChange={event => {
|
||||
const kind = event.currentTarget.value;
|
||||
if (kind === 'and' || kind === 'or') change({ ...fact, kind });
|
||||
}}><option value="and">All conditions · AND</option><option value="or">At least one condition · OR</option></select>
|
||||
{fact.terms.map((term, index) => renderFact(term, `${path}.${index + 1}`,
|
||||
next => replace({ ...fact, terms: fact.terms.map((old, i) => i === index ? next : old) }),
|
||||
() => {
|
||||
const terms = fact.terms.filter((_, i) => i !== index);
|
||||
return replace(terms.length === 1 ? terms[0] : { ...fact, terms });
|
||||
}))}
|
||||
<Button type="button" data-mixed-movement-control={`fact-add-${path}`}
|
||||
disabled={!allowed({ ...fact, terms: [...fact.terms, newFactLeaf()] })}
|
||||
onClick={() => change({ ...fact, terms: [...fact.terms, newFactLeaf()] })}>Add current fact</Button>
|
||||
</> : <TruthFactFields value={fact} game={game} target={target} onChange={change} />}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['and', 'or'] as const).map(kind => <Button key={kind} type="button" data-mixed-movement-control={`fact-${kind}-${path}`}
|
||||
disabled={!allowed(wrap(kind))} onClick={() => change(wrap(kind))}>Group current facts with {kind.toUpperCase()}</Button>)}
|
||||
{remove && <Button type="button" data-mixed-movement-control={`fact-remove-${path}`} disabled={disabled}
|
||||
onClick={() => commit(remove())}>Remove current fact</Button>}
|
||||
</div>
|
||||
</fieldset>;
|
||||
};
|
||||
const render = (node: MixedMovementExpression, path: string, replace: (next: MixedMovementExpression) => MixedMovementExpression,
|
||||
remove?: () => MixedMovementExpression): ReactElement => {
|
||||
const id = `truth-mixed-movement-${path}`;
|
||||
const allowed = (next: MixedMovementExpression) => !disabled && !structuralError(replace(next));
|
||||
const change = (next: MixedMovementExpression) => commit(replace(next));
|
||||
const wrap = (kind: 'and' | 'or'): MixedMovementExpression => ({ kind, terms: [node, node.kind === 'fact' ? newMove() : newFact()] });
|
||||
return <fieldset key={path} data-mixed-movement-path={path} className="grid min-w-0 gap-2 p-2">
|
||||
<legend>{node.kind === 'and' || node.kind === 'or' ? `Mixed group ${path} · ${node.kind.toUpperCase()}` : `Mixed condition ${path}`}</legend>
|
||||
{node.kind === 'and' || node.kind === 'or' ? <>
|
||||
<label htmlFor={`${id}-meaning`}>Mixed 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-mixed-movement-control={`add-fact-${path}`}
|
||||
disabled={!allowed({ ...node, terms: [...node.terms, newFact()] })}
|
||||
onClick={() => change({ ...node, terms: [...node.terms, newFact()] })}>Add current fact</Button>
|
||||
<Button type="button" data-mixed-movement-control={`add-action-${path}`}
|
||||
disabled={!allowed({ ...node, terms: [...node.terms, newMove()] })}
|
||||
onClick={() => change({ ...node, terms: [...node.terms, newMove()] })}>Add ground-movement condition</Button>
|
||||
{(['and', 'or'] as const).map(kind => {
|
||||
const next: MixedMovementExpression = { ...node, terms: [...node.terms, { kind, terms: [newFact(), newMove()] }] };
|
||||
return <Button key={kind} type="button" data-mixed-movement-control={`nested-${kind}-${path}`}
|
||||
disabled={!allowed(next)} onClick={() => change(next)}>Add nested {kind.toUpperCase()} group</Button>;
|
||||
})}
|
||||
</div>
|
||||
</> : <>
|
||||
<label htmlFor={`${id}-timing`}>Condition timing</label>
|
||||
<select id={`${id}-timing`} value={node.kind} disabled={disabled} onChange={event => {
|
||||
if (event.currentTarget.value === 'fact') change(newFact());
|
||||
if (event.currentTarget.value === 'action') change(newMove());
|
||||
}}><option value="fact">Current fact · at the answer</option><option value="action">Future ground move · this turn</option></select>
|
||||
{node.kind === 'fact' ? renderFact(node.fact, path, fact => replace({ kind: 'fact', fact })) : node.kind === 'action' ? <>
|
||||
<label htmlFor={`${id}-territory`}>Ground-movement destination</label>
|
||||
<select id={`${id}-territory`} value={TERRITORIES.some(destination => destination.id === node.action.territory) ? node.action.territory : ''}
|
||||
disabled={disabled} aria-invalid={!TERRITORIES.some(destination => destination.id === node.action.territory)}
|
||||
onChange={event => change({ ...node, action: { ...node.action, 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.action.minimum) ? '' : node.action.minimum}
|
||||
aria-invalid={!Number.isSafeInteger(node.action.minimum) || node.action.minimum < 1 || node.action.minimum > 20}
|
||||
onChange={event => change({ ...node, action: { ...node.action, minimum: event.currentTarget.valueAsNumber } })} />
|
||||
</> : null}
|
||||
</>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['and', 'or'] as const).map(kind => <Button key={kind} type="button" data-mixed-movement-control={`${kind}-${path}`}
|
||||
disabled={!allowed(wrap(kind))} onClick={() => change(wrap(kind))}>Group with {kind.toUpperCase()}</Button>)}
|
||||
{remove && <Button type="button" data-mixed-movement-control={`remove-${path}`} disabled={disabled}
|
||||
onClick={() => commit(remove())}>Remove {node.kind === 'and' || node.kind === 'or' ? 'group' : 'condition'}</Button>}
|
||||
</div>
|
||||
</fieldset>;
|
||||
};
|
||||
return <fieldset disabled={disabled} data-testid="mixed-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>Mixed current-fact / future ground-movement claim</legend>
|
||||
<p className="fine">Current facts are evaluated when the player answers. They create no promise to keep cards, spice or forces afterward.
|
||||
AND requires every condition; OR requires at least one. No makes the whole grouped claim false, not necessarily every condition.
|
||||
Only the aggregate answer is shared; an unknown current fact can still allow a definite whole answer.</p>
|
||||
<p className="fine">Each movement condition needs one actual future ground move after the answer, before your Movement turn ends this round.
|
||||
Smaller moves never add together. Prior moves and shipments do not count. The claim grants no extra movement opportunity.</p>
|
||||
<p className="fine">App input limits: {MOVEMENT_EXPRESSION_MAX_LEAVES} conditions and {MOVEMENT_EXPRESSION_MAX_DEPTH} levels of grouping.
|
||||
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,59 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import type { MixedActionExpression } from '@/game/mixed-action-question';
|
||||
import type { MovementClaim } from '@/game/movement-promises';
|
||||
import { liveMovementPromises } from '@/game/movement-promises';
|
||||
import { liveMovementExpressionPromises } from '@/game/movement-expression-promises';
|
||||
import { truthQuestionText } from '@/game/truthtrance';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
import { MovementExpressionPromiseGuideButton } from './movement-expression-promises';
|
||||
|
||||
const destinationName = (game: GameView, id: string) => game.combatLocations.find(location => location.id === id)?.name ?? id;
|
||||
function destinations(mixed: MixedActionExpression<MovementClaim>): string[] {
|
||||
const found = new Set<string>();
|
||||
const visit = (node: MixedActionExpression<MovementClaim>): void => {
|
||||
if (node.kind === 'action') found.add(node.action.territory);
|
||||
else if (node.kind === 'and' || node.kind === 'or') node.terms.forEach(visit);
|
||||
};
|
||||
visit(mixed);
|
||||
return [...found];
|
||||
}
|
||||
|
||||
/** Display only the original question, aggregate answer and physical progress.
|
||||
* No current-fact evaluation or reduced future branch is projected or inferred. */
|
||||
export function MixedMovementPromises(props: { game: GameView; busy: boolean; send: (action: Action) => void }) {
|
||||
const game = props.game, own = game.mixedMovementPromises.filter(promise => promise.player === game.me);
|
||||
if (!own.length) return null;
|
||||
const live = own.some(promise => promise.turn === game.turn && !promise.fulfilled && !promise.released);
|
||||
const waiting = live && (game.phase < 5 || game.phase === 5 && game.active !== game.me);
|
||||
const sharedGuideElsewhere = liveMovementPromises(game.movementPromises ?? [], game.me, game.turn).length > 0 ||
|
||||
liveMovementExpressionPromises(game.movementExpressionPromises, game.me, game.turn).length > 0;
|
||||
return <section className="notice space-y-3" aria-label="Your Truthtrance mixed movement promises" id="mixed-movement-promise-guidance">
|
||||
<h3>Your mixed movement promises</h3>
|
||||
<ul>{own.map(promise => <li key={promise.event}>
|
||||
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'mixedMovement', 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 => destinationName(game, id), id => game.players.find(player => player.id === id)?.name ?? id)}
|
||||
{' '}Largest future ground move to each named destination: {destinations(promise.mixed).map(territory =>
|
||||
`${destinationName(game, territory)}: ${promise.progress.find(progress => progress.territory === territory)?.maximum ?? 0} physical forces`).join('; ')}.
|
||||
{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>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.
|
||||
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.</p>
|
||||
<p className="fine">Future movement progress records the maximum physical forces in one actual move after the answer, never the sum of smaller moves.
|
||||
Prior moves and shipments do not count. A shipment can prepare a move but does not fulfill a movement condition.
|
||||
The original movement, shipment, preparation, payment and turn rules still apply; this claim grants no extra movement opportunity.
|
||||
A paid Box search must finish before moving; its fee is paid only once.
|
||||
{' '}<a href="/rules#truthtrance-mixed-movement">Mixed movement rules</a>.</p>
|
||||
{waiting && <p>Your Movement turn is still ahead. Keep any 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>}
|
||||
{live && !sharedGuideElsewhere && <MovementExpressionPromiseGuideButton {...props} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -42,7 +42,9 @@ export function MovementExpressionPromiseGuideButton(props: MovementExpressionPr
|
||||
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) &&
|
||||
typeof event === 'string' && (liveMovementExpressionPromises(current.movementExpressionPromises, actor, turn).some(promise => promise.event === event) ||
|
||||
current.mixedMovementPromises.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 || payment) &&
|
||||
!current.phaseOpening && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
|
||||
};
|
||||
@@ -113,6 +115,6 @@ export function MovementExpressionPromises(props: MovementExpressionPromiseProps
|
||||
{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} />}
|
||||
{live && !liveMovementPromises(game.movementPromises ?? [], game.me, game.turn).length && <MovementExpressionPromiseGuideButton {...props} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { MixedMovementFields, mixedMovementInputError } from './mixed-movement-fields';
|
||||
import type { MixedActionExpression } from '@/game/mixed-action-question';
|
||||
import { MovementExpressionFields, movementExpressionInputError } from './movement-expression-fields';
|
||||
import type { MovementExpression } from '@/game/movement-expression-promises';
|
||||
import { TERRITORIES } from '@/game/board';
|
||||
@@ -71,7 +73,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'
|
||||
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement'
|
||||
>('fact');
|
||||
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
|
||||
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
|
||||
@@ -109,6 +111,11 @@ export function Truthtrance({
|
||||
{ kind: 'move', territory: 'imperial_basin', minimum: 1 }, { kind: 'move', territory: 'carthag', minimum: 1 },
|
||||
] });
|
||||
const movementExpressionError = movementExpressionInputError(movementExpression);
|
||||
const [mixedMovement, setMixedMovement] = useState<MixedActionExpression<MovementClaim>>({ kind: 'or', terms: [
|
||||
{ kind: 'fact', fact: { kind: 'hand', name: 'Hajr' } },
|
||||
{ kind: 'action', action: { territory: 'carthag', minimum: 1 } },
|
||||
] });
|
||||
const mixedMovementError = mixedMovementInputError(mixedMovement, g, target);
|
||||
const [shipment, setShipment] = useState<ShipmentExpression>({ territory: 'carthag', minimum: 6 });
|
||||
const [mixed, setMixed] = useState<MixedShipmentExpression>({
|
||||
kind: 'and',
|
||||
@@ -145,6 +152,7 @@ export function Truthtrance({
|
||||
claim: planClaim,
|
||||
}
|
||||
: kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' }
|
||||
: kind === 'mixedMovement' ? { kind, target, turn: g.turn, mixed: mixedMovement }
|
||||
: kind === 'movementExpression' ? { kind, target, turn: g.turn, claim: movementExpression }
|
||||
: kind === 'movement' ? { kind, target, ...movement }
|
||||
: kind === 'revivalExpression' ? { kind, target, turn: g.turn, claim: revivalExpression }
|
||||
@@ -310,6 +318,13 @@ export function Truthtrance({
|
||||
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
|
||||
</div>)}
|
||||
</>
|
||||
) : w.question!.kind === 'mixedMovement' ? (
|
||||
<>
|
||||
<p className="notice">This answers the whole current-fact and future-movement question. Facts are fixed at this answer; later card or spice changes do not rewrite it. Unknown facts require an answer you can guarantee through the remaining legal movement. Individual fact answers and compiled branches are not published.</p>
|
||||
{(g.truthMixedMovementAnswers ?? []).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 === '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>
|
||||
@@ -435,6 +450,7 @@ export function Truthtrance({
|
||||
</option>
|
||||
<option value="movement">Bind ground movement this turn</option>
|
||||
<option value="movementExpression">Combine ground moves with AND/OR</option>
|
||||
<option value="mixedMovement">Combine current facts and future ground moves</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>
|
||||
@@ -523,6 +539,14 @@ 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 === 'mixedMovement' ? (
|
||||
<>
|
||||
<MixedMovementFields value={mixedMovement} game={g} target={target}
|
||||
disabled={busy || !g.truthMixedMovementTargets.includes(target)} onChange={setMixedMovement} />
|
||||
<p className="fine">Current facts freeze when answered. Only future actual physical ground moves can fulfill movement conditions; smaller groups never add together. No negates the whole mixed question. Native entitlements, owned preparations and earlier obligations remain.</p>
|
||||
{!g.truthMixedMovementTargets.includes(target) && <p className="notice">Choose a supported unused movement opportunity and finish pending decisions. Optional modules remain unfinished.</p>}
|
||||
<p className="notice" aria-label="Public mixed movement question preview">{mixedMovementError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||
</>
|
||||
) : kind === 'movementExpression' ? (
|
||||
<>
|
||||
<MovementExpressionFields value={movementExpression} territories={TERRITORIES}
|
||||
@@ -623,6 +647,7 @@ export function Truthtrance({
|
||||
{!['fact', 'battlePlan', 'shipment'].includes(kind) && button(
|
||||
'Ask publicly',
|
||||
{ type: 'truthAsk', question },
|
||||
(kind === 'mixedMovement' && (!!mixedMovementError || !g.truthMixedMovementTargets.includes(target))) ||
|
||||
(kind === 'movementExpression' && (!!movementExpressionError || !g.truthMovementExpressionTargets.includes(target))) ||
|
||||
(kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) ||
|
||||
(kind === 'revivalExpression' && (!!revivalExpressionInputError(revivalExpression) || !validRevivalExpressionNames || !g.truthRevivalExpressionTargets.includes(target))) ||
|
||||
|
||||
Reference in new issue
Block a user