Support whole AND/OR ordinary bribe commitments
This commit is contained in:
1 parent
c81e00e6dd
commit
0e3c155876
21 files changed
+1778
-111
No files matched your search
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import {
|
||||
parseBribeExpression, BRIBE_EXPRESSION_MAX_DEPTH, BRIBE_EXPRESSION_MAX_LEAVES,
|
||||
type BribeExpression,
|
||||
} from '@/game/bribe-expression-promises';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
type Recipient = { id: string; name: string };
|
||||
// Every new condition requires the author to choose its public recipient.
|
||||
const newLeaf = (): BribeExpression => ({ kind: 'payment', recipient: '', minimum: 1 });
|
||||
export function bribeExpressionInputError(value: unknown): string | null {
|
||||
try { parseBribeExpression(value); return null; }
|
||||
catch (error) { return error instanceof Error ? error.message : 'Choose a valid ordinary bribe expression.'; }
|
||||
}
|
||||
|
||||
// Detached placeholders validate only the structure. They allow empty recipient
|
||||
// choices and invalid numeric drafts to remain visible while the author repairs
|
||||
// them; the real tree is always passed intact to the parent.
|
||||
const structuralDraft = (node: BribeExpression): BribeExpression => node.kind === 'payment'
|
||||
? { ...node, recipient: 'recipient', minimum: 1 }
|
||||
: { ...node, terms: node.terms.map(structuralDraft) };
|
||||
const inputSize = (node: BribeExpression): { leaves: number; depth: number } => {
|
||||
if (node.kind !== 'and' && node.kind !== 'or') 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 editor. The server owns recipient eligibility, funding
|
||||
* and compliance with the whole answer. */
|
||||
export function BribeExpressionFields({ value, recipients, disabled, onChange }: {
|
||||
value: BribeExpression;
|
||||
recipients: readonly Recipient[];
|
||||
disabled: boolean;
|
||||
onChange: (expression: BribeExpression) => void;
|
||||
}) {
|
||||
const size = inputSize(value), error = bribeExpressionInputError(value);
|
||||
const commit = (next: BribeExpression) => {
|
||||
if (!disabled && !bribeExpressionInputError(structuralDraft(next))) onChange(next);
|
||||
};
|
||||
const render = (node: BribeExpression, path: string, replace: (next: BribeExpression) => BribeExpression,
|
||||
remove?: () => BribeExpression): ReactElement => {
|
||||
const group = node.kind !== 'payment';
|
||||
const id = `truth-bribe-expression-${path}`;
|
||||
const allowed = (next: BribeExpression) => !disabled && !bribeExpressionInputError(structuralDraft(replace(next)));
|
||||
const change = (next: BribeExpression) => commit(replace(next));
|
||||
const wrap = (kind: 'and' | 'or'): BribeExpression => ({ kind, terms: [node, newLeaf()] });
|
||||
return <fieldset key={path} data-bribe-expression-path={path} className="grid min-w-0 gap-2 p-2">
|
||||
<legend>{group ? `Bribe group ${path} · ${node.kind.toUpperCase()}` : `Bribe condition ${path}`}</legend>
|
||||
{group ? <>
|
||||
<label htmlFor={`${id}-meaning`}>Bribe 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-bribe-expression-control={`add-${path}`}
|
||||
disabled={!allowed({ ...node, terms: [...node.terms, newLeaf()] })}
|
||||
onClick={() => change({ ...node, terms: [...node.terms, newLeaf()] })}>Add bribe condition</Button>
|
||||
{(['and', 'or'] as const).map(kind => {
|
||||
const next: BribeExpression = { ...node, terms: [...node.terms, { kind, terms: [newLeaf(), newLeaf()] }] };
|
||||
return <Button key={kind} type="button" data-bribe-expression-control={`nested-${kind}-${path}`}
|
||||
disabled={!allowed(next)} onClick={() => change(next)}>Add nested {kind.toUpperCase()} group</Button>;
|
||||
})}
|
||||
</div>
|
||||
</> : <>
|
||||
<label htmlFor={`${id}-recipient`}>Bribe recipient</label>
|
||||
<select id={`${id}-recipient`} value={recipients.some(recipient => recipient.id === node.recipient) ? node.recipient : ''}
|
||||
disabled={disabled} aria-invalid={!recipients.some(recipient => recipient.id === node.recipient)} onChange={event => {
|
||||
const recipient = event.currentTarget.value;
|
||||
if (recipients.some(choice => choice.id === recipient)) change({ ...node, recipient });
|
||||
}}>
|
||||
<option value="">Choose a recipient</option>
|
||||
{recipients.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(node.minimum) ? node.minimum : ''}
|
||||
aria-invalid={!!bribeExpressionInputError(node)} onChange={event => change({ ...node, minimum: event.currentTarget.valueAsNumber })} />
|
||||
</>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['and', 'or'] as const).map(kind => <Button key={kind} type="button" data-bribe-expression-control={`${kind}-${path}`}
|
||||
disabled={!allowed(wrap(kind))} onClick={() => change(wrap(kind))}>Group with {kind.toUpperCase()}</Button>)}
|
||||
{remove && <Button type="button" data-bribe-expression-control={`remove-${path}`} disabled={disabled}
|
||||
onClick={() => commit(remove())}>Remove {group ? 'group' : 'condition'}</Button>}
|
||||
</div>
|
||||
</fieldset>;
|
||||
};
|
||||
return <fieldset disabled={disabled} data-testid="bribe-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>Ordinary bribe 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.
|
||||
Count cumulative future ordinary bribe payments after the answer this turn; smaller payments add together.
|
||||
Earlier payments, gifts and ally aid do not count.
|
||||
</p>
|
||||
<p className="fine">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.</p>
|
||||
<p className="fine">App input limits: {BRIBE_EXPRESSION_MAX_LEAVES} conditions and {BRIBE_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} / {BRIBE_EXPRESSION_MAX_LEAVES} conditions · {size.depth} / {BRIBE_EXPRESSION_MAX_DEPTH} levels of grouping</p>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{render(value, '1', next => next)}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { bribeExpressionRecipients, bribeExpressionText, liveBribeExpressionPromises } from '@/game/bribe-expression-promises';
|
||||
import { BribePromiseGuideButton } from './bribe-promises';
|
||||
|
||||
type BribeExpressionPromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
|
||||
|
||||
export function BribeExpressionPromises(props: BribeExpressionPromiseProps) {
|
||||
const game = props.game, own = game.bribeExpressionPromises.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 = liveBribeExpressionPromises(own, game.me, game.turn).length > 0;
|
||||
return <section className="notice space-y-3" aria-label="Your Truthtrance bribe expression promises" id="bribe-expression-promise-guidance">
|
||||
<h3>Your bribe expression promises</h3>
|
||||
<ul>{own.map(promise => <li key={promise.event}>
|
||||
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {bribeExpressionText(promise.claim, name)}.
|
||||
{' '}Recorded future ordinary payments: {bribeExpressionRecipients(promise.claim).map(recipient =>
|
||||
`${name(recipient)}: ${promise.progress.find(progress => progress.recipient === recipient)?.amount ?? 0} spice`).join('; ')}.
|
||||
{promise.released ? ' Released: this commitment is no longer possible.'
|
||||
: promise.fulfilled ? ' Fulfilled: this commitment is complete.' : ' This whole answer is binding for this turn.'}
|
||||
</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.
|
||||
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.
|
||||
</p>
|
||||
<p className="fine">
|
||||
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-bribe-expression">Bribe expression 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>}
|
||||
{!game.bribePromises.some(promise => promise.player === game.me) && <BribePromiseGuideButton {...props} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { bribeAction } from '@/game/bribe-options';
|
||||
import { bribeClaimText, bribePromiseModeSupported, liveBribePromises } from '@/game/bribe-promises';
|
||||
import { liveBribeExpressionPromises } from '@/game/bribe-expression-promises';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type BribePromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
|
||||
@@ -23,7 +24,8 @@ export function BribePromiseGuideButton(props: BribePromiseProps) {
|
||||
current.turn === turn && current.me === actor &&
|
||||
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) &&
|
||||
(liveBribePromises(current.bribePromises, actor, turn).some(promise => promise.event === event) ||
|
||||
liveBribeExpressionPromises(current.bribeExpressionPromises, actor, turn).some(promise => promise.event === event)) &&
|
||||
!!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 &&
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { ShipmentPromises } from './shipment-promises';
|
||||
import { AlliancePromises } from './alliance-promises';
|
||||
import { BribePromises } from './bribe-promises';
|
||||
import { BribeExpressionPromises } from './bribe-expression-promises';
|
||||
import { RevivalPromises } from './revival-promises';
|
||||
import { RevivalExpressionPromises } from './revival-expression-promises';
|
||||
import { LeaderRevivalPromises } from './leader-revival-promises';
|
||||
@@ -2037,6 +2038,7 @@ export function GameTable({
|
||||
<ShipmentPromises game={g} act={act} busy={busy} />
|
||||
)}
|
||||
{g.status === 'playing' && <BribePromises game={g} send={act} busy={busy} />}
|
||||
{g.status === 'playing' && <BribeExpressionPromises 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} />}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { BribeExpressionFields, bribeExpressionInputError } from './bribe-expression-fields';
|
||||
import { bribeExpressionRecipients, type BribeExpression } from '@/game/bribe-expression-promises';
|
||||
import { RevivalExpressionFields, revivalExpressionInputError } from './revival-expression-fields';
|
||||
import type { RevivalExpression } from '@/game/revival-expression-promises';
|
||||
import { LeaderRevivalClaimFields, leaderRevivalClaimInputError } from './leader-revival-claim-fields';
|
||||
@@ -65,7 +68,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'
|
||||
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression'
|
||||
>('fact');
|
||||
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
|
||||
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
|
||||
@@ -87,6 +90,12 @@ export function Truthtrance({
|
||||
const bribeClaim: BribeClaim = { turn: g.turn, ...bribeSelection };
|
||||
const bribeRespondent = g.players.find(player => player.id === target);
|
||||
const bribeRecipients = g.players.filter(player => player.id !== target && player.id !== bribeRespondent?.ally);
|
||||
const [bribeExpression, setBribeExpression] = useState<BribeExpression>({ kind: 'and', terms: [
|
||||
{ kind: 'payment', recipient: g.me, minimum: 3 }, { kind: 'payment', recipient: '', minimum: 2 },
|
||||
] });
|
||||
const bribeExpressionError = bribeExpressionInputError(bribeExpression);
|
||||
const validBribeExpressionRecipients = !bribeExpressionError && bribeExpressionRecipients(bribeExpression)
|
||||
.every(recipient => bribeRecipients.some(player => player.id === recipient));
|
||||
const [revivalSelection, setRevivalSelection] = useState<Pick<RevivalClaim, 'counter' | 'minimum'>>({ counter: 'total', minimum: 1 });
|
||||
const revivalClaim: RevivalClaim = { turn: g.turn, ...revivalSelection };
|
||||
const [bidMinimum, setBidMinimum] = useState(1);
|
||||
@@ -133,6 +142,7 @@ export function Truthtrance({
|
||||
: kind === 'revivalExpression' ? { kind, target, turn: g.turn, claim: revivalExpression }
|
||||
: kind === 'leaderRevival' ? { kind, target, ...leaderRevivalClaim }
|
||||
: kind === 'alliance' ? { kind, target, ...allianceClaim }
|
||||
: kind === 'bribeExpression' ? { kind, target, turn: g.turn, claim: bribeExpression }
|
||||
: kind === 'bribe' ? { kind, target, ...bribeClaim }
|
||||
: kind === 'revival' ? { kind, target, ...revivalClaim }
|
||||
: kind === 'bid' ? { kind, target, ...bidClaim }
|
||||
@@ -264,6 +274,13 @@ export function Truthtrance({
|
||||
{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>
|
||||
{(g.truthBribeExpressionAnswers ?? []).map(answer => <div key={answer}>
|
||||
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
|
||||
</div>)}
|
||||
</>
|
||||
) : w.question!.kind === 'bribe' ? (
|
||||
<>
|
||||
<p className="notice">Yes requires cumulative ordinary bribe payments to this named recipient while possible. No keeps all later payments below the minimum. Earlier payments, gifts and allied aid do not count. You pay now; the recipient collects at the next Mentat opening. Payments during Mentat wait until next turn's opening.</p>
|
||||
@@ -404,6 +421,7 @@ export function Truthtrance({
|
||||
<option value="movement">Bind ground movement this turn</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>
|
||||
<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>
|
||||
@@ -448,6 +466,16 @@ 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 === 'bribeExpression' ? (
|
||||
<>
|
||||
<BribeExpressionFields value={bribeExpression} recipients={bribeRecipients}
|
||||
disabled={busy || !g.truthBribeExpressionTargets.includes(target)} onChange={setBribeExpression} />
|
||||
<p className="fine">Count only ordinary payer payments after this answer until this turn ends. AND requires all conditions; OR permits any full alternative. No negates the whole expression. Original payment costs, non-allied recipients, earlier obligations and deferred receipts remain.</p>
|
||||
{!g.truthBribeExpressionTargets.includes(target) && <p className="notice">Choose a supported participant before this turn ends and finish pending decisions.</p>}
|
||||
<p className="notice" aria-label="Public combined bribe question preview">{bribeExpressionError
|
||||
? bribeExpressionError : !validBribeExpressionRecipients ? 'Choose a non-allied recipient for every condition.'
|
||||
: truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||
</>
|
||||
) : kind === 'bribe' ? (
|
||||
<>
|
||||
<BribeClaimFields value={bribeClaim} recipients={bribeRecipients} disabled={busy || !g.truthBribeTargets.includes(target)}
|
||||
@@ -573,6 +601,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 === 'bribeExpression' && (!!bribeExpressionError || !validBribeExpressionRecipients || !g.truthBribeExpressionTargets.includes(target))) ||
|
||||
(kind === 'bribe' && (!!bribeClaimInputError(bribeClaim, bribeRecipients) || !g.truthBribeTargets.includes(target))) ||
|
||||
(kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) ||
|
||||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
|
||||
|
||||
Reference in new issue
Block a user