From b64b732e50c60a3411c9051fc3553e24a3ae7ce5 Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Wed, 28 Oct 2026 18:44:38 +1100
Subject: [PATCH] Support early Truthtrance cache bid commitments
---
components/early-richese-bid-promises.tsx | 19 +++
components/game-table.tsx | 2 +
components/truthtrance.tsx | 44 ++++-
docs/CURRENT_STATUS.md | 12 ++
docs/RULE_DECISIONS.md | 11 ++
docs/TRUTHTRANCE_BID_PROMISES.md | 33 ++++
game/bots.ts | 4 +
game/early-richese-bid.ts | 152 ++++++++++++++++++
game/engine.ts | 93 ++++++++++-
game/reference.ts | 6 +-
game/truthtrance.ts | 24 +++
tests/early-richese-bid-controls.test.tsx | 68 ++++++++
...arly-richese-bid-waiting-controls.test.tsx | 30 ++++
tests/early-richese-bid.test.ts | 84 ++++++++++
tests/truth-early-richese-bid-edges.test.ts | 114 +++++++++++++
tests/truth-early-richese-bid-runtime.test.ts | 104 ++++++++++++
16 files changed, 795 insertions(+), 5 deletions(-)
create mode 100644 components/early-richese-bid-promises.tsx
create mode 100644 game/early-richese-bid.ts
create mode 100644 tests/early-richese-bid-controls.test.tsx
create mode 100644 tests/early-richese-bid-waiting-controls.test.tsx
create mode 100644 tests/early-richese-bid.test.ts
create mode 100644 tests/truth-early-richese-bid-edges.test.ts
create mode 100644 tests/truth-early-richese-bid-runtime.test.ts
diff --git a/components/early-richese-bid-promises.tsx b/components/early-richese-bid-promises.tsx
new file mode 100644
index 00000000..3659c915
--- /dev/null
+++ b/components/early-richese-bid-promises.tsx
@@ -0,0 +1,19 @@
+'use client';
+
+import type { GameView } from '@/game/engine';
+
+/** Waiting receipts are private to their respondent. Once attached to a real
+ * lot, the ordinary Richese bid panel owns the subsequent action and status. */
+export function EarlyRicheseBidPromises({ game }: { game: GameView }) {
+ if (!game.me) return null;
+ const waiting = game.ownEarlyRicheseBidPromises.filter(row => row.player === game.me &&
+ row.turn === game.turn && !row.boundEvent && !row.fulfilled && !row.released);
+ if (!waiting.length) return null;
+ return
+ Your upcoming Richese cache bid promises
+ {waiting.map(row =>
+ Turn {row.turn}: You answered {row.answer ? 'Yes' : 'No'} to bidding at least {row.minimum} spice in the first Richese cache auction this turn.
+ )}
+ Waiting for the first Richese cache auction. Its card and method are not yet decided. Your answer applies while the opportunity remains possible.
+ ;
+}
diff --git a/components/game-table.tsx b/components/game-table.tsx
index ee184c63..bcb9ce20 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -1,5 +1,6 @@
'use client';
import { RicheseBidPromisePanel } from './richese-bid-promise-panel';
+import { EarlyRicheseBidPromises } from './early-richese-bid-promises';
import { TravelPromises } from './travel-promises';
import { TurnActionPromises } from './turn-action-promises';
import { MixedTraitorPromises } from './mixed-traitor-promises';
@@ -2095,6 +2096,7 @@ export function GameTable({
{g.status === 'playing' && }
{g.status === 'playing' && }
{g.status === 'playing' && }
+ {g.status === 'playing' && }
{g.status === 'playing' && }
{g.status === 'playing' && }
{g.status === 'playing' && }
diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx
index 1042cb46..9e223075 100644
--- a/components/truthtrance.tsx
+++ b/components/truthtrance.tsx
@@ -60,6 +60,7 @@ import type { MixedShipmentExpression } from '@/game/mixed-shipment-question';
import type { PlanClaim } from '@/game/battle-promises';
import { useId, useState } from 'react';
import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
import {
truthQuestionText,
@@ -69,6 +70,31 @@ import {
import type { Action, GameView } from '@/game/engine';
import { canUseAsTruthtranceRole } from '@/game/shrine';
+/** The future cache lot is not selected when this condition is authored. */
+export function EarlyRicheseBidFields({ minimum, onMinimum, disabled }: {
+ minimum: number;
+ onMinimum: (value: number) => void;
+ disabled: boolean;
+}) {
+ const error = richeseBidMinimumError(minimum);
+ return
+ Bid in the first Richese cache auction this turn
+ Minimum bid in spice
+ {
+ const next = event.currentTarget.valueAsNumber;
+ if (!disabled && !richeseBidMinimumError(next)) onMinimum(next);
+ }} />
+ {error && {error}
}
+
+ This asks about the first Richese cache auction this turn. Its card and method have not yet been decided.
+ A qualifying bid does not promise a win or change the winning payment.
+
+ ;
+}
+
export function Truthtrance({
game: g,
act,
@@ -99,7 +125,7 @@ export function Truthtrance({
g.players.find((p) => p.id !== me.id)!.id,
);
const [kind, setKind] = useState<
- 'fact' | 'freeform' | 'charity' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'phaseBid' | 'richeseBid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
+ 'fact' | 'freeform' | 'charity' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'phaseBid' | 'richeseBid' | 'earlyRicheseBid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
>('fact');
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
@@ -244,6 +270,7 @@ export function Truthtrance({
: kind === 'mixedBid' ? { kind, target, turn: bidClaim.turn, lot: bidClaim.lot, mixed: mixedBid }
: kind === 'bid' ? { kind, target, ...bidClaim }
: kind === 'phaseBid' ? { kind, target, ...phaseBidClaim }
+ : kind === 'earlyRicheseBid' ? { kind, target, turn:g.turn, minimum:bidMinimum }
: kind === 'richeseBid' ? { kind, target, turn:g.turn, event:g.richeseAuction?.event ?? '', minimum:bidMinimum }
: kind === 'charity' ? { kind, target, turn:g.turn }
: kind === 'shipment'
@@ -439,6 +466,13 @@ export function Truthtrance({
{button(answer === 'unknown' ? 'I don’t know' : `Answer ${answer === 'yes' ? 'Yes' : 'No'}`, { type: 'truthAnswer', answer })}
)}
>
+ ) : w.question!.kind === 'earlyRicheseBid' ? (
+ <>
+ This concerns the first Richese cache auction this turn. Its card and method are not yet decided. Yes requires a qualifying bid while possible; No forbids a qualifying bid. The normal auction and payment rules still apply.
+ {(g.truthEarlyRicheseBidAnswers ?? []).map(answer =>
+ {button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
+
)}
+ >
) : w.question!.kind === 'richeseBid' ? (
<>
Yes requires a qualifying bid in your one remaining opportunity while possible. No forbids a qualifying bid. Silent amounts remain sealed until all submit; winning still requires payment.
@@ -610,6 +644,7 @@ export function Truthtrance({
Bind a bid in this ordinary lot
Bind a Charity claim this turn
Bind a bid this Bidding phase
+ Bind a bid in the first Richese cache auction
Bind a bid in this Richese lot
Combine current facts and ordinary bids
Bind this battle’s Traitor declaration
@@ -760,6 +795,12 @@ export function Truthtrance({
{!g.truthCharityTargets.includes(target) && Choose a player with a supported remaining Charity opportunity.
}
{truthQuestionText(question, leaderName, locationName, name)}
>
+ ) : kind === 'earlyRicheseBid' ? (
+ <>
+
+ {!g.truthEarlyRicheseBidTargets.includes(target) && Choose a supported player before the first Richese cache auction and finish pending decisions.
}
+ {truthQuestionText(question, leaderName, locationName, name)}
+ >
) : kind === 'richeseBid' ? (
<>
@@ -911,6 +952,7 @@ export function Truthtrance({
(kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) ||
(kind === 'mixedBid' && (!!mixedBidError || !g.truthMixedBidTargets.includes(target))) ||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
+ (kind === 'earlyRicheseBid' && (!!richeseBidMinimumError(bidMinimum) || !g.truthEarlyRicheseBidTargets.includes(target))) ||
(kind === 'richeseBid' && (!!richeseBidMinimumError(bidMinimum) || !g.richeseAuction || !g.truthRicheseBidTargets.includes(target))) ||
(kind === 'charity' && !g.truthCharityTargets.includes(target)) ||
(kind === 'phaseBid' && (!!phaseBidClaimInputError(phaseBidClaim) || !g.truthPhaseBidTargets.includes(target))) ||
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 2f445a51..0f5cbc9d 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,18 @@ open mode gates.
## Current checkpoint and work
+**Basic early Richese cache-bid commitments — 28 October. Prototyped /
+Partial.** Before Bidding, ask about a minimum bid in the first actual cache
+lot of the turn. The waiting answer attaches to its real event and uses the
+existing Once Around or Silent enforcement and guide. Funding, hand space,
+voluntary spending and external cancellation are accounted for under an
+explicit provisional policy. A persistent reminder shows the waiting answer.
+Thirteen focused files pass 74 cases, including
+Basic native first/last lots, both methods and answers, owner opportunity,
+cancel, last-slot protection and all four minimal bots; selected edge fixtures
+stage conserved unopened cards. Advanced, combinations, browser and deployed
+acceptance remain open. [Scope](TRUTHTRANCE_BID_PROMISES.md#basic-early-richese-cache-bid--28-october-2026).
+
**Truthtrance and revealed Prescience — 28 October. Prototyped / Partial.**
Single and compound questions can compare a live answered battle Prescience
field: a named leader/card or None, or an exact dial. Only recipients entitled
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 9453d7e8..10e166a0 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,16 @@
# Rule decision index
+## Early Richese cache-bid commitments — 28 October 2026
+
+The FAQ permits current-turn action questions but does not specify a future
+cache auction's unknown method, rivals or feasibility. This Basic prototype
+provisionally asks about the first actual cache lot, retains a waiting receipt
+until that event exists, uses current personal funding as its Yes witness and
+releases an impossible promise on external cancellation. A seller choosing
+Once Around may lose its turn when all others pass. This is an online
+interpretation rather than a publisher clarification.
+[Scope](TRUTHTRANCE_BID_PROMISES.md#basic-early-richese-cache-bid--28-october-2026).
+
## Truthtrance and answered battle Prescience — 28 October 2026
The publisher FAQ permits game-related yes/no and compound questions. The
diff --git a/docs/TRUTHTRANCE_BID_PROMISES.md b/docs/TRUTHTRANCE_BID_PROMISES.md
index 8f80a12a..c97b75f4 100644
--- a/docs/TRUTHTRANCE_BID_PROMISES.md
+++ b/docs/TRUTHTRANCE_BID_PROMISES.md
@@ -1,5 +1,38 @@
# Truthtrance ordinary bid commitments
+## Basic early Richese cache bid — 28 October 2026
+
+**Prototyped / Partial.** Before Bidding, a player may ask whether the target
+will make at least a stated bid in this turn's first Richese cache auction.
+The card, method, direction and public auction event do not yet exist in the
+question. A waiting receipt attaches to the real cache event before settlement,
+then reuses the existing Once Around or Silent obligation, legal guide and
+sealed-bid behavior. Yes is fulfilled by a qualifying accepted declaration,
+not a win; No caps the bid. An actual canceled or unavailable cache releases
+Yes and fulfills No. A real opposing bid can also make a later Yes impossible.
+
+The early feasibility witness conservatively uses current uncommitted personal
+spice and hand space, without assuming future allied funding or rival bids;
+uncertain cases can answer No or unknown rather than inventing a Yes witness.
+Other positive financial/action promises remain guarded until a joint planner
+exists. During the wait, a voluntary action cannot eliminate a funded Yes;
+an outstanding leading ordinary purchase reserves a hand slot (two for a
+Harkonnen bonus draw). A waiting Richese owner can choose Silent; if the owner
+chooses Once Around and all others pass, the unreachable Yes is released. This
+is an online feasibility policy, not a publisher ruling. The general
+[GF9 FAQ p. 8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8)
+permits game-related current-turn questions and compliance while possible.
+
+Thirteen focused files pass 74 cases. Basic native first/last Once Around and
+Silent paths exercise both answers, real event binding, guides and all four
+minimal bot profiles. Additional real-auction cases exercise voluntary spending,
+the final hand slot, canceled cache and the owner's Once Around turn; some
+explicitly stage conserved unopened cards. The owner sees a persistent waiting
+reminder; controls are tested with component callbacks. Printed Kull, broader
+combinations, Advanced early questions and deployed
+acceptance remain open.
+`npm run check:quick` and the production build passed for this batch.
+
## Richese Silent bid prototype — 27 October 2026
**Prototyped / Partial.** The existing event-bound Richese minimum-bid question
diff --git a/game/bots.ts b/game/bots.ts
index 348c04dc..bcef8398 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -1393,6 +1393,10 @@ function policyActions(g: GameView): Action[] {
const answer = g.truthRicheseBidAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
+ if (w.question.kind === 'earlyRicheseBid') {
+ const answer = g.truthEarlyRicheseBidAnswers?.[0];
+ return answer ? [{ type: 'truthAnswer', answer }] : [];
+ }
if (w.question.kind === 'phaseBid') {
const answer = g.truthPhaseBidAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
diff --git a/game/early-richese-bid.ts b/game/early-richese-bid.ts
new file mode 100644
index 00000000..3df0b116
--- /dev/null
+++ b/game/early-richese-bid.ts
@@ -0,0 +1,152 @@
+import type { RicheseBidPromise } from './richese-bid-promises';
+
+/** A single bid in the first actual cache auction of this turn, before its
+ * card, method, direction and public event have been chosen. */
+export type EarlyRicheseBidClaim = { turn: number; minimum: number };
+export type EarlyRicheseBidPromise = EarlyRicheseBidClaim & {
+ id: string;
+ player: string;
+ asker: string;
+ answer: boolean;
+ boundEvent?: string;
+ fulfilled?: boolean;
+ released?: boolean;
+};
+export type EarlyRicheseBidChange = 'voluntary' | 'external';
+export type EarlyRicheseBidWitness = {
+ /** Whether the original cache auction may still occur this turn. */
+ cachePending: boolean;
+ /** Whether the respondent has an engine-verified funded opportunity. */
+ canBid: boolean;
+ /** Maximum funded bid, not a forecast of another player's hidden action. */
+ maximum: number;
+ change: EarlyRicheseBidChange;
+};
+export class EarlyRicheseBidError extends Error {}
+
+const positive = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
+const identifier = (value: unknown): value is string =>
+ typeof value === 'string' && value.length > 0 && value === value.trim();
+const validMaximum = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
+
+export function parseEarlyRicheseBidClaim(value: unknown): EarlyRicheseBidClaim {
+ if (!value || typeof value !== 'object' || Array.isArray(value))
+ throw new EarlyRicheseBidError('Choose a future cache auction bid question.');
+ const input = value as Record;
+ if (!positive(input.turn) || !positive(input.minimum))
+ throw new EarlyRicheseBidError('Choose the current turn and a positive whole bid minimum.');
+ return { turn: input.turn, minimum: input.minimum };
+}
+
+export function earlyRicheseBidClaimText(claim: EarlyRicheseBidClaim): string {
+ return `place a bid of at least ${claim.minimum} spice in this turn's first Richese cache auction`;
+}
+
+/** Bound receipts have been transferred to the real lot and are no longer
+ * waiting; the normal Richese promise ledger owns their subsequent outcome. */
+export function liveEarlyRicheseBidPromises(
+ promises: readonly EarlyRicheseBidPromise[], player: string, turn: number,
+): EarlyRicheseBidPromise[] {
+ return promises.filter(row => row.player === player && row.turn === turn &&
+ !row.boundEvent && !row.fulfilled && !row.released);
+}
+
+function checkPeople(player: string, asker: string): void {
+ if (!identifier(player) || !identifier(asker) || player === asker)
+ throw new EarlyRicheseBidError('Choose different seated asker and respondent identities.');
+}
+function checkMaximum(maximum: number): void {
+ if (!validMaximum(maximum)) throw new EarlyRicheseBidError('Retain the funded cache bid maximum.');
+}
+
+/** Return truthfully compatible definite answers, using only a native maximum
+ * independently verified by the engine. It may pass zero when a Yes witness
+ * cannot be established. All earlier waiting answers remain jointly feasible. */
+export function earlyRicheseBidAnswerOptions(
+ promises: readonly EarlyRicheseBidPromise[], claim: EarlyRicheseBidClaim,
+ player: string, asker: string, maximum: number,
+): boolean[] {
+ parseEarlyRicheseBidClaim(claim);
+ checkPeople(player, asker);
+ checkMaximum(maximum);
+ const live = liveEarlyRicheseBidPromises(promises, player, claim.turn);
+ return [true, false].filter(answer => {
+ const rows = [...live, { ...claim, answer }];
+ const required = rows.filter(row => row.answer);
+ if (!required.length) return true;
+ const lower = Math.max(...required.map(row => row.minimum));
+ const upper = Math.min(maximum, ...rows.filter(row => !row.answer).map(row => row.minimum - 1));
+ return lower <= upper;
+ });
+}
+
+export function bindEarlyRicheseBid(
+ promises: readonly EarlyRicheseBidPromise[], claim: EarlyRicheseBidClaim,
+ player: string, asker: string, answer: boolean, id: string, maximum: number,
+): EarlyRicheseBidPromise[] {
+ if (!identifier(id) || promises.some(row => row.id === id) || typeof answer !== 'boolean')
+ throw new EarlyRicheseBidError('Retain a distinct receipt and definite answer.');
+ if (!earlyRicheseBidAnswerOptions(promises, claim, player, asker, maximum).includes(answer))
+ throw new EarlyRicheseBidError('Choose an answer compatible with the funded cache bid and existing answers.');
+ return [...promises, { ...parseEarlyRicheseBidClaim(claim), id, player, asker, answer }];
+}
+
+/** Called only for creation of the first physical cache lot, before that lot
+ * can auto-settle. No card identity or method is stored in the early receipt. */
+export function attachEarlyRicheseBids(
+ promises: readonly EarlyRicheseBidPromise[], turn: number, event: string,
+): { promises: EarlyRicheseBidPromise[]; bound: RicheseBidPromise[] } {
+ if (!positive(turn) || !identifier(event))
+ throw new EarlyRicheseBidError('Retain the current turn and real cache auction event.');
+ const pending = promises.filter(row => row.turn === turn && !row.boundEvent && !row.fulfilled && !row.released);
+ const bound = pending.map(({ id, player, asker, answer, minimum }) =>
+ ({ id, player, asker, answer, turn, event, minimum }));
+ return { promises: promises.map(row => pending.includes(row) ? { ...row, boundEvent: event } : row), bound };
+}
+
+/** The caller supplies a complete current witness. An actor cannot voluntarily
+ * eliminate their own last funded Yes; an external event releases only the
+ * answers it makes impossible. No closes only when the cache is unavailable. */
+export function reconcileEarlyRicheseBids(
+ promises: readonly EarlyRicheseBidPromise[], player: string, turn: number,
+ witness: EarlyRicheseBidWitness,
+): EarlyRicheseBidPromise[] {
+ if (!identifier(player) || !positive(turn) || !witness ||
+ typeof witness.cachePending !== 'boolean' || typeof witness.canBid !== 'boolean' ||
+ !validMaximum(witness.maximum) ||
+ (witness.change !== 'voluntary' && witness.change !== 'external'))
+ throw new EarlyRicheseBidError('Retain the cache auction witness and the cause of the change.');
+ const live = liveEarlyRicheseBidPromises(promises, player, turn);
+ const noCap = Math.min(witness.maximum, ...live.filter(row => !row.answer).map(row => row.minimum - 1));
+ const impossible = live.filter(row => row.answer &&
+ (!witness.cachePending || !witness.canBid || row.minimum > noCap));
+ if (impossible.length && witness.change === 'voluntary')
+ throw new EarlyRicheseBidError('This action would remove your last funded cache bid opportunity.');
+ return promises.map(row => {
+ if (!live.includes(row)) return row;
+ if (impossible.includes(row)) return { ...row, released: true };
+ if (!witness.cachePending && !row.answer) return { ...row, fulfilled: true };
+ return row;
+ });
+}
+
+export function validateEarlyRicheseBidPromises(
+ promises: readonly EarlyRicheseBidPromise[], players: readonly string[],
+): void {
+ if (!Array.isArray(promises) || !Array.isArray(players))
+ throw new EarlyRicheseBidError('The early cache bid records are invalid.');
+ const ids = new Set();
+ for (const row of promises) {
+ parseEarlyRicheseBidClaim(row);
+ if (!identifier(row.id) || ids.has(row.id) || !players.includes(row.player) ||
+ !players.includes(row.asker) || row.player === row.asker || typeof row.answer !== 'boolean' ||
+ (row.boundEvent !== undefined && !identifier(row.boundEvent)) ||
+ (row.fulfilled !== undefined && typeof row.fulfilled !== 'boolean') ||
+ (row.released !== undefined && typeof row.released !== 'boolean') ||
+ (row.fulfilled && row.released) || (row.boundEvent && (row.fulfilled || row.released)))
+ throw new EarlyRicheseBidError('The early cache bid receipt lost its identity, participants or state.');
+ ids.add(row.id);
+ }
+}
diff --git a/game/engine.ts b/game/engine.ts
index c809879a..6c3184fb 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -19,6 +19,9 @@ import { SaphoTruthOrderError, quoteSaphoTruthOrder, reorderSaphoTruthOrder, val
import { RicheseBidPromiseError, richeseBidCompletionRange, richeseBidRequired, recordRicheseBid,
liveRicheseBidPromises, reconcileRicheseBidPromises, finishRicheseBidLot, validateRicheseBidPromises,
type RicheseBidClaim, type RicheseBidPromise, type RicheseBidOpportunity } from './richese-bid-promises';
+import { EarlyRicheseBidError, earlyRicheseBidAnswerOptions, bindEarlyRicheseBid,
+ attachEarlyRicheseBids, liveEarlyRicheseBidPromises, reconcileEarlyRicheseBids,
+ validateEarlyRicheseBidPromises, type EarlyRicheseBidClaim, type EarlyRicheseBidPromise } from './early-richese-bid';
import { AuctionClockError, AUCTION_BID_MS, quoteAuctionClock, viewAuctionClock,
type AuctionClock, type AuctionClockView } from './auction-clock';
import { PrintedCardPlayError, printedCardPlayCount, recordPrintedCardPlay,
@@ -2323,6 +2326,7 @@ export type Game = {
bidPromises?: BidPromise[];
phaseBidPromises?: PhaseBidPromise[];
richeseBidPromises?: RicheseBidPromise[];
+ earlyRicheseBidPromises?: EarlyRicheseBidPromise[];
/** Raw mixed question linked to one private native current-lot obligation. */
mixedBidQuestions?: (MixedBidClaim & {
event: string; player: string; asker: string; answer: boolean; binding: boolean;
@@ -14985,6 +14989,11 @@ function beginRicheseLot(
tieOrder: g.order,
});
g.richeseAuction = lot;
+ if (source === 'cache' && g.earlyRicheseBidPromises?.length) {
+ const attached = earlyRicheseBidRule(() => attachEarlyRicheseBids(g.earlyRicheseBidPromises!, g.turn, lot.event));
+ g.earlyRicheseBidPromises = attached.promises;
+ (g.richeseBidPromises ??= []).push(...attached.bound);
+ }
g.richeseOfferedCard = structuredClone(card);
g.richeseFunding = {};
g.richesePeekKnown = false;
@@ -16596,6 +16605,67 @@ function projectedCharityTruthGuide(g: Game,player: string): {action:Action} | n
function richeseBidRule(run: () => T): T {
try { return run(); } catch (error) { if (error instanceof RicheseBidPromiseError) throw new RuleError(error.message); throw error; }
}
+function earlyRicheseBidRule(run: () => T): T {
+ try { return run(); } catch (error) { if (error instanceof EarlyRicheseBidError) throw new RuleError(error.message); throw error; }
+}
+function earlyRicheseBidWindow(g: Game): boolean {
+ return bidPromiseModeSupported({ ...g, semutaPreview: undefined }) && !g.kullPreview && !g.advanced &&
+ g.status === 'playing' && g.phase >= 0 && g.phase <= 2 && !!byFaction(g, 'richese') &&
+ (g.richeseCache?.length ?? 0) > 0 && !g.response && !g.decision && !g.phaseOpening &&
+ !g.pendingKarama && !g.pendingTreacheryDiscard;
+}
+/** A conservative witness uses the respondent's current uncommitted spice;
+ * it does not assume an ally's future aid or an opponent's future bid. */
+function earlyRicheseBidMaximum(g: Game, p: Player): number {
+ // A leading ordinary bid may become a real purchase when the other players
+ // pass. Reserve its physical hand slot, including Harkonnen's bonus draw.
+ const pendingPurchase = g.auction?.bidder === p.id && !g.currentAuctionSale
+ ? 1 + Number(p.faction === 'harkonnen') : 0;
+ if (p.hand.length + pendingPurchase >= handLimit(p)) return 0;
+ const amount = uncommittedSpice(g,p);
+ return Number.isSafeInteger(amount) ? Math.max(0,amount) : 0;
+}
+function earlyRicheseBidTruthAnswers(g: Game,p: Player,claim: EarlyRicheseBidClaim): TruthAnswer[] {
+ if (!earlyRicheseBidWindow(g) || claim.turn !== g.turn) return [];
+ if (richeseBidOtherPromises(g,p)) return ['unknown'];
+ const asker = g.truthtrance?.queue[0]?.player ?? g.players.find(row => row.id !== p.id)?.id ?? g.host;
+ const answers = earlyRicheseBidRule(() => earlyRicheseBidAnswerOptions(g.earlyRicheseBidPromises ?? [],claim,p.id,asker,
+ earlyRicheseBidMaximum(g,p))).map(answer => answer ? 'yes' as const : 'no' as const);
+ return answers.includes('yes') ? answers : [...answers,'unknown'];
+}
+function bindEarlyRicheseBidTruth(g: Game,p: Player,claim: EarlyRicheseBidClaim,answer: boolean,asker: string): void {
+ requireRule(earlyRicheseBidTruthAnswers(g,p,claim).includes(answer ? 'yes' : 'no'),
+ 'This early cache bid answer no longer has a funded completion.');
+ g.earlyRicheseBidPromises = earlyRicheseBidRule(() => bindEarlyRicheseBid(g.earlyRicheseBidPromises ?? [],claim,
+ p.id,asker,answer,crypto.randomUUID(),earlyRicheseBidMaximum(g,p)));
+}
+function reconcileEarlyRicheseTruth(g: Game,voluntary?: {actor:string;action:Action}): void {
+ earlyRicheseBidRule(() => validateEarlyRicheseBidPromises(g.earlyRicheseBidPromises ?? [],g.players.map(p => p.id)));
+ if (!g.earlyRicheseBidPromises?.length) return;
+ const pending = !!(g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard);
+ if (pending && !voluntary) return;
+ for (const p of g.players) {
+ // While a response is unsettled, preflight the voluntary actor only. Other
+ // seats wait for the settled continuation, so temporary reservations do
+ // not prematurely release their commitments.
+ if (pending && voluntary?.actor !== p.id) continue;
+ const turns: Set = new Set((g.earlyRicheseBidPromises ?? []).filter(row => row.player === p.id && !row.boundEvent &&
+ !row.fulfilled && !row.released).map(row => row.turn));
+ for (const turn of turns) {
+ const round = g.richeseBidding;
+ const cachePending = g.status === 'playing' && g.turn === turn && g.phase <= 3 &&
+ (g.richeseCache?.length ?? 0) > 0 &&
+ (g.phase < 3 || !round || round.turn !== turn || !round.cacheCanceled && round.stage !== 'complete');
+ const before: EarlyRicheseBidPromise[] = g.earlyRicheseBidPromises ?? [];
+ g.earlyRicheseBidPromises = earlyRicheseBidRule((): EarlyRicheseBidPromise[] => reconcileEarlyRicheseBids(before,p.id,turn,{
+ cachePending,canBid:p.hand.length < handLimit(p),maximum:earlyRicheseBidMaximum(g,p),
+ change:voluntary?.actor === p.id ? 'voluntary' : 'external',
+ }));
+ if (g.earlyRicheseBidPromises.some((row,index) => row.player === p.id && row.released && !before[index]?.released))
+ log(g, `${p.name} can no longer fulfill the early Richese cache bid answer; it is no longer binding.`);
+ }
+ }
+}
function richeseBidWindow(g: Game): boolean {
return bidPromiseModeSupported({ ...g, semutaPreview: undefined }) && g.status === 'playing' && g.phase === 3 &&
!!g.richeseAuction && ['onceAround','silent'].includes(g.richeseAuction.method) && !g.richeseAuction.outcome &&
@@ -16628,7 +16698,8 @@ function richeseBidOtherPromises(g: Game, p: Player): boolean {
* admission, not later when neither promised action can be completed. */
function richeseBidCompatibleLaterPromises(g: Game,p: Player): boolean {
const lot = g.richeseAuction;
- if (!lot) return true;
+ if (!lot) return !liveEarlyRicheseBidPromises(g.earlyRicheseBidPromises ?? [],p.id,g.turn).some(row => row.answer) ||
+ !richeseBidOtherPromises(g,p);
const context = {turn:g.turn,event:lot.event,player:p.id};
return !richeseBidRequired(g.richeseBidPromises ?? [],context) || !richeseBidOtherPromises(g,p);
}
@@ -16664,6 +16735,12 @@ function reconcileRicheseBids(g: Game,voluntary?: {actor:string;action:Action})
const context = {turn:row.turn,event:row.event,player:row.player};
const change = voluntary?.actor === row.player ? 'voluntary' : 'external';
const lot = g.richeseAuction;
+ // A waiting cache promise may attach before the owner can act in Once
+ // Around. Preserve it while other seats still have their bid opportunity;
+ // if everyone passes, the native close releases the impossible Yes.
+ if (lot?.event === row.event && lot.method === 'onceAround' && lot.owner === row.player &&
+ lot.active !== row.player && !lot.bidder &&
+ g.earlyRicheseBidPromises?.some(early => early.id === row.id && early.boundEvent === lot.event)) continue;
if (g.turn !== row.turn || !lot || lot.event !== row.event || lot.outcome)
g.richeseBidPromises = richeseBidRule(() => finishRicheseBidLot(g.richeseBidPromises!,context,change));
else g.richeseBidPromises = richeseBidRule(() => reconcileRicheseBidPromises(g.richeseBidPromises!,context,
@@ -16881,7 +16958,8 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length ||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length ||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length ||
- hasBribePromises(g, p.id)) {
+ hasBribePromises(g, p.id) ||
+ liveEarlyRicheseBidPromises(g.earlyRicheseBidPromises ?? [], p.id, g.turn).some(row => row.answer)) {
const trial = structuredClone(g);
trial.truthtrance = null;
trial.auction!.bid = action.amount as number;
@@ -16895,6 +16973,7 @@ function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
reconcileRevivalPromises(trial, { actor: p.id, action });
reconcileBribePromises(trial, { actor: p.id, action });
+ reconcileEarlyRicheseTruth(trial, { actor: p.id, action });
}
return action;
}
@@ -40327,6 +40406,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
reconcileTravelPromises(g, promiseActor, action, state.phase);
reconcileBidPromises(g, voluntary);
reconcilePhaseBids(g, voluntary);
+ reconcileEarlyRicheseTruth(g, voluntary);
reconcileRicheseBids(g, voluntary);
reconcileCharityTruth(g, voluntary);
reconcileRevivalPromises(g, voluntary);
@@ -40454,6 +40534,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
reconcileShipmentPromises(g, { actor: id, action, phase: state.phase });
reconcileBidPromises(g, { actor: id, action });
reconcilePhaseBids(g, { actor: id, action });
+ reconcileEarlyRicheseTruth(g, { actor: id, action });
reconcileRicheseBids(g, { actor: id, action });
reconcileCharityTruth(g, { actor: id, action });
reconcileRevivalPromises(g, { actor: id, action });
@@ -40688,6 +40769,7 @@ function settleAutomaticContinuations(g: Game) {
reconcileShipmentPromises(g);
reconcileBidPromises(g);
reconcilePhaseBids(g);
+ reconcileEarlyRicheseTruth(g);
reconcileRicheseBids(g);
reconcileCharityTruth(g);
reconcileRevivalPromises(g);
@@ -40804,6 +40886,7 @@ export function normalizeAutomaticGame(state: Game): Game {
reconcileShipmentPromises(g);
reconcileBidPromises(g);
reconcilePhaseBids(g);
+ reconcileEarlyRicheseTruth(g);
reconcileRicheseBids(g);
reconcileCharityTruth(g);
reconcileRevivalPromises(g);
@@ -41093,6 +41176,8 @@ function applyActionInner(
bindCharity: bindCharityTruth,
richeseBidAnswers: richeseBidTruthAnswers,
bindRicheseBid: bindRicheseBidTruth,
+ earlyRicheseBidAnswers: earlyRicheseBidTruthAnswers,
+ bindEarlyRicheseBid: bindEarlyRicheseBidTruth,
phaseBidAnswers: phaseBidTruthAnswers,
bindPhaseBid: bindPhaseBidTruth,
turnActionAnswers: turnActionTruthAnswers,
@@ -45263,6 +45348,10 @@ export function viewGame(state: Game, id: string, now?: number) {
}).map(player => player.id) : [],
truthRicheseBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'richeseBid' ? richeseBidTruthAnswers(g,me,g.truthtrance.question) : null,
+ truthEarlyRicheseBidTargets: earlyRicheseBidWindow(g) ? g.players.map(player => player.id) : [],
+ truthEarlyRicheseBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
+ g.truthtrance.question.kind === 'earlyRicheseBid' ? earlyRicheseBidTruthAnswers(g,me,g.truthtrance.question) : null,
+ ownEarlyRicheseBidPromises: (g.earlyRicheseBidPromises ?? []).filter(row => row.player === id).map(row => ({...row})),
richeseBidPromises: (g.richeseBidPromises ?? []).filter(row => row.player === id).map(row => ({...row})),
richeseBidPromiseGuide: projectedRicheseBidGuide(g,id),
truthPhaseBidTargets: phaseBidQuestionWindow(g) ? g.players.map(player => player.id) : [],
diff --git a/game/reference.ts b/game/reference.ts
index f6868739..6f087a78 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3692,12 +3692,14 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
- summary: 'Bind a minimum bid in an ordinary lot, the remaining ordinary Bidding phase, or a Richese Once Around or Silent lot, preserving native funding and order.',
+ summary: 'Bind a minimum bid in an ordinary lot, the remaining ordinary Bidding phase, a Richese lot or an upcoming Basic cache auction, preserving native funding and 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; broader compositions remain unfinished.',
'In a quiet Richese Once Around auction, choose Bind a bid in this Once Around lot to bind the respondent’s one remaining bid or pass. A waiting Richese owner cannot promise Yes until an actual bid ensures an owner turn. Current hand space, funds and pledged aid remain required; an opposing raise can release an impossible Yes. Normal special auctions remain outside this prototype.',
'Alternatively choose Bind a bid this Bidding phase. Yes requires one qualifying accepted ordinary bid after the answer and before Bidding closes; No forbids qualifying later ordinary bids across lots. Earlier bids do not count, and a bid does not promise a win.',
'In a Silent Richese lot the question binds the respondent’s single sealed submission. Zero declines; a positive bid remains hidden until the native simultaneous reveal. Available answers use that bidder’s own funding, without using opponents’ concealed amounts.',
+ 'Before Basic Bidding, ask about a minimum bid in the first actual Richese cache auction of this turn. The answer waits for the real event and does not predict its card or method. Current uncommitted personal funds and hand space supply a conservative Yes witness; cancellation or an opposing action can release an impossible commitment. The Richese owner’s Once Around turn may vanish if everyone else passes.',
+ 'Your waiting cache commitment remains visible until its auction opens or it ends. Once the real lot opens, the ordinary Richese bid guide takes over.',
'The phase-wide guide may wait for a later lot only when the existing funded sale, prepared next card and post-sale space and funds support it. An unbid lot, hypothetical rival raise, cancelable unfunded overbid or Harkonnen bonus slot is not invented as a future opportunity. Before Bidding, current funding and available card count provide a provisional forecast; special auction preparation and combined future commitments remain limited.',
'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.',
@@ -3708,7 +3710,7 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['card-truthtrance', 'bidding', 'truthtrance-shipment', 'implementation-checklist'],
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'Current-lot and separate phase-wide minimum bids use native declarations, funded witnesses, joint Yes/No constraints and their own expiry. No new game-start gate.', evidence: ['game/bid-promises.ts', 'game/turn-bid-promises.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/turn-bid-promises.test.ts', 'tests/truth-turn-bid-runtime.test.ts'] },
+ { area: 'Implementation', status: 'Partial', detail: 'Current-lot, phase-wide and early Basic Richese cache minimum bids use native declarations, funded witnesses, joint Yes/No constraints and their own expiry. No new game-start gate.', evidence: ['game/bid-promises.ts', 'game/turn-bid-promises.ts', 'game/early-richese-bid.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/turn-bid-promises.test.ts', 'tests/truth-early-richese-bid-runtime.test.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Separate editors, public previews, target-only answers and persistent event-bound native guides keep winning payments separate.', evidence: ['components/bid-claim-fields.tsx', 'components/bid-promises.tsx', 'components/phase-bid-claim-fields.tsx', 'components/phase-bid-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-phase-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'] },
diff --git a/game/truthtrance.ts b/game/truthtrance.ts
index b7f46d29..92481489 100644
--- a/game/truthtrance.ts
+++ b/game/truthtrance.ts
@@ -75,6 +75,7 @@ import { parseFaceDancerFact, faceDancerFactMatches, faceDancerFactText, type Fa
import { parseBidClaim, bidClaimText, bidPromiseModeSupported, type BidClaim } from './bid-promises';
import { parsePhaseBidClaim, phaseBidClaimText, phaseBidPromiseModeSupported, type PhaseBidClaim } from './turn-bid-promises';
import { parseRicheseBidClaim, richeseBidClaimText, type RicheseBidClaim } from './richese-bid-promises';
+import { parseEarlyRicheseBidClaim, earlyRicheseBidClaimText, type EarlyRicheseBidClaim } from './early-richese-bid';
export type TruthFact =
| KnowledgeFact
@@ -110,6 +111,7 @@ export type TruthQuestion =
| ({ kind: 'bid'; target: string } & BidClaim)
| ({ kind: 'phaseBid'; target: string } & PhaseBidClaim)
| ({ kind: 'richeseBid'; target: string } & RicheseBidClaim)
+ | ({ kind: 'earlyRicheseBid'; target: string } & EarlyRicheseBidClaim)
| { kind: 'traitorCall'; target: string; battle: string; territory: string }
| ({ kind: 'movement'; target: string } & MovementClaim)
| ({ kind: 'shipment'; target: string } & ShipmentClaim)
@@ -375,6 +377,17 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
'Choose a remaining native Revival opportunity this turn in a supported table and finish pending decisions.');
return { kind: 'revival', target: v.target, ...claim };
}
+ if (v.kind === 'earlyRicheseBid') {
+ check(Object.keys(v).sort().join(',') === 'kind,minimum,target,turn',
+ 'Supply the current turn, cache bid minimum and respondent.');
+ const claim = parseEarlyRicheseBidClaim(v);
+ check(bidPromiseModeSupported({ ...g, semutaPreview: undefined }) && !g.kullPreview && !g.advanced &&
+ g.status === 'playing' && g.phase >= 0 && g.phase <= 2 && claim.turn === g.turn &&
+ g.players.some(player => player.faction === 'richese') && (g.richeseCache?.length ?? 0) > 0 &&
+ !g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard,
+ 'Choose a quiet Basic turn before the first Richese cache auction.');
+ return { kind: 'earlyRicheseBid', target: v.target, ...claim };
+ }
if (v.kind === 'richeseBid') {
check(Object.keys(v).sort().join(',') === 'event,kind,minimum,target,turn',
'Choose the current Richese lot and bid minimum.');
@@ -773,6 +786,7 @@ export function truthQuestionText(
if (q.kind === 'bid') return `Will you ${bidClaimText(q)}?`;
if (q.kind === 'phaseBid') return `Will you ${phaseBidClaimText(q)}?`;
if (q.kind === 'richeseBid') return `Will you ${richeseBidClaimText(q)}?`;
+ if (q.kind === 'earlyRicheseBid') return `Will you ${earlyRicheseBidClaimText(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 === 'movementExpression') return `Will you ${movementExpressionText(q.claim)} before your Shipment and Movement turn ends?`;
if (q.kind === 'movement') return `Will you ${movementClaimText(q)} before your Shipment and Movement turn ends?`;
@@ -912,6 +926,8 @@ export function resolveTruthAction(
bindCharity?: (g: Game, p: Player, turn: number, answer: boolean, asker: string) => void;
richeseBidAnswers?: (g: Game, p: Player, claim: RicheseBidClaim) => TruthAnswer[];
bindRicheseBid?: (g: Game, p: Player, claim: RicheseBidClaim, answer: boolean, asker: string) => void;
+ earlyRicheseBidAnswers?: (g: Game, p: Player, claim: EarlyRicheseBidClaim) => TruthAnswer[];
+ bindEarlyRicheseBid?: (g: Game, p: Player, claim: EarlyRicheseBidClaim, answer: boolean, asker: string) => void;
phaseBidAnswers?: (g: Game, p: Player, claim: PhaseBidClaim) => TruthAnswer[];
bindPhaseBid?: (g: Game, p: Player, claim: PhaseBidClaim, answer: boolean, asker: string) => void;
travelAnswers?: (g: Game, p: Player, claim: TravelClaim) => TruthAnswer[];
@@ -1104,6 +1120,9 @@ export function resolveTruthAction(
if (w.question.kind === 'richeseBid')
check(effects.richeseBidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a bidder with a remaining Richese auction opportunity.');
+ if (w.question.kind === 'earlyRicheseBid')
+ check(effects.earlyRicheseBidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
+ 'Choose a bidder with a compatible future cache auction opportunity.');
if (w.question.kind === 'phaseBid')
check(effects.phaseBidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a phase bid question compatible with supported native opportunities and earlier answers.');
@@ -1247,6 +1266,11 @@ export function resolveTruthAction(
'Choose an answer consistent with this Richese lot and your legal bid.');
if (a.answer !== 'unknown') effects.bindRicheseBid(g, p, q, a.answer === 'yes', current.player);
}
+ if (q.kind === 'earlyRicheseBid') {
+ check(effects.earlyRicheseBidAnswers && effects.bindEarlyRicheseBid && effects.earlyRicheseBidAnswers(g, p, q).includes(a.answer),
+ 'Choose an answer compatible with your future cache auction opportunity.');
+ if (a.answer !== 'unknown') effects.bindEarlyRicheseBid(g, p, q, a.answer === 'yes', current.player);
+ }
if (q.kind === 'phaseBid') {
check(effects.phaseBidAnswers && effects.bindPhaseBid && effects.phaseBidAnswers(g, p, q).includes(a.answer),
'Choose an answer consistent with native bids and earlier phase commitments.');
diff --git a/tests/early-richese-bid-controls.test.tsx b/tests/early-richese-bid-controls.test.tsx
new file mode 100644
index 00000000..87a92a4b
--- /dev/null
+++ b/tests/early-richese-bid-controls.test.tsx
@@ -0,0 +1,68 @@
+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 { viewGame } from '../game/engine';
+import { createTruthtranceRichesePromisesFixture, actTruthtranceRichesePromises as act,
+ advanceTruthtranceRichesePromises as advance, askTruthtranceRichesePromises } from './fixture-truthtrance-richese-promises';
+
+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 { EarlyRicheseBidFields, Truthtrance } = await import('../components/truthtrance');
+aliases.deregister();
+
+type ElementProps = { children?: ReactNode; [key: string]: unknown };
+function elements(node: ReactNode): ReactElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!isValidElement(node)) return [];
+ return [node, ...elements(node.props.children)];
+}
+
+void test('early Richese threshold controls describe the undecided first cache lot and reject invalid edits', () => {
+ const changes: number[] = [];
+ const props = { minimum: 3, disabled: false, onMinimum(value: number) { changes.push(value); } };
+ const html = renderToStaticMarkup(createElement(EarlyRicheseBidFields, props));
+ assert.match(html, /first Richese cache auction this turn/i);
+ assert.match(html, /card and method have not yet been decided/);
+ assert.match(html, /does not promise a win/);
+ const edit = (value: number, disabled = false) => {
+ const input = elements(EarlyRicheseBidFields({ ...props, disabled })).find(element =>
+ element.props.id === 'truth-early-richese-minimum' && typeof element.props.onChange === 'function');
+ assert.ok(input);
+ (input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber: value } });
+ };
+ edit(4); for (const value of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) edit(value);
+ edit(7, true);
+ assert.deepEqual(changes, [4]);
+});
+
+void test('native Basic Truthtrance exposes the early selector and target-only answer controls', () => {
+ const f = createTruthtranceRichesePromisesFixture({ advanced: false });
+ let declared = act(f.earlySource, f.asker, f.declarationStep.action);
+ declared = advance(declared, state => state.truthtrance?.stage === 'ask');
+ const askerView = viewGame(declared, f.asker);
+ assert.ok(askerView.truthEarlyRicheseBidTargets.includes(f.emperor));
+ const asking = renderToStaticMarkup(createElement(Truthtrance, { game: askerView, busy: false, act() {} }));
+ assert.match(asking, /Bind a bid in the first Richese cache auction<\/option>/);
+ const asked = askTruthtranceRichesePromises(f.earlySource, f, {
+ kind: 'earlyRicheseBid', target: f.emperor, turn: f.earlySource.turn, minimum: 1,
+ });
+ const respondent = viewGame(asked, f.emperor);
+ assert.ok(respondent.truthEarlyRicheseBidAnswers?.length);
+ assert.equal(viewGame(asked, f.asker).truthEarlyRicheseBidAnswers, null);
+ const answer = renderToStaticMarkup(createElement(Truthtrance, { game: respondent, busy: false, act() {} }));
+ assert.match(answer, /first Richese cache auction this turn/i);
+ assert.match(answer, /Its card and method are not yet decided/);
+ assert.match(answer, /Answer (Yes|No)|I don’t know/);
+});
diff --git a/tests/early-richese-bid-waiting-controls.test.tsx b/tests/early-richese-bid-waiting-controls.test.tsx
new file mode 100644
index 00000000..3ed36355
--- /dev/null
+++ b/tests/early-richese-bid-waiting-controls.test.tsx
@@ -0,0 +1,30 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { createGame, joinGame, newPlayer, viewGame } from '../game/engine';
+import { EarlyRicheseBidPromises } from '../components/early-richese-bid-promises';
+
+void test('waiting receipts explain only the current player’s live unattached cache bid answers', () => {
+ const game = createGame('EARLYWAIT', newPlayer('e', 'Emperor', 'emperor'), false, ['choam']);
+ joinGame(game, newPlayer('r', 'Richese', 'richese'));
+ const view = viewGame(game, 'e');
+ view.ownEarlyRicheseBidPromises = [
+ { id: 'private-id', player: 'e', asker: 'r', turn: view.turn, minimum: 4, answer: true },
+ { id: 'negative-id', player: 'e', asker: 'r', turn: view.turn, minimum: 7, answer: false },
+ { id: 'other-id', player: 'r', asker: 'e', turn: view.turn, minimum: 9, answer: true },
+ { id: 'done-id', player: 'e', asker: 'r', turn: view.turn, minimum: 11, answer: true, fulfilled: true },
+ { id: 'release-id', player: 'e', asker: 'r', turn: view.turn, minimum: 12, answer: true, released: true },
+ { id: 'bound-id', player: 'e', asker: 'r', turn: view.turn, minimum: 13, answer: true, boundEvent: 'private-lot' },
+ { id: 'old-id', player: 'e', asker: 'r', turn: view.turn + 1, minimum: 14, answer: true },
+ ];
+ const html = renderToStaticMarkup(createElement(EarlyRicheseBidPromises, { game: view }));
+ assert.match(html, /You answered Yes to bidding at least 4 spice/);
+ assert.match(html, /You answered No to bidding at least 7 spice/);
+ assert.match(html, /Waiting for the first Richese cache auction/);
+ assert.match(html, /Its card and method are not yet decided/);
+ assert.doesNotMatch(html, /at least (9|11|12|13|14) spice/);
+ assert.doesNotMatch(html, /private-id|negative-id|other-id|private-lot| !!row.boundEvent || row.fulfilled || row.released || row.turn !== view.turn);
+ assert.equal(renderToStaticMarkup(createElement(EarlyRicheseBidPromises, { game: view })), '');
+});
diff --git a/tests/early-richese-bid.test.ts b/tests/early-richese-bid.test.ts
new file mode 100644
index 00000000..ee7bc01c
--- /dev/null
+++ b/tests/early-richese-bid.test.ts
@@ -0,0 +1,84 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ EarlyRicheseBidError, attachEarlyRicheseBids, bindEarlyRicheseBid,
+ earlyRicheseBidAnswerOptions, earlyRicheseBidClaimText,
+ liveEarlyRicheseBidPromises, parseEarlyRicheseBidClaim,
+ reconcileEarlyRicheseBids, validateEarlyRicheseBidPromises,
+ type EarlyRicheseBidPromise,
+} from '../game/early-richese-bid';
+
+const claim = { turn: 3, minimum: 4 };
+const bind = (rows: EarlyRicheseBidPromise[], minimum: number, answer: boolean, id: string, maximum = 8) =>
+ bindEarlyRicheseBid(rows, { turn: 3, minimum }, 'target', 'asker', answer, id, maximum);
+
+void test('the early claim binds a turn and positive bid without a speculative lot', () => {
+ assert.deepEqual(parseEarlyRicheseBidClaim({ ...claim, event: 'discarded' }), claim);
+ assert.match(earlyRicheseBidClaimText(claim), /first Richese cache auction/);
+ for (const input of [null, {}, { turn: 0, minimum: 1 }, { turn: 2, minimum: 0 },
+ { turn: 2, minimum: 1.5 }, { turn: 2, minimum: Infinity }])
+ assert.throws(() => parseEarlyRicheseBidClaim(input), EarlyRicheseBidError);
+});
+
+void test('candidate answers preserve all jointly feasible waiting promises', () => {
+ assert.deepEqual(earlyRicheseBidAnswerOptions([], claim, 'target', 'asker', 8), [true, false]);
+ assert.deepEqual(earlyRicheseBidAnswerOptions([], claim, 'target', 'asker', 3), [false]);
+ assert.deepEqual(earlyRicheseBidAnswerOptions([], claim, 'target', 'asker', 0), [false]);
+ const yes = bind([], 4, true, 'yes');
+ assert.deepEqual(earlyRicheseBidAnswerOptions(yes, { turn: 3, minimum: 4 }, 'target', 'asker', 8), [true]);
+ const no = bind([], 5, false, 'no');
+ assert.deepEqual(earlyRicheseBidAnswerOptions(no, { turn: 3, minimum: 4 }, 'target', 'asker', 8), [true, false]);
+ assert.deepEqual(earlyRicheseBidAnswerOptions(no, { turn: 3, minimum: 5 }, 'target', 'asker', 8), [false]);
+ assert.throws(() => bind(yes, 4, false, 'no'), /compatible/);
+ assert.throws(() => bind(yes, 4, true, 'yes'), /distinct receipt/);
+ assert.throws(() => earlyRicheseBidAnswerOptions([], claim, 'target', 'target', 8));
+ assert.throws(() => earlyRicheseBidAnswerOptions([], claim, 'target', 'asker', -1));
+});
+
+void test('first actual cache event attaches waiting receipts once without changing other turns', () => {
+ const source = [...bind([], 4, true, 'yes'),
+ { ...claim, turn: 2, id: 'old', player: 'target', asker: 'asker', answer: false },
+ { ...claim, id: 'closed', player: 'target', asker: 'asker', answer: true, released: true }];
+ const before = structuredClone(source);
+ const attached = attachEarlyRicheseBids(source, 3, 'real-cache-event');
+ assert.deepEqual(source, before);
+ assert.deepEqual(attached.bound, [{ ...claim, id: 'yes', player: 'target', asker: 'asker', answer: true, event: 'real-cache-event' }]);
+ assert.equal(attached.promises[0].boundEvent, 'real-cache-event');
+ assert.deepEqual(liveEarlyRicheseBidPromises(attached.promises, 'target', 3), []);
+ assert.deepEqual(attachEarlyRicheseBids(attached.promises, 3, 'another-event').bound, []);
+ validateEarlyRicheseBidPromises(attached.promises, ['target', 'asker']);
+});
+
+void test('voluntary losses cannot abandon a Yes, external losses release only impossible promises', () => {
+ const rows = bind(bind([], 4, true, 'y4'), 7, true, 'y7');
+ const witness = { cachePending: true, canBid: true, maximum: 5, change: 'voluntary' as const };
+ assert.throws(() => reconcileEarlyRicheseBids(rows, 'target', 3, witness), /last funded/);
+ assert.deepEqual(rows, bind(bind([], 4, true, 'y4'), 7, true, 'y7'));
+ const external = reconcileEarlyRicheseBids(rows, 'target', 3, { ...witness, change: 'external' });
+ assert.equal(external[0].released, undefined);
+ assert.equal(external[1].released, true);
+ const noCapacity = reconcileEarlyRicheseBids(external, 'target', 3,
+ { cachePending: true, canBid: false, maximum: 5, change: 'external' });
+ assert.equal(noCapacity[0].released, true);
+});
+
+void test('closing unavailable cache releases Yes and establishes No', () => {
+ const rows = bind(bind([], 4, true, 'yes'), 7, false, 'no');
+ assert.throws(() => reconcileEarlyRicheseBids(rows, 'target', 3,
+ { cachePending: false, canBid: false, maximum: 0, change: 'voluntary' }));
+ const closed = reconcileEarlyRicheseBids(rows, 'target', 3,
+ { cachePending: false, canBid: false, maximum: 0, change: 'external' });
+ assert.equal(closed[0].released, true);
+ assert.equal(closed[1].fulfilled, true);
+ assert.deepEqual(attachEarlyRicheseBids(closed, 3, 'later-cache').bound, []);
+ validateEarlyRicheseBidPromises(closed, ['target', 'asker']);
+});
+
+void test('validation rejects malformed or duplicated receipts and conflicting states', () => {
+ const valid = bind([], 4, true, 'one');
+ validateEarlyRicheseBidPromises(valid, ['target', 'asker']);
+ assert.throws(() => validateEarlyRicheseBidPromises([...valid, valid[0]], ['target', 'asker']));
+ assert.throws(() => validateEarlyRicheseBidPromises(valid, ['target']));
+ assert.throws(() => validateEarlyRicheseBidPromises([{ ...valid[0], fulfilled: true, released: true }], ['target', 'asker']));
+ assert.throws(() => validateEarlyRicheseBidPromises([{ ...valid[0], boundEvent: 'event', released: true }], ['target', 'asker']));
+});
diff --git a/tests/truth-early-richese-bid-edges.test.ts b/tests/truth-early-richese-bid-edges.test.ts
new file mode 100644
index 00000000..f5a276c5
--- /dev/null
+++ b/tests/truth-early-richese-bid-edges.test.ts
@@ -0,0 +1,114 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame, type Action, type Game } from '../game/engine';
+import {
+ actTruthtranceRichesePromises as act,
+ advanceTruthtranceRichesePromises as advance,
+ askTruthtranceRichesePromises as ask,
+ createTruthtranceRichesePromisesFixture as fixture,
+ passTruthRicheseFreshDiscard,
+ truthtranceRichesePromisesClean as clean,
+ truthtranceRichesePromisesInventory as inventory,
+ truthtranceRichesePromisesPlayer as player,
+} from './fixture-truthtrance-richese-promises';
+
+function bind(f: ReturnType, source: Game, target: string, minimum = 4) {
+ const asked = ask(source, f, { kind: 'earlyRicheseBid', target, turn: source.turn, minimum });
+ assert.ok(viewGame(asked, target).truthEarlyRicheseBidAnswers?.includes('yes'));
+ return passTruthRicheseFreshDiscard(act(asked, target, { type: 'truthAnswer', answer: 'yes' }));
+}
+
+function rejects(source: Game, actor: string, action: Action) {
+ const before = structuredClone(source);
+ assert.throws(() => act(source, actor, action));
+ assert.deepEqual(source, before, 'A rejected action must leave its input unchanged');
+}
+
+function onceAround(source: Game) {
+ return advance(source, game => clean(game) && game.richeseAuction?.source === 'cache' &&
+ game.richeseAuction.method === 'onceAround' && !game.richeseAuction.outcome,
+ [], { position: 'first', method: 'onceAround' });
+}
+
+void test('an early funded Yes rejects an otherwise legal bribe that spends its last bid funds', () => {
+ const f = fixture({ advanced: false });
+ const bound = bind(f, structuredClone(f.earlySource), f.emperor);
+ assert.equal(player(bound, f.emperor).spice, 10);
+ rejects(bound, f.emperor, { type: 'bribe', target: f.asker, amount: 7 });
+ const affordable = act(bound, f.emperor, { type: 'bribe', target: f.asker, amount: 6 });
+ assert.equal(player(affordable, f.emperor).spice, 4);
+ assert.ok(affordable.earlyRicheseBidPromises?.some(row => row.player === f.emperor &&
+ row.answer && !row.boundEvent && !row.released));
+ assert.deepEqual(inventory(affordable), f.originalCards);
+});
+
+void test('a voluntary ordinary bid cannot eliminate the last cache hand slot for a waiting Yes', () => {
+ const f = fixture({ advanced: false });
+ const staged = structuredClone(f.earlySource);
+ // Controlled physical supplement: transfer two original unopened ordinary
+ // cards to Emperor before the question, preserving every card and position.
+ for (let count = 0; count < 2; count++) {
+ const index = staged.deck.findIndex(card => card.id !== f.ordinaryLot);
+ assert.ok(index >= 0);
+ player(staged, f.emperor).hand.push(staged.deck.splice(index, 1)[0]);
+ }
+ assert.equal(player(staged, f.emperor).hand.length, 3);
+ assert.deepEqual(inventory(staged), f.originalCards);
+ const bound = bind(f, staged, f.emperor);
+ const current = advance(bound, game => clean(game) && game.phase === 3 &&
+ game.auction?.active === f.emperor, [], { position: 'last' });
+ rejects(current, f.emperor, { type: 'bid', amount: 1, allyPayment: 0 });
+ assert.ok(current.earlyRicheseBidPromises?.some(row => row.player === f.emperor && !row.released));
+ assert.deepEqual(inventory(current), f.originalCards);
+});
+
+void test('an external printed Karama canceling the cache releases the waiting Yes', () => {
+ const f = fixture({ advanced: false });
+ const staged = structuredClone(f.earlySource);
+ // Controlled physical supplement: the actual printed Karama moves from the
+ // unopened main deck to Atreides before the original Truthtrance is played.
+ const index = staged.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
+ const card = staged.deck.splice(index, 1)[0];
+ player(staged, f.asker).hand.push(card);
+ assert.deepEqual(inventory(staged), f.originalCards);
+ const bound = bind(f, staged, f.emperor);
+ const declaration = advance(bound, game => game.decision?.kind === 'richeseDeclaration');
+ const opened = act(declaration, f.richese, { type: 'decision', event: declaration.richeseBidding!.event, position: 'first' });
+ assert.equal(opened.response?.kind, 'richeseAuction');
+ let canceled = act(opened, f.asker, { type: 'card', card: card.id, mode: 'cancel' });
+ canceled = passTruthRicheseFreshDiscard(canceled);
+ assert.equal(canceled.richeseBidding?.cacheCanceled, true);
+ assert.ok(canceled.earlyRicheseBidPromises?.some(row => row.player === f.emperor && row.released));
+ assert.deepEqual(inventory(canceled), f.originalCards);
+});
+
+void test('a waiting Richese owner may honor a Yes when a rival starts the Once Around lot', () => {
+ const f = fixture({ advanced: false });
+ const bound = bind(f, structuredClone(f.earlySource), f.richese);
+ let game = onceAround(bound);
+ const event = game.richeseAuction!.event;
+ assert.ok(game.richeseBidPromises?.some(row => row.event === event && row.player === f.richese &&
+ row.answer && !row.released));
+ const first = game.richeseAuction!.active; assert.ok(first && first !== f.richese);
+ game = act(game, first, { type: 'richeseBid', event, amount: 1, allyPayment: 0 });
+ while (game.richeseAuction?.active && game.richeseAuction.active !== f.richese) {
+ const actor = game.richeseAuction.active;
+ game = act(game, actor, { type: 'richeseBid', event, amount: null });
+ }
+ assert.equal(game.richeseAuction?.active, f.richese);
+ rejects(game, f.richese, { type: 'richeseBid', event, amount: null });
+ const guide = viewGame(game, f.richese).richeseBidPromiseGuide; assert.ok(guide);
+ assert.equal(guide.action.amount, 4);
+ const bid = act(game, f.richese, guide.action);
+ assert.ok(bid.richeseBidPromises?.some(row => row.event === event && row.player === f.richese && row.fulfilled));
+ assert.deepEqual(inventory(bid), f.originalCards);
+});
+
+void test('a waiting Richese owner is released when all rivals pass the Once Around lot', () => {
+ const f = fixture({ advanced: false });
+ const bound = bind(f, structuredClone(f.earlySource), f.richese);
+ const opened = onceAround(bound), event = opened.richeseAuction!.event;
+ const closed = advance(opened, game => !!game.richeseBidPromises?.some(row =>
+ row.event === event && row.player === f.richese && row.released));
+ assert.deepEqual(inventory(closed), f.originalCards);
+});
diff --git a/tests/truth-early-richese-bid-runtime.test.ts b/tests/truth-early-richese-bid-runtime.test.ts
new file mode 100644
index 00000000..7e72e22b
--- /dev/null
+++ b/tests/truth-early-richese-bid-runtime.test.ts
@@ -0,0 +1,104 @@
+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 {
+ actTruthtranceRichesePromises as act,
+ advanceTruthtranceRichesePromises as advance,
+ askTruthtranceRichesePromises as ask,
+ createTruthtranceRichesePromisesFixture as fixture,
+ passTruthRicheseFreshDiscard,
+ truthtranceRichesePromisesClean as clean,
+ truthtranceRichesePromisesInventory as inventory,
+ truthtranceRichesePromisesPlayer as player,
+} from './fixture-truthtrance-richese-promises';
+
+/** Stop at actual Charity: the existing fixture only orders original unopened
+ * cards and Spice Cards; setup, starting hands, funding and phases are real. */
+function begin(answer: 'yes' | 'no') {
+ const f = fixture({ advanced: false });
+ const source = structuredClone(f.earlySource);
+ const question = { kind: 'earlyRicheseBid' as const, target: f.emperor,
+ turn: source.turn, minimum: 4 };
+ const asked = ask(source, f, question);
+ assert.ok(viewGame(asked, f.emperor).truthEarlyRicheseBidAnswers?.includes(answer));
+ const bound = passTruthRicheseFreshDiscard(act(asked, f.emperor, { type: 'truthAnswer', answer }));
+ assert.equal(bound.discard.filter(card => card.id === f.truth).length, 1);
+ assert.deepEqual(inventory(bound), f.originalCards);
+ return { f, bound, asked };
+}
+
+function reachLot(source: Game, method: 'onceAround' | 'silent', position: 'first' | 'last') {
+ return advance(source, game => clean(game) && !!game.richeseAuction &&
+ game.richeseAuction.source === 'cache' && game.richeseAuction.method === method &&
+ !game.richeseAuction.outcome, [], { position, method });
+}
+
+function reachTarget(source: Game, target: string, method: 'onceAround' | 'silent') {
+ if (method === 'silent') return source;
+ return advance(source, game => clean(game) && game.richeseAuction?.active === target);
+}
+
+function reject(source: Game, actor: string, action: Action) {
+ const before = structuredClone(source);
+ assert.throws(() => act(source, actor, action));
+ assert.deepEqual(source, before, 'A rejected action leaves the original state intact');
+}
+
+for (const method of ['onceAround', 'silent'] as const) {
+ for (const position of ['first', 'last'] as const) {
+ void test(`Basic early Richese Yes reaches a real ${position} ${method} lot and requires its qualifying bid`, () => {
+ const { f, bound } = begin('yes');
+ const lot = reachLot(bound, method, position);
+ assert.equal(lot.richeseAuction?.source, 'cache');
+ const event = lot.richeseAuction!.event, card = lot.richeseAuction!.cardId;
+ assert.ok(event);
+ let current = reachTarget(lot, f.emperor, method);
+ assert.ok(current.richeseBidPromises?.some(row => row.event === event &&
+ row.player === f.emperor && row.answer && !row.fulfilled && !row.released));
+ reject(current, f.emperor, { type: 'richeseBid', event, amount: method === 'silent' ? 0 : null });
+ reject(current, f.emperor, { type: 'richeseBid', event, amount: 3, allyPayment: 0 });
+ const guide = viewGame(current, f.emperor).richeseBidPromiseGuide;
+ assert.ok(guide); assert.equal(guide.action.type, 'richeseBid');
+ assert.equal(guide.action.event, event); assert.equal(guide.action.amount, 4);
+ current = act(current, f.emperor, guide.action);
+ assert.ok(current.richeseBidPromises?.some(row => row.event === event && row.player === f.emperor && row.fulfilled));
+ const won = advance(current, game => player(game, f.emperor).hand.some(item => item.id === card));
+ assert.ok(!won.richeseCache?.some(item => item.id === card));
+ assert.deepEqual(inventory(won), f.originalCards);
+ });
+
+ void test(`Basic early Richese No limits a real ${position} ${method} cache bid`, () => {
+ const { f, bound } = begin('no');
+ const opened = reachLot(bound, method, position);
+ const event = opened.richeseAuction!.event;
+ const current = reachTarget(opened, f.emperor, method);
+ assert.ok(current.richeseBidPromises?.some(row => row.event === event && row.player === f.emperor && !row.answer));
+ reject(current, f.emperor, { type: 'richeseBid', event, amount: 4, allyPayment: 0 });
+ const bid = act(current, f.emperor, { type: 'richeseBid', event, amount: 2, allyPayment: 0 });
+ assert.ok(bid.richeseBidPromises?.some(row => row.event === event && row.player === f.emperor && row.fulfilled));
+ assert.deepEqual(inventory(bid), f.originalCards);
+ });
+ }
+}
+
+void test('all four legal bot profiles answer an early Richese question and follow the actual lot guide', () => {
+ const { f, asked } = begin('yes');
+ for (const difficulty of DIFFICULTIES) {
+ const answerView = viewGame(asked, f.emperor);
+ answerView.players.find(row => row.id === f.emperor)!.bot = difficulty;
+ const answer = botActions(answerView)[0];
+ assert.equal(answer?.type, 'truthAnswer');
+ assert.ok(answer.answer === 'yes' || answer.answer === 'no');
+ const bound = passTruthRicheseFreshDiscard(act(structuredClone(asked), f.emperor, answer));
+ const opened = reachLot(bound, 'silent', 'first');
+ const bidView = viewGame(opened, f.emperor);
+ bidView.players.find(row => row.id === f.emperor)!.bot = difficulty;
+ const guide = bidView.richeseBidPromiseGuide; assert.ok(guide);
+ const bid = botActions(bidView)[0]; assert.deepEqual(bid, guide.action);
+ const complete = act(opened, f.emperor, bid);
+ assert.ok(complete.richeseBidPromises?.some(row => row.player === f.emperor && row.fulfilled));
+ assert.deepEqual(inventory(complete), f.originalCards);
+ }
+});