setBidMinimum(claim.minimum)} />
+ One accepted bid from now until this lot closes. Earlier bids do not count. A larger opposing bid may make Yes impossible; your own pass, lower bid or voluntary spending cannot evade it.
+ {!g.truthBidTargets.includes(target) && Choose a bidder with an unused opportunity in the current ordinary lot and finish pending decisions. Registered native Basic and Advanced tables without optional modules are supported; other auctions and module compositions remain unfinished.
}
+ {truthQuestionText(question, leaderName, locationName)}
+ >
+ ) : kind === 'traitorCall' ? (
<>
Choose a still-unsubmitted voter in the current revealed battle. A definite answer binds that player’s original declaration, without reopening any sealed vote or promising that a later response will allow it.
{!g.truthTraitorTargets.includes(target) && Reveal both plans and choose a voter who has not submitted their Traitor decision.
}
@@ -429,6 +451,7 @@ export function Truthtrance({
'Ask publicly',
{ type: 'truthAsk', question },
(kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) ||
+ (kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
(kind === 'traitorCall' && !g.truthTraitorTargets.includes(target)) ||
(kind === 'mixedShipment' &&
(!!invalidMixed || !shipmentAvailable)) ||
@@ -449,7 +472,7 @@ export function TruthHistory({ game: g }: { game: GameView }) {
Truthtrance answers · Turn {g.turn}
- Structured battle-plan and supported reserve-shipment answers are
+ Structured battle-plan, ordinary-bid and supported reserve-shipment answers are
enforced. Current-turn freeform promises remain binding when possible,
but their automatic enforcement is unfinished.
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index bc49b66c..ff4e7f0b 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,36 @@ open mode gates.
## Current checkpoint and work
+**Truthtrance / ordinary bid commitments — 26 October. Prototyped / Partial.**
+Registered bare native Basic/Advanced ordinary lots now support a structured
+future minimum bid. Actual declarations fulfill Yes; No persists to exact lot
+close. Original funding, payment, native turn order and earlier commitments
+remain. Current-lot/next-owned-opportunity timing is explicitly provisional;
+no game-start gate opens and optional/native-special compositions remain due.
+
+Root shared integration with disjoint model-regression/control/native lanes
+passes **14 files,175/175**, types/lint/build and **four native runtime paths**,
+17 GameTable SSR renders and two actual guide callbacks. Native Basic/Advanced
+H/A/E active sources use22/25 actions plus8 each: H really holds both original
+Truthtrances, E answers, bids4 before cash changes, then pays its original4.
+Four minimal profiles each submit a legal native guide action per source.
+
+Twenty-action waiting sources add12 No/expiry or9 opposing-release actions.
+E may bid3 below No4, then bid4 in the next lot. A's real10 bid prices out E10,
+releasing Yes8; A later pays10 and native E collects10. Other new cases retain
+two-copy No6/Yes4, actual loss after fulfillment, Karama11/free purchase,
+voluntary funding guards, exact event/ownership, and an earlier shipment Yes9
+that rules out bid Yes4 but permits a paid1 card and actual9-force shipment.
+All33 original cards remain; no played hand/purse/force/auction/promise is installed.
+
+Persistent own controls expose no private lot identity and preserve original
+winning-payment instructions. Frozen report/manifest:
+`/home/quintine/.local/share/dune/checkpoints/20261026-truth-bid-proof-v1/`;
+journeys/HTML: adjacent `20261026-truth-bid-runtime/`.
+No saved-room/D1/server, reset/restart, deployment or assurance campaign.
+SSR/callback proof leaves browser/mobile/deployed, broader promises/modules
+and complete-mode acceptance open. [Source and contract](TRUTHTRANCE_BID_PROMISES.md).
+
**World occupation / live Sandtrout — 26 October. Prototyped / Partial.**
A real original Basic World/Tech draw reproduced an occupation-context rejection.
Clean World and World/all14 profiles now retain canonical pending Sandtrout as
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index e265187e..64262538 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,35 @@
# Dune implementation status
+## 26 October 2026 — Truthtrance ordinary bid commitments
+
+**Prototyped / Partial.** Registered bare native
+Basic/Advanced ordinary lots now support a structured future minimum bid with
+real Yes/No, exact lot expiry, joint constraints, original funding/payment and
+forced-impossibility release. Prior bids and payment do not fulfill it; a real
+declaration does, even when another bidder wins. Current-lot/next-opportunity
+timing remains provisional under general current-turn authority. No start gate
+changes; full module/native-special and broader commitment work remain.
+
+Root model/shared integration and disjoint pure/control/native lanes pass
+**14 files,175/175**, types/lint/build and **four native paths**,17 GameTable SSR
+renders/two actual guide callbacks. Original H/A/E Basic22/Advanced25 sources
+plus8 actions each bind Yes4, submit the exact guide bid before money changes,
+then pay4. Four minimal profiles per source submit legal next actions. Original
+waiting sources20+12/9 prove No4 permits3 and expires before next-lot4; A's10
+prices out E10 and releases Yes8 before original A payment/native E income10.
+
+Fourteen native cases also cover two original copies No6/Yes4, actual loss after
+declaration, original Karama11/free purchase, own funding/stale-event guards,
+and earlier shipment Yes9: bid Yes4 is unavailable, but an actual paid1 card
+leaves9 for the actual nine-force shipment. Native card inventory remains33;
+no played card/purse/force/auction/question/promise is installed. Controlled
+UI supplements retain their staging labels. Frozen report/manifest:
+`/home/quintine/.local/share/dune/checkpoints/20261026-truth-bid-proof-v1/`;
+journeys/HTML: adjacent `20261026-truth-bid-runtime/`.
+No saved-room/D1/server/reset/restart/deployment or assurance campaign.
+Browser/mobile/deployed, broader promises/modules and complete-mode acceptance remain.
+[Source and contract](TRUTHTRANCE_BID_PROMISES.md).
+
## 26 October 2026 — Live World Sandtrout
**Prototyped / Partial.** A real original Basic
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index c9311477..927a5b28 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,21 @@
# Rule decision index
+## Truthtrance ordinary bid commitments — 26 October 2026
+
+Apply the existing November2020 FAQ p8 current-turn commitment authority to one
+future accepted minimum bid in the current ordinary lot. Under standing
+choose-recommended priority, use the next owned available opportunity rather
+than assuming a future rival raise; current-highest/passed/full-hand targets
+have no fresh opportunity. Actual declaration fulfills Yes without promising a
+win; No persists to lot close. Original winning payment remains binding.
+
+This exact-lot/interval/owned-funding and accepted-order release model is a
+provisional software composition, not a dedicated publisher bidding ruling.
+Keep current cash/aid/Karama, earlier commitments and original quote/custody
+rules. No new start permission; optional modules and native special auctions
+remain outside the first registered native Basic/Advanced prototype.
+[Source, controls and boundaries](TRUTHTRANCE_BID_PROMISES.md).
+
## Sapho in ordered Advanced Revival — 26 October 2026
The printed Sapho first/last alternative applies to the now-connected ordered
diff --git a/docs/TRUTHTRANCE_BID_PROMISES.md b/docs/TRUTHTRANCE_BID_PROMISES.md
new file mode 100644
index 00000000..67f947e7
--- /dev/null
+++ b/docs/TRUTHTRANCE_BID_PROMISES.md
@@ -0,0 +1,109 @@
+# Truthtrance ordinary bid commitments
+
+## Current-lot prototype — 26 October 2026
+
+**Prototyped / Partial.** The question binds one
+future ordinary bid declaration of at least a chosen positive whole amount in
+the current lot. Registered native Basic/Advanced tables use their selected
+decks and ordinary auctions. Optional modules, independent cards, Semuta/Kull,
+native special auctions, phase-wide/grouped bidding claims and force-revival
+promises remain required work. This does not change game-start permissions.
+
+## Authority and selected interpretation
+
+The [GF9 November 2020 FAQ, printed p8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8)
+permits game-related yes/no questions and current-turn action commitments,
+requires compliance while possible, and releases a later impossible answer.
+Its worked example concerns shipment; it does not prescribe a bidding-specific
+packet or online auction boundary. The official-domain indexed text was checked
+for this slice. The previous [general source audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md)
+remains applicable.
+
+Under the standing 14 October choose-recommended instruction, use a single
+accepted bid in this exact ordinary lot. Only a still-eligible active or waiting
+bidder can answer; current-highest, already-passed and full-hand players have no
+new opportunity for this question. An earlier bid does not fulfill it. Yes
+requires a qualifying declaration at the player's next available opportunity
+while possible; a smaller bid or pass cannot rely on a future rival raise.
+No forbids every later qualifying bid through the close of the lot. These are
+explicit provisional software interpretations, not a dedicated publisher FAQ.
+
+Fulfillment records the declaration, even if someone else wins. The original
+winning invoice and Karama payment remain separate and binding. Legal native
+Karama overbidding is retained; the question creates no spice, discount or free
+purchase. Existing current cash, prepaid allied aid, capacity and normal quote
+checks remain authoritative. Unknown purchases and future rival income are not
+assumed. Earlier shipment/movement funding obligations constrain the bid quote.
+
+## State, actions and continuation
+
+`game/bid-promises.ts` validates positive safe-integer `turn`, one-based `lot`
+and `minimum`. The new question is `{kind:'bid',target,turn,lot,minimum}`.
+It uses the original card declaration, priority, Ask, public Yes/No, physical
+discard and interrupted-auction continuation. It adds no automatic human reply.
+
+The engine privately binds the original physical lot, turn, respondent and
+asker with a distinct event. Public history contains the stated lot/minimum and
+answer; the unrevealed card binding is never projected. Only the respondent
+receives feasible answers and its own sanitized commitment/guide. The guide
+waits for the real active seat and original pending windows, and its event
+rejects stale guided callbacks. Ordinary manual bid packets retain their shape.
+
+The native bid quote is shared with ordinary bidding. Yes requirements form a
+lower bound; No requirements cap the permitted amount. Several original
+Truthtrances must remain jointly satisfiable. A real accepted bid fulfills each
+matching Yes; No persists. Native settlement, custody and income then continue
+normally. A paid purchase is not another bid.
+
+Own voluntary spending or closure cannot erase a remaining Yes. A completed
+opposing raise can price out the respondent and release only impossible Yes
+answers in accepted order; feasible answers and No constraints remain. Finished
+lots fulfill No and release any forced impossible remainder. Records may stay
+in history, but never bind another lot or turn. Recovery/privacy/custody
+certification is deferred with the rest of the full-rules review.
+
+## Connected controls and evidence
+
+The original Truthtrance selector offers **Bind a bid in this ordinary lot**,
+with fixed public lot/turn, editable validated minimum and an explicit preview.
+Only feasible respondent answers are shown. `components/bid-promises.tsx`
+keeps the owner's obligation and fulfilled/released status visible; its guarded
+button submits the authoritative bid/pass packet without duplicating prices.
+No transient notice is required. All four existing policies use the same legal
+answer/next-action guidance; strategy and strength tuning remain deferred.
+
+Pure cases: `tests/bid-promises.test.ts`. Native source and engine cases:
+`tests/fixture-truth-bid.ts`, `tests/truth-bid-runtime.test.ts`. Controlled and
+source-earned callbacks: `tests/truth-bid-controls.test.tsx`. Original H/A/E
+starts genuinely deal Harkonnen both physical Truthtrances and the other native
+hands. Only unopened deal/lot/Spice positions and scalar lotteries are selected;
+no played card, purse, force, auction, question or promise is installed.
+
+Frozen integration passes **14 files,175/175**, types/lint/build and four native
+runtime paths with17 GameTable SSR renders and two actual guide callbacks.
+Ten pure cases, fourteen native engine cases and six controlled/source-earned
+UI cases cover the new contract. Related original auction, Banker/Bureaucrat,
+funding and Truthtrance cases remain passing after the native quote extraction.
+
+Public H/A/E Basic/Advanced active sources use22/25 actions plus8 each. Actual
+declaration/priority/Ask/answer precede the authoritative bid callback; Yes4
+fulfills before cash changes, then the original winner pays4. Four existing
+minimal policies each submit a legal guide action per source. Waiting sources
+use20+12 actions for No4/lower3/lot expiry/new-lot4, or20+9 for an actual A10
+opposing bid, E10's released Yes8 and original A payment/native E income10.
+
+Other native cases bind both original cards as No6 plus Yes4, lose after a
+fulfilled declaration, spend an original Karama after the promised11 bid and
+reject stale/foreign packets and voluntary loss of required funding. An earlier
+shipment Yes9 disallows later bid Yes4; a paid1 card leaves the actual9 for a
+native nine-force shipment. All33 original cards remain physical. SSR confirms
+the original authoring option, respondent answers, persistent guide and final
+statuses; actual hook-free callbacks execute the original native bid packet.
+This is not DOM/hydration or browser evidence.
+
+Report/source manifest:
+`/home/quintine/.local/share/dune/checkpoints/20261026-truth-bid-proof-v1/`;
+journeys/HTML: adjacent `20261026-truth-bid-runtime/`.
+No saved-room/D1/server, reset/restart, deployment or separate assurance campaign.
+Full Truthtrance, module/native-special composition, browser/mobile/deployed
+acceptance and final assurance remain open.
diff --git a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md
index cb45debb..c250b172 100644
--- a/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md
+++ b/docs/TRUTHTRANCE_NONBATTLE_READINESS_20260907.md
@@ -1,5 +1,11 @@
# Truthtrance nonbattle readiness and first implementation slice
+The [26 October ordinary-bid follow-up](TRUTHTRANCE_BID_PROMISES.md) adds a
+structured current-lot bid commitment. The historical absence table below is
+superseded for that slice, as well as the previously linked fact, shipment and
+movement work. Native force-revival promises and broader action commitments
+remain unfinished; do not reimplement existing recorded-knowledge predicates.
+
## Grouped battle-plan authoring — 26 October 2026
The existing battle-plan question now has a complete bounded expression form,
diff --git a/game/bid-promises.ts b/game/bid-promises.ts
new file mode 100644
index 00000000..bf3e1ab4
--- /dev/null
+++ b/game/bid-promises.ts
@@ -0,0 +1,93 @@
+import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope';
+
+export type BidClaim = { turn: number; lot: number; minimum: number };
+export type BidPromise = BidClaim & {
+ event: string;
+ /** Internal physical-lot binding. Never project the unrevealed card identity. */
+ source: string;
+ player: string;
+ asker: string;
+ answer: boolean;
+ fulfilled?: boolean;
+ released?: boolean;
+};
+export type BidOpportunity = {
+ turn: number; lot: number; source: string; bid: number;
+ bidder: string | null; passed: readonly string[]; eligible: readonly string[];
+};
+export class BidPromiseError extends Error {}
+
+export function parseBidClaim(value: unknown): BidClaim {
+ if (!value || typeof value !== 'object' || Array.isArray(value))
+ throw new BidPromiseError('Choose a current ordinary-auction bid question.');
+ const input = value as Record;
+ for (const field of ['turn', 'lot', 'minimum'] as const)
+ if (typeof input[field] !== 'number' || !Number.isSafeInteger(input[field]) || input[field] < 1)
+ throw new BidPromiseError('Choose the current turn and lot, and a positive whole bid minimum.');
+ return { turn: input.turn as number, lot: input.lot as number, minimum: input.minimum as number };
+}
+
+/** Original ordinary lots first; native special auctions and optional modules
+ * retain their own required future commitment work. Never changes start gates. */
+export function bidPromiseModeSupported(game: NativePromiseScope & {
+ semutaPreview?: unknown; kullPreview?: unknown; nexusKullPreview?: unknown;
+ richeseBetrayalPreview?: unknown;
+}): boolean {
+ return nativePromiseScopeSupported(game) && !game.semutaPreview && !game.kullPreview &&
+ !game.nexusKullPreview && !game.richeseBetrayalPreview;
+}
+
+export function bidClaimText(claim: BidClaim): string {
+ return `place a bid of at least ${claim.minimum} spice from now until ordinary lot ${claim.lot} closes`;
+}
+
+export function liveBidPromises(promises: readonly BidPromise[], player: string, opportunity: BidOpportunity): BidPromise[] {
+ return promises.filter(promise => promise.player === player && promise.turn === opportunity.turn &&
+ promise.source === opportunity.source && !promise.released && !promise.fulfilled);
+}
+
+/** One future accepted declaration, not a total of bids or a purchase invoice. */
+export function bidPromiseRange(promises: readonly BidPromise[], player: string,
+ opportunity: BidOpportunity, maximum: number): { minimum: number; maximum: number } | null {
+ if (!opportunity.eligible.includes(player) || opportunity.passed.includes(player) ||
+ opportunity.bidder === player || !Number.isSafeInteger(maximum) || maximum < 1 ||
+ !Number.isSafeInteger(opportunity.bid) || opportunity.bid < 0 || opportunity.bid === Number.MAX_SAFE_INTEGER)
+ return null;
+ let minimum = opportunity.bid + 1;
+ for (const promise of liveBidPromises(promises, player, opportunity)) {
+ if (promise.answer) minimum = Math.max(minimum, promise.minimum);
+ else maximum = Math.min(maximum, promise.minimum - 1);
+ }
+ return minimum <= maximum ? { minimum, maximum } : null;
+}
+
+export function bidRequired(promises: readonly BidPromise[], player: string, opportunity: BidOpportunity): boolean {
+ return liveBidPromises(promises, player, opportunity).some(promise => promise.answer);
+}
+
+/** Record before the native bid advances/settles its lot. Caller owns the
+ * detached transaction, so a later native validation failure remains atomic. */
+export function recordPromisedBid(promises: readonly BidPromise[], player: string,
+ opportunity: BidOpportunity, amount: number): BidPromise[] {
+ const live = liveBidPromises(promises, player, opportunity);
+ if (live.some(promise => !promise.answer && amount >= promise.minimum))
+ throw new BidPromiseError('This bid conflicts with your Truthtrance No answer for this lot.');
+ return promises.map(promise => live.includes(promise) && promise.answer && amount >= promise.minimum
+ ? { ...promise, fulfilled: true } : promise);
+}
+
+export function validateBidPromises(promises: readonly BidPromise[], players: readonly string[]): void {
+ if (!Array.isArray(promises)) throw new BidPromiseError('The bid commitment record is invalid.');
+ const events = new Set();
+ for (const promise of promises) {
+ parseBidClaim(promise);
+ if (typeof promise.event !== 'string' || !promise.event || events.has(promise.event) ||
+ typeof promise.source !== 'string' || !promise.source || !players.includes(promise.player) ||
+ !players.includes(promise.asker) || promise.player === promise.asker || typeof promise.answer !== 'boolean' ||
+ (promise.fulfilled !== undefined && typeof promise.fulfilled !== 'boolean') ||
+ (promise.released !== undefined && typeof promise.released !== 'boolean') ||
+ promise.fulfilled && promise.released)
+ throw new BidPromiseError('The bid commitment lost its original event, source or participants.');
+ events.add(promise.event);
+ }
+}
diff --git a/game/bots.ts b/game/bots.ts
index 264b25b0..0c0f07a2 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -1307,12 +1307,17 @@ function policyActions(g: GameView): Action[] {
? [{ type: 'decision', event: search.event, card: ordered[0].id }]
: [];
}
+ if (g.bidPromiseGuide) return [g.bidPromiseGuide.action];
if (g.truthtrance) {
const w = g.truthtrance;
if (w.stage === 'priority')
return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }];
if (w.stage === 'answer') {
if (w.question?.target !== me.id) return [];
+ if (w.question.kind === 'bid') {
+ const answer = g.truthBidAnswers?.[0];
+ return answer ? [{ type: 'truthAnswer', answer }] : [];
+ }
if (w.question.kind === 'traitorCall') {
const answers = g.truthTraitorAnswers ?? [];
const answer = answers.find(answer => answer === 'yes') ?? answers[0];
diff --git a/game/engine.ts b/game/engine.ts
index 6059cbd6..2b4d48f0 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -18,6 +18,8 @@ import { findMovementCompletion, type MovementCompletion } from './movement-prom
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile, selectedFactionStrongholdProfile, selectedIndependentCardsProfile } from './faction-module-profile';
import { homeworldOccupationLeaderSkillsProfile, homeworldOccupationModulesProfile, homeworldPhaseModulesProfile } from './homeworld-module-profile';
+import { BidPromiseError, bidPromiseModeSupported, bidPromiseRange, bidRequired, liveBidPromises,
+ recordPromisedBid, validateBidPromises, type BidClaim, type BidOpportunity, type BidPromise } from './bid-promises';
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3LeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
@@ -2133,6 +2135,7 @@ export type Game = {
truthtrance?: TruthWindow | null;
truthHistory?: TruthRecord[];
shipmentPromises?: ShipmentPromise[];
+ bidPromises?: BidPromise[];
movementPromises?: MovementPromise[];
/** Server-only certainty of movement-relevant cards still in the discard. */
movementDiscardKnowledge?: MovementDiscardKnowledge;
@@ -15075,6 +15078,132 @@ function decideNexusEmperorPayment(g: Game, p: Player, action: Action): void {
if (action.use) settleAuction(g, false);
else recoverAuctionPayment(g, getPlayer(g, source.winner));
}
+function normalBidMaximum(g: Game, p: Player): number {
+ return (g.kullPreview ? p.hand.some(card => canUseAsKaramaRole(g, p, card)) : !!karamaCard(g, p))
+ ? Number.MAX_SAFE_INTEGER : p.spice + (aidFor(g, p)?.amount ?? 0);
+}
+/** Same native quote for ordinary controls and the detached current-lot guide. */
+function normalBidQuote(g: Game, p: Player, amount: unknown, requestedAid?: unknown) {
+ requireRule(g.auction, 'The ordinary auction is no longer present.');
+ const capacity = normalAuctionHandBlock(p.hand.length, handLimit(p));
+ requireRule(!capacity, capacity ?? 'Your Treachery hand is full.');
+ const quote = quoteNormalAuctionBid({ currentBid: g.auction.bid, amount, maximum: normalBidMaximum(g, p) });
+ if (!quote.ok) throw new RuleError(quote.reason);
+ requireRule(!g.kullPreview || (p.id === byFaction(g, 'choam')?.id &&
+ !kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, p.id, 'auction-free-purchase')) ||
+ quote.amount <= p.spice + (aidFor(g, p)?.amount ?? 0),
+ 'Kull preview defers an unfunded Karama-dependent overbid before acceptance.');
+ const allyPayment = quote.amount <= p.spice + (aidFor(g, p)?.amount ?? 0)
+ ? contribution(g, p, quote.amount, requestedAid) : 0;
+ const parties = auctionBureaucratParties(g, p, quote.amount, allyPayment);
+ checkBureaucratContributors(g, parties.payee, parties.shares);
+ return { amount: quote.amount, allyPayment };
+}
+function bidOpportunity(g: Game): BidOpportunity | null {
+ const a = g.auction;
+ if (g.status !== 'playing' || g.phase !== 3 || !a || g.currentAuctionSale ||
+ !a.cards[a.index] || !bidPromiseModeSupported(g)) return null;
+ return { turn: g.turn, lot: a.index + 1,
+ source: JSON.stringify(['ordinaryBid', g.turn, a.index, a.cards[a.index].id]),
+ bid: a.bid, bidder: a.bidder, passed: a.passed,
+ eligible: g.players.filter(player => player.hand.length < handLimit(player)).map(player => player.id) };
+}
+function bidQuestionWindow(g: Game): boolean {
+ return !!bidOpportunity(g) && !g.response && !g.decision && !g.phaseOpening && !g.richeseAuction &&
+ !g.ixAuction && !g.pendingKarama && !g.pendingTreacheryDiscard;
+}
+function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []): Action | null {
+ const opportunity = bidOpportunity(g);
+ if (!opportunity) return null;
+ if (!bidRequired(promises, p.id, opportunity)) return { type: 'passBid' };
+ const range = bidPromiseRange(promises, p.id, opportunity, normalBidMaximum(g, p));
+ if (!range) return null;
+ try {
+ const action: Action = { type: 'bid', ...normalBidQuote(g, p, range.minimum) };
+ if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
+ liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length) {
+ const trial = structuredClone(g);
+ trial.truthtrance = null;
+ trial.auction!.bid = action.amount as number;
+ trial.auction!.bidder = p.id;
+ trial.auction!.allyPayment = action.allyPayment as number;
+ reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase });
+ reconcileGroundMovementPromises(trial, p.id, action, g.phase);
+ }
+ return action;
+ }
+ catch (error) { if (error instanceof RuleError) return null; throw error; }
+}
+function bidTruthAnswers(g: Game, p: Player, claim: BidClaim): ('yes' | 'no' | 'unknown')[] {
+ 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 [];
+ return (['yes', 'no'] as const).filter(answer => !!bidPromiseCompletion(g, p, [...(g.bidPromises ?? []),
+ { ...claim, event: 'candidate', source: opportunity.source, player: p.id,
+ asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes' }]));
+}
+function bindBidTruth(g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) {
+ const opportunity = bidOpportunity(g);
+ requireRule(opportunity && bidTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
+ 'This ordinary bid answer no longer has a legal completion.');
+ (g.bidPromises ??= []).push({ ...claim, event: crypto.randomUUID(), source: opportunity.source,
+ player: p.id, asker, answer });
+}
+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; }
+}
+function reconcileBidPromises(g: Game, voluntary?: { actor: string; action: Action }) {
+ bidPromiseIntegrity(g);
+ if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingTreacheryDiscard ||
+ g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal) return;
+ const opportunity = bidOpportunity(g);
+ const kept: BidPromise[] = [];
+ const no = (g.bidPromises ?? []).filter(promise => !promise.answer && !promise.fulfilled && !promise.released);
+ for (const promise of g.bidPromises ?? []) {
+ if (promise.fulfilled || promise.released) continue;
+ const same = opportunity && promise.turn === opportunity.turn && promise.source === opportunity.source;
+ if (!promise.answer && same) continue;
+ const own = voluntary?.actor === promise.player &&
+ ['bid', 'passBid', 'card', 'bribe', 'pledgeAid', 'emperorGift', 'richeseGift', 'richeseBid', 'decision', 'karama', 'specialKarama'].includes(voluntary.action.type);
+ const possible = same && bidPromiseCompletion(g, getPlayer(g, promise.player), [...no, ...kept, promise]);
+ if (promise.answer && !possible) {
+ requireRule(!own, 'You cannot voluntarily evade your Truthtrance bid answer.');
+ promise.released = true;
+ log(g, `${getPlayer(g, promise.player).name} can no longer fulfill the Truthtrance bid answer for ordinary lot ${promise.lot}; that answer is no longer binding.`);
+ } else if (!promise.answer) promise.fulfilled = true;
+ else kept.push(promise);
+ }
+}
+function projectedBidPromises(g: Game, player: string) {
+ return (g.bidPromises ?? []).filter(promise => promise.player === player).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 }),
+ ...(promise.released === undefined ? {} : { released: promise.released }),
+ }));
+}
+function projectedBidPromiseGuide(g: Game, player: string): { action: Action } | null {
+ const opportunity = bidOpportunity(g);
+ if (!opportunity || !bidQuestionWindow(g) || g.truthtrance || g.auction?.active !== player) return null;
+ const promise = liveBidPromises(g.bidPromises ?? [], player, opportunity)[0];
+ if (!promise) return null;
+ const action = bidPromiseCompletion(g, getPlayer(g, player));
+ return action ? { action: { ...action, bidPromiseEvent: promise.event } } : null;
+}
+function guardPromisedBid(g: Game, p: Player, action: Action, amount?: number) {
+ const opportunity = bidOpportunity(g);
+ const live = opportunity ? liveBidPromises(g.bidPromises ?? [], p.id, opportunity) : [];
+ if (action.bidPromiseEvent !== undefined) requireRule(typeof action.bidPromiseEvent === 'string' &&
+ live.some(promise => promise.event === action.bidPromiseEvent), 'This bid commitment guide is stale.');
+ if (!opportunity) return;
+ if (amount !== undefined && live.length) {
+ try { g.bidPromises = recordPromisedBid(g.bidPromises ?? [], p.id, opportunity, amount); }
+ catch (error) { if (error instanceof BidPromiseError) throw new RuleError(error.message); throw error; }
+ }
+ requireRule(!bidRequired(g.bidPromises ?? [], p.id, opportunity),
+ 'Place the qualifying bid required by your Truthtrance Yes before passing or taking a lower bid.');
+}
function auctionNext(g: Game) {
const a = g.auction!;
const eligible = g.order.filter((id) => {
@@ -34438,6 +34567,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
+ bidPromiseIntegrity(state);
revivalOrderIntegrity(state);
state = migrateAtomicsTargets(state);
state = migrateFremenNexusRevival(state);
@@ -34542,6 +34672,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return normalizeAutomaticGame(state);
}
const g = applyActionInner(state, id, action);
+ reconcileBidPromises(g, { actor: id, action });
reconcileGroundMovementPromises(g, id, action, state.phase);
reconcileTraitorTruth(g, id, action);
harkonnenNexusBetrayalIntegrity(g);
@@ -34663,6 +34794,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
leaderSkillsIntegrity(g);
reconcileBattlePromises(g, { actor: id, action });
reconcileShipmentPromises(g, { actor: id, action, phase: state.phase });
+ reconcileBidPromises(g, { actor: id, action });
settleAutomaticContinuations(g);
observeOccupation(g);
reconcileHomeworldEcazDuke(g);
@@ -34863,6 +34995,7 @@ function settleAutomaticContinuations(g: Game) {
// they are not the original actor voluntarily spending a promised resource.
reconcileBattlePromises(g);
reconcileShipmentPromises(g);
+ reconcileBidPromises(g);
reconcileGroundMovementPromises(g);
reconcileTraitorTruth(g);
requireRule(
@@ -34876,6 +35009,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
+ bidPromiseIntegrity(state);
state = migrateAtomicsTargets(state);
state = migrateFremenNexusRevival(state);
harkonnenNexusBetrayalIntegrity(state);
@@ -34959,6 +35093,7 @@ export function normalizeAutomaticGame(state: Game): Game {
}
reconcileBattlePromises(g);
reconcileShipmentPromises(g);
+ reconcileBidPromises(g);
reconcileGroundMovementPromises(g);
reconcileTraitorTruth(g);
settleAutomaticContinuations(g);
@@ -35204,6 +35339,8 @@ function applyActionInner(
bindShipment: bindShipmentTruth,
movementAnswers: movementTruthAnswers,
bindMovement: bindMovementTruth,
+ bidAnswers: bidTruthAnswers,
+ bindBid: bindBidTruth,
traitorAnswers: traitorTruthAnswers,
bindTraitor: bindTraitorTruth,
battleAnswers: battleTruthAnswers,
@@ -35213,7 +35350,7 @@ function applyActionInner(
)
return g;
} catch (error) {
- if (error instanceof TruthError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
+ if (error instanceof TruthError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
throw new RuleError(error.message);
throw error;
}
@@ -37700,31 +37837,16 @@ function applyActionInner(
);
const a = g.auction;
if (t === 'bid') {
- const capacity = normalAuctionHandBlock(p.hand.length, handLimit(p));
- requireRule(!capacity, capacity ?? 'Your Treachery hand is full.');
- const quotedBid = quoteNormalAuctionBid({
- currentBid: a.bid,
- amount: action.amount,
- maximum: (g.kullPreview ? p.hand.some(card => canUseAsKaramaRole(g, p, card)) : !!karamaCard(g, p))
- ? Number.MAX_SAFE_INTEGER
- : p.spice + (aidFor(g, p)?.amount ?? 0),
- });
- if (!quotedBid.ok) throw new RuleError(quotedBid.reason);
- requireRule(!g.kullPreview ||
- (p.id === byFaction(g, 'choam')?.id &&
- !kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, p.id, 'auction-free-purchase')) ||
- quotedBid.amount <= p.spice + (aidFor(g, p)?.amount ?? 0),
- 'Kull preview defers an unfunded Karama-dependent overbid before acceptance.');
+ const quotedBid = normalBidQuote(g, p, action.amount, action.allyPayment);
+ guardPromisedBid(g, p, action, quotedBid.amount);
a.bid = quotedBid.amount;
- a.allyPayment =
- a.bid <= p.spice + (aidFor(g, p)?.amount ?? 0)
- ? contribution(g, p, a.bid, action.allyPayment)
- : 0;
- const parties = auctionBureaucratParties(g,p,a.bid,a.allyPayment);
- checkBureaucratContributors(g,parties.payee,parties.shares);
+ a.allyPayment = quotedBid.allyPayment;
a.bidder = id;
a.passed = [];
- } else if (!a.passed.includes(id)) a.passed.push(id);
+ } else {
+ guardPromisedBid(g, p, action);
+ if (!a.passed.includes(id)) a.passed.push(id);
+ }
auctionNext(g);
return g;
}
@@ -39173,6 +39295,15 @@ export function viewGame(state: Game, id: string) {
g.truthtrance.question.kind === 'battlePlan'
? battleTruthAnswers(g, me, g.truthtrance.question.claim)
: null,
+ bidPromiseLot: (() => { const lot = bidOpportunity(g); return lot ? { turn: lot.turn, lot: lot.lot } : null; })(),
+ truthBidTargets: 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) : [],
+ truthBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
+ g.truthtrance.question.kind === 'bid' ? bidTruthAnswers(g, me, g.truthtrance.question) : null,
+ bidPromises: projectedBidPromises(g, id),
+ bidPromiseGuide: projectedBidPromiseGuide(g, id),
truthtrance: g.truthtrance ?? null,
truthHistory: g.truthHistory ?? [],
truthAnswer:
diff --git a/game/reference.ts b/game/reference.ts
index 7f1904ea..b0f498b3 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3208,6 +3208,31 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: '13 files101/101, types/lint/build and six native engine/answer-callback/GameTable SSR journeys pass. Genuine26/72/77-action Basic/allied/Cunning sources earn original cards, alliances, payments, reveal and draw/return. Four profiles answer/vote legally. Unmatched-card staging is labelled; submitted-false and response/typing fixture feedback is repaired. Full future promises, combinations and final assurance remain open.', evidence: ['tests/traitor-decision-promises.test.ts', 'tests/fixture-truth-traitor-decision.ts', 'tests/fixture-truth-traitor-harkonnen.ts', 'tests/truth-traitor-decision-engine.test.ts', 'tests/truth-traitor-harkonnen-engine.test.ts', 'tests/truth-traitor-decision-controls.test.tsx', 'docs/TRUTHTRANCE_TRAITOR_DECISIONS.md'] },
],
},
+ {
+ id: 'truthtrance-bid',
+ title: 'Truthtrance: a bid in the current ordinary lot',
+ category: 'Cards',
+ coverage: 'Partial',
+ developmentStage: 'Prototyped',
+ summary: 'Bind one future minimum bid in the current ordinary bank lot, while preserving its original funding, payment and turn order.',
+ steps: [
+ 'Choose Bind a bid in this ordinary lot, another remaining eligible bidder and a positive whole spice minimum. The turn and lot are fixed. Registered native Basic and Advanced tables without optional modules are supported first; special auctions and broader compositions remain unfinished.',
+ 'Yes requires one new accepted bid at or above the minimum while possible. An earlier bid does not count. A pass or lower bid cannot rely on another player raising later. No forbids every later qualifying bid until this lot closes.',
+ 'The declaration fulfills Yes even if another player later wins. If you win, the original winning invoice and Karama payment rules still apply. A promise creates no spice or free purchase. Current-highest, already-passed and full-hand players have no new opportunity for this structured question.',
+ 'Available answers use current native funding, prepaid allied aid and a spendable Karama. Earlier shipment and movement obligations retain their funding. Unknown future purchases or rival income are not assumed. Several answers must remain jointly possible: Yes sets the required minimum and No can cap a later bid.',
+ 'Your own voluntary spending cannot evade Yes. A completed opposing raise may make it impossible and release that answer; still-possible answers and No constraints remain. Closed lots finish No and cannot bind the next lot or turn.',
+ 'Your persistent bid-promise panel waits for your actual active seat, then offers a legal bid or pass. It shows fulfilled and released status without timing out. Only the questioned player receives its feasible answers and private guide; the question and reply are public.',
+ 'This current-lot and next-opportunity protocol is a provisional online composition of the general current-turn Truthtrance rule. Native special auctions, optional modules, grouped/phase-wide bidding commitments, force-revival commitments and complete-mode acceptance remain unfinished.',
+ ],
+ related: ['card-truthtrance', 'bidding', 'truthtrance-shipment', 'implementation-checklist'],
+ checklist: [
+ { area: 'Implementation', status: 'Partial', detail: 'Exact current-lot minimum bids use the native quote, real declaration, joint Yes/No interval, accepted-order forced release and lot expiry. Original registered bare native Basic/Advanced scope; no new game-start gate.', evidence: ['game/bid-promises.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/bid-promises.test.ts', 'tests/truth-bid-runtime.test.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Original question selector, public preview, target-only answers and persistent event-bound guide use the native bid packet. Winning payments remain separate.', evidence: ['components/bid-claim-fields.tsx', 'components/bid-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-bid-controls.test.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'All four existing profiles use the projected feasible answer and ordinary guide. Minimal legal participation only; strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-bid-runtime.test.ts'] },
+ { area: 'Documentation', status: 'Implemented', detail: 'General FAQ authority, provisional current-lot meaning, physical declaration/payment distinction and remaining scope are explicit.', evidence: ['docs/TRUTHTRANCE_BID_PROMISES.md', 'docs/RULE_DECISIONS.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Original public H/A/E sources and actual callbacks cover Yes/No, payments, release, multiple original cards, stale ownership and earlier shipment funding. Broader rule/module, recovery and deployed acceptance remain required.', evidence: ['tests/fixture-truth-bid.ts', 'tests/truth-bid-runtime.test.ts', 'tests/truth-bid-controls.test.tsx'] },
+ ],
+ },
{
id: 'truthtrance-movement',
title: 'Truthtrance: ground-movement promises',
@@ -3357,7 +3382,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards',
coverage: 'Partial',
summary:
- 'Ask another player a public yes/no question. Verified facts, supported shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
+ 'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
steps: [
'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.',
'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
@@ -3368,6 +3393,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Freeform questions also accept public Yes, No, or I don’t know answers. Their truthfulness relies on the players. AI answers structured facts and battle-plan questions; it cannot yet interpret freeform questions.',
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Ground-movement minima include earlier Basic phases with declared native families, owned Charity/ordinary revival preparation and exact normal/native auction funding, plus active or unused waiting phase5 turns before or after shipment. Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.',
'After both plans reveal, a separate current-Traitor question binds an actual unsubmitted native voter to Reveal or decline. The declaration fulfills it before later Karama/betrayal prevention. Cunning cannot voluntarily return a matching card needed by a live Yes; completed declarations retain their native later custody rights. See the current-Traitor guide for scope.',
+ 'An ordinary-bid question can bind one new qualifying bid in the current lot for a supported active or waiting bidder. Yes requires that declaration while possible; No forbids it to lot close. A bid need not win to fulfill Yes, but a winning bid still follows its original payment rules. See the current-lot guide for funding, expiry and unfinished configurations.',
'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. The complete grouped builder supports eight children per group, sixteen total conditions including groups and depth four. Add/wrap/remove/collapse retains the whole claim and validates every field. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.',
'Named conditions include Reinforcements and Harass slot occupancy without making either a weapon/defense category. In clean classic Homeworlds, a source-aware legal completion can choose the native fee needed for a promised dial; review and filling retain that exact allocation. Active preview, answers/history and server records use the actual combat location name, including native Worlds, rather than treating every battle as an Arrakis territory.',
'If a completed opposing power makes a battle answer impossible, it is publicly released. A pending Voice response resolves before that release. A player cannot voluntarily spend a promised battle card or give away necessary support spice to evade the answer. Feasibility includes available Ghola cards, eligible leader/Kwisatz or force revivals, and possible revival income. Each actual card can be spent only once. Other future card/power sequences and the full timing audit remain unfinished.',
@@ -3384,6 +3410,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'truthtrance-plan-expression',
'truthtrance-shipment',
'truthtrance-movement',
+ 'truthtrance-bid',
'richese-no-field',
'card-reinforcements',
],
diff --git a/game/truthtrance.ts b/game/truthtrance.ts
index 949b38bc..8764132e 100644
--- a/game/truthtrance.ts
+++ b/game/truthtrance.ts
@@ -46,6 +46,7 @@ import { canUseAsTruthtranceRole } from './shrine';
import { isKnowledgeFact, parseKnowledgeFact, knowledgeFactAnswer, knowledgeFactText, truthKnowledgeOf, type KnowledgeFact, type TruthKnowledge } from './truthtrance-knowledge';
import { parseNoFieldValueFact, noFieldValueFactMatches, noFieldValueFactText, type NoFieldValueFact } from './truthtrance-no-field-value';
import { parseFaceDancerFact, faceDancerFactMatches, faceDancerFactText, type FaceDancerFact } from './truthtrance-face-dancer';
+import { parseBidClaim, bidClaimText, bidPromiseModeSupported, type BidClaim } from './bid-promises';
export type TruthFact =
| KnowledgeFact
@@ -59,6 +60,7 @@ export type TruthFact =
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
| { kind: 'and' | 'or'; terms: TruthFact[] };
export type TruthQuestion =
+ | ({ kind: 'bid'; target: string } & BidClaim)
| { kind: 'traitorCall'; target: string; battle: string; territory: string }
| ({ kind: 'movement'; target: string } & MovementClaim)
| ({ kind: 'shipment'; target: string } & ShipmentClaim)
@@ -210,6 +212,17 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
g.players.some((p) => p.id === v.target),
'Ask one other player.',
);
+ 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.');
+ const claim = parseBidClaim(v);
+ 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 bid lot in a supported table and finish pending decisions.');
+ return { kind: 'bid', target: v.target, ...claim };
+ }
if (v.kind === 'traitorCall') {
check(Object.keys(v).sort().join(',') === 'battle,kind,target,territory' &&
g.status === 'playing' && g.phase === 6 && g.battle?.revealed &&
@@ -434,6 +447,7 @@ export function truthQuestionText(
locationName: (id: string) => string,
): string {
if (q.kind === 'freeform') return q.text;
+ if (q.kind === 'bid') return `Will you ${bidClaimText(q)}?`;
if (q.kind === 'traitorCall') return `In this revealed battle in ${locationName(q.territory)}, will you declare the opposing leader a Traitor?`;
if (q.kind === 'movement') return `Will you ${movementClaimText(q)} before your Shipment and Movement turn ends?`;
if (q.kind === 'shipment')
@@ -479,6 +493,8 @@ export function resolveTruthAction(
) => void;
movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[];
bindMovement?: (g: Game, p: Player, claim: MovementClaim, 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[];
bindTraitor?: (g: Game, p: Player, answer: boolean, asker: string) => void;
battleAnswers: (g: Game, p: Player, claim: PlanClaim) => TruthAnswer[];
@@ -597,6 +613,9 @@ export function resolveTruthAction(
'Waiting for the Truthtrance holder to ask a question.',
);
w.question = parseQuestion(g, p.id, a.question);
+ 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.');
if (w.question.kind === 'traitorCall')
check(effects.traitorAnswers?.(g, g.players.find(other => other.id === w.question!.target)!).length,
'Choose an actual unsubmitted native Traitor voter.');
@@ -647,6 +666,11 @@ export function resolveTruthAction(
'Choose an answer consistent with legal ground movement and earlier promises.');
if (a.answer !== 'unknown') effects.bindMovement(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.');
+ if (a.answer !== 'unknown') effects.bindBid(g, p, q, a.answer === 'yes', current.player);
+ }
if (q.kind === 'traitorCall') {
check(effects.traitorAnswers && effects.bindTraitor && effects.traitorAnswers(g, p).includes(a.answer),
'Choose an answer compatible with your current legal Traitor declaration and earlier answers.');
diff --git a/tests/bid-promises.test.ts b/tests/bid-promises.test.ts
new file mode 100644
index 00000000..c282c0bf
--- /dev/null
+++ b/tests/bid-promises.test.ts
@@ -0,0 +1,183 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ BidPromiseError,
+ bidClaimText,
+ bidPromiseModeSupported,
+ bidPromiseRange,
+ bidRequired,
+ liveBidPromises,
+ parseBidClaim,
+ recordPromisedBid,
+ validateBidPromises,
+ type BidOpportunity,
+ type BidPromise,
+} from '../game/bid-promises';
+
+const seats = ['respondent', 'asker', 'rival'] as const;
+function opportunity(extra: Partial = {}): BidOpportunity {
+ return { turn: 2, lot: 3, source: 'original-physical-lot', bid: 4,
+ bidder: 'rival', passed: [], eligible: seats, ...extra };
+}
+function promise(event: string, minimum: number, answer = true,
+ extra: Partial = {}): BidPromise {
+ return { event, turn: 2, lot: 3, source: 'original-physical-lot',
+ player: 'respondent', asker: 'asker', answer, minimum, ...extra };
+}
+
+void test('bid claims normalize one-based safe-integer turn, lot and minimum without adding a purchase or cumulative amount', () => {
+ const input = { turn: 2, lot: 3, minimum: 6 };
+ const parsed = parseBidClaim(input);
+ assert.deepEqual(parsed, input); assert.notEqual(parsed, input);
+ assert.deepEqual(parseBidClaim({ turn: 1, lot: 1, minimum: 1 }), { turn: 1, lot: 1, minimum: 1 });
+ assert.deepEqual(parseBidClaim({ turn: Number.MAX_SAFE_INTEGER, lot: Number.MAX_SAFE_INTEGER,
+ minimum: Number.MAX_SAFE_INTEGER }), { turn: Number.MAX_SAFE_INTEGER, lot: Number.MAX_SAFE_INTEGER,
+ minimum: Number.MAX_SAFE_INTEGER });
+ for (const field of ['turn', 'lot', 'minimum'] as const)
+ for (const value of [0, -1, 1.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '6', null, undefined])
+ assert.throws(() => parseBidClaim({ ...input, [field]: value }), BidPromiseError, `${field}/${String(value)}`);
+ for (const value of [null, undefined, [], '6', 6]) assert.throws(() => parseBidClaim(value), BidPromiseError);
+ const text = bidClaimText(parsed);
+ assert.match(text, /place a bid of at least 6 spice/); assert.match(text, /from now until ordinary lot 3 closes/);
+ assert.doesNotMatch(text, /win|purchase|sum|original-physical-lot/);
+});
+
+void test('bid commitment scope retains original bare Basic and Advanced native rosters without admitting optional modules', () => {
+ const classic = { advanced: false, expansions: [], players: [{ faction: 'atreides' }, { faction: 'emperor' }] };
+ const native = { advanced: true, expansions: ['ix', 'choam', 'ecaz'],
+ players: ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].map(faction => ({ faction })) };
+ for (const game of [classic, { ...classic, advanced: true }, native, { ...native, advanced: false },
+ { ...classic, expansions: ['ix', 'choam', 'ecaz'] },
+ { ...classic, advanced: true, expansions: ['ecaz'], players: [{ faction: 'moritani' }, { faction: 'harkonnen' }] },
+ ]) assert.equal(bidPromiseModeSupported(game), true);
+ for (const option of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
+ 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery',
+ 'semutaPreview', 'kullPreview', 'nexusKullPreview', 'richeseBetrayalPreview'] as const)
+ assert.equal(bidPromiseModeSupported({ ...native, [option]: true }), false, option);
+ for (const game of [
+ { ...native, expansions: ['ix', 'ix'] }, { ...native, expansions: ['ix', 'choam'] },
+ { ...classic, expansions: ['unknown'] }, { ...classic, players: [{ faction: 'atreides' }, { faction: 'unknown' }] },
+ { ...classic, players: [{ faction: 'atreides' }] },
+ { ...classic, players: [...native.players, { faction: 'atreides' }] },
+ { ...classic, advanced: 'basic' as unknown as boolean },
+ ]) assert.equal(bidPromiseModeSupported(game), false);
+});
+
+void test('one new accepted bid fulfills Yes while smaller bids, an old high bid and a purchase do not substitute', () => {
+ const original = [promise('at-least-six', 6)];
+ let records = recordPromisedBid(original, 'respondent', opportunity({ bid: 2 }), 3);
+ records = recordPromisedBid(records, 'respondent', opportunity({ bid: 4 }), 5);
+ assert.equal(records[0].fulfilled, undefined, 'Accepted bids 3 and 5 are not a cumulative bid of 8');
+ assert.equal(bidRequired(records, 'respondent', opportunity()), true);
+ assert.deepEqual(bidPromiseRange(records, 'respondent', opportunity({ bid: 6 }), 10), { minimum: 7, maximum: 10 });
+ const alreadyHighest = opportunity({ bid: 9, bidder: 'respondent' });
+ assert.equal(bidPromiseRange(records, 'respondent', alreadyHighest, 10), null);
+ assert.equal(bidRequired(records, 'respondent', alreadyHighest), true, 'A pre-existing winning bid does not answer a new promise');
+ assert.equal(records[0].fulfilled, undefined, 'Holding the high bid or later paying its invoice is not a new accepted declaration');
+ const accepted = recordPromisedBid(records, 'respondent', opportunity({ bid: 6 }), 7);
+ assert.equal(accepted[0].fulfilled, true, 'A qualifying accepted bid is sufficient without winning the card');
+ assert.equal(bidRequired(accepted, 'respondent', opportunity({ bid: 8, bidder: 'rival' })), false);
+ assert.equal(original[0].fulfilled, undefined);
+});
+
+void test('joint Yes lower bounds and No caps preserve every qualifying single-bid amount', () => {
+ const records = [promise('yes-six', 6), promise('yes-seven', 7),
+ promise('no-ten', 10, false), promise('no-eight', 8, false)];
+ const source = opportunity();
+ assert.deepEqual(bidPromiseRange(records, 'respondent', source, 20), { minimum: 7, maximum: 7 });
+ assert.deepEqual(bidPromiseRange(records.slice(0, 1).concat(records.slice(2)), 'respondent', source, 20),
+ { minimum: 6, maximum: 7 });
+ assert.deepEqual(bidPromiseRange(records, 'respondent', source, 7), { minimum: 7, maximum: 7 });
+ assert.equal(bidPromiseRange(records, 'respondent', source, 6), null, 'Funding cannot be invented to meet Yes');
+ assert.equal(bidPromiseRange(records, 'respondent', opportunity({ bid: 7 }), 20), null,
+ 'A later competing bid can remove the entire permissible interval');
+});
+
+void test('conflicting promises have no completion while a No-only cap never requires placing a bid', () => {
+ const conflict = [promise('yes-eight', 8), promise('no-eight', 8, false)];
+ assert.equal(bidPromiseRange(conflict, 'respondent', opportunity(), 20), null);
+ assert.equal(bidRequired(conflict, 'respondent', opportunity()), true);
+ const no = [promise('stay-below-six', 6, false), promise('stay-below-nine', 9, false)];
+ assert.deepEqual(bidPromiseRange(no, 'respondent', opportunity(), 20), { minimum: 5, maximum: 5 });
+ assert.equal(bidRequired(no, 'respondent', opportunity()), false);
+ assert.equal(bidPromiseRange(no, 'respondent', opportunity({ bid: 5 }), 20), null);
+ assert.equal(bidRequired(no, 'respondent', opportunity({ bid: 5 })), false,
+ 'An unavailable permitted bid does not turn a No answer into a required action');
+});
+
+void test('passed, current-highest and ineligible players have no future own declaration route in this opportunity', () => {
+ const records = [promise('yes-six', 6)];
+ for (const source of [opportunity({ passed: ['respondent'] }),
+ opportunity({ bidder: 'respondent', bid: 6 }), opportunity({ eligible: ['asker', 'rival'] })]) {
+ assert.equal(bidPromiseRange(records, 'respondent', source, 20), null);
+ assert.equal(records[0].fulfilled, undefined, 'Eligibility and current high bids cannot fulfill a promise');
+ }
+ assert.deepEqual(bidPromiseRange(records, 'respondent', opportunity({ passed: ['asker'] }), 20),
+ { minimum: 6, maximum: 20 });
+});
+
+void test('live obligations retain their original turn, physical lot and respondent while independent askers combine', () => {
+ const own = promise('own', 6), secondAsker = promise('second-asker', 8, false, { asker: 'rival' });
+ const records = [own, secondAsker,
+ promise('other-turn', 99, true, { turn: 1 }),
+ promise('other-physical-lot', 99, true, { source: 'another-original-lot', lot: 4 }),
+ promise('other-respondent', 99, true, { player: 'rival' }),
+ promise('already-fulfilled', 99, true, { fulfilled: true }),
+ promise('already-released', 99, true, { released: true })];
+ assert.deepEqual(liveBidPromises(records, 'respondent', opportunity()), [own, secondAsker]);
+ assert.deepEqual(bidPromiseRange(records, 'respondent', opportunity(), 20), { minimum: 6, maximum: 7 });
+ const accepted = recordPromisedBid(records, 'respondent', opportunity(), 6);
+ assert.equal(accepted[0].fulfilled, true); assert.equal(accepted[1], secondAsker);
+ for (let index = 2; index < records.length; index++) assert.equal(accepted[index], records[index]);
+ assert.deepEqual(liveBidPromises(records, 'absent', opportunity()), []);
+});
+
+void test('maximum safe bids keep exact inclusive bounds and never overflow into an invented next bid', () => {
+ const maximum = Number.MAX_SAFE_INTEGER;
+ const source = opportunity({ bid: maximum - 1 });
+ const yes = [promise('largest-yes', maximum)];
+ assert.deepEqual(bidPromiseRange(yes, 'respondent', source, maximum), { minimum: maximum, maximum });
+ assert.equal(recordPromisedBid(yes, 'respondent', source, maximum)[0].fulfilled, true);
+ assert.equal(bidPromiseRange(yes, 'respondent', opportunity({ bid: maximum }), maximum), null);
+ assert.equal(bidPromiseRange([promise('largest-no', maximum, false)], 'respondent', source, maximum), null);
+ for (const invalid of [0, -1, 1.5, Number.NaN, Infinity, maximum + 1])
+ assert.equal(bidPromiseRange([], 'respondent', opportunity(), invalid), null);
+ for (const invalid of [-1, 1.5, Number.NaN, Infinity, maximum + 1])
+ assert.equal(bidPromiseRange([], 'respondent', opportunity({ bid: invalid }), maximum), null);
+});
+
+void test('a rejected No bid is immutable and fulfilled Yes leaves No binding for later accepted declarations', () => {
+ const yes = Object.freeze(promise('yes-six', 6)), no = Object.freeze(promise('no-eight', 8, false));
+ const records = Object.freeze([yes, no]); const before = structuredClone(records);
+ assert.throws(() => recordPromisedBid(records, 'respondent', opportunity(), 8), BidPromiseError);
+ assert.deepEqual(records, before);
+ const accepted = recordPromisedBid(records, 'respondent', opportunity(), 7);
+ assert.notEqual(accepted, records); assert.notEqual(accepted[0], yes); assert.equal(accepted[0].fulfilled, true);
+ assert.equal(accepted[1], no); assert.equal(accepted[1].fulfilled, undefined);
+ assert.equal(bidRequired(accepted, 'respondent', opportunity()), false);
+ assert.deepEqual(liveBidPromises(accepted, 'respondent', opportunity()), [no]);
+ assert.throws(() => recordPromisedBid(accepted, 'respondent', opportunity({ bid: 7 }), 8), BidPromiseError);
+ const repeated = recordPromisedBid(accepted, 'respondent', opportunity(), 7);
+ assert.equal(repeated[0], accepted[0]); assert.equal(repeated[1], no);
+ assert.deepEqual(records, before);
+});
+
+void test('persisted bid records require distinct events, physical sources and two actual participants with valid status', () => {
+ const valid = [promise('live', 6), promise('fulfilled', 7, true, { fulfilled: true }),
+ promise('released', 8, false, { released: true })];
+ const before = structuredClone(valid);
+ assert.doesNotThrow(() => validateBidPromises(valid, seats)); assert.deepEqual(valid, before);
+ assert.doesNotThrow(() => validateBidPromises([], seats));
+ const invalid: unknown[] = [
+ null, {}, [null], [valid[0], { ...valid[1], event: 'live' }],
+ [{ ...valid[0], event: '' }], [{ ...valid[0], source: '' }],
+ [{ ...valid[0], player: 'outsider' }], [{ ...valid[0], asker: 'outsider' }],
+ [{ ...valid[0], asker: 'respondent' }], [{ ...valid[0], answer: 'yes' }],
+ [{ ...valid[0], fulfilled: 'true' }], [{ ...valid[0], released: 1 }],
+ [{ ...valid[0], fulfilled: true, released: true }],
+ [{ ...valid[0], turn: 0 }], [{ ...valid[0], lot: 0 }], [{ ...valid[0], minimum: 0 }],
+ ];
+ for (const records of invalid)
+ assert.throws(() => validateBidPromises(records as BidPromise[], seats), BidPromiseError);
+ assert.deepEqual(valid, before);
+});
diff --git a/tests/fixture-truth-bid.ts b/tests/fixture-truth-bid.ts
new file mode 100644
index 00000000..3100e166
--- /dev/null
+++ b/tests/fixture-truth-bid.ts
@@ -0,0 +1,142 @@
+import assert from 'node:assert/strict';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
+import { actTruthMovement, type TruthMovementStep } from './fixture-truth-movement';
+
+export type TruthBidStep = TruthMovementStep;
+export const actTruthBid = actTruthMovement;
+export const truthBidPlayer = (game: Game, actor: string) => {
+ const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
+};
+export const truthBidClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
+ !game.truthtrance && !game.pendingTreacheryDiscard;
+export function truthBidInventory(game: Game) {
+ const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
+ ...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])];
+ const ids = cards.map(card => card.id).sort();
+ assert.equal(new Set(ids).size, ids.length, 'Each original Treachery Card keeps one physical slot');
+ return ids;
+}
+export function nextTruthBid(game: Game): TruthBidStep | null {
+ if (game.truthtrance) return null;
+ if (truthBidClean(game) && game.phase === 3 && game.auction) {
+ const actor = game.auction.active, guide = viewGame(game, actor).bidPromiseGuide;
+ return { actor, action: guide?.action ?? { type: 'passBid' } };
+ }
+ if (!game.phaseOpening && !game.response && game.decision?.kind === 'auctionPayment' && game.auction &&
+ game.auction.bid > truthBidPlayer(game, game.decision.player).spice)
+ return null; // Caller owns the actual unfunded Karama payment choice.
+ return nextStrongholdFactionsNativeStep(game);
+}
+export function advanceTruthBid(game: Game, until: (state: Game) => boolean, actions: TruthBidStep[] = []): Game {
+ for (let count = 0; count < 900; count++) {
+ if (until(game)) return game;
+ assert.notEqual(game.status, 'finished', 'The original bid source ended before its owned control');
+ const next = nextTruthBid(game); assert.ok(next, 'The original Truthtrance or unfunded Karama choice belongs to its caller');
+ game = actTruthBid(game, next.actor, next.action, actions);
+ }
+ throw Error('The original bid source did not reach its finite boundary');
+}
+export function continueTruthBidLot(game: Game, actions: TruthBidStep[] = []): Game {
+ assert.ok(game.auction); const turn = game.turn, lot = game.auction.index + 1;
+ return advanceTruthBid(game, state => state.turn !== turn || state.phase !== 3 ||
+ !state.auction || state.auction.index + 1 !== lot, actions);
+}
+export function finishTruthBid(game: Game, actions: TruthBidStep[] = []): Game {
+ return advanceTruthBid(game, state => state.phase === 4 && truthBidClean(state), actions);
+}
+
+/** Public original H/A/E base33, Basic or Advanced preview. Only original
+ * unopened starting-deal/lot/Spice positions and scalar lotteries are selected.
+ * Harkonnen really receives both original consecutive Truthtrances, Atreides
+ * Baliset and Emperor Shield (or original Karama). No dealt hand, purse, force,
+ * auction, question, answer or promise is installed. */
+export function createTruthBidFixture(options: {
+ advanced?: boolean; window?: 'waiting' | 'active'; targetKarama?: boolean;
+} = {}) {
+ const advanced = options.advanced ?? false, window = options.window ?? 'waiting';
+ const asker = 'harkonnen', opponent = 'atreides', target = 'emperor';
+ let game = createGame('TRUTHBIDSOURCE', newPlayer(asker, 'Harkonnen', 'harkonnen'), advanced);
+ joinGame(game, newPlayer(opponent, 'Atreides', 'atreides'));
+ joinGame(game, newPlayer(target, 'Emperor', 'emperor'));
+ const initial = structuredClone(game), actions: TruthBidStep[] = [];
+ const act = (actor: string, action: Action) => { game = actTruthBid(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => { game = advanceTruthBid(game, until, actions); };
+ for (const player of game.players) act(player.id, { type: 'ready' });
+ const lobby = structuredClone(game);
+ act(asker, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
+ const setup = structuredClone(game); assert.equal(game.status, 'setup');
+ assert.ok(game.players.every(player => player.hand.length === 0));
+ const originalCards = truthBidInventory(game); assert.equal(originalCards.length, 33);
+ const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
+ const filler = game.deck.find(card => card.name === 'Baliset');
+ const targetCard = game.deck.find(card => options.targetKarama ? card.effect === 'karama' : card.kind === 'shield');
+ const lotCards = [game.deck.find(card => card.kind === 'projectile'), game.deck.find(card => card.kind === 'poison'),
+ game.deck.find(card => card.kind === 'snooper')];
+ assert.ok(filler && targetCard && lotCards.every(Boolean));
+ const deal = [truths[0].id, truths[1].id, filler.id, targetCard.id];
+ const prefix = [...deal, ...lotCards.map(card => card!.id)];
+ const dealOrder = prefix.map((id, position) => {
+ const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
+ [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
+ return { position, from, card: id };
+ });
+ const beforeStartingDeal = structuredClone(game);
+ reach(state => state.status === 'playing');
+ const afterSetup = structuredClone(game);
+ assert.deepEqual(truthBidPlayer(game, asker).hand.map(card => card.id), truths.map(card => card.id));
+ assert.deepEqual(truthBidPlayer(game, opponent).hand.map(card => card.id), [filler.id]);
+ assert.deepEqual(truthBidPlayer(game, target).hand.map(card => card.id), [targetCard.id]);
+ const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
+ const spiceOrder = [0, 1].map(position => {
+ const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
+ assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
+ return { position, from, card: structuredClone(card) };
+ });
+ reach(state => state.turn === 1 && state.phase === 3 && !!state.auction && truthBidClean(state));
+ const beforePasses = structuredClone(game);
+ if (window === 'active') reach(state => state.auction?.active === target && truthBidClean(state));
+ assert.equal(game.auction!.index, 0); assert.equal(game.auction!.bid, 0);
+ assert.equal(game.auction!.active, window === 'active' ? target : asker);
+ assert.equal(truthBidPlayer(game, target).spice, 10); assert.equal(truthBidPlayer(game, opponent).spice, 10);
+ assert.equal(viewGame(game, target).bidPromiseLot?.lot, 1);
+ assert.deepEqual(truthBidInventory(game), originalCards);
+ const question = { kind: 'bid' as const, target, turn: game.turn, lot: game.auction!.index + 1, minimum: 4 };
+ return { game, sourceGame: structuredClone(game), initial, lobby, setup, beforeStartingDeal, afterSetup, beforePasses,
+ advanced, window, asker, opponent, target, actor: asker, truth: truths[0].id, card: truths[0].id,
+ truths: truths.map(card => card.id), targetCard: targetCard.id, karama: options.targetKarama ? targetCard.id : null,
+ question, declarationAction: { type: 'card', card: truths[0].id } as Action,
+ questionAction: { type: 'truthAsk', question } as Action,
+ lotCard: game.auction!.cards[game.auction!.index].id, lotCards: lotCards.map(card => card!.id),
+ deal, dealOrder, spiceOrder, originalSpice, originalCards, originalInventory: originalCards,
+ actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
+ staging: [] as string[], sourceDisclosure: 'Genuine public H/A/E base33 setup. Only original unopened four-card starting deal, three ordinary lot positions, two ordinary Spice positions and scalar lotteries are selected. Harkonnen owns both physical Truthtrances; active Emperor additionally follows real H/A passes. No played hand, wallet, force, lot, Truthtrance or promise is installed.' };
+}
+export type TruthBidFixture = ReturnType;
+
+export function declareTruthBid(f: TruthBidFixture, state = f.game, options: { copies?: 1 | 2 } = {}) {
+ let game = state; const before = structuredClone(game), actions: TruthBidStep[] = [];
+ const declaration: Action = { type: 'card', card: f.truths[0], ...(options.copies === 2 ? { cards: [f.truths[1]] } : {}) };
+ game = actTruthBid(game, f.asker, declaration, actions);
+ for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
+ const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
+ game = actTruthBid(game, actor.id, { type: 'truthPass' }, actions);
+ }
+ assert.equal(game.truthtrance?.stage, 'ask');
+ return { game, before, declaration, actions, actionCount: actions.length };
+}
+export function askTruthBid(game: Game, asker: string, target: string, minimum = 4, actions: TruthBidStep[] = []): Game {
+ assert.ok(game.auction);
+ return actTruthBid(game, asker, { type: 'truthAsk', question: {
+ kind: 'bid', target, turn: game.turn, lot: game.auction.index + 1, minimum,
+ } }, actions);
+}
+export function answerTruthBid(game: Game, target: string, answer: 'yes' | 'no', actions: TruthBidStep[] = []): Game {
+ return actTruthBid(game, target, { type: 'truthAnswer', answer }, actions);
+}
+export function bindTruthBid(f: TruthBidFixture, answer: 'yes' | 'no' = 'yes', minimum = 4, state = f.game) {
+ const declared = declareTruthBid(f, state), actions = [...declared.actions];
+ const asked = askTruthBid(declared.game, f.asker, f.target, minimum, actions);
+ const game = answerTruthBid(asked, f.target, answer, actions);
+ return { game, declared, asked, actions, actionCount: actions.length };
+}
diff --git a/tests/truth-bid-controls.test.tsx b/tests/truth-bid-controls.test.tsx
new file mode 100644
index 00000000..7244cd20
--- /dev/null
+++ b/tests/truth-bid-controls.test.tsx
@@ -0,0 +1,183 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { registerHooks } from 'node:module';
+import { readFileSync } from 'node:fs';
+import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { BidClaimFields, bidClaimInputError } from '../components/bid-claim-fields';
+import { BidPromises, BidPromiseGuideButton } from '../components/bid-promises';
+import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
+import type { BidClaim } from '../game/bid-promises';
+import { createTechTokens } from '../game/tech-tokens';
+import { createTruthBidFixture, bindTruthBid, actTruthBid, truthBidInventory } from './fixture-truth-bid';
+
+const aliases = registerHooks({
+ resolve(specifier, context, next) {
+ return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
+ },
+ load(url, context, next) {
+ if (!url.endsWith('.module.css')) return next(url, context);
+ const css = readFileSync(new URL(url), 'utf8');
+ const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
+ return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
+ },
+});
+const { Truthtrance } = await import('../components/truthtrance');
+aliases.deregister();
+
+type ElementProps = { children?: ReactNode; [key: string]: unknown };
+type Element = ReactElement;
+function elements(node: ReactNode): Element[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!isValidElement(node)) return [];
+ return [node, ...elements(node.props.children)];
+}
+function edit(props: Parameters[0], minimum: number) {
+ const input = elements(BidClaimFields(props)).find(element => element.props.id === 'truth-bid-minimum' && typeof element.props.onChange === 'function');
+ assert.ok(input);
+ (input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber: minimum } });
+}
+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, busy = false) => renderToStaticMarkup(createElement(BidPromises, { game, busy, send() {} }));
+
+/** Controlled personalized projections exercise UI guards only. The native
+ * producer below supplies earned questions, answers and ordinary lot actions. */
+function projected(action: 'bid' | 'passBid' = 'bid'): GameView {
+ const source = createGame('TRUTHBIDCONTROLS', newPlayer('p', 'Respondent', 'emperor'));
+ 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: 1 };
+ game.bidPromises = [{ event: 'controlled-bid-event', player: 'p', asker: 'a', turn: 2, lot: 1,
+ minimum: 4, answer: action === 'bid' }];
+ game.bidPromiseGuide = { action: action === 'bid'
+ ? { type: 'bid', amount: 4, allyPayment: 1, bidPromiseEvent: 'controlled-bid-event' }
+ : { type: 'passBid', bidPromiseEvent: 'controlled-bid-event' } };
+ return game;
+}
+
+void test('bid claim fields keep the displayed turn and lot fixed and accept only parser-valid whole minimum edits', () => {
+ const value: BidClaim = { turn: 2, lot: 3, minimum: 4 }, changes: BidClaim[] = [];
+ const props = { value, onChange(next: BidClaim) { changes.push(next); } };
+ const html = renderToStaticMarkup(createElement(BidClaimFields, props));
+ assert.match(html, /Turn 2 · Ordinary lot 3/); assert.match(html, /Minimum bid in spice/);
+ assert.match(html, /min="1"/); assert.match(html, /max="9007199254740991"/);
+ assert.match(html, /If you win, the original payment rules still apply/);
+ assert.match(html, /optional modules and native special auctions are excluded/);
+ assert.equal(elements(BidClaimFields(props)).filter(element => typeof element.props.onChange === 'function').length, 1);
+ for (const minimum of [1, 7, Number.MAX_SAFE_INTEGER]) edit(props, minimum);
+ assert.deepEqual(changes, [1, 7, Number.MAX_SAFE_INTEGER].map(minimum => ({ ...value, minimum })));
+ for (const minimum of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
+ assert.ok(bidClaimInputError({ ...value, minimum })); edit(props, minimum);
+ }
+ assert.equal(changes.length, 3);
+ edit({ ...props, disabled: true }, 8); assert.equal(changes.length, 3);
+ edit({ ...props, value: { ...value, lot: 0 } }, 8); assert.equal(changes.length, 3);
+ assert.match(renderToStaticMarkup(createElement(BidClaimFields, { ...props, value: { ...value, minimum: 0 } })), /role="alert"/);
+});
+
+void test('own bid commitments remain visible with exact Yes/No meaning, completion and release statuses', () => {
+ const game = projected();
+ game.bidPromises.push(
+ { event: 'no-event', player: 'p', asker: 'a', turn: 2, lot: 1, minimum: 8, answer: false },
+ { event: 'fulfilled-event', player: 'p', asker: 'a', turn: 2, lot: 1, minimum: 2, answer: true, fulfilled: true },
+ { event: 'released-event', player: 'p', asker: 'a', turn: 2, lot: 1, minimum: 12, answer: true, released: true },
+ { event: 'foreign-event', player: 'a', asker: 'p', turn: 2, lot: 1, minimum: 99, answer: true },
+ );
+ game.auction!.active = 'a'; game.bidPromiseGuide = null;
+ const before = structuredClone(game);
+ for (let render = 0; render < 2; render++) {
+ const html = markup(game);
+ assert.match(html, /Your bid promises/); assert.match(html, /ordinary lot 1/);
+ assert.match(html, /Yes requires one qualifying bid/); assert.match(html, /No forbids every later bid at or above/);
+ assert.match(html, /If you win, the original payment rules still apply/);
+ assert.match(html, /Fulfilled: this commitment is complete/); assert.match(html, /Released: this commitment is no longer possible/);
+ assert.match(html, /Your next bid opportunity is still ahead/);
+ assert.doesNotMatch(html, /99 spice|foreign-event|controlled-bid-event/);
+ }
+ assert.deepEqual(game, before);
+ assert.equal(BidPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
+});
+
+void test('the actual guide callback forwards bid or pass with the authoritative funding and event fields unchanged', () => {
+ for (const type of ['bid', 'passBid'] as const) {
+ const game = projected(type), sent: Action[] = [];
+ const props = { game, busy: false, send(action: Action) { sent.push(action); } };
+ const button = guide(props); press(button);
+ assert.deepEqual(sent, [game.bidPromiseGuide!.action]); assert.equal(sent[0], game.bidPromiseGuide!.action);
+ assert.equal(button.props.disabled, false);
+ press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
+ }
+});
+
+void test('busy and stale guide callbacks cannot cross current owner, lot, event, stage or continuation boundaries', () => {
+ 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: 2 }; }, game => { game.bidPromiseLot = null; },
+ game => { game.bidPromiseGuide = { action: { ...game.bidPromiseGuide!.action } }; },
+ game => { game.bidPromiseGuide = null; }, game => { game.bidPromiseGuide!.action.amount = 5; },
+ game => { game.bidPromises[0].event = 'another-event'; }, game => { game.bidPromises[0].player = 'a'; },
+ game => { game.bidPromises[0].fulfilled = true; }, game => { game.bidPromises[0].released = true; },
+ game => { game.bidPromises = []; }, game => { game.auction = null; },
+ game => { game.phaseOpening = { passed: [] }; },
+ game => { game.decision = { kind: 'auctionPayment', player: 'p' }; },
+ game => { game.response = { kind: 'guildIncome', owner: 'a', amount: 1, passed: [] }; },
+ 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(); },
+ game => { game.automaticContinuationPending = true; },
+ game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
+ game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
+ ];
+ for (const change of changes) {
+ const game = projected(), sent: Action[] = [];
+ const props = { game, busy: false, send(action: Action) { sent.push(action); } };
+ const button = guide(props); change(game); press(button); assert.deepEqual(sent, []);
+ }
+ const props = { game: projected(), busy: false, send() { assert.fail('A now-busy guide cannot dispatch'); } };
+ const button = guide(props); props.busy = true; press(button);
+});
+
+void test('bid controls use the public lot and own guide without reading card faces, source fingerprints or rival holdings', () => {
+ const game = projected(), sent: Action[] = [];
+ for (const player of game.players) {
+ for (const key of ['hand', 'traitors', 'faceDancers']) Object.defineProperty(player, key, {
+ get() { throw Error(`Bid controls cannot inspect ${key}`); }, configurable: true,
+ });
+ }
+ Object.defineProperty(game.auction!, 'card', { get() { throw Error('No ordinary-lot face inspection'); } });
+ Object.defineProperty(game.bidPromises[0], 'source', { get() { throw Error('No internal bid source inspection'); } });
+ assert.match(markup(game), /Bid 4 spice while honoring your answers/);
+ press(guide({ game, busy: false, send(action) { sent.push(action); } }));
+ assert.deepEqual(sent, [game.bidPromiseGuide!.action]);
+});
+
+void test('source-earned Basic and Advanced current-lot answers connect persistent guidance to the actual qualifying bid callback', () => {
+ for (const advanced of [false, true]) {
+ const f = createTruthBidFixture({ advanced, window: 'active' }), bound = bindTruthBid(f, 'yes', 4);
+ const respondent = viewGame(bound.asked, f.target);
+ assert.ok(respondent.truthBidAnswers?.includes('yes'));
+ assert.equal(viewGame(bound.asked, f.asker).truthBidAnswers, null);
+ const answerHtml = renderToStaticMarkup(createElement(Truthtrance, { game: respondent, busy: false, act() {} }));
+ assert.match(answerHtml, /Answer Yes/); assert.match(answerHtml, /Answer No/);
+ const game = viewGame(bound.game, f.target), sent: Action[] = [];
+ assert.deepEqual(game.bidPromiseLot, { turn: bound.game.turn, lot: 1 });
+ assert.match(markup(game), /Your bid promises/); assert.match(markup(game), /Bid 4 spice/);
+ const next = game.bidPromiseGuide; assert.ok(next);
+ press(guide({ game, busy: false, send(action) { sent.push(action); } }));
+ assert.deepEqual(sent, [next.action]);
+ const after = actTruthBid(bound.game, f.target, sent[0]);
+ const event = next.action.bidPromiseEvent; assert.equal(typeof event, 'string');
+ assert.equal(after.bidPromises?.find(promise => promise.event === event)?.fulfilled, true);
+ assert.equal(after.players.find(player => player.id === f.target)!.spice, 10,
+ 'The qualifying bid fulfills Yes before any later winning invoice is paid.');
+ assert.deepEqual(truthBidInventory(after), f.originalCards);
+ assert.equal(BidPromises({ game: viewGame(after, f.asker), busy: false, send() {} }), null);
+ }
+});
diff --git a/tests/truth-bid-runtime.test.ts b/tests/truth-bid-runtime.test.ts
new file mode 100644
index 00000000..a80179db
--- /dev/null
+++ b/tests/truth-bid-runtime.test.ts
@@ -0,0 +1,215 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import {
+ actTruthBid as act, advanceTruthBid as advance, answerTruthBid as answer,
+ askTruthBid as ask, bindTruthBid as bind, continueTruthBidLot as closeLot,
+ createTruthBidFixture as fixture, declareTruthBid as declare, finishTruthBid as finish,
+ truthBidClean as clean, truthBidInventory as inventory, truthBidPlayer as player,
+} from './fixture-truth-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);
+}
+
+void test('two original Truth cards keep a prior shipment commitment funded when answering a later bid question', () => {
+ 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, 4);
+ assert.deepEqual(viewGame(game, f.target).truthBidAnswers, ['no']);
+ rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
+ game = answer(game, f.target, 'no');
+ game = current(game, f.target);
+ game = act(game, f.target, { type: 'bid', amount: 1, allyPayment: 0 });
+ game = closeLot(game);
+ assert.equal(player(game, f.target).spice, 9);
+ 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.ok(game.shipmentPromises?.some(promise => promise.player === f.target && promise.fulfilled));
+ assert.ok(game.bidPromises?.every(promise => promise.fulfilled));
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} current-lot Truth Yes requires a real new bid and preserves original payment`, () => {
+ const f = fixture({ advanced, window: 'active' });
+ assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
+ assert.deepEqual(f.game.expansions, []); assert.deepEqual(f.staging, []);
+ assert.equal(f.originalCards.length, 33); assert.equal(f.truths.length, 2);
+ assert.equal(f.sourceActionCount, f.sourceActions.length);
+ assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(card => card.id), f.deal);
+ const before = structuredClone(f.game), bound = bind(f);
+ assert.deepEqual(f.game, before);
+ assert.equal(bound.game.truthtrance, null);
+ assert.equal(bound.game.discard.filter(card => card.id === f.truth).length, 1);
+ assert.equal(player(bound.game, f.asker).hand.filter(card => card.effect === 'truthtrance').length, 1);
+ rejected(bound.game, f.target, { type: 'passBid' });
+ rejected(bound.game, f.target, { type: 'bid', amount: 3 });
+ const guide = viewGame(bound.game, f.target).bidPromiseGuide; assert.ok(guide);
+ assert.equal(guide.action.type, 'bid'); assert.equal(guide.action.amount, 4);
+ const bid = act(bound.game, f.target, guide.action);
+ assert.equal(bid.bidPromises!.find(promise => promise.player === f.target)!.fulfilled, true);
+ assert.equal(player(bid, f.target).spice, 10, 'Declaring the bid does not pay its later invoice');
+ const paid = closeLot(bid);
+ assert.equal(player(paid, f.target).spice, 6);
+ assert.equal(player(paid, f.target).hand.filter(card => card.id === f.lotCard).length, 1);
+ assert.ok((paid.bidPromises ?? []).every(promise => promise.fulfilled || promise.released));
+ assert.deepEqual(inventory(paid), f.originalCards);
+ const done = finish(paid); assert.equal(done.phase, 4); assert.deepEqual(inventory(done), f.originalCards);
+});
+
+for (const lowerBid of [false, true]) void test(`native current-lot Truth No permits ${lowerBid ? 'a lower bid' : 'a pass'} and expires before the next ordinary lot`, () => {
+ const f = fixture({ window: lowerBid ? 'active' : 'waiting' });
+ let source = f.game;
+ if (!lowerBid) {
+ source = act(source, f.asker, { type: 'bid', amount: 1 });
+ source = act(source, f.opponent, { type: 'passBid' });
+ }
+ const bound = bind(f, 'no', 4, source);
+ rejected(bound.game, f.target, { type: 'bid', amount: 4 });
+ const chosen = act(bound.game, f.target, lowerBid ? { type: 'bid', amount: 2 } : { type: 'passBid' });
+ const next = chosen.auction?.index === 0 ? closeLot(chosen) : chosen;
+ assert.equal(next.auction!.index, 1);
+ assert.ok((next.bidPromises ?? []).every(promise => promise.fulfilled || promise.released));
+ const ready = current(next, f.target);
+ const accepted = act(ready, f.target, { type: 'bid', amount: 4 });
+ assert.equal(accepted.auction!.bid, 4); assert.equal(accepted.auction!.bidder, f.target);
+ assert.deepEqual(inventory(accepted), f.originalCards);
+});
+
+void test('native waiting bidder Yes is released by a real opposing ten-spice bid, with ordinary Emperor income later', () => {
+ const f = fixture(), bound = bind(f);
+ assert.equal(bound.game.auction!.active, f.asker);
+ assert.equal(viewGame(bound.game, f.target).bidPromiseGuide, null);
+ let game = act(bound.game, f.asker, { type: 'passBid' });
+ assert.equal(game.auction!.active, f.opponent);
+ game = act(game, f.opponent, { type: 'bid', amount: 10 });
+ assert.equal(game.auction!.active, f.target);
+ assert.equal(game.bidPromises!.find(promise => promise.player === f.target)!.released, true);
+ game = act(game, f.target, { type: 'passBid' }); game = closeLot(game);
+ assert.equal(player(game, f.opponent).spice, 0);
+ assert.equal(player(game, f.target).spice, 20, 'Original Emperor auction income remains separate from its released declaration promise');
+ assert.ok(player(game, f.opponent).hand.some(card => card.id === f.lotCard));
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native bidder cannot bribe away the last Yes funding but may keep its real four-spice witness', () => {
+ const f = fixture(), bound = bind(f);
+ rejected(bound.game, f.target, { type: 'bribe', target: f.asker, amount: 7 });
+ let game = act(bound.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);
+ game = current(game, f.target);
+ const guide = viewGame(game, f.target).bidPromiseGuide; assert.ok(guide);
+ game = act(game, f.target, guide.action); game = closeLot(game);
+ assert.equal(player(game, f.target).spice, 0); assert.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native earlier qualifying bid does not fulfill a Truth promise answered after a real outbid', () => {
+ const f = fixture(); 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 });
+ assert.equal(game.auction!.active, f.target);
+ game = bind(f, 'yes', 4, game).game;
+ assert.equal(game.bidPromises!.find(promise => promise.player === f.target)!.fulfilled ?? false, 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(game.bidPromises![0].fulfilled, true);
+ game = closeLot(game); assert.equal(player(game, f.target).spice, 3);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native bid declaration fulfills Yes even when a different real bidder later wins and pays', () => {
+ const f = fixture(); let game = act(f.game, f.asker, { type: 'bid', amount: 1 });
+ game = act(game, f.opponent, { type: 'bid', amount: 2 });
+ game = bind(f, 'yes', 4, game).game;
+ game = act(game, f.target, { type: 'bid', amount: 4 }); assert.equal(game.bidPromises![0].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.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('two original queued Truthtrances combine No six with Yes four and each discard only once', () => {
+ const f = fixture({ window: 'active' }), declared = declare(f, f.game, { copies: 2 });
+ let game = ask(declared.game, f.asker, f.target, 6); game = answer(game, f.target, 'no');
+ assert.equal(game.truthtrance!.stage, 'ask');
+ game = ask(game, f.asker, f.target, 4); game = answer(game, f.target, 'yes');
+ assert.equal(game.bidPromises!.length, 2);
+ for (const card of f.truths) assert.equal(game.discard.filter(held => held.id === card).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 }); game = closeLot(game);
+ assert.equal(player(game, f.target).spice, 5);
+ assert.ok((game.bidPromises ?? []).every(promise => promise.fulfilled || promise.released));
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native question ownership, exact current-lot binding and stale guided callbacks retain original action boundaries', () => {
+ const f = fixture({ window: 'active' }), declared = declare(f), question = f.question;
+ rejected(declared.game, f.opponent, { type: 'truthAsk', question });
+ rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...question, lot: question.lot + 1 } });
+ rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...question, turn: question.turn + 1 } });
+ let game = ask(declared.game, f.asker, f.target);
+ assert.deepEqual(viewGame(game, f.target).truthBidAnswers, ['yes', 'no']);
+ assert.equal(viewGame(game, f.opponent).truthBidAnswers, 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.ok(guide.action.bidPromiseEvent);
+ assert.ok(viewGame(game, f.target).bidPromises.every(promise => !Object.hasOwn(promise, 'source')));
+ game = act(game, f.target, guide.action); game = closeLot(game); game = current(game, f.target);
+ rejected(game, f.target, guide.action);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('native already-passed and current-highest bidders cannot supply a new Truth bid opportunity', () => {
+ const passed = fixture({ window: 'active' }), first = declare(passed);
+ assert.ok(passed.game.auction!.passed.includes(passed.opponent));
+ rejected(first.game, passed.asker, { type: 'truthAsk', question: { ...passed.question, target: passed.opponent } });
+ const highest = fixture(); 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 });
+ assert.equal(game.auction!.bidder, highest.target);
+ game = declare(highest, game).game;
+ rejected(game, highest.asker, { type: 'truthAsk', question: highest.question });
+ assert.deepEqual(inventory(game), highest.originalCards);
+});
+
+void test('native actual Karama funds the promised overbid and its original free purchase consumes one card', () => {
+ const f = fixture({ window: 'active', targetKarama: true }); let game = bind(f, 'yes', 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);
+ 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.ok(player(game, f.target).hand.some(card => card.id === f.lotCard));
+ assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
+ assert.deepEqual(inventory(game), f.originalCards);
+});
+
+void test('all four minimal policies offer a legal native bid for the current answered Yes', () => {
+ const f = fixture({ window: 'active' }), bound = bind(f).game;
+ for (const difficulty of DIFFICULTIES) {
+ const view = viewGame(bound, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
+ const offered = botActions(view).find(action => action.type === 'bid' && Number(action.amount) >= 4); assert.ok(offered, difficulty);
+ const accepted = act(structuredClone(bound), f.target, offered);
+ assert.equal(accepted.bidPromises![0].fulfilled, true); assert.deepEqual(inventory(accepted), f.originalCards);
+ }
+});