Combine current facts with native bribe payments
This commit is contained in:
1 parent
b63a60da64
commit
6b2dfb84a2
23 files changed
+1445
-170
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { bribeExpressionRecipients, bribeExpressionText, liveBribeExpressionPromises } from '@/game/bribe-expression-promises';
|
||||
import { liveBribePromises } from '@/game/bribe-promises';
|
||||
import { BribePromiseGuideButton } from './bribe-promises';
|
||||
|
||||
type BribeExpressionPromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
|
||||
@@ -37,6 +38,6 @@ export function BribeExpressionPromises(props: BribeExpressionPromiseProps) {
|
||||
? 'Late payments still honor this turn’s answers; their recipients collect at next turn’s Mentat opening.'
|
||||
: 'Your next Mentat opening is still ahead. Accepted payments count now and their recipients collect at that opening.'}
|
||||
{' '}No stays binding until the actual turn ends; opening collection does not end it.</p>}
|
||||
{!game.bribePromises.some(promise => promise.player === game.me) && <BribePromiseGuideButton {...props} />}
|
||||
{live && !liveBribePromises(game.bribePromises, game.me, game.turn).length && <BribePromiseGuideButton {...props} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -25,7 +25,9 @@ export function BribePromiseGuideButton(props: BribePromiseProps) {
|
||||
current.bribePromiseGuide === guide && current.bribePromiseGuide.action === action && JSON.stringify(action) === signature &&
|
||||
action.type === 'bribe' && typeof event === 'string' &&
|
||||
(liveBribePromises(current.bribePromises, actor, turn).some(promise => promise.event === event) ||
|
||||
liveBribeExpressionPromises(current.bribeExpressionPromises, actor, turn).some(promise => promise.event === event)) &&
|
||||
liveBribeExpressionPromises(current.bribeExpressionPromises, actor, turn).some(promise => promise.event === event) ||
|
||||
current.mixedBribePromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
|
||||
!promise.fulfilled && !promise.released)) &&
|
||||
!!me && !me.autopilot && !!target && target.id !== actor && target.id !== me.ally &&
|
||||
options.blocked === null && Number.isSafeInteger(options.available) &&
|
||||
typeof action.amount === 'number' && action.amount <= options.available &&
|
||||
@@ -64,6 +66,6 @@ export function BribePromises(props: BribePromiseProps) {
|
||||
Ordinary bribes can be paid out of turn when the current bribe controls permit them; original timing and payment rules still apply.
|
||||
{' '}<a href="/rules#truthtrance-bribe">Bribe promise rules</a>.
|
||||
</p>
|
||||
<BribePromiseGuideButton {...props} />
|
||||
{liveBribePromises(own, props.game.me, props.game.turn).length > 0 && <BribePromiseGuideButton {...props} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { ShipmentPromises } from './shipment-promises';
|
||||
import { AlliancePromises } from './alliance-promises';
|
||||
import { BribePromises } from './bribe-promises';
|
||||
import { BribeExpressionPromises } from './bribe-expression-promises';
|
||||
import { MixedBribePromises } from './mixed-bribe-promises';
|
||||
import { RevivalPromises } from './revival-promises';
|
||||
import { RevivalExpressionPromises } from './revival-expression-promises';
|
||||
import { LeaderRevivalPromises } from './leader-revival-promises';
|
||||
@@ -2043,6 +2044,7 @@ export function GameTable({
|
||||
)}
|
||||
{g.status === 'playing' && <BribePromises game={g} send={act} busy={busy} />}
|
||||
{g.status === 'playing' && <BribeExpressionPromises game={g} send={act} busy={busy} />}
|
||||
{g.status === 'playing' && <MixedBribePromises game={g} send={act} busy={busy} />}
|
||||
{g.status === 'playing' && <AlliancePromises game={g} send={act} busy={busy} />}
|
||||
{g.status === 'playing' && <RevivalExpressionPromises game={g} send={act} busy={busy} />}
|
||||
{g.status === 'playing' && <LeaderRevivalPromises game={g} send={act} busy={busy} />}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import type { GameView } from '@/game/engine';
|
||||
import { parseMixedActionExpression, type MixedActionExpression } from '@/game/mixed-action-question';
|
||||
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';
|
||||
|
||||
export type MixedActionConfig<A> = {
|
||||
prefix: string;
|
||||
legend: string;
|
||||
actionLabel: string;
|
||||
actionTiming: string;
|
||||
actionHelp: ReactNode;
|
||||
fallbackError: string;
|
||||
maxLeaves: number;
|
||||
maxDepth: number;
|
||||
newAction: () => A;
|
||||
parseAction: (value: unknown, game: GameView, target: string) => A;
|
||||
renderAction: (props: { value: A; id: string; game: GameView; target: string;
|
||||
disabled: boolean; onChange: (action: A) => void }) => ReactNode;
|
||||
};
|
||||
|
||||
const newFact = <A,>(): MixedActionExpression<A> => ({ kind: 'fact', fact: { kind: 'hand', name: 'Shield' } });
|
||||
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<A>(node: MixedActionExpression<A>): { 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<A>(node: MixedActionExpression<A>, config: MixedActionConfig<A>): string | null {
|
||||
const size = sizeOf(node);
|
||||
if (size.leaves > config.maxLeaves || size.depth > config.maxDepth)
|
||||
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, config);
|
||||
if (error) return error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Current-fact eligibility and each action family's parser share one envelope. */
|
||||
export function mixedActionInputError<A>(value: unknown, game: GameView, target: string,
|
||||
config: MixedActionConfig<A>): 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;
|
||||
}, input => config.parseAction(input, game, target), { maxDepth: config.maxDepth, maxLeaves: config.maxLeaves });
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : config.fallbackError;
|
||||
}
|
||||
}
|
||||
|
||||
/** Preserve the complete original tree, including incomplete numeric drafts.
|
||||
* Structural changes never evaluate current facts or select a future branch. */
|
||||
export function MixedActionFields<A>({ value, game, target, disabled, onChange, config }: {
|
||||
value: MixedActionExpression<A>;
|
||||
game: GameView;
|
||||
target: string;
|
||||
disabled: boolean;
|
||||
onChange: (expression: MixedActionExpression<A>) => void;
|
||||
config: MixedActionConfig<A>;
|
||||
}) {
|
||||
const newAction = (): MixedActionExpression<A> => ({ kind: 'action', action: config.newAction() });
|
||||
const attrs = (suffix: string, value: string) => ({ [`data-${config.prefix}-${suffix}`]: value });
|
||||
const size = sizeOf(value), error = mixedActionInputError(value, game, target, config);
|
||||
const commit = (next: MixedActionExpression<A>) => {
|
||||
if (!disabled && !structuralError(next, config)) onChange(next);
|
||||
};
|
||||
const renderFact = (fact: TruthFact, path: string, replace: (next: TruthFact) => MixedActionExpression<A>,
|
||||
remove?: () => MixedActionExpression<A>): ReactElement => {
|
||||
const allowed = (next: TruthFact) => !disabled && !structuralError(replace(next), config);
|
||||
const change = (next: TruthFact) => commit(replace(next));
|
||||
const wrap = (kind: 'and' | 'or'): TruthFact => ({ kind, terms: [fact, newFactLeaf()] });
|
||||
return <fieldset key={path} {...attrs('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-${config.prefix}-${path}-fact-meaning`}>Current-fact group {path} meaning</label>
|
||||
<select id={`truth-${config.prefix}-${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" {...attrs('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" {...attrs('control', `fact-${kind}-${path}`)}
|
||||
disabled={!allowed(wrap(kind))} onClick={() => change(wrap(kind))}>Group current facts with {kind.toUpperCase()}</Button>)}
|
||||
{remove && <Button type="button" {...attrs('control', `fact-remove-${path}`)} disabled={disabled}
|
||||
onClick={() => commit(remove())}>Remove current fact</Button>}
|
||||
</div>
|
||||
</fieldset>;
|
||||
};
|
||||
const render = (node: MixedActionExpression<A>, path: string, replace: (next: MixedActionExpression<A>) => MixedActionExpression<A>,
|
||||
remove?: () => MixedActionExpression<A>): ReactElement => {
|
||||
const id = `truth-${config.prefix}-${path}`;
|
||||
const allowed = (next: MixedActionExpression<A>) => !disabled && !structuralError(replace(next), config);
|
||||
const change = (next: MixedActionExpression<A>) => commit(replace(next));
|
||||
const wrap = (kind: 'and' | 'or'): MixedActionExpression<A> => ({ kind, terms: [node, node.kind === 'fact' ? newAction() : newFact<A>()] });
|
||||
return <fieldset key={path} {...attrs('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" {...attrs('control', `add-fact-${path}`)}
|
||||
disabled={!allowed({ ...node, terms: [...node.terms, newFact<A>()] })}
|
||||
onClick={() => change({ ...node, terms: [...node.terms, newFact<A>()] })}>Add current fact</Button>
|
||||
<Button type="button" {...attrs('control', `add-action-${path}`)}
|
||||
disabled={!allowed({ ...node, terms: [...node.terms, newAction()] })}
|
||||
onClick={() => change({ ...node, terms: [...node.terms, newAction()] })}>Add {config.actionLabel}</Button>
|
||||
{(['and', 'or'] as const).map(kind => {
|
||||
const next: MixedActionExpression<A> = { ...node, terms: [...node.terms, { kind, terms: [newFact<A>(), newAction()] }] };
|
||||
return <Button key={kind} type="button" {...attrs('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<A>());
|
||||
if (event.currentTarget.value === 'action') change(newAction());
|
||||
}}><option value="fact">Current fact · at the answer</option><option value="action">{config.actionTiming}</option></select>
|
||||
{node.kind === 'fact' ? renderFact(node.fact, path, fact => replace({ kind: 'fact', fact })) : node.kind === 'action' ? config.renderAction({ value: node.action, id, game, target, disabled,
|
||||
onChange: action => change({ ...node, action }) }) : null}
|
||||
</>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['and', 'or'] as const).map(kind => <Button key={kind} type="button" {...attrs('control', `${kind}-${path}`)}
|
||||
disabled={!allowed(wrap(kind))} onClick={() => change(wrap(kind))}>Group with {kind.toUpperCase()}</Button>)}
|
||||
{remove && <Button type="button" {...attrs('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={`${config.prefix}-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>{config.legend}</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">{config.actionHelp}</p>
|
||||
<p className="fine">App input limits: {config.maxLeaves} conditions and {config.maxDepth} levels of grouping.
|
||||
These are input limits, not additional printed rules.</p>
|
||||
<p className="fine">{size.leaves} / {config.maxLeaves} conditions · {size.depth} / {config.maxDepth} levels of grouping</p>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{render(value, '1', next => next)}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import type { GameView } from '@/game/engine';
|
||||
import type { MixedActionExpression } from '@/game/mixed-action-question';
|
||||
import { BRIBE_EXPRESSION_MAX_DEPTH, BRIBE_EXPRESSION_MAX_LEAVES } from '@/game/bribe-expression-promises';
|
||||
import { parseBribePaymentClaim, type BribePaymentClaim } from '@/game/mixed-bribe-question';
|
||||
import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
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<BribePaymentClaim> = {
|
||||
prefix: 'mixed-bribe',
|
||||
legend: 'Mixed current-fact / future ordinary bribe claim',
|
||||
actionLabel: 'ordinary bribe condition',
|
||||
actionTiming: 'Future ordinary bribes · this turn',
|
||||
actionHelp: <>Count cumulative future ordinary bribe payments after the answer this turn; smaller payments to the same recipient add together.
|
||||
Prior 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 the start of next turn's Mentat Pause. Original payment and timing rules still apply.</>,
|
||||
fallbackError: 'Choose a current fact and a future ordinary bribe condition.',
|
||||
maxLeaves: BRIBE_EXPRESSION_MAX_LEAVES,
|
||||
maxDepth: BRIBE_EXPRESSION_MAX_DEPTH,
|
||||
newAction: () => ({ recipient: '', minimum: 1 }),
|
||||
parseAction: (value, game, target) => {
|
||||
const action = parseBribePaymentClaim(value);
|
||||
if (!recipients(game, target).some(recipient => recipient.id === action.recipient))
|
||||
throw new Error('Choose a public non-allied recipient for the future ordinary bribe.');
|
||||
return action;
|
||||
},
|
||||
renderAction: ({ value, id, game, target, disabled, onChange }) => {
|
||||
const choices = recipients(game, target), validRecipient = choices.some(recipient => recipient.id === value.recipient);
|
||||
return <>
|
||||
<label htmlFor={`${id}-recipient`}>Bribe recipient</label>
|
||||
<select id={`${id}-recipient`} value={validRecipient ? value.recipient : ''} disabled={disabled} aria-invalid={!validRecipient}
|
||||
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}-minimum`}>Minimum future ordinary bribe payments in spice</label>
|
||||
<Input id={`${id}-minimum`} type="number" min={1} max={Number.MAX_SAFE_INTEGER} step={1} required disabled={disabled}
|
||||
value={Number.isFinite(value.minimum) ? value.minimum : ''}
|
||||
aria-invalid={!Number.isSafeInteger(value.minimum) || value.minimum < 1}
|
||||
onChange={event => onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} />
|
||||
</>;
|
||||
},
|
||||
};
|
||||
|
||||
export function mixedBribeInputError(value: unknown, game: GameView, target: string): string | null {
|
||||
return mixedActionInputError(value, game, target, config);
|
||||
}
|
||||
export function MixedBribeFields(props: {
|
||||
value: MixedActionExpression<BribePaymentClaim>;
|
||||
game: GameView;
|
||||
target: string;
|
||||
disabled: boolean;
|
||||
onChange: (expression: MixedActionExpression<BribePaymentClaim>) => void;
|
||||
}) {
|
||||
return MixedActionFields({ ...props, config });
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import type { MixedActionExpression } from '@/game/mixed-action-question';
|
||||
import type { BribePaymentClaim } from '@/game/mixed-bribe-question';
|
||||
import { liveBribePromises } from '@/game/bribe-promises';
|
||||
import { liveBribeExpressionPromises } from '@/game/bribe-expression-promises';
|
||||
import { truthQuestionText } from '@/game/truthtrance';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
import { BribePromiseGuideButton } from './bribe-promises';
|
||||
|
||||
function recipients(mixed: MixedActionExpression<BribePaymentClaim>): string[] {
|
||||
const found = new Set<string>();
|
||||
const visit = (node: MixedActionExpression<BribePaymentClaim>): void => {
|
||||
if (node.kind === 'action') found.add(node.action.recipient);
|
||||
else if (node.kind === 'and' || node.kind === 'or') node.terms.forEach(visit);
|
||||
};
|
||||
visit(mixed);
|
||||
return [...found];
|
||||
}
|
||||
|
||||
/** Only the original question, aggregate answer and cumulative payments are
|
||||
* public. Never evaluate facts or inspect the private compiled commitment. */
|
||||
export function MixedBribePromises(props: { game: GameView; busy: boolean; send: (action: Action) => void }) {
|
||||
const game = props.game, own = game.mixedBribePromises.filter(promise => promise.player === game.me);
|
||||
if (!own.length) return null;
|
||||
const name = (id: string) => game.players.find(player => player.id === id)?.name ?? 'the named recipient';
|
||||
const live = own.some(promise => promise.turn === game.turn && !promise.fulfilled && !promise.released);
|
||||
const sharedGuideElsewhere = liveBribePromises(game.bribePromises, game.me, game.turn).length > 0 ||
|
||||
liveBribeExpressionPromises(game.bribeExpressionPromises, game.me, game.turn).length > 0;
|
||||
return <section className="notice space-y-3" aria-label="Your Truthtrance mixed bribe promises" id="mixed-bribe-promise-guidance">
|
||||
<h3>Your mixed bribe promises</h3>
|
||||
<ul>{own.map(promise => <li key={promise.event}>
|
||||
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'mixedBribe', 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, name)}
|
||||
{' '}Recorded future ordinary payments: {recipients(promise.mixed).map(recipient =>
|
||||
`${name(recipient)}: ${promise.progress.find(progress => progress.recipient === recipient)?.amount ?? 0} spice`).join('; ')}.
|
||||
{promise.released ? ' Released: this whole commitment is no longer possible.'
|
||||
: promise.fulfilled ? ' Fulfilled: this whole commitment is complete.' : ' This whole answer is binding for this turn.'}
|
||||
</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">Smaller payments to the same recipient add together after the answer. Prior payments, gifts and ally aid do not count.
|
||||
Preserve the spendable spice needed for your Yes answers.
|
||||
The sender pays immediately. The recipient collects the payment at the next Mentat opening.
|
||||
Payments made during Mentat Pause wait until the start of next turn's Mentat Pause.
|
||||
Collection does not count the payment a second time.
|
||||
Ordinary bribes can be paid out of turn when the current bribe controls permit them; original timing and payment rules still apply.
|
||||
{' '}<a href="/rules#truthtrance-mixed-bribe">Mixed bribe rules</a>.</p>
|
||||
{live && <p>{game.phase === 8
|
||||
? 'Late payments still honor this turn’s answers; their recipients collect at next turn’s Mentat opening.'
|
||||
: 'Your next Mentat opening is still ahead. Accepted payments count now and their recipients collect at that opening.'}
|
||||
{' '}No stays binding until the actual turn ends; opening collection does not end it.</p>}
|
||||
{live && !sharedGuideElsewhere && <BribePromiseGuideButton {...props} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,181 +1,52 @@
|
||||
'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 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 { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields';
|
||||
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;
|
||||
}
|
||||
const config: MixedActionConfig<MovementClaim> = {
|
||||
prefix: 'mixed-movement',
|
||||
legend: 'Mixed current-fact / future ground-movement claim',
|
||||
actionLabel: 'ground-movement condition',
|
||||
actionTiming: 'Future ground move · this turn',
|
||||
actionHelp: <>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.</>,
|
||||
fallbackError: 'Choose a current fact and a future ground-movement condition.',
|
||||
maxLeaves: MOVEMENT_EXPRESSION_MAX_LEAVES,
|
||||
maxDepth: MOVEMENT_EXPRESSION_MAX_DEPTH,
|
||||
newAction: () => ({ territory: 'carthag', minimum: 1 }),
|
||||
parseAction: parseMovementClaim,
|
||||
renderAction: ({ value, id, disabled, onChange }) => <>
|
||||
<label htmlFor={`${id}-territory`}>Ground-movement destination</label>
|
||||
<select id={`${id}-territory`} value={TERRITORIES.some(destination => destination.id === value.territory) ? value.territory : ''}
|
||||
disabled={disabled} aria-invalid={!TERRITORIES.some(destination => destination.id === value.territory)}
|
||||
onChange={event => onChange({ ...value, territory: event.currentTarget.value })}>
|
||||
<option value="">Choose a printed Arrakis territory</option>
|
||||
{TERRITORIES.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
|
||||
</select>
|
||||
<label htmlFor={`${id}-minimum`}>Minimum physical forces in one ground move</label>
|
||||
<Input id={`${id}-minimum`} type="number" min={1} max={20} step={1} required disabled={disabled}
|
||||
value={Number.isNaN(value.minimum) ? '' : value.minimum}
|
||||
aria-invalid={!Number.isSafeInteger(value.minimum) || value.minimum < 1 || value.minimum > 20}
|
||||
onChange={event => onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} />
|
||||
</>,
|
||||
};
|
||||
|
||||
/** Draft feedback reuses the existing fact fields. The server remains the
|
||||
* authority for current-fact eligibility and legal future movement routes. */
|
||||
/** Keep the movement API and rendered control identifiers stable. */
|
||||
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.';
|
||||
}
|
||||
return mixedActionInputError(value, game, target, config);
|
||||
}
|
||||
|
||||
/** 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 }: {
|
||||
export function MixedMovementFields(props: {
|
||||
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>;
|
||||
return MixedActionFields({ ...props, config });
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { MixedBribeFields, mixedBribeInputError } from './mixed-bribe-fields';
|
||||
import type { BribePaymentClaim } from '@/game/mixed-bribe-question';
|
||||
import { MixedMovementFields, mixedMovementInputError } from './mixed-movement-fields';
|
||||
import type { MixedActionExpression } from '@/game/mixed-action-question';
|
||||
import { MovementExpressionFields, movementExpressionInputError } from './movement-expression-fields';
|
||||
@@ -73,7 +75,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'
|
||||
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe'
|
||||
>('fact');
|
||||
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
|
||||
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
|
||||
@@ -116,6 +118,11 @@ export function Truthtrance({
|
||||
{ kind: 'action', action: { territory: 'carthag', minimum: 1 } },
|
||||
] });
|
||||
const mixedMovementError = mixedMovementInputError(mixedMovement, g, target);
|
||||
const [mixedBribe, setMixedBribe] = useState<MixedActionExpression<BribePaymentClaim>>({ kind: 'and', terms: [
|
||||
{ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 10 } },
|
||||
{ kind: 'action', action: { recipient: g.me, minimum: 3 } },
|
||||
] });
|
||||
const mixedBribeError = mixedBribeInputError(mixedBribe, g, target);
|
||||
const [shipment, setShipment] = useState<ShipmentExpression>({ territory: 'carthag', minimum: 6 });
|
||||
const [mixed, setMixed] = useState<MixedShipmentExpression>({
|
||||
kind: 'and',
|
||||
@@ -158,6 +165,7 @@ export function Truthtrance({
|
||||
: kind === 'revivalExpression' ? { kind, target, turn: g.turn, claim: revivalExpression }
|
||||
: kind === 'leaderRevival' ? { kind, target, ...leaderRevivalClaim }
|
||||
: kind === 'alliance' ? { kind, target, ...allianceClaim }
|
||||
: kind === 'mixedBribe' ? { kind, target, turn: g.turn, mixed: mixedBribe }
|
||||
: kind === 'bribeExpression' ? { kind, target, turn: g.turn, claim: bribeExpression }
|
||||
: kind === 'bribe' ? { kind, target, ...bribeClaim }
|
||||
: kind === 'revival' ? { kind, target, ...revivalClaim }
|
||||
@@ -290,6 +298,13 @@ export function Truthtrance({
|
||||
{button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}
|
||||
</div>)}
|
||||
</>
|
||||
) : w.question!.kind === 'mixedBribe' ? (
|
||||
<>
|
||||
<p className="notice">This answers the whole current-fact and future-payment question. Facts freeze at this answer; later spending does not rewrite them. Unknown facts require a guaranteed whole answer. Only actual future ordinary payer payments count; receipts remain deferred until their next Mentat opening. Individual fact answers and reduced obligations are not published.</p>
|
||||
{(g.truthMixedBribeAnswers ?? []).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 === 'bribeExpression' ? (
|
||||
<>
|
||||
<p className="notice">Yes requires the whole AND/OR question to become true through your future ordinary payments. No keeps the whole expression false, and may permit some payments. Earlier payments and later recipient collection do not count. Keep the funds needed by every answer; late Mentat receipts wait for next turn's opening.</p>
|
||||
@@ -454,6 +469,7 @@ export function Truthtrance({
|
||||
<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>
|
||||
<option value="mixedBribe">Combine current facts and ordinary payments</option>
|
||||
<option value="revivalExpression">Bind combined force and leader revivals</option>
|
||||
<option value="leaderRevival">Bind a named leader revival this turn</option>
|
||||
<option value="revival">Bind ordinary force revivals this turn</option>
|
||||
@@ -498,6 +514,14 @@ export function Truthtrance({
|
||||
{!g.truthAllianceTargets.includes(target) && <p className="notice">Choose a supported participant in a quiet open ordinary Nexus. Optional modules and broader alliance questions remain unfinished.</p>}
|
||||
<p className="notice" aria-label="Public alliance question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||
</>
|
||||
) : kind === 'mixedBribe' ? (
|
||||
<>
|
||||
<MixedBribeFields value={mixedBribe} game={g} target={target}
|
||||
disabled={busy || !g.truthMixedBribeTargets.includes(target)} onChange={setMixedBribe} />
|
||||
<p className="fine">Current facts freeze when answered. Later ordinary payer payments add together until this turn ends. No negates the whole mixed question. Current funds and earlier promises stay available; late Mentat receipts wait until next turn's opening.</p>
|
||||
{!g.truthMixedBribeTargets.includes(target) && <p className="notice">Choose a supported quiet native window and finish pending decisions. Optional modules remain unfinished.</p>}
|
||||
<p className="notice" aria-label="Public mixed bribe question preview">{mixedBribeError ?? truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||
</>
|
||||
) : kind === 'bribeExpression' ? (
|
||||
<>
|
||||
<BribeExpressionFields value={bribeExpression} recipients={bribeRecipients}
|
||||
@@ -653,6 +677,7 @@ export function Truthtrance({
|
||||
(kind === 'revivalExpression' && (!!revivalExpressionInputError(revivalExpression) || !validRevivalExpressionNames || !g.truthRevivalExpressionTargets.includes(target))) ||
|
||||
(kind === 'leaderRevival' && (!!leaderRevivalClaimInputError(leaderRevivalClaim, leaderRevivalChoices) || !g.truthLeaderRevivalTargets.includes(target))) ||
|
||||
(kind === 'alliance' && (!!allianceClaimInputError(allianceClaim, alliancePartners) || !g.truthAllianceTargets.includes(target))) ||
|
||||
(kind === 'mixedBribe' && (!!mixedBribeError || !g.truthMixedBribeTargets.includes(target))) ||
|
||||
(kind === 'bribeExpression' && (!!bribeExpressionError || !validBribeExpressionRecipients || !g.truthBribeExpressionTargets.includes(target))) ||
|
||||
(kind === 'bribe' && (!!bribeClaimInputError(bribeClaim, bribeRecipients) || !g.truthBribeTargets.includes(target))) ||
|
||||
(kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) ||
|
||||
|
||||
Reference in new issue
Block a user