({
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 })}
)}
>
+ ) : w.question!.kind === 'mixedBribe' ? (
+ <>
+ 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.
+ {(g.truthMixedBribeAnswers ?? []).map(answer =>
+ {button(answer === 'unknown' ? 'I don’t know yet' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}
+
)}
+ >
) : w.question!.kind === 'bribeExpression' ? (
<>
Yes requires the whole AND/OR question to become true through your future ordinary payments. No keeps the whole expression false, and may permit some payments. Earlier payments and later recipient collection do not count. Keep the funds needed by every answer; late Mentat receipts wait for next turn's opening.
@@ -454,6 +469,7 @@ export function Truthtrance({
Bind alliance membership at this Nexus close
Bind ordinary bribe payments this turn
Combine ordinary bribe payments with AND/OR
+ Combine current facts and ordinary payments
Bind combined force and leader revivals
Bind a named leader revival this turn
Bind ordinary force revivals this turn
@@ -498,6 +514,14 @@ export function Truthtrance({
{!g.truthAllianceTargets.includes(target) && Choose a supported participant in a quiet open ordinary Nexus. Optional modules and broader alliance questions remain unfinished.
}
{truthQuestionText(question, leaderName, locationName, name)}
>
+ ) : kind === 'mixedBribe' ? (
+ <>
+
+ Current facts freeze when answered. Later ordinary payer payments add together until this turn ends. No negates the whole mixed question. Current funds and earlier promises stay available; late Mentat receipts wait until next turn's opening.
+ {!g.truthMixedBribeTargets.includes(target) && Choose a supported quiet native window and finish pending decisions. Optional modules remain unfinished.
}
+ {mixedBribeError ?? truthQuestionText(question, leaderName, locationName, name)}
+ >
) : kind === 'bribeExpression' ? (
<>
`; 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.
diff --git a/docs/TRUTHTRANCE_MIXED_MOVEMENT.md b/docs/TRUTHTRANCE_MIXED_MOVEMENT.md
index db133820..4d65f228 100644
--- a/docs/TRUTHTRANCE_MIXED_MOVEMENT.md
+++ b/docs/TRUTHTRANCE_MIXED_MOVEMENT.md
@@ -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
diff --git a/game/bots.ts b/game/bots.ts
index 560741c6..c534246d 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -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 }] : [];
diff --git a/game/engine.ts b/game/engine.ts
index 7a0944ad..79db16f6 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -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): 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,
diff --git a/game/mixed-bribe-question.ts b/game/mixed-bribe-question.ts
new file mode 100644
index 00000000..874f337c
--- /dev/null
+++ b/game/mixed-bribe-question.ts
@@ -0,0 +1,15 @@
+import { BribePromiseError, parseBribeClaim, type BribeClaim } from './bribe-promises';
+import type { MixedActionExpression } from './mixed-action-question';
+
+export type BribePaymentClaim = Pick;
+export type MixedBribeClaim = { turn: number; mixed: MixedActionExpression };
+
+/** 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 };
+}
diff --git a/game/reference.ts b/game/reference.ts
index 6daf8e54..d1f6fb1e 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -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',
diff --git a/game/truthtrance.ts b/game/truthtrance.ts
index bdba3f53..06d43ac0 100644
--- a/game/truthtrance.ts
+++ b/game/truthtrance.ts
@@ -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 };
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.');
diff --git a/tests/fixture-truth-mixed-bribe.ts b/tests/fixture-truth-mixed-bribe.ts
new file mode 100644
index 00000000..1f5fe4e7
--- /dev/null
+++ b/tests/fixture-truth-mixed-bribe.ts
@@ -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;
+
+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;
+}
diff --git a/tests/truth-bribe-expression-controls.test.tsx b/tests/truth-bribe-expression-controls.test.tsx
index 47784f13..28c6cf24 100644
--- a/tests/truth-bribe-expression-controls.test.tsx
+++ b/tests/truth-bribe-expression-controls.test.tsx
@@ -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() {} })));
diff --git a/tests/truth-mixed-bribe-controls.test.tsx b/tests/truth-mixed-bribe-controls.test.tsx
new file mode 100644
index 00000000..dd7ac04e
--- /dev/null
+++ b/tests/truth-mixed-bribe-controls.test.tsx
@@ -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;
+type ElementProps = { children?: ReactNode; [key: string]: unknown };
+type Element = ReactElement;
+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(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[0]) {
+ const button = BribePromiseGuideButton(props); assert.ok(button); return button;
+}
+const press = (button: ReturnType) => 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 = { kind: 'fact', fact: { kind: 'hand', name: 'Shield' } };
+ const game = projected(); let parsed = 0;
+ const config: MixedActionConfig = {
+ 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 => {props.value.token} ,
+ };
+ 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(), //);
+ 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'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"/);
+});
diff --git a/tests/truth-mixed-bribe-funding.test.ts b/tests/truth-mixed-bribe-funding.test.ts
new file mode 100644
index 00000000..33724bef
--- /dev/null
+++ b/tests/truth-mixed-bribe-funding.test.ts
@@ -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);
+});
diff --git a/tests/truth-mixed-bribe-runtime.test.ts b/tests/truth-mixed-bribe-runtime.test.ts
new file mode 100644
index 00000000..376a8e3e
--- /dev/null
+++ b/tests/truth-mixed-bribe-runtime.test.ts
@@ -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);
+ }
+});