({
kind: 'and',
@@ -177,6 +184,7 @@ export function Truthtrance({
: kind === 'bribeExpression' ? { kind, target, turn: g.turn, claim: bribeExpression }
: kind === 'bribe' ? { kind, target, ...bribeClaim }
: kind === 'revival' ? { kind, target, ...revivalClaim }
+ : kind === 'mixedBid' ? { kind, target, turn: bidClaim.turn, lot: bidClaim.lot, mixed: mixedBid }
: kind === 'bid' ? { kind, target, ...bidClaim }
: kind === 'shipment'
? {
@@ -285,6 +293,13 @@ export function Truthtrance({
))}
>
+ ) : w.question!.kind === 'mixedBid' ? (
+ <>
+ Answer the whole mixed question. Current facts freeze now; any remaining bid conditions concern future accepted declarations in this ordinary lot. No keeps the whole expression false. Passing, funding, Karama and the original winning payment still follow the auction rules. An unknown fact requires a commitment that guarantees the whole answer; otherwise choose I don’t know.
+ {(g.truthMixedBidAnswers ?? []).map(answer =>
+ {button(answer === 'unknown' ? 'I don’t know' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}
+
)}
+ >
) : w.question!.kind === 'mixedRevival' ? (
<>
Answer the complete mixed question. Current facts freeze now; only future ordinary returns can fulfill any remaining Revival conditions. No keeps the whole expression false. Native eligibility, costs, limits and turn order still apply. An unknown fact requires a future commitment that guarantees your answer; otherwise choose I don’t know.
@@ -490,6 +505,7 @@ export function Truthtrance({
Bind a named leader revival this turn
Bind ordinary force revivals this turn
Bind a bid in this ordinary lot
+ Combine current facts and ordinary bids
Bind this battle’s Traitor declaration
Bind a shipment from reserves
@@ -505,7 +521,14 @@ export function Truthtrance({
)}
- {kind === 'mixedRevival' ? (
+ {kind === 'mixedBid' ? (
+ <>
+
+ {!g.truthMixedBidTargets.includes(target) && Choose an eligible participant with a future bid opportunity in this quiet ordinary lot.
}
+ {mixedBidError ?? truthQuestionText(question, leaderName, locationName, name)}
+ >
+ ) : kind === 'mixedRevival' ? (
<>
@@ -705,6 +728,7 @@ export function Truthtrance({
(kind === 'bribeExpression' && (!!bribeExpressionError || !validBribeExpressionRecipients || !g.truthBribeExpressionTargets.includes(target))) ||
(kind === 'bribe' && (!!bribeClaimInputError(bribeClaim, bribeRecipients) || !g.truthBribeTargets.includes(target))) ||
(kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) ||
+ (kind === 'mixedBid' && (!!mixedBidError || !g.truthMixedBidTargets.includes(target))) ||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
(kind === 'traitorCall' && !g.truthTraitorTargets.includes(target)) ||
(kind === 'mixedShipment' &&
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index f9f335a4..d3c56c70 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,39 @@ open mode gates.
## Current checkpoint and work
+**Truthtrance / current facts and ordinary bids — 26 October. Prototyped / Partial.**
+Full mixed AND/OR questions freeze facts before actual current-lot declarations
+and winning invoices. Remaining same-interval minima reduce exactly (AND max,
+OR min) to the existing native bid ledger; constants need no bid. Whole No,
+original opportunity/funding/Karama/payment/accepted release and actual close
+remain. Own raw question/aggregate status hides reduced minima, frozen values
+and physical lot source. Shared full editor, fixed public lot, raw Ask/private
+answers, one live exact guide and four minimal policies connect.
+
+Root integration with disjoint controls/native lanes passes **20 files,225/225**,
+types/lint/build and **ten native paths**,33 GameTable SSR renders/six actual
+guide callbacks plus one editor callback followed by native Ask. Original H/A/E
+base33 Basic22/Advanced25 prefixes add11: accepted4 fulfills before actual
+invoice changes E10→6, retaining the original fact. Edited true OR needs no
+binding; false AND permits a real qualifying bid. Native unknown OR Yes requires
+actual4; AND No permits lower3 only through the real close. Nested AND4/OR7,5
+requires5. Waiting20 adds12: A10 releases E's impossible answer and actual A
+payment earns original E income10. Waiting20 adds14: bribe7 fails, bribe6 plus
+actual bid4/invoice ends E0. Original Karama25 adds12: actual11 declaration and
+physical free invoice. All33 cards remain physical; four policies actually bid.
+
+Six pure,19 native,three original-funding and nine controlled cases supplement
+existing shared/scalar/auction evidence. Two original Truths keep No6/Yes4 with
+one real5 declaration and retain shipment9 funding. V1 types pass; lint catches
+a nested shared-editor helper call. The adapter restores established tail
+composition with fixed timing in the legend; all v2 checks pass. Proof/manifest:
+`/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-bid-proof-v2/`;
+journeys/HTML: adjacent `20261026-truth-mixed-bid-runtime/`.
+No saved-game/database/server/reset/restart/deployment operation is performed.
+Other families, correlated unknowns, phase-wide/special auctions, further
+preparations/compositions and final rules/browser/deployed/assurance remain.
+[Authority, exact native reduction and evidence](TRUTHTRANCE_MIXED_BIDS.md).
+
**Truthtrance / current facts and ordinary Revival — 26 October. Prototyped / Partial.**
Full mixed AND/OR questions now freeze facts at the answer and bind only the
necessary native force/personal-leader obligation. Constants need no return;
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 32e266d0..d7e2c8a8 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,41 @@
# Dune implementation status
+## 26 October 2026 — Current facts with ordinary bid declarations
+
+**Prototyped / Partial.**
+Full mixed AND/OR questions freeze facts before actual current-lot declarations
+and winning invoices. Remaining same-interval minima reduce exactly (AND max,
+OR min) to the existing native bid ledger; constants need no bid. Whole No,
+original opportunity/funding/Karama/payment/accepted release and actual close
+remain. Own raw question/aggregate status hides reduced minima, frozen values
+and physical lot source. Shared full editor, fixed public lot, raw Ask/private
+answers, one live exact guide and four minimal policies connect.
+
+Root integration with disjoint controls/native lanes passes **20 files,225/225**,
+types/lint/build and **ten native paths**,33 GameTable SSR renders/six actual
+guide callbacks plus one editor callback followed by native Ask. Original H/A/E
+base33 Basic22/Advanced25 prefixes add11: accepted4 fulfills before actual
+invoice changes E10→6, retaining the original fact. Edited true OR needs no
+binding; false AND permits a real qualifying bid. Native unknown OR Yes requires
+actual4; AND No permits lower3 only through the real close. Nested AND4/OR7,5
+requires5. Waiting20 adds12: A10 releases E's impossible answer and actual A
+payment earns original E income10. Waiting20 adds14: bribe7 fails, bribe6 plus
+actual bid4/invoice ends E0. Original Karama25 adds12: actual11 declaration and
+physical free invoice. All33 cards remain physical; four policies actually bid.
+
+Six pure,19 native,three original-funding and nine controlled cases supplement
+existing shared/scalar/auction evidence. Two original Truths keep No6/Yes4 with
+one real5 declaration and retain shipment9 funding. V1 types pass; lint catches
+a nested shared-editor helper call. The adapter restores established tail
+composition with fixed timing in the legend; all v2 checks pass. Proof/manifest:
+`/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-bid-proof-v2/`;
+journeys/HTML: adjacent `20261026-truth-mixed-bid-runtime/`.
+No saved-game/database/server/reset/restart/deployment operation is performed.
+Other families, correlated unknowns, phase-wide/special auctions, further
+preparations/compositions and final rules/browser/deployed/assurance remain.
+[Authority, exact native reduction and evidence](TRUTHTRANCE_MIXED_BIDS.md).
+
+
## 26 October 2026 — Current facts with ordinary force and named-leader Revival
**Prototyped / Partial.**
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index bf6211cc..6eb77a68 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,19 @@
# Rule decision index
+## Current facts combined with ordinary bids — 26 October 2026
+
+Reuse recorded publisher FAQp8 current-turn/AND/OR and supplied Advanced
+physical39 compliance/release/unknown authority, mixed fact snapshots and the
+existing exact current-lot native bid interval. Facts freeze before declaration
+or later winning payment. Unknown is false for definite Yes and true for No.
+Remaining positive minima in the same interval reduce exactly: AND maximum,
+OR minimum; whole No retains the resulting cap. Constants need no bid binding.
+Native eligibility, funding/Karama, declaration-versus-invoice, accepted order
+and actual lot close remain. No publisher-specific mixed-bid algorithm or
+correlated-unknown solver is claimed; the online opportunity/release model
+stays provisional. No mode/save/server/publication operation is granted.
+[Contract and evidence](TRUTHTRANCE_MIXED_BIDS.md).
+
## Current facts combined with ordinary Revival — 26 October 2026
Reuse the recorded publisher FAQp8 current-turn/AND/OR and supplied Advanced
diff --git a/docs/TRUTHTRANCE_MIXED_BIDS.md b/docs/TRUTHTRANCE_MIXED_BIDS.md
new file mode 100644
index 00000000..a72fbc0a
--- /dev/null
+++ b/docs/TRUTHTRANCE_MIXED_BIDS.md
@@ -0,0 +1,132 @@
+# Truthtrance current facts and ordinary bids
+
+## Mixed aggregate prototype — 26 October 2026
+
+**Prototyped / Partial.** The packet is
+`{kind:'mixedBid',target,turn,lot,mixed}`. Fact/action/AND/OR clauses reuse
+`MixedActionExpression`; each action contains only `{minimum}`.
+All bid leaves concern the same current ordinary lot. Quiet native Basic/
+Advanced tables retain existing [bid](TRUTHTRANCE_BID_PROMISES.md) eligibility,
+mode gates, funded completion and original payment rules.
+
+## Authority and snapshot meaning
+
+Reuse the recorded publisher November2020 FAQp8 current-turn/AND/OR and supplied
+Advanced physical39/printed37 compliance/release/unknown authority. The existing
+[mixed Revival](TRUTHTRANCE_MIXED_REVIVAL.md) and movement models define the same
+answer-time fact snapshot. No source supplies a complete online solver; native
+current-lot opportunity, owned witness and accepted-order release remain
+provisional under standing14October priority.
+
+Every fact freezes at the actual answer. A current-spice fact at10 stays that
+answer-time fact after an accepted bid4 and later winning payment leave6.
+Declaration itself spends no spice; the actual winning invoice does. Later
+resource/custody changes do not create new fact answers. No negates the entire
+mixed expression. Unknown takes false for Yes and true for No; any remaining
+native obligation must guarantee the selected aggregate. Correlations between
+unknown predicates are not inferred. If neither definite answer has a known
+guaranteed completion, unknown/save/change-question remains available.
+
+True fact OR bid can settle Yes immediately, false fact AND bid No immediately,
+without binding any bid. Otherwise every positive leaf is a threshold on the
+maximum future accepted declaration in this exact lot. AND takes the maximum
+threshold; OR takes the minimum. This exact reduction preserves all legal
+amounts and whole No; it chooses no fixed route. Smaller declarations never
+sum into a larger bid, and a purchase invoice is not a new declaration.
+
+## Native interval and shared implementation
+
+`game/mixed-bid-question.ts` strictly accepts one positive safe whole minimum,
+rejecting turn/lot/private metadata inside a leaf. Shared parsing validates all
+raw clauses under one16-leaf/depth4 budget including nested facts. Public frozen
+constants are not accepted. `mixedBidMinimum` reduces only an action residual;
+it never evaluates a fact or invents funds/opportunities.
+
+The shared compiler eagerly snapshots facts before folding known constants,
+identities and singleton groups. Only a needed residual enters the existing
+`bidPromises` ledger with its original hidden physical-lot source/event.
+Internal `mixedBidQuestions` links the original raw tree to that native event;
+constants create only terminal aggregate metadata. No second auction, native
+bid quote, winning-price solver or custody path is added.
+
+Existing current cash, prepaid ally aid, native Karama, capacity, active/waiting
+eligibility and earlier shipment/movement/revival/payment budgets remain.
+Current highest, passed and full-hand participants have no supported future
+opportunity. Prior declarations cannot fulfill a new answer. Yes requires a
+qualifying declaration at the next available opportunity while possible; a
+lower bid/pass assumes no future rival raise. Fulfillment survives losing.
+No caps every later declaration until actual lot close. Opposing raises may
+release an impossible Yes in accepted order; voluntary evasion is rejected.
+No finished record binds a new lot or turn.
+
+Own projection contains only original raw question, turn/lot, whole answer and
+fulfilled/released status. Frozen values, reduced minimum, binding metadata and
+hidden lot source remain unprojected; pure bid projection excludes mixed events.
+There is no additional progress counter because one qualifying declaration
+immediately fulfills Yes; No persists until actual close. The shared exact
+native bid/pass guide accepts a live pure or mixed event and retains its
+original owner/turn/lot/phase/funding guards. Historical panels stay passive.
+New mixed discard signatures include raw/native metadata without changing old
+record signatures or reactivating history.
+
+The full shared editor adds/wraps/nests/switches/removes groups and exposes only
+per-leaf minimum controls with the fixed current turn/lot. Invalid drafts remain
+repairable. Raw preview/Ask, private answer choices and persistent own aggregate
+status connect through the table. Four minimal profiles answer and execute
+native guides; strategy/calibration remain deferred.
+
+## Evidence and remaining gates
+
+Pure reduction: `tests/mixed-bid-question.test.ts`. Original native adapter and
+journeys: `tests/fixture-truth-mixed-bid.ts`,
+`tests/truth-mixed-bid-runtime.test.ts`, `tests/truth-mixed-bid-funding.test.ts`.
+Controlled editor/guide: `tests/truth-mixed-bid-controls.test.tsx`.
+Six pure reduction,19 original native,three funding and nine controlled cases
+supplement existing shared compiler/scalar bid/auction/funding coverage. The
+frozen **20-file,225/225** union, types/lint, production build and ten native
+paths pass on the same unchanged repository source. Actual GameTable SSR renders
+33 times; six exact bid-guide callbacks and one editor callback submit native
+packets. All four minimal profiles actually answer and declare legal bids on
+both original Basic/Advanced sources.
+
+| Original source | Prefix actions | Further actions | Actual result |
+| --- | ---: | ---: | --- |
+| Basic frozen spice | 22 | 11 | Future bid4 fulfills before original invoice changes E10→6 |
+| Advanced frozen spice | 25 | 11 | Same snapshot, actual declaration and original payment |
+| Basic edited true OR | 22 | 11 | Actual AND→OR editor/Ask; immediate Yes, later real bid5 remains legal |
+| Basic false AND | 22 | 11 | Immediate No leaves a real bid4/purchase legal |
+| Basic unknown OR Yes | 22 | 11 | Unknown forecast requires actual future bid4 |
+| Basic unknown AND No | 22 | 11 | Bid4 forbidden, actual lower3 purchase settles No at close |
+| Basic nested thresholds | 22 | 11 | AND4 with OR7/5 requires exact5; actual purchase leaves E5 |
+| Basic waiting opposing raise | 20 | 12 | Real A10 releases E's impossible Yes; A pays and E earns native10 income |
+| Basic own bribe budget | 20 | 14 | Bribe7 rejected, real bribe6 plus bid4/invoice leaves E0 |
+| Original Karama source | 25 | 12 | Actual11 declaration fulfills, original physical Karama settles free invoice |
+
+Other native cases retain every later No cap after an outbid, previous bid
+exclusion, exact wire/owner/lot/event, raw own projection, losing after a fulfilled
+Yes, and new-lot nonreactivation. Two original Truths preserve No6 plus Yes4 with
+one real5 declaration; shipment9 funding rejects later bidYes4 while actual bid1
+leaves the native9-force shipment affordable. All33 cards remain physical.
+
+V1 passes types but lint flags the adapter's capitalized helper call nested in
+JSX. The adapter retains the existing shared tail-delegation pattern, with fixed
+turn/lot in its legend. No rule or native test expectation changes. All frozen
+checks pass in v2. Verified source tree:
+`09637da04aa264eca212233ba2e33fb40529babab8e92d3caf46e0072425b3a8`
+(3179 files). Final evidence prose is a separate three-document overlay.
+Proof/report/manifest: `/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-bid-proof-v2/`;
+journeys/HTML: `/home/quintine/.local/share/dune/checkpoints/20261026-truth-mixed-bid-runtime/`.
+SSR/editor/guide callbacks are local engine/control evidence, not DOM,
+hydration, browser/mobile or deployed acceptance.
+
+The inherited original H/A/E base33 source genuinely deals both original
+Harkonnen Truths, opens the native bank lot and reaches active Basic22/Advanced25
+or waiting Basic20/Advanced23 histories. Only the raw question changes; no played clock, purse,
+hand, bid, answer, promise or fulfilled state is installed. Controlled fixtures
+retain their labels rather than claiming original acquisition histories.
+
+Other action families, correlated unknowns, phase-wide/native-special auction
+claims, future 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_NONBATTLE_READINESS_20260907.md b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md
index 613676ba..302ab34e 100644
--- a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md
+++ b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md
@@ -1,5 +1,10 @@
# Truthtrance nonbattle readiness and first implementation slice
+The [mixed current-lot bid follow-up](TRUTHTRANCE_MIXED_BIDS.md) composes
+answer-time facts with future accepted ordinary declarations. Native scalar
+thresholds exactly represent remaining same-lot AND/OR minima; phase-wide and
+native special auction commitments remain unfinished.
+
The [mixed ordinary-Revival follow-up](TRUTHTRANCE_MIXED_REVIVAL.md) composes
answer-time facts with future native force/personal-leader AND/OR returns.
It supersedes that mixed-family absence only, using the existing whole Revival
diff --git a/game/bots.ts b/game/bots.ts
index ba9d3bb2..a176eadb 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -1320,6 +1320,10 @@ function policyActions(g: GameView): Action[] {
return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }];
if (w.stage === 'answer') {
if (w.question?.target !== me.id) return [];
+ if (w.question.kind === 'mixedBid') {
+ const answer = g.truthMixedBidAnswers?.[0];
+ return answer ? [{ type: 'truthAnswer', answer }] : [];
+ }
if (w.question.kind === 'mixedRevival') {
const answer = g.truthMixedRevivalAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
diff --git a/game/engine.ts b/game/engine.ts
index 143dc2e1..bbb57608 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1,3 +1,4 @@
+import { mixedBidMinimum, type MixedBidClaim } from './mixed-bid-question';
import type { RevivalActionClaim, MixedRevivalClaim } from './mixed-revival-question';
import type { BribePaymentClaim, MixedBribeClaim } from './mixed-bribe-question';
import { compileMixedActionExpression, MixedActionQuestionError, type CompiledMixedAction } from './mixed-action-question';
@@ -2157,6 +2158,10 @@ export type Game = {
truthHistory?: TruthRecord[];
shipmentPromises?: ShipmentPromise[];
bidPromises?: BidPromise[];
+ /** Raw mixed question linked to one private native current-lot obligation. */
+ mixedBidQuestions?: (MixedBidClaim & {
+ event: string; player: string; asker: string; answer: boolean; binding: boolean;
+ })[];
revivalPromises?: RevivalPromise[];
leaderRevivalPromises?: LeaderRevivalPromise[];
revivalExpressionPromises?: RevivalExpressionPromise[];
@@ -6918,6 +6923,9 @@ function truthtranceDiscardSignature(g: Game, next: TruthDiscardContinuation) {
battle: g.battle,
shipmentPromises: g.shipmentPromises,
movementPromises: g.movementPromises,
+ ...(next.record.question.kind === 'mixedBid' ? {
+ mixedBidQuestions: g.mixedBidQuestions, bidPromises: g.bidPromises,
+ } : {}),
...(next.record.question.kind === 'mixedRevival' ? {
mixedRevivalQuestions: g.mixedRevivalQuestions, revivalExpressionPromises: g.revivalExpressionPromises,
} : {}),
@@ -15572,6 +15580,50 @@ function bindBidTruth(g: Game, p: Player, claim: BidClaim, answer: boolean, aske
(g.bidPromises ??= []).push({ ...claim, event: crypto.randomUUID(), source: opportunity.source,
player: p.id, asker, answer });
}
+function compileMixedBid(g: Game, p: Player, claim: MixedBidClaim, answer: boolean) {
+ const compiled = compileMixedActionExpression(claim.mixed,
+ fact => truthFactAnswer(p, fact, truthKnowledgeOf(g, p)), answer);
+ return compiled.kind === 'constant' ? { constant: compiled.value } :
+ { residual: { turn: claim.turn, lot: claim.lot, minimum: mixedBidMinimum(compiled) } };
+}
+function mixedBidTruthAnswers(g: Game, p: Player, claim: MixedBidClaim): TruthAnswer[] {
+ const opportunity = bidOpportunity(g);
+ if (!opportunity || !bidQuestionWindow(g) || claim.turn !== opportunity.turn || claim.lot !== opportunity.lot ||
+ !opportunity.eligible.includes(p.id) || opportunity.passed.includes(p.id) || opportunity.bidder === p.id) return [];
+ const answers: TruthAnswer[] = [];
+ for (const answer of [true, false]) {
+ const compiled = compileMixedBid(g, p, claim, answer);
+ if (compiled.constant !== undefined ? compiled.constant === answer :
+ bidTruthAnswers(g, p, compiled.residual!).includes(answer ? 'yes' : 'no')) answers.push(answer ? 'yes' : 'no');
+ }
+ return answers.length ? answers : ['unknown'];
+}
+function bindMixedBidTruth(g: Game, p: Player, claim: MixedBidClaim, answer: boolean, asker: string) {
+ requireRule(mixedBidTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
+ 'Choose a mixed bid answer compatible with the whole current-fact and ordinary-lot question.');
+ const compiled = compileMixedBid(g, p, claim, answer);
+ let event: string;
+ if (compiled.residual) {
+ bindBidTruth(g, p, compiled.residual, answer, asker);
+ event = g.bidPromises!.at(-1)!.event;
+ } else {
+ requireRule(compiled.constant === answer, 'Retain the truthful whole mixed-bid answer.');
+ event = crypto.randomUUID();
+ }
+ (g.mixedBidQuestions ??= []).push({ turn: claim.turn, lot: claim.lot, mixed: structuredClone(claim.mixed),
+ event, player: p.id, asker, answer, binding: !!compiled.residual });
+}
+function isMixedBidEvent(g: Game, event: string): boolean {
+ return !!g.mixedBidQuestions?.some(question => question.event === event);
+}
+function projectedMixedBidPromises(g: Game, player: string) {
+ return (g.mixedBidQuestions ?? []).filter(question => question.player === player).map(question => {
+ const bound = g.bidPromises?.find(promise => promise.event === question.event);
+ return { event: question.event, player: question.player, asker: question.asker, turn: question.turn, lot: question.lot,
+ mixed: structuredClone(question.mixed), answer: question.answer,
+ fulfilled: !question.binding || bound?.fulfilled === true, released: bound?.released === true };
+ });
+}
function bidPromiseIntegrity(g: Game) {
try { validateBidPromises(g.bidPromises ?? [], g.players.map(player => player.id)); }
catch (error) { if (error instanceof BidPromiseError) throw new RuleError(error.message); throw error; }
@@ -15599,7 +15651,7 @@ function reconcileBidPromises(g: Game, voluntary?: { actor: string; action: Acti
}
}
function projectedBidPromises(g: Game, player: string) {
- return (g.bidPromises ?? []).filter(promise => promise.player === player).map(promise => ({
+ return (g.bidPromises ?? []).filter(promise => promise.player === player && !isMixedBidEvent(g, promise.event)).map(promise => ({
event: promise.event, player: promise.player, asker: promise.asker, turn: promise.turn,
lot: promise.lot, minimum: promise.minimum, answer: promise.answer,
...(promise.fulfilled === undefined ? {} : { fulfilled: promise.fulfilled }),
@@ -36583,6 +36635,8 @@ function applyActionInner(
bindLeaderRevival: bindLeaderRevivalTruth,
revivalAnswers: revivalTruthAnswers,
bindRevival: bindRevivalTruth,
+ mixedBidAnswers: mixedBidTruthAnswers,
+ bindMixedBid: bindMixedBidTruth,
bidAnswers: bidTruthAnswers,
bindBid: bindBidTruth,
traitorAnswers: traitorTruthAnswers,
@@ -40465,6 +40519,13 @@ export function viewGame(state: Game, id: string) {
g.truthtrance.question.kind === 'mixedRevival' ? mixedRevivalTruthAnswers(g, me, g.truthtrance.question) : null,
mixedRevivalPromises: projectedMixedRevivalPromises(g, id),
revivalExpressionPromiseGuide: projectedRevivalExpressionPromiseGuide(g, id),
+ truthMixedBidTargets: bidQuestionWindow(g) ? g.players.filter(player => {
+ const lot = bidOpportunity(g)!;
+ return lot.eligible.includes(player.id) && !lot.passed.includes(player.id) && lot.bidder !== player.id;
+ }).map(player => player.id) : [],
+ truthMixedBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
+ g.truthtrance.question.kind === 'mixedBid' ? mixedBidTruthAnswers(g, me, g.truthtrance.question) : null,
+ mixedBidPromises: projectedMixedBidPromises(g, id),
bidPromises: projectedBidPromises(g, id),
bidPromiseGuide: projectedBidPromiseGuide(g, id),
truthtrance: g.truthtrance ?? null,
diff --git a/game/mixed-bid-question.ts b/game/mixed-bid-question.ts
new file mode 100644
index 00000000..3b2bb02b
--- /dev/null
+++ b/game/mixed-bid-question.ts
@@ -0,0 +1,26 @@
+import { BidPromiseError, parseBidClaim } from './bid-promises';
+import type { CompiledMixedAction, MixedActionExpression } from './mixed-action-question';
+
+export const MIXED_BID_MAX_LEAVES = 16;
+export const MIXED_BID_MAX_DEPTH = 4;
+export type BidActionClaim = { minimum: number };
+export type MixedBidClaim = { turn: number; lot: number; mixed: MixedActionExpression };
+
+export function parseBidActionClaim(value: unknown): BidActionClaim {
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
+ Object.keys(value).join(',') !== 'minimum')
+ throw new BidPromiseError('Supply one positive whole bid minimum without turn or lot metadata.');
+ const { minimum } = parseBidClaim({ turn: 1, lot: 1, minimum: (value as Record).minimum });
+ return { minimum };
+}
+
+/** All positive leaves concern the same future accepted declaration interval.
+ * max(a,b) satisfies AND, min(a,b) satisfies OR; whole No retains that exact
+ * threshold. This creates neither a second bid search nor a selected route. */
+export function mixedBidMinimum(node: CompiledMixedAction): number {
+ if (node.kind === 'action') return parseBidActionClaim(node.action).minimum;
+ if (node.kind === 'constant' || !node.terms.length)
+ throw new BidPromiseError('Reduce only a nonconstant future ordinary-bid expression.');
+ const minima = node.terms.map(mixedBidMinimum);
+ return node.kind === 'and' ? Math.max(...minima) : Math.min(...minima);
+}
diff --git a/game/reference.ts b/game/reference.ts
index 0cc17620..0809d275 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3406,6 +3406,31 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Focused native original-deal/death, pure and control evidence; broader combinations, browser and final deployed acceptance remain open.', evidence: ['tests/fixture-truth-revival.ts', 'tests/truth-revival-runtime.test.ts', 'tests/truth-revival-controls.test.tsx'] },
],
},
+ {
+ id: 'truthtrance-mixed-bid',
+ title: 'Truthtrance: current facts and ordinary bids',
+ category: 'Cards',
+ coverage: 'Partial',
+ developmentStage: 'Prototyped',
+ summary: 'Combine answer-time facts with future accepted bid declarations in the current ordinary lot, preserving native funding and payment.',
+ steps: [
+ 'Choose Combine current facts and ordinary bids. Add full AND/OR groups with at least one current fact and one bid minimum. The fixed public turn and current ordinary lot apply to every bid condition; nested facts share the sixteen-condition, four-level input limit.',
+ 'Current facts freeze at the actual answer. A statement that you hold at least10 spice remains that answer-time fact after the winning purchase leaves6. The answer does not require preserving the same resources afterward.',
+ 'Yes makes the complete mixed expression true; No keeps the complete expression false. Unknown facts cannot be relied on being true for Yes or false for No. If neither guaranteed answer is available, the unanswered card may be saved or asked differently.',
+ 'A fact may already settle the aggregate, needing no bid. Otherwise only future accepted declarations in this exact lot count. Two smaller bids never add into a larger bid. A qualifying declaration fulfills Yes even when another bidder eventually wins; the original winning payment remains separate.',
+ 'All remaining minima concern one future bid interval. Satisfying two minima requires the larger; satisfying either requires the smaller. This exact reduction retains every legal amount. No forbids qualifying later declarations through actual lot close.',
+ 'Native current funds, prepaid allied aid, Karama overbidding, capacity, active/waiting eligibility, earlier commitments and release order remain. The answer grants no new opportunity, spice or free purchase and assumes no future rival raise.',
+ 'Raw editing/preview/Ask, private answers, own aggregate status and one exact native bid/pass guide connect to four minimal policies. Fulfilled history does not duplicate or suppress a live guide. Phase-wide questions, native special auctions, other action families and broader compositions remain unfinished.',
+ ],
+ related: ['card-truthtrance', 'truthtrance-bid', 'truthtrance-mixed-revival', 'implementation-checklist'],
+ checklist: [
+ { area: 'Implementation', status: 'Partial', detail: 'Shared answer-time compilation reduces remaining same-lot minima to an exact native scalar bid obligation; raw aggregate projection hides frozen values, reduced minima and hidden lot source.', evidence: ['game/mixed-action-question.ts', 'game/mixed-bid-question.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/mixed-bid-question.test.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Full shared editor, fixed current lot, repairable bid drafts, raw preview/Ask, private answers and one exact live event guide are connected.', evidence: ['components/mixed-bid-fields.tsx', 'components/mixed-bid-promises.tsx', 'components/bid-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-mixed-bid-controls.test.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'Four minimal profiles answer and declare native bids. Strategic refinement/calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-mixed-bid-runtime.test.ts'] },
+ { area: 'Documentation', status: 'Implemented', detail: 'Snapshot/unknown meaning, exact threshold reduction, original declaration/invoice boundaries and remaining gates are explicit.', evidence: ['docs/TRUTHTRANCE_MIXED_BIDS.md', 'docs/RULE_DECISIONS.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Pure reduction, original native bid/payment/funding and controlled shared editing/guide cases; broader combinations and final deployed acceptance remain open.', evidence: ['tests/fixture-truth-mixed-bid.ts', 'tests/truth-mixed-bid-runtime.test.ts', 'tests/truth-mixed-bid-funding.test.ts', 'tests/truth-mixed-bid-controls.test.tsx'] },
+ ],
+ },
{
id: 'truthtrance-bid',
title: 'Truthtrance: a bid in the current ordinary lot',
diff --git a/game/truthtrance.ts b/game/truthtrance.ts
index 1f9ca2b8..5b28741d 100644
--- a/game/truthtrance.ts
+++ b/game/truthtrance.ts
@@ -1,3 +1,4 @@
+import { parseBidActionClaim, MIXED_BID_MAX_DEPTH, MIXED_BID_MAX_LEAVES, type MixedBidClaim } from './mixed-bid-question';
import { parseRevivalActionClaim, type MixedRevivalClaim } from './mixed-revival-question';
import { parseBribePaymentClaim, type MixedBribeClaim } from './mixed-bribe-question';
import { parseMixedActionExpression, mixedActionExpressionText, type MixedActionExpression } from './mixed-action-question';
@@ -72,6 +73,7 @@ export type TruthFact =
| { kind: 'and' | 'or'; terms: TruthFact[] };
export type MixedMovementClaim = { turn: number; mixed: MixedActionExpression };
export type TruthQuestion =
+ | ({ kind: 'mixedBid'; target: string } & MixedBidClaim)
| ({ kind: 'mixedRevival'; target: string } & MixedRevivalClaim)
| ({ kind: 'mixedBribe'; target: string } & MixedBribeClaim)
| ({ kind: 'mixedMovement'; target: string } & MixedMovementClaim)
@@ -322,6 +324,19 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
'Choose a remaining native Revival opportunity this turn in a supported table and finish pending decisions.');
return { kind: 'revival', target: v.target, ...claim };
}
+ if (v.kind === 'mixedBid') {
+ check(Object.keys(v).sort().join(',') === 'kind,lot,mixed,target,turn',
+ 'Supply the current ordinary lot, turn, complete mixed bid question and respondent.');
+ const claim = parseBidClaim({ turn: v.turn, lot: v.lot, minimum: 1 });
+ check(bidPromiseModeSupported(g) && g.status === 'playing' && g.phase === 3 && g.auction &&
+ !g.currentAuctionSale && !g.richeseAuction && !g.ixAuction && !g.response && !g.decision &&
+ !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard && claim.turn === g.turn &&
+ claim.lot === g.auction.index + 1, 'Choose the current ordinary lot in a quiet supported window.');
+ const respondent = g.players.find(player => player.id === v.target)!;
+ return { kind: 'mixedBid', target: v.target as string, turn: claim.turn, lot: claim.lot,
+ mixed: parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), parseBidActionClaim,
+ { maxLeaves: MIXED_BID_MAX_LEAVES, maxDepth: MIXED_BID_MAX_DEPTH }) };
+ }
if (v.kind === 'bid') {
check(Object.keys(v).sort().join(',') === 'kind,lot,minimum,target,turn',
'Supply the current ordinary lot, turn, bid minimum and respondent.');
@@ -605,6 +620,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 === 'mixedBid') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
+ action => `you ${bidClaimText({ turn: q.turn, lot: q.lot, minimum: action.minimum })}`)}?`;
if (q.kind === 'mixedRevival') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
action => `you ${revivalExpressionText(action, id => id === 'kwisatz' ? 'Kwisatz Haderach' : leaderName(id))} before your Revival opportunity ends`)}?`;
if (q.kind === 'mixedBribe') return `Is it true that ${mixedActionExpressionText(q.mixed, clause,
@@ -657,6 +674,8 @@ export function resolveTruthAction(
bindLeaderRevival?: (g: Game, p: Player, claim: LeaderRevivalClaim, answer: boolean, asker: string) => void;
revivalAnswers?: (g: Game, p: Player, claim: RevivalClaim) => TruthAnswer[];
bindRevival?: (g: Game, p: Player, claim: RevivalClaim, answer: boolean, asker: string) => void;
+ mixedBidAnswers?: (g: Game, p: Player, claim: MixedBidClaim) => TruthAnswer[];
+ bindMixedBid?: (g: Game, p: Player, claim: MixedBidClaim, answer: boolean, asker: string) => void;
bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[];
bindBid?: (g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) => void;
traitorAnswers?: (g: Game, p: Player) => TruthAnswer[];
@@ -807,6 +826,9 @@ export function resolveTruthAction(
if (w.question.kind === 'revival')
check(effects.revivalAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a respondent with a remaining native Revival opportunity.');
+ if (w.question.kind === 'mixedBid')
+ check(effects.mixedBidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
+ 'Choose a mixed current-fact and bid question with an available ordinary-lot opportunity.');
if (w.question.kind === 'bid')
check(effects.bidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a bidder with a remaining ordinary opportunity in this lot.');
@@ -911,6 +933,11 @@ export function resolveTruthAction(
'Choose an answer consistent with native force revivals and earlier answers.');
if (a.answer !== 'unknown') effects.bindRevival(g, p, q, a.answer === 'yes', current.player);
}
+ if (q.kind === 'mixedBid') {
+ check(effects.mixedBidAnswers && effects.bindMixedBid && effects.mixedBidAnswers(g, p, q).includes(a.answer),
+ 'Choose a whole mixed-bid answer compatible with current facts and native ordinary declarations.');
+ if (a.answer !== 'unknown') effects.bindMixedBid(g, p, q, a.answer === 'yes', current.player);
+ }
if (q.kind === 'bid') {
check(effects.bidAnswers && effects.bindBid && effects.bidAnswers(g, p, q).includes(a.answer),
'Choose an answer consistent with this lot, your legal bids and earlier answers.');
diff --git a/tests/fixture-truth-mixed-bid.ts b/tests/fixture-truth-mixed-bid.ts
new file mode 100644
index 00000000..3e6edb58
--- /dev/null
+++ b/tests/fixture-truth-mixed-bid.ts
@@ -0,0 +1,81 @@
+import assert from 'node:assert/strict';
+import { viewGame, type Action, type Game } from '../game/engine';
+import type { BidActionClaim } from '../game/mixed-bid-question';
+import type { MixedActionExpression } from '../game/mixed-action-question';
+import type { TruthFact } from '../game/truthtrance';
+import {
+ actTruthBid, advanceTruthBid, answerTruthBid, continueTruthBidLot, createTruthBidFixture,
+ declareTruthBid, finishTruthBid, nextTruthBid, truthBidClean, truthBidInventory,
+ truthBidPlayer, type TruthBidStep,
+} from './fixture-truth-bid';
+
+export type TruthMixedBidExpression = MixedActionExpression;
+export type TruthMixedBidStep = TruthBidStep;
+export const actTruthMixedBid = actTruthBid;
+export const advanceTruthMixedBid = advanceTruthBid;
+export const answerTruthMixedBid = answerTruthBid;
+export const continueTruthMixedBidLot = continueTruthBidLot;
+export const finishTruthMixedBid = finishTruthBid;
+export const nextTruthMixedBid = nextTruthBid;
+export const truthMixedBidClean = truthBidClean;
+export const truthMixedBidInventory = truthBidInventory;
+export const truthMixedBidPlayer = truthBidPlayer;
+
+export const truthMixedBidFact = (fact: TruthFact): TruthMixedBidExpression => ({ kind: 'fact', fact });
+export const truthMixedBidMinimum = (minimum = 4): TruthMixedBidExpression => ({ kind: 'action', action: { minimum } });
+export const truthMixedBidGroup = (kind: 'and' | 'or', ...terms: TruthMixedBidExpression[]): TruthMixedBidExpression =>
+ ({ kind, terms });
+
+/** Original public H/A/E base33, Basic22/Advanced25 active or Basic20/Advanced23
+ * waiting. This thin adapter changes only the raw question. It installs no
+ * played hand, purse, auction clock, response, frozen fact or residual binding. */
+export function createTruthMixedBidFixture(options: {
+ advanced?: boolean; window?: 'waiting' | 'active'; targetKarama?: boolean; mixed?: TruthMixedBidExpression;
+} = {}) {
+ const { mixed: suppliedMixed, ...sourceOptions } = options;
+ const base = createTruthBidFixture({ ...sourceOptions, window: options.window ?? 'active' });
+ 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 original unopened base33 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(suppliedMixed ?? truthMixedBidGroup('and', truthMixedBidFact(spiceFact),
+ truthMixedBidMinimum()));
+ const question = { kind: 'mixedBid' as const, target: base.target, turn: base.game.turn,
+ lot: base.game.auction!.index + 1, mixed };
+ return { ...base, bidSource: base, mixed, question, shieldFact, hajrFact, spiceFact, forecastFact,
+ questionAction: { type: 'truthAsk', question } satisfies Action,
+ sourceCounts: { inherited: base.sourceActions.length, basicActive: 22, advancedActive: 25,
+ basicWaiting: 20, advancedWaiting: 23 },
+ sourceDisclosure: `${base.sourceDisclosure} This thin mixed adapter changes only the original raw question: current facts and future accepted minimum bids share one AND/OR tree in the original current lot. No compiled threshold or answer-time fact is installed.` };
+}
+export type TruthMixedBidFixture = ReturnType;
+
+export function declareTruthMixedBid(f: TruthMixedBidFixture, source = f.game, options: { copies?: 1 | 2 } = {}) {
+ return declareTruthBid(f.bidSource, source, options);
+}
+export function askTruthMixedBid(game: Game, asker: string, target: string, mixed: TruthMixedBidExpression,
+ actions: TruthMixedBidStep[] = []): Game {
+ assert.ok(game.auction);
+ return actTruthMixedBid(game, asker, { type: 'truthAsk', question: {
+ kind: 'mixedBid', target, turn: game.turn, lot: game.auction.index + 1, mixed: structuredClone(mixed),
+ } }, actions);
+}
+export function bindTruthMixedBid(f: TruthMixedBidFixture, answer: 'yes' | 'no' = 'yes',
+ mixed = f.mixed, source = f.game) {
+ const declared = declareTruthMixedBid(f, source), actions = [...declared.actions];
+ const asked = askTruthMixedBid(declared.game, f.asker, f.target, mixed, actions);
+ const game = answerTruthMixedBid(asked, f.target, answer, actions);
+ return { game, declared, asked, actions, actionCount: actions.length };
+}
+
+/** Own original question and whole answer only; no frozen fact, reduced minimum
+ * or physical-lot source is read through this accessor. */
+export function truthMixedBidPromise(game: Game,
+ target = game.truthHistory?.at(-1)?.question.target ?? game.players[0].id) {
+ const row = viewGame(game, target).mixedBidPromises.find(row => row.player === target);
+ assert.ok(row, 'The respondent retains the original mixed question and aggregate status');
+ return row;
+}
diff --git a/tests/mixed-bid-question.test.ts b/tests/mixed-bid-question.test.ts
new file mode 100644
index 00000000..473aca11
--- /dev/null
+++ b/tests/mixed-bid-question.test.ts
@@ -0,0 +1,57 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { mixedBidMinimum, parseBidActionClaim, type BidActionClaim } from '../game/mixed-bid-question';
+import { compileMixedActionExpression, type CompiledMixedAction, type MixedActionExpression } from '../game/mixed-action-question';
+import type { TruthFact } from '../game/truthtrance';
+
+type Compiled = CompiledMixedAction;
+const bid = (minimum: number): Compiled => ({ kind: 'action', action: { minimum } });
+const group = (kind: 'and' | 'or', ...terms: Compiled[]): Compiled => ({ kind, terms });
+function evaluate(node: Compiled, maximum: number): boolean {
+ if (node.kind === 'constant') return node.value;
+ if (node.kind === 'action') return maximum >= node.action.minimum;
+ return node.kind === 'and' ? node.terms.every(term => evaluate(term, maximum)) : node.terms.some(term => evaluate(term, maximum));
+}
+void test('strict bid leaves reject metadata and unsafe thresholds while retaining uncapped exact whole amounts', () => {
+ for (const minimum of [1, 4, 11, Number.MAX_SAFE_INTEGER]) assert.deepEqual(parseBidActionClaim({ minimum }), { minimum });
+ for (const value of [null, [], {}, { minimum: 0 }, { minimum: -1 }, { minimum: 1.5 }, { minimum: NaN },
+ { minimum: Infinity }, { minimum: Number.MAX_SAFE_INTEGER + 1 }, { minimum: '4' },
+ { minimum: 4, lot: 1 }, { minimum: 4, turn: 1 }, { minimum: 4, amount: 4 }]) assert.throws(() => parseBidActionClaim(value));
+});
+void test('same-lot reduction preserves every compound Yes and whole No over actual future declaration maxima', () => {
+ const trees = [group('and', bid(4), bid(7)), group('or', bid(4), bid(7)),
+ group('and', group('or', bid(3), bid(8)), group('or', bid(6), bid(9))),
+ group('or', group('and', bid(3), bid(8)), group('and', bid(6), bid(9)))];
+ for (const tree of trees) for (const bids of [[], [1], [3, 5], [2, 6, 8], [4, 7, 10], [11]]) {
+ const maximum = Math.max(0, ...bids), expected = evaluate(tree, maximum), actual = maximum >= mixedBidMinimum(tree);
+ assert.equal(actual, expected); assert.equal(!actual, !expected);
+ }
+});
+void test('compound thresholds never sum bids or imply a purchase or extra opportunity', () => {
+ const both = group('and', bid(4), bid(7));
+ assert.equal(mixedBidMinimum(both), 7); assert.equal(evaluate(both, Math.max(3, 4)), false);
+ assert.equal(evaluate(both, 7), true); assert.equal(evaluate(group('or', bid(4), bid(7)), 4), true);
+ assert.equal(mixedBidMinimum(group('or', bid(Number.MAX_SAFE_INTEGER), bid(11))), 11);
+});
+void test('frozen facts determine only the necessary exact native bid obligation', () => {
+ const fact: TruthFact = { kind: 'spice', compare: 'gte', value: 10 };
+ const mixed: MixedActionExpression = { kind: 'and', terms: [
+ { kind: 'fact', fact }, { kind: 'or', terms: [{ kind: 'action', action: { minimum: 4 } }, { kind: 'action', action: { minimum: 7 } }] },
+ ] };
+ const residual = compileMixedActionExpression(mixed, () => 'yes', true); assert.equal(mixedBidMinimum(residual), 4);
+ assert.deepEqual(compileMixedActionExpression(mixed, () => 'no', false), { kind: 'constant', value: false });
+ const raw = structuredClone(mixed); mixedBidMinimum(residual); assert.deepEqual(mixed, raw);
+});
+void test('unknown fact compilation keeps the conservative exact bid constraint for each definite answer', () => {
+ const fact: TruthFact = { kind: 'stormForecast', compare: 'gte', value: 1 };
+ for (const answer of [true, false]) {
+ const mixed: MixedActionExpression = { kind: answer ? 'or' : 'and', terms: [
+ { kind: 'fact', fact }, { kind: 'action', action: { minimum: 4 } },
+ ] };
+ assert.equal(mixedBidMinimum(compileMixedActionExpression(mixed, () => 'unknown', answer)), 4);
+ }
+});
+void test('constant or empty groups cannot masquerade as a native threshold', () => {
+ for (const node of [{ kind: 'constant', value: true }, { kind: 'constant', value: false },
+ { kind: 'and', terms: [] }, { kind: 'or', terms: [] }] as Compiled[]) assert.throws(() => mixedBidMinimum(node));
+});
diff --git a/tests/truth-mixed-bid-controls.test.tsx b/tests/truth-mixed-bid-controls.test.tsx
new file mode 100644
index 00000000..8ff61bd2
--- /dev/null
+++ b/tests/truth-mixed-bid-controls.test.tsx
@@ -0,0 +1,274 @@
+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 { MixedBidFields, mixedBidInputError } from '../components/mixed-bid-fields';
+import { MixedBidPromises } from '../components/mixed-bid-promises';
+import { TruthFactFields } from '../components/mixed-shipment-fields';
+import { BidPromiseGuideButton, BidPromises } from '../components/bid-promises';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
+import type { MixedActionExpression } from '../game/mixed-action-question';
+import type { BidActionClaim } from '../game/mixed-bid-question';
+import { createTechTokens } from '../game/tech-tokens';
+import type { TruthFact } from '../game/truthtrance';
+
+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 bid = (minimum = 1): Expression => ({ kind: 'action', action: { minimum } });
+const claim = (): Expression => ({ kind: 'or', terms: [fact(), { kind: 'and', terms: [bid(3), bid(7)] }] });
+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-bid-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-bid-${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-bid-${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-bid-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 projections exercise fields and exact callback guards only.
+ * Source-earned Truthtrance, declarations and lot close have native proof. */
+function projected(type: 'bid' | 'passBid' = 'bid', advanced = false): GameView {
+ const source = createGame('TRUTHMIXEDBIDCONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced);
+ joinGame(source, newPlayer('a', 'Asker', 'atreides'));
+ const game = viewGame(source, 'p');
+ game.status = 'playing'; game.phase = 3; game.turn = 2;
+ game.auction = { bid: 1, bidder: 'a', active: 'p', remaining: 2, inspectionBlocked: null, card: null };
+ game.bidPromiseLot = { turn: 2, lot: 3 };
+ game.bidPromises = [];
+ game.mixedBidPromises = [{ event: 'controlled-mixed-bid-event', player: 'p', asker: 'a', turn: 2, lot: 3,
+ mixed: claim(), answer: type === 'bid', fulfilled: false, released: false }];
+ game.bidPromiseGuide = { action: type === 'bid'
+ ? { type, amount: 7, allyPayment: 1, bidPromiseEvent: 'controlled-mixed-bid-event' }
+ : { type, bidPromiseEvent: 'controlled-mixed-bid-event' } };
+ return game;
+}
+function editor(initial: Expression, disabled = false, game = projected(), target = 'p') {
+ let value = initial, changes = 0;
+ return {
+ get value() { return value; }, get changes() { return changes; },
+ tree: () => MixedBidFields({ value, game, target, disabled, onChange(next) { value = next; changes++; } }),
+ html: () => renderToStaticMarkup(createElement(MixedBidFields, { value, game, target, disabled, onChange() {} })),
+ error: () => mixedBidInputError(value, game, target),
+ };
+}
+function guide(props: Parameters[0]) {
+ const button = BidPromiseGuideButton(props); assert.ok(button); return button;
+}
+const press = (button: ReturnType) => button.props.onClick();
+const markup = (game: GameView) => renderToStaticMarkup(createElement(MixedBidPromises, { game, busy: false, send() {} }));
+const pureMarkup = (game: GameView) => renderToStaticMarkup(createElement(BidPromises, { game, busy: false, send() {} }));
+
+void test('mixed bid editor retains sixteen original conditions with only per-action minimum fields and fixed public turn and lot', () => {
+ const e = editor(fact()); click(control(e.tree(), 'or-1'));
+ for (let count = 3; count <= 16; count++) {
+ click(control(e.tree(), 'add-action-1')); minimum(e.tree(), `1.${count}`, count);
+ }
+ minimum(e.tree(), '1.2', Number.MAX_SAFE_INTEGER);
+ const full = e.value; assert.ok(full.kind === 'or'); assert.equal(full.terms.length, 16);
+ assert.deepEqual(full.terms[0], fact()); assert.deepEqual(full.terms[1], bid(Number.MAX_SAFE_INTEGER));
+ assert.deepEqual(full.terms[15], bid(16)); assert.equal(e.error(), null);
+ const html = e.html(), ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]);
+ assert.match(html, /Turn 2 · Ordinary lot 3/); assert.match(html, /16 \/ 16 conditions/);
+ assert.equal((html.match(/Minimum bid in spice/g) ?? []).length, 15);
+ assert.match(html, /max="9007199254740991"/); assert.equal(new Set(ids).size, ids.length);
+ assert.ok(!elements(e.tree()).some(element => typeof element.props.id === 'string' && /-(turn|lot)$/.test(element.props.id)));
+ const before = structuredClone(full), changes = e.changes;
+ for (const key of ['add-fact-1', 'add-action-1', 'nested-and-1', 'nested-or-1', 'and-1.1', 'fact-and-1.1', 'or-1']) {
+ assert.equal(control(e.tree(), key).props.disabled, true, key); click(control(e.tree(), key));
+ }
+ assert.equal(e.changes, changes); assert.deepEqual(e.value, before);
+});
+
+void test('mixed bid and embedded fact groups preserve all branches through edits, collapse and the shared depth-four envelope', () => {
+ const e = editor({ kind: 'and', terms: [fact(), bid(3)] });
+ click(control(e.tree(), 'or-1.2')); select(e.tree(), '1.2', 'meaning', 'and');
+ click(control(e.tree(), 'nested-or-1')); click(control(e.tree(), 'nested-and-1'));
+ assert.ok(e.value.kind === 'and'); assert.equal(e.value.terms.length, 4);
+ 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.match(e.html(), /Current-fact group 1\.1/); 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.4')); click(control(e.tree(), 'remove-1.3'));
+ assert.deepEqual(e.value, { kind: 'and', terms: [fact(), bid(3)] });
+ select(e.tree(), '1.2', 'timing', 'fact'); assert.ok(e.error());
+ select(e.tree(), '1.2', 'timing', 'action'); assert.equal(e.error(), null);
+ click(control(e.tree(), 'remove-1.1')); assert.deepEqual(e.value, bid()); assert.ok(e.error());
+ click(control(e.tree(), 'and-1')); assert.equal(e.error(), null);
+ let nested = fact();
+ for (let depth = 0; depth < 4; depth++) nested = { kind: 'and', terms: [nested, bid()] };
+ const deep = editor(nested); 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.1', 'fact-and-1.1.1.1.1', 'nested-and-1.1.1.1']) {
+ assert.equal(control(deep.tree(), key).props.disabled, true); click(control(deep.tree(), key));
+ }
+ assert.equal(deep.changes, 0);
+ assert.ok(mixedBidInputError({ kind: 'and', terms: [nested, bid()] }, projected(), 'p'));
+});
+
+void test('mixed bid numeric drafts remain repairable while strict leaves reject metadata and busy callbacks preserve the original tree', () => {
+ for (const invalid of [NaN, -1, 0, 0.5, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
+ const e = editor({ kind: 'and', terms: [fact(), bid()] });
+ minimum(e.tree(), '1.2', invalid); assert.ok(e.error()); click(control(e.tree(), 'or-1.2'));
+ const raw = e.value; assert.ok(raw.kind === 'and');
+ assert.deepEqual(raw.terms[1], { kind: 'or', terms: [bid(invalid), fact()] });
+ assert.match(e.html(), /role="alert"/); minimum(e.tree(), '1.2.1', 2); assert.equal(e.error(), null);
+ }
+ for (const action of [{ minimum: 1, turn: 2 }, { minimum: 1, lot: 3 }, { minimum: 1, source: 'hidden-lot' },
+ { minimum: 1, answer: true }, { minimum: 1, fulfilled: true }, { kind: 'or', terms: [{ minimum: 1 }] }, { minimum: '2' }])
+ assert.ok(mixedBidInputError({ kind: 'and', terms: [fact(), { kind: 'action', action }] }, projected(), 'p'));
+ const frozen = editor({ kind: 'and', terms: [fact(), bid()] }, 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.2', 'timing', 'fact');
+ editFact(frozen.tree(), '1.1', { kind: 'spice', compare: 'gte', value: 4 });
+ assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, before);
+});
+
+void test('mixed bid authoring uses public current-lot scope and explains declaration, unknown and original payment semantics without inspecting holdings', () => {
+ const game = projected();
+ for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'leaders', 'spice', 'forces', 'reserves', 'tanks', 'elites'])
+ Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
+ Object.defineProperty(game.auction!, 'card', { get() { throw Error('No hidden auction face inspection'); } });
+ for (const key of ['deck', 'discard', 'spiceDeck']) Object.defineProperty(game, key, {
+ get() { throw Error(`No private ${key} inspection`); }, configurable: true,
+ });
+ const e = editor({ kind: 'and', terms: [fact({ kind: 'stormForecast', compare: 'gte', value: 3 }), bid(5)] }, false, game);
+ assert.equal(e.error(), null);
+ const html = e.html(); assert.match(html, /Turn 2 · Ordinary lot 3/);
+ assert.match(html, /Smaller bids do not add together/); assert.match(html, /even if another player wins/);
+ assert.match(html, /If you win, the original payment rules still apply/);
+ assert.match(html, /Existing funding, Karama and prepaid ally aid rules still apply/);
+ assert.match(html, /optional modules and native special auctions are excluded/);
+ assert.match(html, /conservative future commitment/); assert.match(html, /If no such value is available, the answer is I don’t know/);
+ 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);
+});
+
+void test('own mixed bid panel displays the original question and aggregate answer without progress, frozen facts or reduced thresholds', () => {
+ const game = projected(); game.bidPromiseGuide = null;
+ game.mixedBidPromises.push(
+ { ...game.mixedBidPromises[0], event: 'no-event', answer: false },
+ { ...game.mixedBidPromises[0], event: 'fulfilled-event', fulfilled: true },
+ { ...game.mixedBidPromises[0], event: 'released-event', released: true },
+ { ...game.mixedBidPromises[0], event: 'foreign-event', player: 'a', mixed: { kind: 'or', terms: [fact({ kind: 'hand', name: 'Poison Blade' }), bid(99)] } },
+ );
+ for (const promise of game.mixedBidPromises) for (const key of ['binding', 'residual', 'factAnswers', 'frozenFacts', 'compiled', 'source', 'minimum', 'progress'])
+ Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
+ const html = markup(game);
+ assert.match(html, /Your mixed bid promises/); assert.match(html, /Turn 2, ordinary lot 3/);
+ assert.match(html, /You answered Yes/); assert.match(html, /You answered No/); assert.match(html, /you hold Shield/);
+ assert.match(html, / OR /); assert.match(html, / AND /); assert.match(html, /at least 3 spice/); assert.match(html, /at least 7 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 threshold remain private/);
+ assert.match(html, /No negates the whole expression, not each condition separately/);
+ assert.match(html, /fulfilled immediately by one qualifying native declaration/);
+ assert.match(html, /binding No remains in force until this ordinary lot actually closes/);
+ assert.doesNotMatch(html, /Poison Blade|99 spice|controlled-mixed-bid-event|fulfilled-event|Future ordinary progress/);
+ assert.equal(MixedBidPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
+});
+
+void test('shared Basic and Advanced guides forward the exact native mixed bid or pass packet including original funding and card fields', () => {
+ for (const advanced of [false, true]) for (const type of ['bid', 'passBid'] as const) {
+ const game = projected(type, advanced), sent: Action[] = [];
+ if (type === 'bid') game.bidPromiseGuide!.action = { ...game.bidPromiseGuide!.action,
+ karama: true, card: 'controlled-original-karama' };
+ const button = guide({ game, busy: false, send(action) { sent.push(action); } });
+ assert.equal(button.props.disabled, false); press(button);
+ assert.deepEqual(sent, [game.bidPromiseGuide!.action]); assert.equal(sent[0], game.bidPromiseGuide!.action);
+ assert.match(markup(game), /data-testid="bid-promise-guide"/); assert.doesNotMatch(markup(game), /controlled-original-karama/);
+ if (type === 'bid') assert.deepEqual(sent[0], { type, amount: 7, allyPayment: 1, bidPromiseEvent: 'controlled-mixed-bid-event',
+ karama: true, card: 'controlled-original-karama' });
+ else assert.deepEqual(sent[0], { type, bidPromiseEvent: 'controlled-mixed-bid-event' });
+ }
+});
+
+void test('mixed bid guide callbacks reject busy, stale, changed lot, event, owner, funding and quiet-window changes', () => {
+ const changes: ((game: GameView) => void)[] = [
+ game => { game.me = 'a'; }, game => { game.auction!.active = 'a'; },
+ game => { game.turn = 3; }, game => { game.phase = 5; }, game => { game.status = 'setup'; },
+ game => { game.bidPromiseLot = { turn: 2, lot: 4 }; }, game => { game.bidPromiseLot = { turn: 1, lot: 3 }; },
+ game => { game.bidPromiseLot = null; }, game => { game.auction = null; },
+ game => { game.bidPromiseGuide = { action: { ...game.bidPromiseGuide!.action } }; }, game => { game.bidPromiseGuide = null; },
+ game => { game.bidPromiseGuide!.action.amount = 8; }, game => { game.bidPromiseGuide!.action.allyPayment = 2; },
+ game => { game.bidPromiseGuide!.action.karama = true; }, game => { game.bidPromiseGuide!.action.type = 'ready'; },
+ game => { game.bidPromiseGuide!.action.bidPromiseEvent = 'later-event'; },
+ game => { game.mixedBidPromises[0].event = 'expired'; }, game => { game.mixedBidPromises[0].player = 'a'; },
+ game => { game.mixedBidPromises[0].turn = 1; }, game => { game.mixedBidPromises[0].lot = 4; },
+ game => { game.mixedBidPromises[0].fulfilled = true; }, game => { game.mixedBidPromises[0].released = true; },
+ game => { game.mixedBidPromises = []; },
+ game => { game.phaseOpening = { passed: [] }; }, game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
+ game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
+ 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); } });
+ change(game); press(button); assert.deepEqual(sent, []);
+ }
+ const busy = { game: projected(), busy: false, send() { assert.fail('A now-busy guide cannot dispatch'); } };
+ const button = guide(busy); busy.busy = true; press(button);
+ const replaced = { game: projected(), busy: false, send() { assert.fail('A replaced view cannot dispatch'); } };
+ const previous = guide(replaced); replaced.game = { ...replaced.game }; press(previous);
+});
+
+void test('pure and mixed bid panels share exactly one current-lot guide while historical or foreign rows neither duplicate nor suppress it', () => {
+ const game = projected(), assertGuides = (pure: boolean, mixed: boolean) => {
+ assert.equal(pureMarkup(game).includes('data-testid="bid-promise-guide"'), pure);
+ assert.equal(markup(game).includes('data-testid="bid-promise-guide"'), mixed);
+ };
+ assertGuides(false, true);
+ game.bidPromises.push({ event: 'controlled-pure-event', player: 'p', asker: 'a', turn: 2, lot: 3, minimum: 4, answer: true });
+ assertGuides(true, false);
+ for (const change of [() => { game.bidPromises[0].fulfilled = true; },
+ () => { game.bidPromises[0].fulfilled = false; game.bidPromises[0].released = true; },
+ () => { game.bidPromises[0].released = false; game.bidPromises[0].turn = 1; },
+ () => { game.bidPromises[0].turn = 2; game.bidPromises[0].lot = 2; },
+ () => { game.bidPromises[0].lot = 3; game.bidPromises[0].player = 'a'; }]) {
+ change(); assertGuides(false, true);
+ }
+ game.bidPromises[0].player = 'p'; game.bidPromiseGuide!.action.bidPromiseEvent = 'controlled-pure-event';
+ assertGuides(true, false);
+ const sent: Action[] = []; press(guide({ game, busy: false, send(action) { sent.push(action); } }));
+ assert.deepEqual(sent, [game.bidPromiseGuide!.action]);
+ game.bidPromises = []; game.bidPromiseGuide!.action.bidPromiseEvent = 'controlled-mixed-bid-event';
+ game.auction!.active = 'a'; assert.match(markup(game), /Your next bid opportunity is still ahead/);
+ for (const change of [() => { game.mixedBidPromises[0].fulfilled = true; },
+ () => { game.mixedBidPromises[0].fulfilled = false; game.mixedBidPromises[0].released = true; },
+ () => { game.mixedBidPromises[0].released = false; game.mixedBidPromises[0].turn = 1; },
+ () => { game.mixedBidPromises[0].turn = 2; game.mixedBidPromises[0].lot = 2; }]) {
+ change(); assertGuides(false, false); assert.doesNotMatch(markup(game), /Your next bid opportunity is still ahead/);
+ }
+});
+
+void test('mixed bid panels and shared guide use public lot and own original question without hidden card, source, holdings or reduced-branch inspection', () => {
+ const game = projected(), sent: Action[] = [];
+ for (const player of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'leaders', 'spice', 'forces', 'reserves', 'tanks', 'elites'])
+ Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
+ Object.defineProperty(game.auction!, 'card', { get() { throw Error('No hidden ordinary-lot face inspection'); } });
+ for (const promise of game.mixedBidPromises) for (const key of ['source', 'minimum', 'residual', 'factAnswers', 'frozenFacts', 'compiled', 'progress'])
+ Object.defineProperty(promise, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
+ assert.match(markup(game), /Bid 7 spice while honoring your answers/);
+ press(guide({ game, busy: false, send(action) { sent.push(action); } }));
+ assert.deepEqual(sent, [game.bidPromiseGuide!.action]);
+});
diff --git a/tests/truth-mixed-bid-funding.test.ts b/tests/truth-mixed-bid-funding.test.ts
new file mode 100644
index 00000000..9460193c
--- /dev/null
+++ b/tests/truth-mixed-bid-funding.test.ts
@@ -0,0 +1,68 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { truthFactAnswer } from '../game/truthtrance';
+import {
+ actTruthMixedBid as act, advanceTruthMixedBid as advance, answerTruthMixedBid as answer,
+ askTruthMixedBid as ask, bindTruthMixedBid as bind, continueTruthMixedBidLot as closeLot,
+ createTruthMixedBidFixture as fixture, declareTruthMixedBid as declare, finishTruthMixedBid as finish,
+ truthMixedBidClean as clean, truthMixedBidInventory as inventory, truthMixedBidPlayer as player,
+ truthMixedBidPromise as promise,
+} from './fixture-truth-mixed-bid';
+
+function rejected(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
+}
+
+void test('two original Truths combine scalar bid No six and mixed bid Yes four with one real five-spice declaration', () => {
+ const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
+ game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'bid', target: f.target,
+ turn: game.turn, lot: game.auction!.index + 1, minimum: 6 } });
+ game = answer(game, f.target, 'no'); assert.equal(game.truthtrance?.stage, 'ask');
+ game = ask(game, f.asker, f.target, f.mixed);
+ assert.deepEqual(viewGame(game, f.target).truthMixedBidAnswers, ['yes', 'no']); game = answer(game, f.target, 'yes');
+ assert.equal(game.bidPromises?.length, 2); assert.equal(viewGame(game, f.target).bidPromises.length, 1);
+ assert.equal(viewGame(game, f.target).bidPromises[0].minimum, 6);
+ assert.notEqual(viewGame(game, f.target).bidPromises[0].event, promise(game).event);
+ for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
+ rejected(game, f.target, { type: 'passBid' }); rejected(game, f.target, { type: 'bid', amount: 6 });
+ game = act(game, f.target, { type: 'bid', amount: 5 }); assert.equal(promise(game).fulfilled, true);
+ assert.equal(player(game, f.target).spice, 10); game = closeLot(game);
+ assert.equal(player(game, f.target).spice, 5); assert.equal(promise(game).released, false);
+ assert.ok(game.bidPromises?.every(row => row.fulfilled || row.released));
+ assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard)); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('two original Truths preserve a prior nine-force shipment when a later mixed bid four would spend its shared funding', () => {
+ const f = fixture(); let game = declare(f, f.game, { copies: 2 }).game;
+ game = act(game, f.asker, { type: 'truthAsk', question: { kind: 'shipment', target: f.target,
+ claim: { territory: 'arrakeen', minimum: 9 } } });
+ assert.ok(viewGame(game, f.target).truthShipmentAnswers?.includes('yes')); game = answer(game, f.target, 'yes');
+ assert.equal(game.truthtrance?.stage, 'ask'); game = ask(game, f.asker, f.target, f.mixed);
+ assert.deepEqual(viewGame(game, f.target).truthMixedBidAnswers, ['no']);
+ rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no');
+ rejected(game, f.target, { type: 'bid', amount: 4 }); game = act(game, f.target, { type: 'bid', amount: 1, allyPayment: 0 });
+ game = closeLot(game); assert.equal(player(game, f.target).spice, 9);
+ assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.equal(promise(game).fulfilled, true);
+ game = finish(game); game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
+ game = act(game, f.target, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 9, elite: 0, allyPayment: 0 });
+ game = advance(game, clean); assert.equal(player(game, f.target).forces['arrakeen:10'], 9);
+ assert.equal(player(game, f.target).spice, 0); assert.ok(game.shipmentPromises?.some(row => row.player === f.target && row.fulfilled));
+ assert.equal(promise(game).fulfilled, true);
+ for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native mixed bid Yes retains four original spice while a real six-spice bribe is allowed', () => {
+ const f = fixture({ window: 'waiting' }); let game = bind(f).game;
+ rejected(game, f.target, { type: 'bribe', target: f.asker, amount: 7 });
+ game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 6 });
+ assert.equal(player(game, f.target).spice, 4); assert.equal(player(game, f.asker).bribes, 6);
+ assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no'); assert.equal(promise(game).fulfilled, false);
+ game = advance(game, state => state.auction?.active === f.target && clean(state));
+ const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 4);
+ game = act(game, f.target, guide.action); assert.equal(promise(game).fulfilled, true);
+ game = closeLot(game); assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.asker).bribes, 6);
+ assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
+ assert.equal(game.discard.filter(card => card.id === f.truth).length, 1); assert.deepEqual(inventory(game), f.originalCards);
+});
diff --git a/tests/truth-mixed-bid-runtime.test.ts b/tests/truth-mixed-bid-runtime.test.ts
new file mode 100644
index 00000000..e13a26a3
--- /dev/null
+++ b/tests/truth-mixed-bid-runtime.test.ts
@@ -0,0 +1,247 @@
+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 {
+ actTruthMixedBid as act, advanceTruthMixedBid as advance, answerTruthMixedBid as answer,
+ askTruthMixedBid as ask, bindTruthMixedBid as bind, continueTruthMixedBidLot as closeLot,
+ createTruthMixedBidFixture as fixture, declareTruthMixedBid as declare, finishTruthMixedBid as finish,
+ truthMixedBidClean as clean, truthMixedBidFact as fact, truthMixedBidGroup as group,
+ truthMixedBidInventory as inventory, truthMixedBidMinimum as bid, truthMixedBidPlayer as player,
+ truthMixedBidPromise as promise,
+} from './fixture-truth-mixed-bid';
+
+function rejected(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
+}
+function current(game: Game, actor: string): Game {
+ return advance(game, state => clean(state) && state.auction?.active === actor);
+}
+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 bid freezes ten spice before the actual declaration and original four-spice purchase`, () => {
+ const f = fixture({ advanced }); assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
+ assert.equal(f.sourceActionCount, advanced ? 25 : 22); assert.equal(f.sourceActionCount, f.sourceActions.length);
+ assert.deepEqual(f.staging, []); assert.deepEqual(f.game.expansions, []); assert.equal(f.originalCards.length, 33);
+ assert.equal(f.truths.length, 2); assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(card => card.id), f.deal);
+ assert.deepEqual(player(f.afterSetup, f.asker).hand.map(card => card.id), f.truths);
+ assert.equal(truthFactAnswer(player(f.game, f.target), f.spiceFact), 'yes');
+ const source = structuredClone(f.game); let game = bind(f).game;
+ assert.deepEqual(f.game, source); assert.equal(game.truthtrance, null); truthOnce(game, f.truth);
+ assert.deepEqual(promise(game).mixed, f.mixed); assert.equal(promise(game).fulfilled, false);
+ rejected(game, f.target, { type: 'passBid' }); rejected(game, f.target, { type: 'bid', amount: 3 });
+ const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 4);
+ assert.equal(guide.action.bidPromiseEvent, promise(game).event);
+ game = act(game, f.target, guide.action); assert.equal(promise(game).fulfilled, true);
+ assert.equal(player(game, f.target).spice, 10, 'The accepted declaration fulfills before the original winning invoice');
+ game = closeLot(game); assert.equal(player(game, f.target).spice, 6);
+ assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
+ assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
+ assert.equal(player(game, f.target).hand.filter(card => card.id === f.lotCard).length, 1);
+ truthOnce(game, f.truth); assert.deepEqual(inventory(finish(game)), f.originalCards);
+});
+
+void test('native true held Shield OR a future bid gives only Yes with no binding and leaves the actual bid legal', () => {
+ const f = fixture(), mixed = group('or', fact(f.shieldFact), bid());
+ const asked = ask(declare(f).game, f.asker, f.target, mixed);
+ assert.deepEqual(viewGame(asked, f.target).truthMixedBidAnswers, ['yes']);
+ rejected(asked, f.target, { type: 'truthAnswer', answer: 'no' }); let game = answer(asked, f.target, 'yes');
+ assert.equal(promise(game).fulfilled, true); assert.equal(game.bidPromises?.length ?? 0, 0);
+ assert.equal(viewGame(game, f.target).bidPromiseGuide, null); assert.deepEqual(promise(game).mixed, mixed);
+ game = act(game, f.target, { type: 'bid', amount: 4 }); game = closeLot(game);
+ assert.equal(player(game, f.target).spice, 6); assert.equal(promise(game).fulfilled, true);
+ truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native false held Hajr AND a future bid gives only No while the later qualifying declaration remains legal', () => {
+ const f = fixture(), mixed = group('and', fact(f.hajrFact), bid());
+ const asked = ask(declare(f).game, f.asker, f.target, mixed);
+ assert.deepEqual(viewGame(asked, f.target).truthMixedBidAnswers, ['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.bidPromises?.length ?? 0, 0);
+ game = act(game, f.target, { type: 'bid', amount: 4 }); game = closeLot(game);
+ assert.equal(player(game, f.target).spice, 6); assert.equal(promise(game).fulfilled, true);
+ truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native false Hajr OR future bid Yes requires a new accepted bid', () => {
+ const f = fixture(); let game = bind(f, 'yes', group('or', fact(f.hajrFact), bid())).game;
+ assert.equal(promise(game).fulfilled, false); rejected(game, f.target, { type: 'passBid' });
+ rejected(game, f.target, { type: 'bid', amount: 3 }); game = act(game, f.target, { type: 'bid', amount: 4 });
+ assert.equal(promise(game).fulfilled, true); game = closeLot(game); assert.equal(player(game, f.target).spice, 6);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native true Shield AND future bid No allows a lower bid and forbids every later qualifying declaration in that lot', () => {
+ const f = fixture({ window: 'waiting' }); let game = act(f.game, f.asker, { type: 'bid', amount: 1 });
+ game = act(game, f.opponent, { type: 'bid', amount: 2 });
+ game = bind(f, 'no', group('and', fact(f.shieldFact), bid(6)), game).game;
+ rejected(game, f.target, { type: 'bid', amount: 6 }); game = act(game, f.target, { type: 'bid', amount: 4 });
+ assert.equal(promise(game).fulfilled, false); game = act(game, f.asker, { type: 'bid', amount: 5 });
+ game = act(game, f.opponent, { type: 'passBid' }); rejected(game, f.target, { type: 'bid', amount: 6 });
+ game = act(game, f.target, { type: 'passBid' }); game = advance(game, clean);
+ assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
+ assert.equal(player(game, f.target).spice, 15); assert.equal(player(game, f.asker).spice, 5);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+for (const answerValue of ['yes', 'no'] as const) void test(`native whole nested mixed bid ${answerValue} collapses AND maxima and OR minima to the same threshold`, () => {
+ const f = fixture(), mixed = group('and', fact(f.shieldFact), group('or',
+ group('and', bid(4), bid(7)), group('and', bid(3), bid(5))));
+ let game = bind(f, answerValue, mixed).game; assert.deepEqual(promise(game).mixed, mixed);
+ const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide);
+ if (answerValue === 'yes') {
+ assert.equal(guide.action.amount, 5); rejected(game, f.target, { type: 'bid', amount: 4 });
+ game = act(game, f.target, guide.action); assert.equal(promise(game).fulfilled, true);
+ } else {
+ rejected(game, f.target, { type: 'bid', amount: 5 }); game = act(game, f.target, { type: 'bid', amount: 4 });
+ }
+ game = closeLot(game); assert.equal(promise(game).fulfilled, true);
+ assert.equal(player(game, f.target).spice, answerValue === 'yes' ? 5 : 6); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+for (const answerValue of ['yes', 'no'] as const) void test(`native unknown forecast uses the conservative whole ${answerValue} mixed bid constraint`, () => {
+ const f = fixture(), p = player(f.game, f.target); 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), bid());
+ const asked = ask(declare(f).game, f.asker, f.target, mixed);
+ assert.deepEqual(viewGame(asked, f.target).truthMixedBidAnswers, [answerValue]);
+ rejected(asked, f.target, { type: 'truthAnswer', answer: answerValue === 'yes' ? 'no' : 'yes' });
+ let game = answer(asked, f.target, answerValue); assert.equal(promise(game).fulfilled, false);
+ if (answerValue === 'yes') {
+ rejected(game, f.target, { type: 'passBid' }); game = act(game, f.target, { type: 'bid', amount: 4 });
+ assert.equal(promise(game).fulfilled, true); game = closeLot(game);
+ } else {
+ rejected(game, f.target, { type: 'bid', amount: 4 }); game = act(game, f.target, { type: 'bid', amount: 2 });
+ if (game.auction?.index === 0) game = closeLot(game);
+ }
+ assert.equal(promise(game).fulfilled, true); truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native prior qualifying bid before a real outbid remains outside the newly answered mixed Yes', () => {
+ const f = fixture({ window: 'waiting' }); let game = f.game;
+ for (const [actor, amount] of [[f.asker, 1], [f.opponent, 2], [f.target, 4], [f.asker, 5], [f.opponent, 6]] as const)
+ game = act(game, actor, { type: 'bid', amount });
+ game = bind(f, 'yes', f.mixed, game).game; assert.equal(promise(game).fulfilled, false);
+ rejected(game, f.target, { type: 'passBid' }); const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide);
+ assert.equal(guide.action.amount, 7); game = act(game, f.target, guide.action); assert.equal(promise(game).fulfilled, true);
+ game = closeLot(game); assert.equal(player(game, f.target).spice, 3); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native mixed bid question and guide retain exact owner turn lot event and JSON boundaries', () => {
+ const f = fixture(), declared = declare(f);
+ assert.ok(viewGame(declared.game, f.asker).truthMixedBidTargets.includes(f.target));
+ rejected(declared.game, f.opponent, 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, lot: f.question.lot + 1 } });
+ let game = ask(declared.game, f.asker, f.target, f.mixed);
+ assert.deepEqual(viewGame(game, f.target).truthMixedBidAnswers, ['yes', 'no']);
+ assert.equal(viewGame(game, f.asker).truthMixedBidAnswers, null); assert.equal(viewGame(game, f.opponent).truthMixedBidAnswers, null);
+ rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes');
+ const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide);
+ assert.equal(guide.action.bidPromiseEvent, promise(game).event); rejected(game, f.opponent, guide.action);
+ rejected(game, f.target, { ...guide.action, bidPromiseEvent: 'stale-mixed-bid-event' });
+ 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);
+ game = closeLot(game); game = current(game, f.target); rejected(game, f.target, wire);
+ truthOnce(game, f.truth); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native mixed bid projection retains only owned raw aggregate and filters its native residual', () => {
+ const f = fixture(), game = bind(f).game;
+ for (const actor of [f.target, f.asker, f.opponent]) {
+ const view = viewGame(game, actor); assert.deepEqual(view.bidPromises, []); assert.equal(view.truthMixedBidAnswers, null);
+ assert.deepEqual(view.truthHistory.at(-1)?.question, f.question); assert.equal(view.truthHistory.at(-1)?.answer, 'yes');
+ if (actor !== f.target) {
+ assert.deepEqual(view.mixedBidPromises, []); assert.equal(view.bidPromiseGuide, null);
+ } else {
+ const row = promise(game);
+ assert.deepEqual(Object.keys(row).sort(), ['answer', 'asker', 'event', 'fulfilled', 'lot', 'mixed', 'player', 'released', 'turn']);
+ assert.equal(row.player, f.target); assert.equal(row.asker, f.asker); assert.equal(row.turn, f.game.turn);
+ assert.equal(row.lot, f.question.lot); assert.equal(row.answer, true); assert.deepEqual(row.mixed, f.mixed);
+ assert.equal(row.fulfilled, false); assert.equal(row.released, false);
+ assert.equal(view.bidPromiseGuide?.action.bidPromiseEvent, row.event);
+ }
+ }
+ assert.equal(game.bidPromises?.length, 1); assert.equal(game.bidPromises![0].event, promise(game).event);
+});
+
+void test('native waiting mixed Yes releases after a real opposing ten-spice raise and retains original Emperor income', () => {
+ const f = fixture({ window: 'waiting' }); assert.equal(f.sourceActionCount, 20);
+ let game = bind(f).game; assert.equal(game.auction!.active, f.asker); assert.equal(viewGame(game, f.target).bidPromiseGuide, null);
+ game = act(game, f.asker, { type: 'passBid' }); game = act(game, f.opponent, { type: 'bid', amount: 10 });
+ assert.equal(game.auction!.active, f.target); assert.equal(promise(game).released, true); assert.equal(promise(game).fulfilled, false);
+ game = act(game, f.target, { type: 'passBid' }); game = closeLot(game);
+ assert.equal(player(game, f.target).spice, 20); assert.equal(player(game, f.opponent).spice, 0);
+ assert.ok(player(game, f.opponent).hand.some(card => card.id === f.lotCard)); assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native mixed No allows an actual lower purchase and does not reactivate in the next ordinary lot', () => {
+ const f = fixture(); let game = bind(f, 'no').game;
+ rejected(game, f.target, { type: 'bid', amount: 4 }); game = act(game, f.target, { type: 'bid', amount: 2 });
+ if (game.auction?.index === 0) game = closeLot(game);
+ assert.equal(game.auction!.index, 1); assert.equal(player(game, f.target).spice, 8);
+ assert.equal(promise(game).fulfilled, true); assert.equal(viewGame(game, f.target).bidPromiseGuide, null);
+ game = current(game, f.target); game = act(game, f.target, { type: 'bid', amount: 4 });
+ assert.equal(game.auction!.bid, 4); assert.equal(game.auction!.bidder, f.target); assert.equal(promise(game).fulfilled, true);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native accepted mixed Yes is fulfilled even when a later opposing bidder wins and pays', () => {
+ const f = fixture({ window: 'waiting' }); let game = act(f.game, f.asker, { type: 'bid', amount: 1 });
+ game = act(game, f.opponent, { type: 'bid', amount: 2 }); game = bind(f, 'yes', f.mixed, game).game;
+ game = act(game, f.target, { type: 'bid', amount: 4 }); assert.equal(promise(game).fulfilled, true);
+ game = act(game, f.asker, { type: 'bid', amount: 5 }); game = act(game, f.opponent, { type: 'passBid' });
+ game = act(game, f.target, { type: 'passBid' }); game = advance(game, clean);
+ assert.equal(promise(game).fulfilled, true); assert.equal(promise(game).released, false);
+ assert.ok(player(game, f.asker).hand.some(card => card.id === f.lotCard));
+ assert.ok(!player(game, f.target).hand.some(card => card.id === f.lotCard));
+ assert.equal(player(game, f.asker).spice, 5); assert.equal(player(game, f.target).spice, 15);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native original Karama funds mixed eleven-spice Yes and settles its original free-purchase invoice', () => {
+ const f = fixture({ targetKarama: true }); let game = bind(f, 'yes', group('and', fact(f.spiceFact), bid(11))).game;
+ const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide); assert.equal(guide.action.amount, 11);
+ game = act(game, f.target, guide.action); assert.equal(promise(game).fulfilled, true);
+ game = advance(game, state => !state.response && state.decision?.kind === 'auctionPayment');
+ assert.equal(game.auction!.bid, 11); assert.equal(player(game, f.target).spice, 10);
+ game = act(game, f.target, { type: 'decision', karama: true, card: f.karama });
+ assert.equal(player(game, f.target).spice, 10); assert.equal(promise(game).fulfilled, true);
+ assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard)); truthOnce(game, f.karama!);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native mixed action targets exclude already-passed and current-highest bidders', () => {
+ const passed = fixture(), declared = declare(passed);
+ assert.ok(passed.game.auction!.passed.includes(passed.opponent));
+ assert.ok(!viewGame(declared.game, passed.asker).truthMixedBidTargets.includes(passed.opponent));
+ rejected(declared.game, passed.asker, { type: 'truthAsk', question: { ...passed.question, target: passed.opponent } });
+ const highest = fixture({ window: 'waiting' }); let game = highest.game;
+ for (const [actor, amount] of [[highest.asker, 1], [highest.opponent, 2], [highest.target, 3]] as const)
+ game = act(game, actor, { type: 'bid', amount });
+ game = declare(highest, game).game; assert.ok(!viewGame(game, highest.asker).truthMixedBidTargets.includes(highest.target));
+ rejected(game, highest.asker, highest.questionAction); assert.deepEqual(inventory(game), highest.originalCards);
+});
+
+void test('all four minimal policies answer and execute an exact legal mixed bid Yes guide', () => {
+ const f = fixture(), 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); game = act(game, f.target, answerAction);
+ const view = viewGame(game, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
+ const guide = view.bidPromiseGuide; assert.ok(guide, difficulty);
+ const offered = botActions(view).find(action => action.type === 'bid' && action.bidPromiseEvent === guide.action.bidPromiseEvent);
+ assert.ok(offered, difficulty); assert.deepEqual(offered, guide.action); game = act(game, f.target, offered);
+ assert.equal(promise(game).fulfilled, true); assert.equal(player(game, f.target).spice, 10);
+ game = closeLot(game); assert.equal(player(game, f.target).spice, 6); truthOnce(game, f.truth);
+ assert.deepEqual(inventory(game), f.originalCards);
+ }
+});