Combine current facts with native bribe payments

This commit is contained in:
Quintine committed 2026-10-26 16:39:30 +11:00
1 parent b63a60da64
commit 6b2dfb84a2
23 files changed
+1445 -170

No files matched your search

+2 -1
View File
@@ -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>;
}
+4 -2
View File
@@ -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>;
}
+2
View File
@@ -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} />}
+180
View File
@@ -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>;
}
+64
View File
@@ -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&apos;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 });
}
+60
View File
@@ -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&apos;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>;
}
+33 -162
View File
@@ -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 });
}
+26 -1
View File
@@ -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&apos;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&apos;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))) ||
+33
View File
@@ -16,6 +16,39 @@ open mode gates.
## Current checkpoint and work
**Truthtrance / current facts and ordinary payments — 26 October. Prototyped / Partial.**
Full mixed AND/OR questions now freeze facts before real payer debits, then bind
only the native cumulative payment obligation needed for the whole answer.
Constants require no payment; unknowns retain the conservative guarantee.
Original purse/accepted order/nonallies/timing/receipts and actual turn end stay.
Raw original question/aggregate/progress are own-projected without frozen clause
values or compiled residuals. Movement/payments share the full mixed editor;
movement API/IDs/callbacks stay. Scalar/pure/mixed panels use live ownership for
one exact guide. Private answers and four minimal policies are connected.
Root integration with disjoint controls/native lanes passes **21 files,216/216**,
types/lint/build and **ten native paths**,33 GameTable SSR renders/13 actual
guide callbacks plus two editor callbacks followed by native Ask. Genuine
base33 Basic20/Advanced23 prefixes add22: factual E10 stays binding while H3/A2
debits leave E5; each exact share credits once at its actual opening. Edited
true OR has no payment binding; false AND permits a real payment. Native unknown
OR Yes pays, AND No caps cumulative payments. A32-action Mentat prefix adds37:
only payer readiness reopens and late receipts wait for the next actual opening.
Original H/A/T Ix47 102-action prefixes add16 in both accepted orders: H1/A1
then native leader2 ends T0. The shared movement editor still submits native Ask.
All33/47 cards remain physical; four policies actually answer and pay.
Nineteen native,two native funding and nine controlled cases supplement existing
generic/scalar/grouped/movement coverage. One old historical-scalar guide
expectation now follows live ownership. The first frozen v1 batch passes;
proof/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-bribe-proof-v1/`;
journeys/HTML: adjacent `20261026-truth-mixed-bribe-runtime/`.
No saved-game/database/server/reset/restart/deployment operation is performed.
Other families, correlated unknowns, future preparations, wider compositions,
browser/mobile/deployed and final rules/assurance remain required.
[Authority, original payment boundaries and evidence](TRUTHTRANCE_MIXED_BRIBES.md).
**Truthtrance / current facts and future movement — 26 October. Prototyped / Partial.**
Full mixed AND/OR questions now freeze current facts at actual answer time and
bind only the remaining native ground-movement obligation. Unknown facts use
+47
View File
@@ -1,5 +1,52 @@
# Dune implementation status
## 26 October 2026 — Current facts with ordinary payments and shared mixed authoring
**Prototyped / Partial.** Full mixed fact/payment AND/OR questions freeze current
facts before actual ordinary payer debits. Conservative unknown compilation
creates only the native residual needed by the selected whole answer; decisive
constants require no payment. One physical bribe ledger owns cumulative progress,
funding, accepted order, restrictions, receipts and actual turn-end deadlines.
Raw metadata links the original question/event; pure projection hides residuals
and own mixed projection exposes only raw/aggregate/progress/status. Existing
uniform Yes readiness reopening and new-record-only discard signatures preserve
native timing and earlier record shapes. No old answer is reactivated.
Shared generic mixed fields extract the existing movement tree editor. Its
thin movement adapter retains API/IDs/data attributes/callback traversal while
payment fields provide current nonally recipients and safe integer minima.
Both retain nested facts,16 leaves/depth4, editable invalid drafts, raw preview/
Ask and private answers. Scalar/grouped/mixed panels use live guide ownership;
a fulfilled historical row cannot duplicate or suppress a later payment guide.
Four minimal profiles answer and pay; strategy/calibration remain deferred.
Root integration and disjoint control/native lanes pass **21 files,216/216**,
types/lint/build and **ten native paths**,33 GameTable SSR renders/13 guide
callbacks plus two editor callbacks followed by native Ask. Original H/A/E
base33 Basic20/Advanced23 prefixes add22: actual H3/A2 payment debits reduce E10
to5 without changing its answer-time fact; exact receipts collect once at the
real opening. Edited true OR is immediate Yes; false AND is immediate No while
actual payment stays legal. Native unknown OR Yes pays; unknown AND No caps the
future total. A32-action actual Mentat prefix adds37, reopens only the payer and
earns late receipt at the following opening. Original H/A/T Ix47 102-action
prefixes add16 in either accepted order: real H1/A1 then native leader2 fulfills
both at T0. Shared movement adapter still edits/asks its raw question correctly.
All33/47 original cards remain physical. Four profiles actually answer/pay on
both Basic/Advanced original sources.
Nineteen native,two native funding,nine controlled and existing generic/scalar/
grouped/movement cases cover current debit snapshots, constants/unknowns,
prior intervals, split payments, shared budgets/caps, exact wire/event/ownership,
raw projection and late deadlines. One older controls assertion changes from
historical scalar ownership to live ownership. The first frozen v1 batch passes.
Proof/report/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-bribe-proof-v1/`;
journeys/HTML: adjacent `20261026-truth-mixed-bribe-runtime/`.
No saved-game/database/server/reset/restart/deployment operation is performed.
Other action families, correlated unknowns, future preparation, optional/wider
compositions, final rules/recovery/privacy/custody and browser/deployed remain.
[Contract](TRUTHTRANCE_MIXED_BRIBES.md).
## 26 October 2026 — Current facts with future ground moves
**Prototyped / Partial.** Full mixed questions combine current facts and future
+14
View File
@@ -1,5 +1,19 @@
# Rule decision index
## Current facts combined with ordinary payments — 26 October 2026
Reuse the current-fact/movement snapshot composition and recorded publisher
FAQp8/current-turn/AND/OR plus supplied Advanced physical39 compliance/unknown
text. Facts freeze before real payer debits; unknown takes false for Yes and
true for No. Only an action residual binds native cumulative ordinary payments.
Decisive constant aggregates need none. Raw original question/whole answer are
projected without individual frozen values or compiled branches. Preserve the
original current purse, accepted order, nonallies, pending/Double guards,
opening-only receipts and actual turn end. This remains provisional online
composition, not a dedicated publisher mixed-payment algorithm or a correlated
unknown solver. No mode/save/server/publication gate changes.
[Contract and evidence](TRUTHTRANCE_MIXED_BRIBES.md).
## Current facts combined with future movement — 26 October 2026
Reuse recorded publisher FAQp8 current-turn/AND/OR authority and supplied
+126
View File
@@ -0,0 +1,126 @@
# Truthtrance current facts and ordinary payments
## Mixed aggregate prototype — 26 October 2026
**Prototyped / Partial.** The packet is
`{kind:'mixedBribe',target,turn,mixed}`. Raw fact/action/AND/OR clauses reuse
`MixedActionExpression<BribePaymentClaim>`; a payment leaf contains only
`{recipient,minimum}`. Quiet native Basic/Advanced phases0–8 retain the existing
[ordinary payment](TRUTHTRANCE_BRIBE_EXPRESSIONS.md) scope and all mode gates.
## Authority and snapshot meaning
Reuse the [mixed-movement composition](TRUTHTRANCE_MIXED_MOVEMENT.md), recorded
[publisher November2020 FAQp8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
current-turn/AND/OR authority and supplied Advanced physical39/printed37 public
answer/compliance/release/unknown text. Existing [Mentat timing](MENTAT_BRIBES.md)
and cumulative native payer accounting remain. No source supplies a complete
online solver; current-funded witnesses, snapshot/unknown and accepted-order
release remain provisional under standing14October priority.
Facts freeze at the actual answer. A current-spice fact at10 stays that
answer-time fact after promised payments reduce the same purse to5. Later
spending/card changes create no new fact answer. No negates the entire mixed
expression. Unknown takes false for a definite Yes and true for No, then the
remaining owned payment completion must guarantee the aggregate. Correlations
between unknown predicates are not inferred. When neither definite completion
is known, unknown/save/change-question remains available.
A true held Shield OR payment can settle Yes immediately; false held Hajr AND
payment can settle No immediately. Otherwise the action-only residual uses the
existing cumulative native payment ledger. Smaller payments after the answer
add together per named recipient. Prior payments, gifts, allied aid and later
recipient collection do not count. Different questions keep their own future
intervals; repeated recipient thresholds share actual payer progress.
## Shared implementation and controls
`game/mixed-bribe-question.ts` rejects leaf metadata/extra keys and reuses scalar
recipient/minimum decoding. The generic mixed parser validates every subtree
and shares one16-leaf/depth4 budget with nested current facts. A positive safe
whole minimum has no artificial extra cap. Server context validates every
recipient as another current nonally, including below decisive fact branches.
Public frozen constants or compiled fields are not accepted.
The existing compiler eagerly reads all current facts before folding known
constants/identity children/singletons. A remaining payment tree binds the
original bribe-expression ledger and accepted order; raw `mixedBribeQuestions`
metadata links the original question to that event. A constant aggregate has no
native payment binding. There is no second payment/debit/receipt implementation.
Native quotes preserve earlier scalar, grouped, revival, bid, shipment,
movement and battle budgets using the same current uncommitted purse.
Own raw question, aggregate status and recorded payer totals are projected.
Compiled residuals are excluded from the public pure-expression ledger; raw
internal metadata and frozen per-fact values remain unprojected. The common
exact event/phase payment guide can belong to scalar, grouped or mixed answers.
Uniform existing Yes readiness reopening applies even to decisive constants,
so no immediate compiled-branch readiness flag is added. Required aggregate
release and public actual payments retain their original information.
`components/mixed-action-fields.tsx` shares full add/wrap/nest/switch/remove/
collapse editing and current-fact controls. Movement retains its public API,
IDs/data attributes and callback traversal through a thin adapter. Bribe fields
supply public recipient choices and uniquely identified per-leaf numeric inputs;
invalid numeric/empty-recipient drafts remain repairable. Preview/Ask keep the
complete original tree. Scalar/grouped/mixed panels choose exactly one guide
using live ownership rather than fulfilled historical rows. Four minimal
policies answer and pay legally; strategy/calibration remain deferred.
Payer spice is spent now. Recipient shares remain deferred until the next actual
Mentat opening; late Mentat payments wait for next turn's opening. Existing ally,
pending-choice, Inflation Double and Bureaucrat guards remain. Yes cannot evade
Mentat Ready; No remains binding through collection until actual turn advance.
A known automatic Double closure retains the earlier Collection deadline.
New mixed discard signatures bind metadata/residual without altering older
record signatures. No historical answer is reactivated or database rewritten.
## Evidence and remaining gates
Source cases: `tests/fixture-truth-mixed-bribe.ts`,
`tests/truth-mixed-bribe-runtime.test.ts`,
`tests/truth-mixed-bribe-funding.test.ts`.
Controlled shared editing/guide cases: `tests/truth-mixed-bribe-controls.test.tsx`;
existing mixed-movement controls retain their scope. Nineteen native,two native
funding and nine controlled cases supplement existing generic/scalar/grouped/
movement coverage in the frozen **21-file,216/216** union. Types/lint, production
build and ten native paths pass on the same unchanged source. Actual GameTable
SSR renders33 times;13 guide callbacks submit native packets. Two editor
meaning callbacks (payment and retained movement adapter) preserve their whole
raw questions through native Ask. Four minimal profiles actually answer and pay
on both Basic/Advanced original sources.
| Original source | Prefix actions | Further actions | Actual result |
| --- | ---: | ---: | --- |
| Basic frozen spice | 20 | 22 | H3/A2 reduces E10→5; both exact opening receipts |
| Advanced frozen spice | 23 | 22 | Same answer-time snapshot/native payment/receipt |
| Basic edited true OR | 20 | 20 | Real AND→OR editor/Ask; immediate Yes, no payment binding |
| Basic false AND | 20 | 21 | Immediate No permits an actual ordinary payment |
| Basic unknown OR Yes | 20 | 21 | Unknown forecast requires actual payment |
| Basic unknown AND No | 20 | 21 | Cumulative payments remain below the qualifying minimum |
| Basic late-ready mixed | 32 | 37 | Only payer reopens; receipts wait for next real opening |
| Basic shared leader, bribe first | 102 | 16 | H1/A1 then native leader2 at T0; both obligations fulfilled |
| Basic shared leader, revival first | 102 | 16 | Same real purse in opposite accepted order |
| Basic shared movement editor | 20 | 5 | Retained movement IDs/callbacks edit/Ask the same raw tree |
The first frozen batch passes after updating one old assertion to live scalar
ownership. The passing source snapshot is
`ae77b288d097191c4b1f1ecdea123d564184532a9e06b7555f4b2e3fe79a9d0d`
(3162 files). Final evidence prose is a separate three-document overlay.
Frozen proof/report/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-bribe-proof-v1/`;
journeys/HTML: adjacent `20261026-truth-mixed-bribe-runtime/`.
SSR/editor/guide callbacks are local engine/control evidence, not DOM,
hydration, browser/mobile or deployed acceptance.
Original H/A/E base33 Basic20/Advanced23 and late Mentat32/35 sources supply
E10, its printed Shield and both original H Truths. H/A/T Ix47 sources supply
actual T4/dead native leader2 for shared-funding orders. No played clock,
purse, hand, payment, fact answer, promise or progress is installed. Controlled
views keep those labels rather than claiming original acquisition histories.
Other action families, correlated unknowns, future owned income/preparations,
optional modules, broader faction/card/funding composition and final rules/
recovery/privacy/custody/browser/mobile/deployed acceptance remain required.
No complete mode is certified. No saved-game/database/server/reset/restart/
deployment operation is performed by this batch.
+4
View File
@@ -1,5 +1,9 @@
# Truthtrance current facts and future ground movement
The [mixed ordinary-payment follow-up](TRUTHTRANCE_MIXED_BRIBES.md) reuses
this compiler and extracts the editor into `components/mixed-action-fields.tsx`.
The movement wrapper retains its public API, IDs, callbacks and native behavior.
## Mixed aggregate prototype — 26 October 2026
**Prototyped / Partial.** The packet is
+4
View File
@@ -1332,6 +1332,10 @@ function policyActions(g: GameView): Action[] {
const answer = g.truthAllianceAnswers?.find(answer => answer !== 'unknown') ?? g.truthAllianceAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'mixedBribe') {
const answer = g.truthMixedBribeAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'bribeExpression') {
const answer = g.truthBribeExpressionAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
+64 -2
View File
@@ -1,9 +1,10 @@
import type { BribePaymentClaim, MixedBribeClaim } from './mixed-bribe-question';
import { compileMixedActionExpression, MixedActionQuestionError, type CompiledMixedAction } from './mixed-action-question';
import { MovementExpressionPromiseError, liveMovementExpressionPromises, recordMovementExpressionEvent,
validateMovementExpressionPromises, type MovementExpression, type MovementExpressionClaim, type MovementExpressionPromise } from './movement-expression-promises';
import { BribeExpressionPromiseError, bribeExpressionRecipients, bribeCompletionCandidates,
liveBribeExpressionPromises, recordBribeExpressionPayment, validateBribeExpressionPromises,
type BribeExpressionClaim, type BribeExpressionPromise } from './bribe-expression-promises';
type BribeExpression, type BribeExpressionClaim, type BribeExpressionPromise } from './bribe-expression-promises';
import { RevivalExpressionPromiseError, liveRevivalExpressionPromises, revivalExpressionBranches,
evaluateRevivalExpression, recordRevivalExpressionReturn, validateRevivalExpressionPromises,
type RevivalExpression, type RevivalExpressionClaim, type RevivalExpressionPromise, type RevivalExpressionReturn } from './revival-expression-promises';
@@ -2162,6 +2163,10 @@ export type Game = {
revivalPromiseSequence?: string[];
bribePromises?: BribePromise[];
bribeExpressionPromises?: BribeExpressionPromise[];
/** Public original mixed questions; native residuals own physical payment rules. */
mixedBribeQuestions?: (MixedBribeClaim & {
event: string; player: string; asker: string; answer: boolean; binding: boolean;
})[];
bribePromiseSequence?: string[];
alliancePromises?: AlliancePromise[];
movementPromises?: MovementPromise[];
@@ -6908,6 +6913,9 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
battle: g.battle,
shipmentPromises: g.shipmentPromises,
movementPromises: g.movementPromises,
...(next.record.question.kind === 'mixedBribe' ? {
mixedBribeQuestions: g.mixedBribeQuestions, bribeExpressionPromises: g.bribeExpressionPromises,
} : {}),
...(next.record.question.kind === 'mixedMovement' ? {
mixedMovementQuestions: g.mixedMovementQuestions, movementExpressionPromises: g.movementExpressionPromises,
} : {}),
@@ -15194,6 +15202,54 @@ function bindBribeExpressionTruth(g: Game, p: Player, claim: BribeExpressionClai
(g.bribePromiseSequence ??= []).push(promise.event);
reopenBribePayer(g, p, answer);
}
function compileMixedBribe(g: Game, p: Player, claim: MixedBribeClaim, answer: boolean) {
const compiled = compileMixedActionExpression(claim.mixed,
fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
if (compiled.kind === 'constant') return { constant: compiled.value };
const payments = (node: CompiledMixedAction<BribePaymentClaim>): BribeExpression => {
if (node.kind === 'action') return { kind: 'payment', ...node.action };
requireRule(node.kind !== 'constant', 'Retain the full privately compiled mixed-bribe obligation.');
return { kind: node.kind, terms: node.terms.map(payments) };
};
return { residual: { turn: claim.turn, claim: payments(compiled) } };
}
function mixedBribeTruthAnswers(g: Game, p: Player, claim: MixedBribeClaim): TruthAnswer[] {
if (claim.turn !== g.turn || !bribeQuestionWindow(g)) return [];
const answers: TruthAnswer[] = [];
for (const answer of [true, false]) {
const compiled = compileMixedBribe(g, p, claim, answer);
if (compiled.constant !== undefined ? compiled.constant === answer :
bribeExpressionTruthAnswers(g, p, compiled.residual!).includes(answer ? 'yes' : 'no')) answers.push(answer ? 'yes' : 'no');
}
return answers.length ? answers : ['unknown'];
}
function bindMixedBribeTruth(g: Game, p: Player, claim: MixedBribeClaim, answer: boolean, asker: string) {
requireRule(mixedBribeTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
'Choose a mixed bribe answer compatible with the whole current-fact and payment question.');
const compiled = compileMixedBribe(g, p, claim, answer);
let event: string;
if (compiled.residual) {
bindBribeExpressionTruth(g, p, compiled.residual, answer, asker);
event = g.bribeExpressionPromises!.at(-1)!.event;
} else {
requireRule(compiled.constant === answer, 'Retain the truthful whole mixed-bribe answer.');
reopenBribePayer(g, p, answer);
event = crypto.randomUUID();
}
(g.mixedBribeQuestions ??= []).push({ turn: claim.turn, mixed: structuredClone(claim.mixed),
event, player: p.id, asker, answer, binding: !!compiled.residual });
}
function isMixedBribeEvent(g: Game, event: string): boolean {
return !!g.mixedBribeQuestions?.some(question => question.event === event);
}
function projectedMixedBribePromises(g: Game, player: string) {
return (g.mixedBribeQuestions ?? []).filter(question => question.player === player).map(question => {
const bound = g.bribeExpressionPromises?.find(promise => promise.event === question.event);
return { event: question.event, player: question.player, asker: question.asker, turn: question.turn,
mixed: structuredClone(question.mixed), answer: question.answer, progress: structuredClone(bound?.progress ?? []),
fulfilled: !question.binding || bound?.fulfilled === true, released: bound?.released === true };
});
}
function reconcileBribePromises(g: Game, voluntary?: { actor: string; action: Action }) {
type Promise = BribePromise | BribeExpressionPromise;
const pendingPromises: Promise[] = [...(g.bribePromises ?? []), ...(g.bribeExpressionPromises ?? [])]
@@ -36455,6 +36511,8 @@ function applyActionInner(
bindBribe: bindBribeTruth,
bribeExpressionAnswers: bribeExpressionTruthAnswers,
bindBribeExpression: bindBribeExpressionTruth,
mixedBribeAnswers: mixedBribeTruthAnswers,
bindMixedBribe: bindMixedBribeTruth,
revivalExpressionAnswers: revivalExpressionTruthAnswers,
bindRevivalExpression: bindRevivalExpressionTruth,
leaderRevivalAnswers: leaderRevivalTruthAnswers,
@@ -40319,7 +40377,11 @@ export function viewGame(state: Game, id: string) {
truthBribeExpressionTargets: bribeQuestionWindow(g) ? g.players.map(player => player.id) : [],
truthBribeExpressionAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'bribeExpression' ? bribeExpressionTruthAnswers(g, me, g.truthtrance.question) : null,
bribeExpressionPromises: (g.bribeExpressionPromises ?? []).filter(promise => promise.player === id).map(promise => structuredClone(promise)),
bribeExpressionPromises: (g.bribeExpressionPromises ?? []).filter(promise => promise.player === id && !isMixedBribeEvent(g, promise.event)).map(promise => structuredClone(promise)),
truthMixedBribeTargets: bribeQuestionWindow(g) ? g.players.map(player => player.id) : [],
truthMixedBribeAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'mixedBribe' ? mixedBribeTruthAnswers(g, me, g.truthtrance.question) : null,
mixedBribePromises: projectedMixedBribePromises(g, id),
truthRevivalTargets: revivalQuestionWindow(g) ? g.players.filter(player => revivalOpportunity(g, player.id)).map(player => player.id) : [],
truthRevivalAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'revival' ? revivalTruthAnswers(g, me, g.truthtrance.question) : null,
+15
View File
@@ -0,0 +1,15 @@
import { BribePromiseError, parseBribeClaim, type BribeClaim } from './bribe-promises';
import type { MixedActionExpression } from './mixed-action-question';
export type BribePaymentClaim = Pick<BribeClaim, 'recipient' | 'minimum'>;
export type MixedBribeClaim = { turn: number; mixed: MixedActionExpression<BribePaymentClaim> };
/** Reuse scalar recipient/minimum decoding without accepting question metadata.
* The neutral turn validates only this leaf; engine owns the actual interval. */
export function parseBribePaymentClaim(value: unknown): BribePaymentClaim {
if (!value || typeof value !== 'object' || Array.isArray(value) ||
Object.keys(value).sort().join(',') !== 'minimum,recipient')
throw new BribePromiseError('Supply only an exact ordinary-payment recipient and positive spice minimum.');
const { recipient, minimum } = parseBribeClaim({ ...value, turn: 1 });
return { recipient, minimum };
}
+28 -1
View File
@@ -3258,6 +3258,31 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Focused pure, original deal/payment/Mentat/next-turn runtime and controlled/native UI evidence. Native alliance campaigns, optional compositions, future preparations and final browser/deployed acceptance remain open.', evidence: ['tests/fixture-mentat-bribes.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/fixture-truth-bribe.ts', 'tests/truth-bribe-runtime.test.ts', 'tests/truth-bribe-controls.test.tsx'] },
],
},
{
id: 'truthtrance-mixed-bribe',
title: 'Truthtrance: current facts and ordinary payments',
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Combine answer-time facts with cumulative future ordinary payments while keeping one native purse, deferred receipts and the private aggregate.',
steps: [
'Choose Combine current facts and ordinary payments. Build full AND/OR groups with at least one current fact and one named non-allied recipient/minimum condition. One16-leaf/depth4 software budget includes nested fact groups.',
'Current facts freeze when answered. If you truthfully hold at least10 spice then promise payments totaling5, the fact remains the answer-time fact after your supply drops to5. Later spending or card custody creates no new factual answer.',
'Only the complete yes/no answer and original question are public. A definite Yes cannot rely on an unknown fact being true; No cannot rely on it being false. The remaining funded payment obligation must guarantee the aggregate. If neither definite answer is currently supportable, the unanswered card may be saved or asked differently.',
'A fact can already settle the whole answer, needing no later payment. Otherwise actual ordinary payer payments after the answer add together per recipient; earlier payments, gifts, allied aid and later Mentat collection do not count. No negates the whole mixed expression.',
'Remaining payments use the original scalar/grouped bribe system, current uncommitted funds, nonally restrictions, previous budgets and accepted order. Dynamic OR recipients remain available. Answering grants no spice and pays nobody.',
'Recipient shares stay deferred to the next actual Mentat opening; a late Mentat payment waits for next turn’s opening. Yes cannot evade by finishing Mentat; No remains binding until actual turn advance. Existing known Inflation Double closure retains its earlier Collection deadline.',
'Full shared editing, guarded raw preview/Ask, private answers, own cumulative progress and one exact native payment guide connect to four minimal policies. Historical fulfilled panels do not duplicate or hide a new live guide. Native Basic/Advanced phases0–8 retain their existing quiet supported scope; other action families, correlated unknowns and optional compositions remain unfinished.',
],
related: ['card-truthtrance', 'truthtrance-bribe-expression', 'truthtrance-bribe', 'truthtrance-mixed-movement', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Generic answer-time compilation binds only a necessary native payment expression; raw aggregate projection hides compiled clauses and keeps native funding/receipt rules.', evidence: ['game/mixed-action-question.ts', 'game/mixed-bribe-question.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Reusable mixed editor serves movement/payments with stable movement controls; raw aggregate panel and one shared exact payment guide are connected.', evidence: ['components/mixed-action-fields.tsx', 'components/mixed-bribe-fields.tsx', 'components/mixed-bribe-promises.tsx', 'components/bribe-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer the aggregate and pay through exact native guides. Strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-mixed-bribe-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Snapshot/unknown composition, original current-purse/receipt/deadline and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_MIXED_BRIBES.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Original native payment/receipt/funding and controlled shared editor/guide cases supplement existing scalar and grouped proof; wider composition/final deployed acceptance remain open.', evidence: ['tests/fixture-truth-mixed-bribe.ts', 'tests/truth-mixed-bribe-runtime.test.ts', 'tests/truth-mixed-bribe-funding.test.ts', 'tests/truth-mixed-bribe-controls.test.tsx', 'tests/truth-mixed-movement-controls.test.tsx'] },
],
},
{
id: 'truthtrance-bribe-expression',
title: 'Truthtrance: combined ordinary bribe payments',
@@ -3401,7 +3426,7 @@ export const RULE_TOPICS: RuleTopic[] = [
related: ['card-truthtrance', 'truthtrance-fact-expression', 'truthtrance-movement-expression', 'truthtrance-movement', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Strict generic mixed parser/compiler freezes facts conservatively and binds only the resulting native movement obligation; immediate aggregate answers create none.', evidence: ['game/mixed-action-question.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Full mixed editor with reused fact controls, guarded raw preview/Ask, private answers, aggregate panel and shared exact guide.', evidence: ['components/mixed-movement-fields.tsx', 'components/mixed-movement-promises.tsx', 'components/movement-expression-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx'] },
{ area: 'Player controls', status: 'Partial', detail: 'Full mixed editor with reused fact controls, guarded raw preview/Ask, private answers, aggregate panel and shared exact guide.', evidence: ['components/mixed-action-fields.tsx', 'components/mixed-movement-fields.tsx', 'components/mixed-movement-promises.tsx', 'components/movement-expression-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer the aggregate and follow native movement guides. Strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-mixed-movement-runtime.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Snapshot, conservative unknown semantics, original physical obligations and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_MIXED_MOVEMENT.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused generic, controlled editor/guide and original native known/unknown/Hajr/Charity cases. Broader composition and final browser/deployed acceptance remain open.', evidence: ['tests/mixed-action-question.test.ts', 'tests/fixture-truth-mixed-movement.ts', 'tests/truth-mixed-movement-runtime.test.ts', 'tests/truth-mixed-movement-controls.test.tsx'] },
@@ -3595,6 +3620,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'An ordinary-bid question can bind one new qualifying bid in the current lot for a supported active or waiting bidder. Yes requires that declaration while possible; No forbids it to lot close. A bid need not win to fulfill Yes, but a winning bid still follows its original payment rules. See the current-lot guide for funding, expiry and unfinished configurations.',
'A current-Nexus alliance question binds actual final membership with a named partner at that Nexus close. Yes uses existing partnership or an actual invitation; offers alone do not form alliances. No may require a later break before Ready. Unknown can save the card until consent exists. Other players retain their original withdrawal/break rights. See the alliance-promise guide for its provisional interval and scope.',
'A bribe-payment question binds cumulative future ordinary spice paid to one named non-allied recipient during this turn. Only the real payer action counts; the recipient still spends its receipt at Mentat. Split payments add together, and earlier obligations retain their funding. See the ordinary-bribe guide for the funded prototype and unfinished preparations.',
'A mixed current-fact and ordinary-payment question freezes facts before later debits and keeps only the funded native payment obligation needed by the whole answer. The recipient still waits for its original Mentat opening; individual frozen clauses remain private. See the mixed payment guide for its provisional scope.',
'A mixed current-fact and ground-movement question freezes the fact at answer time and keeps only the native movement obligation needed for the whole answer. Later card disposal does not rewrite the fact, and unknown clauses require a guaranteed aggregate. See the mixed movement guide for its provisional scope.',
'A grouped ground-movement question combines future physical group minima with full AND/OR. One move must meet each leaf; smaller moves never add together. Actual owned entitlements can fulfill several leaves over several moves, while No negates the whole expression. See the combined movement guide for its provisional opportunity and scope.',
'A grouped ordinary-bribe question combines named future payment minima with full AND/OR. No negates the whole question; real payments rebuild legal funded alternatives, and next-opening recipient collection adds no new progress. See the combined bribe guide for its provisional interval and scope.',
@@ -3625,6 +3651,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'truthtrance-revival-expression',
'truthtrance-bribe',
'truthtrance-bribe-expression',
'truthtrance-mixed-bribe',
'truthtrance-alliance',
'richese-no-field',
'card-reinforcements',
+28
View File
@@ -1,3 +1,4 @@
import { parseBribePaymentClaim, type MixedBribeClaim } from './mixed-bribe-question';
import { parseMixedActionExpression, mixedActionExpressionText, type MixedActionExpression } from './mixed-action-question';
import { parseMovementExpressionClaim, movementExpressionText, type MovementExpressionClaim } from './movement-expression-promises';
import { parseBribeExpressionClaim, bribeExpressionRecipients, bribeExpressionText,
@@ -70,6 +71,7 @@ export type TruthFact =
| { kind: 'and' | 'or'; terms: TruthFact[] };
export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression<MovementClaim> };
export type TruthQuestion =
| ({ kind: 'mixedBribe'; target: string } & MixedBribeClaim)
| ({ kind: 'mixedMovement'; target: string } & MixedMovementClaim)
| ({ kind: 'movementExpression'; target: string } & MovementExpressionClaim)
| ({ kind: 'bribeExpression'; target: string } & BribeExpressionClaim)
@@ -260,6 +262,20 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
'Choose the current ordinary Nexus and another named partner in a supported table.');
return { kind: 'alliance', target: v.target, ...claim };
}
if (v.kind === 'mixedBribe') {
check(Object.keys(v).sort().join(',') === 'kind,mixed,target,turn' && v.turn === g.turn &&
bribePromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 8 &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama,
'Supply the current turn, complete mixed bribe question and a supported quiet native window.');
const respondent = g.players.find(player => player.id === v.target)!;
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), input => {
const action = parseBribePaymentClaim(input);
check(action.recipient !== respondent.id && action.recipient !== respondent.ally &&
g.players.some(player => player.id === action.recipient), 'Choose another current non-allied ordinary bribe recipient.');
return action;
});
return { kind: 'mixedBribe', target: v.target as string, turn: g.turn, mixed };
}
if (v.kind === 'bribeExpression') {
check(Object.keys(v).sort().join(',') === 'claim,kind,target,turn',
'Supply the current turn, complete bribe expression and respondent.');
@@ -578,6 +594,8 @@ export function truthQuestionText(
return `you currently hold ${f.compare === 'eq' ? 'exactly' : f.compare === 'gte' ? 'at least' : 'at most'} ${f.value} spice`;
return `(${f.terms.map(clause).join(f.kind === 'and' ? ' AND ' : ' OR ')})`;
};
if (q.kind === 'mixedBribe') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => `you ${bribeExpressionText({ kind: 'payment', ...action }, playerName)} from now until this turn ends`)}?`;
if (q.kind === 'mixedMovement') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => `you ${movementClaimText(action)} before your Movement opportunity ends`)}?`;
return `Is it true that ${q.kind === 'mixedShipment'
@@ -612,6 +630,8 @@ export function resolveTruthAction(
bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void;
allianceAnswers?: (g: Game, p: Player, claim: AllianceClaim) => TruthAnswer[];
bindAlliance?: (g: Game, p: Player, claim: AllianceClaim, answer: boolean, asker: string) => void;
mixedBribeAnswers?: (g: Game, p: Player, claim: MixedBribeClaim) => TruthAnswer[];
bindMixedBribe?: (g: Game, p: Player, claim: MixedBribeClaim, answer: boolean, asker: string) => void;
bribeExpressionAnswers?: (g: Game, p: Player, claim: BribeExpressionClaim) => TruthAnswer[];
bindBribeExpression?: (g: Game, p: Player, claim: BribeExpressionClaim, answer: boolean, asker: string) => void;
bribeAnswers?: (g: Game, p: Player, claim: BribeClaim) => TruthAnswer[];
@@ -757,6 +777,9 @@ export function resolveTruthAction(
if (w.question.kind === 'movementExpression')
check(effects.movementExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a supported whole ground-movement question.');
if (w.question.kind === 'mixedBribe')
check(effects.mixedBribeAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a mixed current-fact and ordinary-payment question compatible with earlier answers.');
if (w.question.kind === 'bribeExpression')
check(effects.bribeExpressionAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a whole bribe expression compatible with earlier answers.');
@@ -845,6 +868,11 @@ export function resolveTruthAction(
'Choose an answer consistent with actual alliance consent and earlier commitments.');
if (a.answer !== 'unknown') effects.bindAlliance(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'mixedBribe') {
check(effects.mixedBribeAnswers && effects.bindMixedBribe && effects.mixedBribeAnswers(g, p, q).includes(a.answer),
'Choose a whole mixed-bribe answer compatible with current facts, funds and earlier commitments.');
if (a.answer !== 'unknown') effects.bindMixedBribe(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'bribeExpression') {
check(effects.bribeExpressionAnswers && effects.bindBribeExpression && effects.bribeExpressionAnswers(g, p, q).includes(a.answer),
'Choose a whole bribe answer consistent with actual funding and earlier promises.');
+88
View File
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TruthFact } from '../game/truthtrance';
import {
actTruthBribeExpression, advanceTruthBribeExpression, completeTruthBribeExpression,
createTruthBribeExpressionFixture, declareTruthBribeExpression, finishTruthBribeExpression,
nextTruthBribeExpression, settleTruthBribeExpression, truthBribeExpressionBalances,
truthBribeExpressionClean, truthBribeExpressionDeferred, truthBribeExpressionInventory,
truthBribeExpressionPlayer, type TruthBribeExpressionStep, type TruthBribeExpressionTransition,
} from './fixture-truth-bribe-expression';
export type TruthMixedBribeExpression = MixedActionExpression<{ recipient: string; minimum: number }>;
export type TruthMixedBribeStep = TruthBribeExpressionStep;
export type TruthMixedBribeTransition = TruthBribeExpressionTransition;
export const actTruthMixedBribe = actTruthBribeExpression;
export const advanceTruthMixedBribe = advanceTruthBribeExpression;
export const finishTruthMixedBribe = finishTruthBribeExpression;
export const nextTruthMixedBribe = nextTruthBribeExpression;
export const settleTruthMixedBribe = settleTruthBribeExpression;
export const truthMixedBribeBalances = truthBribeExpressionBalances;
export const truthMixedBribeClean = truthBribeExpressionClean;
export const truthMixedBribeDeferred = truthBribeExpressionDeferred;
export const truthMixedBribeInventory = truthBribeExpressionInventory;
export const truthMixedBribePlayer = truthBribeExpressionPlayer;
export const truthMixedBribeFact = (fact: TruthFact): TruthMixedBribeExpression => ({ kind: 'fact', fact });
export const truthMixedBribePayment = (recipient: string, minimum = 3): TruthMixedBribeExpression =>
({ kind: 'action', action: { recipient, minimum } });
export const truthMixedBribeGroup = (kind: 'and' | 'or', ...terms: TruthMixedBribeExpression[]): TruthMixedBribeExpression =>
({ kind, terms });
/** Original H/A/E base33 Basic20/Advanced23 prefix, or its actual Collection/
* Mentat continuation. Only the raw question changes. E retains its original
* Shield and ten spice; no played clock, purse, hand, answer or promise is set. */
export function createTruthMixedBribeFixture(options: {
advanced?: boolean; window?: 'waiting' | 'active'; phase?: 3 | 7 | 8; mixed?: TruthMixedBribeExpression;
} = {}) {
const base = createTruthBribeExpressionFixture(options);
const shield = base.setup.deck.find(card => card.kind === 'shield');
const hajr = base.setup.deck.find(card => card.effect === 'hajr');
assert.ok(shield && hajr, 'The unopened base33 inventory supplies printed current-fact identities');
const shieldFact: TruthFact = { kind: 'hand', name: shield.name };
const hajrFact: TruthFact = { kind: 'hand', name: hajr.name };
const spiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: 10 };
const forecastFact: TruthFact = { kind: 'stormForecast', compare: 'gte', value: 1 };
const mixed = structuredClone(options.mixed ?? truthMixedBribeGroup('and', truthMixedBribeFact(spiceFact),
truthMixedBribePayment(base.asker, 3), truthMixedBribePayment(base.opponent, 2)));
const question = { kind: 'mixedBribe' as const, target: base.target, turn: base.game.turn, mixed };
return { ...base, bribeSource: base, mixed, question, shieldFact, hajrFact, spiceFact, forecastFact,
questionAction: { type: 'truthAsk', question } satisfies Action,
sourceCounts: { inherited: base.sourceActionCount, basicBidding: 20, advancedBidding: 23,
basicMentat: 32, advancedMentat: 35 },
sourceDisclosure: `${base.sourceDisclosure} This thin mixed adapter changes only the original raw question: current facts and future ordinary cumulative payments share one AND/OR tree. No frozen answer, compiled residual, promise or progress is installed.` };
}
export type TruthMixedBribeFixture = ReturnType<typeof createTruthMixedBribeFixture>;
export function declareTruthMixedBribe(f: TruthMixedBribeFixture, source = f.game, options: { copies?: 1 | 2 } = {}) {
return declareTruthBribeExpression(f.bribeSource, source, options);
}
export function askTruthMixedBribe(game: Game, asker: string, target: string, mixed: TruthMixedBribeExpression,
actions: TruthMixedBribeStep[] = []): Game {
return actTruthMixedBribe(game, asker, { type: 'truthAsk', question: {
kind: 'mixedBribe', target, turn: game.turn, mixed: structuredClone(mixed),
} }, actions);
}
export function answerTruthMixedBribe(game: Game, target: string, answer: 'yes' | 'no',
actions: TruthMixedBribeStep[] = []): Game {
return actTruthMixedBribe(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthMixedBribe(f: TruthMixedBribeFixture, answer: 'yes' | 'no' = 'yes',
mixed = f.mixed, source = f.game) {
const declared = declareTruthMixedBribe(f, source), actions = [...declared.actions];
const asked = askTruthMixedBribe(declared.game, f.asker, f.target, mixed, actions);
const game = answerTruthMixedBribe(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length };
}
export function completeTruthMixedBribe(f: TruthMixedBribeFixture, source = f.game) {
return completeTruthBribeExpression(f.bribeSource, source);
}
/** The public original question and whole answer only. This accessor never
* reads private frozen clauses or the internal native residual binding. */
export function truthMixedBribePromise(game: Game, target = game.truthHistory?.at(-1)?.question.target ?? game.players[0].id) {
const row = viewGame(game, target).mixedBribePromises.find(row => row.player === target);
assert.ok(row, 'The respondent retains the original mixed question and whole-answer status');
return row;
}
@@ -168,7 +168,8 @@ void test('one shared guide handles scalar and whole promises without duplicatin
const game = projected(); assert.match(markup(game), /data-testid="bribe-promise-guide"/);
game.bribePromises = [{ event: 'scalar-history', player: 'p', asker: 'a', turn: 1, recipient: 'r', minimum: 1,
answer: true, progress: 1, fulfilled: true }];
assert.doesNotMatch(markup(game), /data-testid="bribe-promise-guide"/);
// Historical scalar records do not own a newly live expression guide.
assert.match(markup(game), /data-testid="bribe-promise-guide"/);
const both = renderToStaticMarkup(createElement('div', null,
createElement(BribePromises, { game, busy: false, send() {} }),
createElement(BribeExpressionPromises, { game, busy: false, send() {} })));
+292
View File
@@ -0,0 +1,292 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MixedActionFields, type MixedActionConfig } from '../components/mixed-action-fields';
import { MixedBribeFields, mixedBribeInputError } from '../components/mixed-bribe-fields';
import { MixedBribePromises } from '../components/mixed-bribe-promises';
import { MixedMovementFields } from '../components/mixed-movement-fields';
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { BribePromises, BribePromiseGuideButton } from '../components/bribe-promises';
import { BribeExpressionPromises } from '../components/bribe-expression-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { BribePaymentClaim } from '../game/mixed-bribe-question';
import type { TruthFact } from '../game/truthtrance';
import { createTechTokens } from '../game/tech-tokens';
type Expression = MixedActionExpression<BribePaymentClaim>;
type ElementProps = { children?: ReactNode; [key: string]: unknown };
type Element = ReactElement<ElementProps>;
const fact = (value: TruthFact = { kind: 'hand', name: 'Shield' }): Expression => ({ kind: 'fact', fact: value });
const payment = (minimum = 1, recipient = 'r'): Expression => ({ kind: 'action', action: { recipient, minimum } });
const claim = (): Expression => ({ kind: 'or', terms: [fact(), { kind: 'and', terms: [payment(3), payment(2, 'a')] }] });
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function find(tree: ReactNode, key: string, value: string): Element {
const found = elements(tree).find(element => element.props[key] === value);
assert.ok(found, `${key}=${value}`); return found;
}
const control = (tree: ReactNode, key: string) => find(tree, 'data-mixed-bribe-control', key);
const click = (button: Element) => (button.props.onClick as () => void)();
function select(tree: ReactNode, path: string, field: string, value: string) {
const input = find(tree, 'id', `truth-mixed-bribe-${path}-${field}`);
(input.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value } });
}
function minimum(tree: ReactNode, path: string, valueAsNumber: number) {
const input = find(tree, 'id', `truth-mixed-bribe-${path}-minimum`);
(input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber } });
}
function editFact(tree: ReactNode, path: string, next: TruthFact) {
const scope = find(tree, 'data-mixed-bribe-fact-path', path);
const fields = elements(scope).find(element => element.type === TruthFactFields);
assert.ok(fields, `Current fact fields ${path}`);
(fields.props.onChange as (fact: TruthFact) => void)(next);
}
/** Controlled personalized projections exercise UI ownership and callbacks.
* Native answer-time snapshots, funding and real payments have separate proof. */
function projected(phase = 3, advanced = false): GameView {
const source = createGame('TRUTHMIXEDBRIBECONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced);
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
joinGame(source, newPlayer('r', 'Recipient', 'harkonnen'));
const game = viewGame(source, 'p');
game.status = 'playing'; game.turn = 2; game.phase = phase; game.active = 'a';
game.auction = { bid: 0, bidder: null, active: 'a', remaining: 2, card: null, inspectionBlocked: null };
game.bribeOptions = { available: 8, incoming: 0, blocked: null,
targets: [{ id: 'r', maximum: 6, limitation: null }, { id: 'a', maximum: 8, limitation: null }] };
game.bribePromises = []; game.bribeExpressionPromises = [];
game.mixedBribePromises = [{ event: 'controlled-mixed-bribe-event', player: 'p', asker: 'a', turn: 2,
mixed: claim(), answer: true, progress: [{ recipient: 'r', amount: 1 }], fulfilled: false, released: false }];
game.bribePromiseGuide = { action: { type: 'bribe', target: 'r', amount: 2, bribePromiseEvent: 'controlled-mixed-bribe-event' } };
return game;
}
function editor(initial: Expression, disabled = false, game = projected()) {
let value = initial, changes = 0;
return {
get value() { return value; }, get changes() { return changes; },
tree: () => MixedBribeFields({ value, game, target: 'p', disabled, onChange(next) { value = next; changes++; } }),
html: () => renderToStaticMarkup(createElement(MixedBribeFields, { value, game, target: 'p', disabled, onChange() {} })),
error: () => mixedBribeInputError(value, game, 'p'),
};
}
function guide(props: Parameters<typeof BribePromiseGuideButton>[0]) {
const button = BribePromiseGuideButton(props); assert.ok(button); return button;
}
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
const markup = (game: GameView) => renderToStaticMarkup(createElement(MixedBribePromises, { game, busy: false, send() {} }));
void test('shared mixed editor adapts actions and keeps movement wrapper identifiers and fact controls stable', () => {
type Probe = { token: string };
let value: MixedActionExpression<Probe> = { kind: 'fact', fact: { kind: 'hand', name: 'Shield' } };
const game = projected(); let parsed = 0;
const config: MixedActionConfig<Probe> = {
prefix: 'mixed-probe', legend: 'Probe claim', actionLabel: 'probe condition', actionTiming: 'Future probe',
actionHelp: 'Probe mechanics', fallbackError: 'Choose a probe', maxLeaves: 16, maxDepth: 4,
newAction: () => ({ token: 'exact-token' }),
parseAction: input => { parsed++; return input as Probe; },
renderAction: props => <label htmlFor={`${props.id}-token`}>{props.value.token}</label>,
};
const tree = () => MixedActionFields({ value, game, target: 'p', disabled: false, config, onChange(next) { value = next; } });
click(find(tree(), 'data-mixed-probe-control', 'or-1'));
assert.deepEqual(value, { kind: 'or', terms: [fact(), { kind: 'action', action: { token: 'exact-token' } }] });
find(tree(), 'htmlFor', 'truth-mixed-probe-1.2-token'); assert.ok(parsed > 0);
let movementChanges = 0;
const movement = MixedMovementFields({ value: { kind: 'and', terms: [{ kind: 'fact', fact: { kind: 'hand', name: 'Shield' } },
{ kind: 'action', action: { territory: 'carthag', minimum: 2 } }] }, game, target: 'p', disabled: false,
onChange() { movementChanges++; } });
find(movement, 'id', 'truth-mixed-movement-1.2-territory');
find(movement, 'data-mixed-movement-fact-path', '1.1');
click(find(movement, 'data-mixed-movement-control', 'nested-or-1')); assert.equal(movementChanges, 1);
});
void test('mixed bribe editor preserves sixteen clauses, public recipients, unique IDs and safe integer minima without a spice cap', () => {
const e = editor(fact()); click(control(e.tree(), 'or-1')); select(e.tree(), '1.2', 'recipient', 'r');
for (let count = 3; count <= 16; count++) {
click(control(e.tree(), 'add-action-1')); minimum(e.tree(), `1.${count}`, count);
select(e.tree(), `1.${count}`, 'recipient', count % 2 ? 'r' : 'a');
}
assert.ok(e.value.kind === 'or'); assert.equal(e.value.terms.length, 16); assert.equal(e.error(), null);
minimum(e.tree(), '1.16', Number.MAX_SAFE_INTEGER); assert.equal(e.error(), null);
const html = e.html(), ids = [...html.matchAll(/ id="([^"]+)"/g)].map(match => match[1]);
assert.equal(new Set(ids).size, ids.length); assert.match(html, /16 \/ 16 conditions/);
assert.equal((html.match(/Minimum future ordinary bribe payments in spice/g) ?? []).length, 15);
assert.match(html, /max="9007199254740991"/);
const before = structuredClone(e.value), changes = e.changes;
for (const key of ['add-fact-1', 'add-action-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'fact-or-1.1', 'or-1']) {
assert.equal(control(e.tree(), key).props.disabled, true); click(control(e.tree(), key));
}
assert.equal(e.changes, changes); assert.deepEqual(e.value, before);
const game = projected(); game.players.find(player => player.id === 'p')!.ally = 'r';
const allied = editor({ kind: 'and', terms: [fact(), payment()] }, false, game);
assert.ok(allied.error()); assert.doesNotMatch(allied.html(), /<option value="[pr]">/);
select(allied.tree(), '1.2', 'recipient', 'a'); assert.equal(allied.error(), null);
select(allied.tree(), '1.2', 'recipient', 'unlisted'); assert.equal(allied.changes, 1);
});
void test('nested mixed and fact groups share depth four and retain branches through meaning, timing and singleton collapse', () => {
const e = editor({ kind: 'and', terms: [fact(), payment(3)] });
click(control(e.tree(), 'or-1.2')); select(e.tree(), '1.2', 'meaning', 'and');
click(control(e.tree(), 'nested-or-1')); select(e.tree(), '1.3.2', 'recipient', 'a');
click(control(e.tree(), 'fact-or-1.1')); editFact(e.tree(), '1.1.2', { kind: 'stormForecast', compare: 'gte', value: 4 });
select(e.tree(), '1.1', 'fact-meaning', 'and'); click(control(e.tree(), 'fact-add-1.1'));
editFact(e.tree(), '1.1.3', { kind: 'spice', compare: 'gte', value: 7 }); assert.equal(e.error(), null);
click(control(e.tree(), 'fact-remove-1.1.3')); click(control(e.tree(), 'fact-remove-1.1.2'));
click(control(e.tree(), 'remove-1.2.2')); click(control(e.tree(), 'remove-1.3'));
assert.deepEqual(e.value, { kind: 'and', terms: [fact(), payment(3)] });
select(e.tree(), '1.2', 'timing', 'fact'); assert.ok(e.error());
select(e.tree(), '1.2', 'timing', 'action'); select(e.tree(), '1.2', 'recipient', 'r'); assert.equal(e.error(), null);
click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, payment()); assert.ok(e.error());
let nested = fact();
for (let depth = 0; depth < 3; depth++) nested = { kind: 'and', terms: [nested, payment()] };
const deep = editor(nested); click(control(deep.tree(), 'fact-or-1.1.1.1'));
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
for (const key of ['and-1', 'or-1.1.1.1', 'fact-and-1.1.1.1.1', 'fact-or-1.1.1.1']) {
assert.equal(control(deep.tree(), key).props.disabled, true); click(control(deep.tree(), key));
}
assert.equal(deep.changes, 1);
});
void test('invalid action and fact drafts stay editable and disabled callbacks preserve the original tree', () => {
for (const invalid of [NaN, -1, 0, 0.5, Number.MAX_SAFE_INTEGER + 1, Infinity]) {
const e = editor({ kind: 'and', terms: [fact(), payment()] });
minimum(e.tree(), '1.2', invalid); select(e.tree(), '1.2', 'recipient', ''); assert.ok(e.error());
click(control(e.tree(), 'or-1.2')); assert.ok(e.value.kind === 'and');
assert.deepEqual(e.value.terms[1], { kind: 'or', terms: [payment(invalid, ''), fact()] });
assert.match(e.html(), /role="alert"/);
minimum(e.tree(), '1.2.1', 2); select(e.tree(), '1.2.1', 'recipient', 'a'); assert.equal(e.error(), null);
editFact(e.tree(), '1.1', { kind: 'spice', compare: 'gte', value: NaN }); assert.ok(e.error());
editFact(e.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 2 }); assert.equal(e.error(), null);
}
const frozen = editor({ kind: 'and', terms: [fact(), payment()] }, true), before = structuredClone(frozen.value);
for (const key of ['and-1', 'add-action-1', 'nested-or-1', 'remove-1.2', 'fact-or-1.1']) click(control(frozen.tree(), key));
minimum(frozen.tree(), '1.2', 3); select(frozen.tree(), '1', 'meaning', 'or'); select(frozen.tree(), '1.1', 'timing', 'action');
editFact(frozen.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 4 });
assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before);
});
void test('current fact eligibility and aggregate editor guidance never inspect hidden custody or the held auction card', () => {
const game = projected(), value: Expression = { kind: 'and', terms: [
fact({ kind: 'stormForecast', compare: 'gte', value: 3 }), payment(2),
] };
for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves', 'bribes'])
Object.defineProperty(player, key, { get() { throw Error(`No ${key} inspection`); }, configurable: true });
Object.defineProperty(game.auction!, 'card', { get() { throw Error('No hidden lot inspection'); } });
const html = renderToStaticMarkup(createElement(MixedBribeFields, { value, game, target: 'p', disabled: false, onChange() {} }));
assert.match(html, /Current facts are evaluated when the player answers/); assert.match(html, /Only the aggregate answer is shared/);
assert.match(html, /unknown current fact can still allow a definite whole answer/);
assert.match(html, /If no such value is available, the answer is I don’t know/);
assert.equal(mixedBribeInputError(value, game, 'p'), null);
assert.ok(mixedBribeInputError({ ...value, binding: true }, game, 'p'));
assert.ok(mixedBribeInputError({ kind: 'and', terms: [fact(), { kind: 'constant', value: true }, payment()] }, game, 'p'));
const sent: Action[] = []; press(guide({ game, busy: false, send(action) { sent.push(action); } }));
assert.equal(sent[0], game.bribePromiseGuide!.action);
});
void test('own mixed panel shows raw whole question, aggregate answer and cumulative progress without private compiled fields', () => {
const game = projected(); game.bribePromiseGuide = null;
game.mixedBribePromises.push(
{ ...game.mixedBribePromises[0], event: 'no-event', answer: false },
{ ...game.mixedBribePromises[0], event: 'fulfilled-event', fulfilled: true },
{ ...game.mixedBribePromises[0], event: 'released-event', released: true },
{ ...game.mixedBribePromises[0], event: 'foreign-event', player: 'a', mixed: { kind: 'and', terms: [
fact({ kind: 'hand', name: 'Poison Blade' }), payment(999, 'foreign-recipient'),
] } },
);
for (const promise of game.mixedBribePromises) for (const key of ['binding', 'bindingflag', 'residual', 'factAnswers', 'compiled', 'frozenFact'])
Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
const html = markup(game);
assert.match(html, /you hold Shield/); assert.match(html, /You answered Yes/); assert.match(html, /You answered No/);
assert.match(html, / OR /); assert.match(html, / AND /); assert.match(html, /Recorded future ordinary payments: Recipient: 1 spice; Asker: 0 spice/);
assert.match(html, /Fulfilled: this whole commitment is complete/); assert.match(html, /Released: this whole commitment is no longer possible/);
assert.match(html, /Individual fact answers and any reduced future branch remain private/);
assert.match(html, /No negates the whole expression, not each condition separately/);
assert.match(html, /Smaller payments to the same recipient add together after the answer/);
assert.match(html, /Prior payments, gifts and ally aid do not count/);
assert.doesNotMatch(html, /Poison Blade|999|foreign-recipient|controlled-mixed-bribe-event|fulfilled-event|factAnswers/);
assert.equal(MixedBribePromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
});
void test('mixed receipts and exact native payment guide work through phases zero to eight in Basic and Advanced', () => {
for (const advanced of [false, true]) for (let phase = 0; phase <= 8; phase++) {
const game = projected(phase, advanced), sent: Action[] = [];
game.ready = ['p'];
const button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); press(button);
assert.deepEqual(sent, [game.bribePromiseGuide!.action]); assert.equal(sent[0], game.bribePromiseGuide!.action);
const html = markup(game); assert.match(html, /data-testid="bribe-promise-guide"/);
assert.match(html, /No stays binding until the actual turn ends; opening collection does not end it/);
if (phase === 8) assert.match(html, /Late payments still honor this turn’s answers; their recipients collect at next turn’s Mentat opening/);
else assert.match(html, /Your next Mentat opening is still ahead. Accepted payments count now and their recipients collect at that opening/);
}
const game = projected(8); game.mixedBribePromises[0].fulfilled = true;
assert.doesNotMatch(markup(game), /No stays binding until the actual turn ends|data-testid="bribe-promise-guide"/);
assert.match(markup(game), /Payments made during Mentat Pause wait until the start of next turn&#x27;s Mentat Pause/);
assert.match(markup(game), /Collection does not count the payment a second time/);
});
void test('mixed stale native guide rejects changed event, owner, funding, phase, quiet window and room state', () => {
const changes: ((game: GameView) => void)[] = [
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 8; }, game => { game.status = 'setup'; },
game => { game.bribePromiseGuide = { action: { ...game.bribePromiseGuide!.action } }; },
game => { game.bribePromiseGuide = null; }, game => { game.bribePromiseGuide!.action.amount = 3; },
game => { game.bribePromiseGuide!.action.target = 'a'; }, game => { game.bribePromiseGuide!.action.bribePromiseEvent = 'other'; },
game => { game.mixedBribePromises[0].event = 'expired'; }, game => { game.mixedBribePromises[0].player = 'a'; },
game => { game.mixedBribePromises[0].turn = 1; }, game => { game.mixedBribePromises[0].fulfilled = true; },
game => { game.mixedBribePromises[0].released = true; }, game => { game.mixedBribePromises = []; },
game => { game.players = game.players.filter(player => player.id !== 'r'); },
game => { game.players.find(player => player.id === 'p')!.ally = 'r'; },
game => { game.bribeOptions.blocked = 'Finish a decision first.'; }, game => { game.bribeOptions.blocked = ''; },
game => { game.bribeOptions.available = 1; }, game => { game.bribeOptions.available = NaN; },
game => { game.bribeOptions.targets[0].maximum = 1; }, game => { game.bribeOptions.targets[0].maximum = NaN; },
game => { game.bribeOptions.targets = []; }, game => { game.phaseOpening = { passed: [] }; },
game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
game => { game.automaticContinuationPending = true; }, game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
game => { game.techTokens = createTechTokens(); },
];
for (const change of changes) {
const game = projected(), sent: Action[] = [], button = guide({ game, busy: false, send(action) { sent.push(action); } });
assert.equal(button.props.disabled, false); change(game); press(button); assert.deepEqual(sent, []);
}
const busy = { game: projected(), busy: false, send() { assert.fail('Busy guide cannot send'); } };
const oldBusy = guide(busy); busy.busy = true; press(oldBusy);
const replaced = { game: projected(), busy: false, send() { assert.fail('Replaced view cannot send'); } };
const oldReplaced = guide(replaced); replaced.game = { ...replaced.game }; press(oldReplaced);
for (const event of ['', 'other-event']) {
const game = projected(); game.bribePromiseGuide!.action.bribePromiseEvent = event;
const button = guide({ game, busy: false, send() { assert.fail('Unknown event cannot send'); } });
assert.equal(button.props.disabled, true); press(button);
}
});
void test('one live panel owns the common exact guide across scalar, pure, mixed and fulfilled historical records', () => {
const game = projected();
const both = () => renderToStaticMarkup(createElement('div', null,
createElement(BribePromises, { game, busy: false, send() {} }),
createElement(BribeExpressionPromises, { game, busy: false, send() {} }),
createElement(MixedBribePromises, { game, busy: false, send() {} })));
const one = () => assert.equal((both().match(/data-testid="bribe-promise-guide"/g) ?? []).length, 1);
assert.match(markup(game), /data-testid="bribe-promise-guide"/); one();
game.bribePromises.push({ event: 'scalar-history', player: 'p', asker: 'a', turn: 1, recipient: 'r', minimum: 1,
answer: true, progress: 1, fulfilled: true });
game.bribeExpressionPromises.push({ event: 'pure-history', player: 'p', asker: 'a', turn: 1,
claim: { kind: 'payment', recipient: 'r', minimum: 1 }, answer: true, progress: [{ recipient: 'r', amount: 1 }], fulfilled: true });
assert.match(markup(game), /data-testid="bribe-promise-guide"/); one();
game.bribeExpressionPromises.push({ event: 'live-pure', player: 'p', asker: 'a', turn: 2,
claim: { kind: 'payment', recipient: 'r', minimum: 3 }, answer: true, progress: [] });
assert.doesNotMatch(markup(game), /data-testid="bribe-promise-guide"/); one();
game.bribePromises.push({ event: 'live-scalar', player: 'p', asker: 'a', turn: 2, recipient: 'r', minimum: 3,
answer: true, progress: 0 });
assert.doesNotMatch(renderToStaticMarkup(createElement(BribeExpressionPromises, { game, busy: false, send() {} })), /data-testid="bribe-promise-guide"/);
one(); game.bribePromises[1].fulfilled = true; one();
game.bribeExpressionPromises[1].fulfilled = true; assert.match(markup(game), /data-testid="bribe-promise-guide"/); one();
game.mixedBribePromises[0].fulfilled = true;
assert.doesNotMatch(both(), /data-testid="bribe-promise-guide"/);
});
+52
View File
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { truthFactAnswer, type TruthFact } from '../game/truthtrance';
import {
actTruthLeaderRevival as act, createTruthLeaderRevivalFixture as fixture,
declareTruthLeaderRevival as declare, finishTruthLeaderRevival as finish,
settleTruthLeaderRevival as settle, truthLeaderRevivalInventory as inventory,
truthLeaderRevivalPlayer as player,
} from './fixture-truth-leader-revival';
import {
answerTruthMixedBribe as answer, askTruthMixedBribe as ask, truthMixedBribeFact as fact,
truthMixedBribeGroup as group, truthMixedBribePayment as payment, truthMixedBribePromise as promise,
} from './fixture-truth-mixed-bribe';
for (const first of ['bribe', 'revival'] as const) void test(`native mixed current spice and payments retain actual native leader funding with ${first} accepted first`, () => {
// This original H/A/T Ix47 source really pays for a shipment, loses Hidar
// to the original projectile, and reaches next-turn native Revival with4.
// No purse, death, hand, clock or commitment is installed by this supplement.
const f = fixture(); assert.equal(f.originalCards.length, 47); assert.deepEqual(f.staging, []);
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.game.phase, 4); assert.equal(f.game.turn, 2);
assert.equal(player(f.game, f.target).spice, 4); assert.equal(player(f.game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, true);
const spiceFact: TruthFact = { kind: 'spice', compare: 'gte', value: 4 };
const mixed = group('and', fact(spiceFact), payment(f.asker, 1), payment(f.opponent, 1));
assert.equal(truthFactAnswer(player(f.game, f.target), spiceFact), 'yes');
let game = declare(f, f.game, { copies: 2 }).game;
const bribe = () => { game = answer(ask(game, f.asker, f.target, mixed), f.target, 'yes'); };
const revival = () => {
game = act(game, f.asker, { type: 'truthAsk', question: f.question });
assert.deepEqual(viewGame(game, f.target).truthLeaderRevivalAnswers, ['yes', 'no']);
game = answer(game, f.target, 'yes');
};
if (first === 'bribe') { bribe(); revival(); } else { revival(); bribe(); }
const before = structuredClone(game);
// Actual free force revival can prepare one spice, still insufficient for
// H3 together with the owed A1 payment and this named leader's native2.
assert.throws(() => act(game, f.target, { type: 'bribe', target: f.asker, amount: 3 })); assert.deepEqual(game, before);
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 1 });
assert.equal(player(game, f.target).spice, 3); assert.equal(truthFactAnswer(player(game, f.target), spiceFact), 'no');
assert.deepEqual(promise(game, f.target).progress, [{ recipient: f.asker, amount: 1 }]);
assert.ok(!promise(game, f.target).fulfilled); assert.ok(!game.leaderRevivalPromises![0].fulfilled);
game = act(game, f.target, { type: 'bribe', target: f.opponent, amount: 1 });
assert.equal(player(game, f.target).spice, 2); assert.equal(promise(game, f.target).fulfilled, true);
const guide = viewGame(game, f.target).leaderRevivalPromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'reviveLeader'); assert.equal(guide.action.leader, f.leader);
game = settle(act(game, f.target, guide.action)); assert.equal(player(game, f.target).spice, 0);
assert.equal(game.leaderRevivalPromises![0].fulfilled, true); assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(player(game, f.target).leaders.find(disc => disc.id === f.leader)!.dead, false);
assert.equal(player(game, f.asker).bribes, 1); assert.equal(player(game, f.opponent).bribes, 1);
for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
assert.deepEqual(inventory(finish(game)), f.originalCards);
});
+277
View File
@@ -0,0 +1,277 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { viewGame, type Action, type Game } from '../game/engine';
import { truthFactAnswer } from '../game/truthtrance';
import { truthKnowledgeOf } from '../game/truthtrance-knowledge';
import { enterMentatBribeWithUnpaidPromise } from './fixture-mentat-bribes';
import {
actTruthMixedBribe as act, answerTruthMixedBribe as answer, askTruthMixedBribe as ask,
bindTruthMixedBribe as bind, completeTruthMixedBribe as complete, createTruthMixedBribeFixture as fixture,
declareTruthMixedBribe as declare, finishTruthMixedBribe as finish, truthMixedBribeBalances as balances,
truthMixedBribeFact as fact, truthMixedBribeGroup as group, truthMixedBribeInventory as inventory,
truthMixedBribePayment as payment, truthMixedBribePlayer as player, truthMixedBribePromise as promise,
} from './fixture-truth-mixed-bribe';
const progress = (game: Game, recipient: string) => promise(game).progress.find(row => row.recipient === recipient)?.amount ?? 0;
const pay = (game: Game, actor: string, recipient: string, amount: number) => act(game, actor, { type: 'bribe', target: recipient, amount });
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function truthOnce(game: Game, truth: string) {
assert.equal(game.discard.filter(card => card.id === truth).length, 1);
}
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} mixed spice fact freezes before actual cumulative payments debit its original purse`, () => {
const f = fixture({ advanced }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.equal(f.originalCards.length, 33); assert.deepEqual(f.staging, []); assert.deepEqual(f.game.expansions, []);
assert.equal(f.sourceActionCount, advanced ? 23 : 20); assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.deepEqual(player(f.afterSetup, f.asker).hand.map(card => card.id), f.truths);
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(card => card.id), f.deal);
assert.equal(truthFactAnswer(player(f.game, f.target), f.spiceFact), 'yes');
const source = structuredClone(f.game), before = balances(f.game); let game = bind(f).game;
assert.deepEqual(f.game, source); assert.deepEqual(promise(game).mixed, f.mixed); assert.deepEqual(promise(game).progress, []);
assert.equal(game.auction!.active, f.asker); assert.notEqual(game.auction!.active, f.target); truthOnce(game, f.truth);
game = pay(game, f.target, f.asker, 1);
assert.equal(progress(game, f.asker), 1); assert.ok(!promise(game).fulfilled);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
game = pay(game, f.target, f.asker, 2); assert.equal(progress(game, f.asker), 3); assert.ok(!promise(game).fulfilled);
game = pay(game, f.target, f.opponent, 2); assert.equal(progress(game, f.opponent), 2);
assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false); assert.equal(player(game, f.target).spice, 5);
for (const recipient of [f.asker, f.opponent]) assert.equal(player(game, recipient).spice, before[recipient]);
assert.equal(player(game, f.asker).bribes, 3); assert.equal(player(game, f.opponent).bribes, 2);
const result = complete(f, game);
for (const [recipient, amount] of [[f.asker, 3], [f.opponent, 2]] as const) {
assert.equal(player(result.mentat.before, recipient).bribes, amount);
assert.equal(player(result.mentat.after, recipient).spice - player(result.mentat.before, recipient).spice, amount);
assert.equal(player(result.mentat.after, recipient).bribes, 0); assert.equal(player(result.game, recipient).bribes, 0);
assert.equal(progress(result.game, recipient), amount);
}
assert.equal(result.game.turn, 2); assert.equal(result.game.phase, 0); truthOnce(result.game, f.truth);
assert.deepEqual(inventory(result.game), f.originalCards);
});
void test('native true held Shield OR future payment gives immediate Yes without a residual binding', () => {
const f = fixture(), mixed = group('or', fact(f.shieldFact), payment(f.asker)), declared = declare(f);
assert.equal(truthFactAnswer(player(f.game, f.target), f.shieldFact), 'yes');
const asked = ask(declared.game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedBribeAnswers, ['yes']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'no' });
let game = answer(asked, f.target, 'yes'); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(promise(game).progress, []); assert.deepEqual(promise(game).mixed, mixed);
assert.equal(game.bribeExpressionPromises?.length ?? 0, 0); assert.equal(viewGame(game, f.target).bribePromiseGuide, null);
game = finish(game); assert.equal(player(game, f.target).spice, 10); truthOnce(game, f.truth);
assert.ok(player(game, f.target).hand.some(card => card.id === f.targetCard)); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native false held Hajr AND future payment gives immediate No while the actual payment remains legal', () => {
const f = fixture(), mixed = group('and', fact(f.hajrFact), payment(f.asker)), declared = declare(f);
assert.equal(truthFactAnswer(player(f.game, f.target), f.hajrFact), 'no');
const asked = ask(declared.game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedBribeAnswers, ['no']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
let game = answer(asked, f.target, 'no'); assert.equal(promise(game).fulfilled, true);
assert.equal(game.bribeExpressionPromises?.length ?? 0, 0);
game = pay(game, f.target, f.asker, 3); assert.equal(player(game, f.target).spice, 7);
assert.equal(player(game, f.asker).bribes, 3); assert.deepEqual(promise(game).progress, []);
truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native false Hajr OR future payment Yes requires a real debit rather than the frozen false fact', () => {
const f = fixture(); let game = bind(f, 'yes', group('or', fact(f.hajrFact), payment(f.asker))).game;
assert.ok(!promise(game).fulfilled); assert.deepEqual(promise(game).progress, []);
game = pay(game, f.target, f.asker, 2); assert.ok(!promise(game).fulfilled);
game = pay(game, f.target, f.asker, 1); assert.equal(promise(game).fulfilled, true);
assert.equal(progress(game, f.asker), 3); assert.equal(player(game, f.target).spice, 7);
truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game)), f.originalCards);
});
void test('native true Shield AND future payment No caps the real cumulative amount through turn end', () => {
const f = fixture(); let game = bind(f, 'no', group('and', fact(f.shieldFact), payment(f.asker))).game;
assert.ok(!promise(game).fulfilled); rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
game = pay(game, f.target, f.asker, 2); assert.equal(progress(game, f.asker), 2);
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 1 });
game = finish(game); assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).spice, 8);
assert.deepEqual(inventory(game), f.originalCards);
});
for (const answerValue of ['yes', 'no'] as const) void test(`native unknown non-Fremen forecast uses the conservative whole ${answerValue} payment constraint`, () => {
const f = fixture(), p = player(f.game, f.target);
assert.equal(p.faction, 'emperor'); assert.equal(truthKnowledgeOf(f.game, p).stormForecast, null);
assert.equal(truthFactAnswer(p, f.forecastFact, truthKnowledgeOf(f.game, p)), 'unknown');
const mixed = group(answerValue === 'yes' ? 'or' : 'and', fact(f.forecastFact), payment(f.asker));
const asked = ask(declare(f).game, f.asker, f.target, mixed);
assert.deepEqual(viewGame(asked, f.target).truthMixedBribeAnswers, [answerValue]);
rejected(asked, f.target, { type: 'truthAnswer', answer: answerValue === 'yes' ? 'no' : 'yes' });
let game = answer(asked, f.target, answerValue); assert.ok(!promise(game).fulfilled);
if (answerValue === 'yes') {
game = pay(game, f.target, f.asker, 3); assert.equal(promise(game).fulfilled, true); assert.equal(progress(game, f.asker), 3);
} else {
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 3 });
game = pay(game, f.target, f.asker, 2); assert.ok(!promise(game).fulfilled);
}
game = finish(game); assert.equal(promise(game).fulfilled, true); truthOnce(game, f.truth);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed OR keeps the actual alternate recipient available instead of preselecting a branch', () => {
const f = fixture(), mixed = group('or', group('and', fact(f.shieldFact), payment(f.asker, 3)), payment(f.opponent, 2));
let game = bind(f, 'yes', mixed).game;
const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); assert.ok(typeof guide.action.target === 'string');
const recipient = guide.action.target === f.asker ? f.opponent : f.asker, amount = recipient === f.asker ? 3 : 2;
assert.notEqual(recipient, guide.action.target); game = pay(game, f.target, recipient, amount);
assert.equal(promise(game).fulfilled, true); assert.equal(progress(game, recipient), amount);
assert.equal(progress(game, guide.action.target), 0); assert.equal(player(game, f.target).spice, 10 - amount);
rejected(game, f.target, guide.action); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native repeated mixed recipient thresholds use one cumulative actual total', () => {
const f = fixture(), mixed = group('and', fact(f.shieldFact), payment(f.asker, 3), payment(f.asker, 5));
let game = bind(f, 'yes', mixed).game;
game = pay(game, f.target, f.asker, 3); assert.equal(progress(game, f.asker), 3); assert.ok(!promise(game).fulfilled);
game = pay(game, f.target, f.asker, 2); assert.equal(promise(game).fulfilled, true);
assert.deepEqual(promise(game).progress, [{ recipient: f.asker, amount: 5 }]);
assert.equal(player(game, f.target).spice, 5); assert.equal(player(game, f.asker).bribes, 5);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('native earlier payments remain in the receipt but start outside newly answered mixed progress', () => {
const f = fixture(), mixed = group('and', fact(f.shieldFact), payment(f.asker, 3), payment(f.opponent, 2));
let game = pay(f.game, f.target, f.asker, 3); game = bind(f, 'yes', mixed, game).game;
assert.deepEqual(promise(game).progress, []); assert.equal(player(game, f.asker).bribes, 3);
game = pay(game, f.target, f.opponent, 2); assert.ok(!promise(game).fulfilled);
game = pay(game, f.target, f.asker, 3); assert.equal(promise(game).fulfilled, true);
assert.equal(progress(game, f.asker), 3); assert.equal(player(game, f.asker).bribes, 6); assert.equal(player(game, f.target).spice, 2);
const result = complete(f, game);
assert.equal(player(result.mentat.after, f.asker).spice - player(result.mentat.before, f.asker).spice, 6);
assert.equal(progress(result.game, f.asker), 3); assert.deepEqual(inventory(result.game), f.originalCards);
});
void test('two original Truths retain scalar H7 funding while the mixed OR shares the actual ten spice', () => {
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'bribe', target: f.target, turn: game.turn,
recipient: f.asker, minimum: 7 } });
game = answer(game, f.target, 'yes');
game = ask(game, f.asker, f.target, group('and', fact(f.spiceFact), group('or', payment(f.asker, 7), payment(f.opponent, 4))));
assert.deepEqual(viewGame(game, f.target).truthMixedBribeAnswers, ['yes']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'no' }); game = answer(game, f.target, 'yes');
rejected(game, f.target, { type: 'bribe', target: f.opponent, amount: 4 });
game = pay(game, f.target, f.opponent, 3); assert.ok(!promise(game).fulfilled);
game = pay(game, f.target, f.asker, 7); assert.equal(promise(game).fulfilled, true);
assert.equal(game.bribePromises![0].progress, 7); assert.equal(game.bribePromises![0].fulfilled, true);
assert.equal(progress(game, f.opponent), 3); assert.equal(progress(game, f.asker), 7); assert.equal(player(game, f.target).spice, 0);
for (const truth of f.truths) truthOnce(game, truth); assert.deepEqual(inventory(game), f.originalCards);
});
void test('two original Truths retain scalar No H5 while a mixed Yes follows the actual alternate A2 guide', () => {
const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'bribe', target: f.target, turn: game.turn,
recipient: f.asker, minimum: 5 } });
game = answer(game, f.target, 'no');
game = answer(ask(game, f.asker, f.target, group('and', fact(f.shieldFact),
group('or', payment(f.asker, 5), payment(f.opponent, 2)))), f.target, 'yes');
const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide);
assert.equal(guide.action.target, f.opponent); assert.equal(guide.action.amount, 2);
assert.equal(guide.action.bribePromiseEvent, promise(game).event);
rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 5 }); game = act(game, f.target, guide.action);
assert.equal(game.bribePromises![0].progress, 0); assert.ok(!game.bribePromises![0].fulfilled);
assert.equal(progress(game, f.opponent), 2); assert.equal(promise(game).fulfilled, true);
assert.equal(player(game, f.target).spice, 8); assert.deepEqual(inventory(game), f.originalCards);
});
void test('native mixed projection retains only owned raw question and aggregate with no frozen residual clauses', () => {
const f = fixture(), game = bind(f).game;
for (const actor of [f.target, f.asker, f.opponent]) {
const view = viewGame(game, actor); assert.ok(!('mixedBribeQuestions' in view)); assert.deepEqual(view.bribeExpressionPromises, []);
assert.deepEqual(view.truthHistory.at(-1)?.question, f.question); assert.equal(view.truthHistory.at(-1)?.answer, 'yes');
assert.equal(view.truthMixedBribeAnswers, null);
if (actor !== f.target) {
assert.deepEqual(view.mixedBribePromises, []); assert.equal(view.bribePromiseGuide, null);
} else {
const row = promise(game), required = ['answer', 'asker', 'event', 'mixed', 'player', 'progress', 'turn'];
const allowed = [...required, 'fulfilled', 'released'];
assert.ok(required.every(key => Object.keys(row).includes(key))); assert.ok(Object.keys(row).every(key => allowed.includes(key)));
assert.deepEqual(row.mixed, f.mixed); assert.deepEqual(row.progress, []);
assert.equal(row.asker, f.asker); assert.equal(row.player, f.target); assert.equal(row.turn, game.turn);
assert.ok(view.bribePromiseGuide?.action.bribePromiseEvent); assert.ok(!JSON.stringify(row).includes('"claim"'));
}
}
assert.equal(game.bribeExpressionPromises?.length, 1); assert.equal(game.bribeExpressionPromises![0].event, promise(game).event);
});
void test('native exact mixed payment question and guide keep target turn event and JSON wire authoritative', () => {
const f = fixture(), declared = declare(f);
assert.ok(viewGame(declared.game, f.asker).truthMixedBribeTargets.includes(f.target));
rejected(declared.game, f.target, f.questionAction);
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question,
mixed: group('and', fact(f.shieldFact), payment(f.target, 1)) } });
const asked = ask(declared.game, f.asker, f.target, group('and', fact(f.shieldFact), payment(f.asker, 3)));
assert.equal(viewGame(asked, f.asker).truthMixedBribeAnswers, null);
assert.deepEqual(viewGame(asked, f.target).truthMixedBribeAnswers, ['yes', 'no']);
rejected(asked, f.asker, { type: 'truthAnswer', answer: 'yes' }); let game = answer(asked, f.target, 'yes');
const guide = viewGame(game, f.target).bribePromiseGuide; assert.ok(guide); assert.ok(typeof guide.action.amount === 'number');
assert.equal(guide.action.bribePromiseEvent, promise(game).event); rejected(game, f.opponent, guide.action);
rejected(game, f.target, { ...guide.action, bribePromiseEvent: 'stale-mixed-bribe-event' });
rejected(game, f.target, { ...guide.action, amount: guide.action.amount + 1 });
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, wire);
assert.equal(promise(game).fulfilled, true); rejected(game, f.target, wire); rejected(finish(game), f.target, wire);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
});
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} Mentat mixed Yes reopens its ready payer and pays at the actual next opening`, () => {
const f = fixture({ advanced, phase: 8 }); assert.equal(f.sourceActionCount, advanced ? 35 : 32);
assert.equal(f.sourceActionCount, f.sourceActions.length); let game = act(f.game, f.target, { type: 'ready' });
game = act(game, f.asker, { type: 'ready' }); assert.equal(game.phase, 8);
assert.ok(game.ready.includes(f.target)); assert.ok(game.ready.includes(f.asker));
game = bind(f, 'yes', f.mixed, game).game;
assert.ok(!game.ready.includes(f.target)); assert.ok(game.ready.includes(f.asker)); assert.ok(!game.ready.includes(f.opponent));
rejected(game, f.target, { type: 'ready' }); const before = balances(game);
game = pay(game, f.target, f.asker, 3); rejected(game, f.target, { type: 'ready' });
game = pay(game, f.target, f.opponent, 2); game = act(game, f.target, { type: 'ready' });
assert.equal(promise(game).fulfilled, true); assert.ok(game.ready.includes(f.target));
for (const recipient of [f.asker, f.opponent]) assert.equal(player(game, recipient).spice, before[recipient]);
const result = complete(f, game); assert.equal(result.nextTurn.after.turn, 2); assert.equal(result.nextTurn.after.phase, 0);
for (const [recipient, amount] of [[f.asker, 3], [f.opponent, 2]] as const) {
for (const state of [result.nextTurn.after, result.collection.after, result.mentat.before]) {
assert.equal(player(state, recipient).bribes, amount); assert.equal(player(state, f.target).spice, 5);
}
assert.equal(player(result.mentat.after, recipient).spice - player(result.mentat.before, recipient).spice, amount);
assert.equal(player(result.mentat.after, recipient).bribes, 0);
}
truthOnce(result.game, f.truth); assert.deepEqual(inventory(result.game), f.originalCards);
});
void test('native mixed No remains binding across actual opening receipts and late payment until turn end', () => {
const f = fixture({ phase: 7 }); let game = bind(f, 'no').game;
game = pay(game, f.target, f.asker, 3); const before = player(game, f.asker).spice;
game = enterMentatBribeWithUnpaidPromise(game); assert.equal(game.phase, 8);
assert.equal(player(game, f.asker).spice, before + 3); assert.equal(player(game, f.asker).bribes, 0);
assert.equal(progress(game, f.asker), 3); game = pay(game, f.target, f.opponent, 1);
rejected(game, f.target, { type: 'bribe', target: f.opponent, amount: 1 }); assert.ok(!promise(game).fulfilled);
game = finish(game); assert.equal(game.turn, 2); assert.equal(promise(game).fulfilled, true);
game = pay(game, f.target, f.opponent, 2); assert.equal(progress(game, f.opponent), 1);
assert.equal(player(game, f.opponent).bribes, 3); assert.equal(player(game, f.target).spice, 4);
assert.deepEqual(inventory(game), f.originalCards);
});
void test('all four minimal policies actually answer mixed Yes and submit its exact ordinary payment guides', () => {
const f = fixture({ phase: 8 }), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked);
const answering = viewGame(game, f.target); answering.players.find(seat => seat.id === f.target)!.bot = difficulty;
const answerAction = botActions(answering).find(action => action.type === 'truthAnswer'); assert.ok(answerAction, difficulty);
assert.equal(answerAction.answer, 'yes', difficulty); assert.ok(answering.truthMixedBribeAnswers?.includes('yes'));
game = act(game, f.target, answerAction);
for (let count = 0; count < 8 && !promise(game).fulfilled; count++) {
const view = viewGame(game, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
const guide = view.bribePromiseGuide; assert.ok(guide, difficulty);
const action = botActions(view).find(action => action.type === 'bribe' && action.bribePromiseEvent === guide.action.bribePromiseEvent);
assert.ok(action, difficulty); assert.deepEqual(action, guide.action); game = act(game, f.target, action);
}
assert.equal(promise(game).fulfilled, true, difficulty); assert.equal(player(game, f.target).spice, 5);
assert.equal(player(game, f.asker).bribes, 3); assert.equal(player(game, f.opponent).bribes, 2);
truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
}
});