diff --git a/components/game-table.tsx b/components/game-table.tsx
index 7d17da00..4b5b998b 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -1,4 +1,5 @@
'use client';
+import { RicheseBidPromisePanel } from './richese-bid-promise-panel';
import { TravelPromises } from './travel-promises';
import { TurnActionPromises } from './turn-action-promises';
import { MixedTraitorPromises } from './mixed-traitor-promises';
@@ -4332,12 +4333,15 @@ export function GameTable({
)}
{g.phase === 2 && }
{g.phase === 3 && g.richeseAuction && (
+ <>
+
+ >
)}
{g.phase === 3 && !g.richeseAuction && g.auction && (
<>
diff --git a/components/richese-bid-promise-fields.tsx b/components/richese-bid-promise-fields.tsx
new file mode 100644
index 00000000..740698dc
--- /dev/null
+++ b/components/richese-bid-promise-fields.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import { Input } from './ui/input';
+
+/** Minimum is public, positive and precisely representable in the action. */
+export function richeseBidMinimumError(value: unknown): string | null {
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
+ ? null : 'Choose a positive whole bid minimum.';
+}
+
+export function RicheseBidPromiseFields(props: {
+ minimum: number;
+ onMinimum: (value: number) => void;
+ disabled: boolean;
+}) {
+ const error = richeseBidMinimumError(props.minimum);
+ return
+ Bid in this Once Around auction
+ Minimum bid in spice
+ {
+ const value = event.currentTarget.valueAsNumber;
+ if (!props.disabled && !richeseBidMinimumError(value)) props.onMinimum(value);
+ }} />
+ {error && {error}
}
+
+ This asks about the bidder’s remaining opportunity in this Once Around lot.
+ Earlier declarations do not count; a qualifying bid does not promise a win
+ or change the winning payment.
+
+ ;
+}
diff --git a/components/richese-bid-promise-panel.tsx b/components/richese-bid-promise-panel.tsx
new file mode 100644
index 00000000..b935f6f9
--- /dev/null
+++ b/components/richese-bid-promise-panel.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import type { Action } from '@/game/engine';
+import { Button } from './ui/button';
+
+export type RicheseBidPromiseReceipt = {
+ id: string;
+ turn: number;
+ event: string;
+ minimum: number;
+ answer: boolean;
+ fulfilled?: boolean;
+ released?: boolean;
+};
+export type RicheseBidPromisePanelProps = {
+ promises: RicheseBidPromiseReceipt[];
+ guide: { action: Action } | null;
+ game: { turn: number; richeseAuction: { event: string } | null };
+ act: (action: Action) => void;
+ busy: boolean;
+};
+
+/** The auction owns the physical card, actor, funding and final legality. */
+export function RicheseBidPromiseGuide(props: RicheseBidPromisePanelProps) {
+ const { game, guide } = props;
+ if (!guide) return null;
+ const auction = game.richeseAuction, turn = game.turn, action = guide.action;
+ const auctionEvent = action.event, id = action.richeseBidPromiseId;
+ const signature = JSON.stringify(action);
+ const isCurrent = () => props.game === game && props.guide === guide &&
+ props.guide.action === action && JSON.stringify(action) === signature &&
+ game.turn === turn && !!auction && game.richeseAuction === auction &&
+ typeof auctionEvent === 'string' && auction.event === auctionEvent &&
+ typeof id === 'string' && action.type === 'richeseBid' &&
+ props.promises.some(promise => promise.id === id && promise.turn === turn &&
+ promise.event === auctionEvent && !promise.fulfilled && !promise.released);
+ const canAct = () => !props.busy && isCurrent();
+ if (!isCurrent()) return null;
+ return { if (canAct()) props.act(action); }}>
+ {typeof action.amount === 'number' && action.amount > 0
+ ? `Bid ${action.amount} spice while honoring your answer`
+ : 'Pass this lot while honoring your answer'}
+ ;
+}
+
+/** The server supplies only this player's own receipts and a legal guide. */
+export function RicheseBidPromisePanel(props: RicheseBidPromisePanelProps) {
+ if (!props.promises.length) return null;
+ const waiting = props.promises.some(promise => promise.turn === props.game.turn &&
+ promise.event === props.game.richeseAuction?.event && !promise.fulfilled && !promise.released);
+ return
+ Your Once Around bid promises
+ {props.promises.map(promise =>
+ Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to bidding at least {promise.minimum} spice in this Once Around lot.
+ {promise.released ? ' Released: this commitment is no longer possible.'
+ : promise.fulfilled ? ' Fulfilled: this commitment is complete.' : ' This answer is binding for your remaining opportunity.'}
+ )}
+ Yes requires a qualifying bid while possible. No forbids a qualifying bid.
+ The original auction order and payment rules still apply.
+ {waiting && }
+ ;
+}
diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx
index 9043a2b7..6fe25d4a 100644
--- a/components/truthtrance.tsx
+++ b/components/truthtrance.tsx
@@ -1,4 +1,5 @@
'use client';
+import { RicheseBidPromiseFields, richeseBidMinimumError } from './richese-bid-promise-fields';
import { TravelClaimFields, travelClaimInputError } from './travel-claim-fields';
import { TravelAnswers } from './travel-promises';
@@ -97,7 +98,7 @@ export function Truthtrance({
g.players.find((p) => p.id !== me.id)!.id,
);
const [kind, setKind] = useState<
- 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'phaseBid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
+ 'fact' | 'freeform' | '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');
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
@@ -242,6 +243,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 === 'richeseBid' ? { kind, target, turn:g.turn, event:g.richeseAuction?.event ?? '', minimum:bidMinimum }
: kind === 'shipment'
? {
kind,
@@ -426,6 +428,13 @@ export function Truthtrance({
{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 Once Around opportunity while possible. No forbids a qualifying bid. Winning still requires payment.
+ {(g.truthRicheseBidAnswers ?? []).map(answer =>
+ {button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
+
)}
+ >
) : w.question!.kind === 'phaseBid' ? (
<>
Yes requires one qualifying ordinary bid before this turn’s Bidding closes while possible. No forbids all qualifying later bids. A bid does not promise a win; winning still requires payment.
@@ -589,6 +598,7 @@ export function Truthtrance({
Bind ordinary force revivals this turn
Bind a bid in this ordinary lot
Bind a bid this Bidding phase
+ Bind a bid in this Once Around lot
Combine current facts and ordinary bids
Bind this battle’s Traitor declaration
Combine current facts and this Traitor declaration
@@ -732,6 +742,12 @@ export function Truthtrance({
{!g.truthRevivalTargets.includes(target) && Choose a respondent whose native Revival opportunity remains this turn and finish pending decisions. Registered native Basic and Advanced tables without optional modules are supported first.
}
{truthQuestionText(question, leaderName, locationName, name)}
>
+ ) : kind === 'richeseBid' ? (
+ <>
+
+ {!g.truthRicheseBidTargets.includes(target) && Choose a bidder with a remaining Once Around opportunity.
}
+ {truthQuestionText(question, leaderName, locationName, name)}
+ >
) : kind === 'phaseBid' ? (
<>
(run: () => T): T {
+ try { return run(); } catch (error) { if (error instanceof RicheseBidPromiseError) throw new RuleError(error.message); throw error; }
+}
+function richeseBidWindow(g: Game): boolean {
+ return bidPromiseModeSupported({ ...g, semutaPreview: undefined }) && g.status === 'playing' && g.phase === 3 &&
+ !!g.richeseAuction && g.richeseAuction.method === 'onceAround' && !g.richeseAuction.outcome &&
+ !g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard;
+}
+function richeseBidOpportunity(g: Game, p: Player): RicheseBidOpportunity | null {
+ const lot = g.richeseAuction;
+ if (!lot || !richeseBidWindow(g) || !lot.eligible.includes(p.id) || lot.acted.includes(p.id) ||
+ !richeseBidFitsHand(lot.source,lot.owner,p.id,p.hand.length,handLimit(p)) ||
+ (p.id === lot.owner && lot.active !== p.id && !lot.bidder) || lot.bid >= Number.MAX_SAFE_INTEGER) return null;
+ const own = uncommittedSpice(g,p) + richeseOwnCommitment(lot,g.richeseFunding ?? {},p.id);
+ const max = Math.min(Number.MAX_SAFE_INTEGER,own + (aidFor(g,p)?.amount ?? 0));
+ if (!Number.isSafeInteger(max) || max < lot.bid + 1) return null;
+ return {turn:g.turn,event:lot.event,player:p.id,minimum:lot.bid + 1,maximum:max};
+}
+function richeseBidOtherPromises(g: Game, p: Player): boolean {
+ return liveShipmentPromises(g.shipmentPromises ?? [],p.id,g.turn).some(row => row.answer) ||
+ hasMovementPromises(g,p.id,true) || liveRevivalPromises(g.revivalPromises ?? [],p.id,g.turn).some(row => row.answer) ||
+ liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [],p.id,g.turn).some(row => row.answer) ||
+ liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [],p.id,g.turn).some(row => row.answer) ||
+ hasBribePromises(g,p.id,true) || liveTurnActionPromises(g.turnActionPromises ?? [],p.id,g.turn).length > 0 ||
+ livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn).some(row => row.answer);
+}
+/** Other financial/action answers need a joint planner before they can
+ * coexist with a live Once Around Yes. Reject the unsupported combination at
+ * 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;
+ const context = {turn:g.turn,event:lot.event,player:p.id};
+ return !richeseBidRequired(g.richeseBidPromises ?? [],context) || !richeseBidOtherPromises(g,p);
+}
+function richeseBidTruthAnswers(g: Game, p: Player, claim: RicheseBidClaim): TruthAnswer[] {
+ const lot = g.richeseAuction;
+ if (!richeseBidWindow(g) || !lot || claim.turn !== g.turn || claim.event !== lot.event ||
+ !lot.eligible.includes(p.id) || lot.acted.includes(p.id) ||
+ !richeseBidFitsHand(lot.source,lot.owner,p.id,p.hand.length,handLimit(p))) return [];
+ const base = g.richeseBidPromises ?? [], opportunity = richeseBidOpportunity(g,p);
+ const context = {turn:g.turn,event:lot.event,player:p.id};
+ const asker = g.truthtrance?.queue[0]?.player ?? g.players.find(row => row.id !== p.id)?.id ?? g.host;
+ return (['yes','no'] as const).filter(answer => {
+ const candidate: RicheseBidPromise = {...claim,id:'candidate',player:p.id,asker,answer:answer === 'yes'};
+ const all = [...base,candidate];
+ if (answer === 'yes' && richeseBidOtherPromises(g,p)) return false;
+ return !richeseBidRequired(all,context) || !!opportunity && !!richeseBidCompletionRange(all,opportunity);
+ });
+}
+function bindRicheseBidTruth(g: Game,p: Player,claim: RicheseBidClaim,answer: boolean,asker: string) {
+ requireRule(richeseBidTruthAnswers(g,p,claim).includes(answer ? 'yes' : 'no'),
+ 'Choose an answer compatible with this Once Around bid.');
+ (g.richeseBidPromises ??= []).push({...claim,id:crypto.randomUUID(),player:p.id,asker,answer});
+}
+function richeseBidIntegrity(g: Game) {
+ richeseBidRule(() => validateRicheseBidPromises(g.richeseBidPromises ?? [],g.players.map(row => row.id)));
+}
+function reconcileRicheseBids(g: Game,voluntary?: {actor:string;action:Action}) {
+ richeseBidIntegrity(g);
+ if (!g.richeseBidPromises?.length || g.truthtrance || g.response || g.decision || g.phaseOpening ||
+ g.pendingKarama || g.pendingTreacheryDiscard) return;
+ for (const row of g.richeseBidPromises) {
+ if (row.fulfilled || row.released) continue;
+ const context = {turn:row.turn,event:row.event,player:row.player};
+ const change = voluntary?.actor === row.player ? 'voluntary' : 'external';
+ const lot = g.richeseAuction;
+ 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,
+ richeseBidOpportunity(g,getPlayer(g,row.player)),change));
+ }
+}
+function projectedRicheseBidGuide(g: Game, player: string): {action:Action} | null {
+ const lot = g.richeseAuction, p = getPlayer(g,player);
+ if (!lot || lot.active !== player || g.truthtrance || !richeseBidWindow(g)) return null;
+ const context = {turn:g.turn,event:lot.event,player}, live = liveRicheseBidPromises(g.richeseBidPromises ?? [],context);
+ if (!live.length) return null;
+ if (!richeseBidRequired(live,context)) return {action:{type:'richeseBid',event:lot.event,amount:null,allyPayment:0,richeseBidPromiseId:live[0].id}};
+ const opportunity = richeseBidOpportunity(g,p), range = opportunity && richeseBidCompletionRange(live,opportunity);
+ if (!range) return null;
+ const own = uncommittedSpice(g,p) + richeseOwnCommitment(lot,g.richeseFunding ?? {},player);
+ return {action:{type:'richeseBid',event:lot.event,amount:range.minimum,
+ allyPayment:Math.max(0,range.minimum-own),richeseBidPromiseId:live[0].id}};
+}
function phaseBidQuestionWindow(g: Game): boolean {
return g.status === 'playing' && g.phase >= 0 && g.phase <= 3 && phaseBidPromiseModeSupported(g) &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard &&
@@ -16159,6 +16250,7 @@ function viablePhaseBidWitnesses(g: Game, p: Player, promises: readonly PhaseBid
* bid impossible. If the future joint planner has no witness, a definite
* answer is withheld rather than offered and rejected after binding. */
function phaseBidCompatibleLaterPromises(g: Game, p: Player): boolean {
+ if (!richeseBidCompatibleLaterPromises(g,p)) return false;
const live = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn);
if (!live.some(row => row.answer)) return true;
return viablePhaseBidWitnesses(g,p,g.phaseBidPromises ?? [])
@@ -20695,6 +20787,7 @@ function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): Tru
if (promise.compiled.kind === 'constant' && promise.compiled.value !== answer) continue;
const trial = { ...turnActionAnswerSource(g, p.id, !!promise.alliance, !!promise.windowCards, { claim, answer }),
turnActionPromises: [...(g.turnActionPromises ?? []), promise], travelPromiseOrder: undefined };
+ if (!richeseBidCompatibleLaterPromises(trial,p) && promise.compiled.kind !== 'constant') continue;
const phases = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn);
const lot = bidOpportunity(trial);
if (phases.length && lot) {
@@ -38937,6 +39030,7 @@ function normalizeCardNames(g: Game) {
export function applyAction(state: Game, id: string, action: Action): Game {
bidPromiseIntegrity(state);
phaseBidIntegrity(state);
+ richeseBidIntegrity(state);
revivalPromiseIntegrity(state);
bribePromiseIntegrity(state);
alliancePromiseIntegrity(state);
@@ -39109,6 +39203,12 @@ export function applyAction(state: Game, id: string, action: Action): Game {
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
'This phase bid guide is stale or belongs to another auction lot.');
}
+ if (action.richeseBidPromiseId !== undefined) {
+ const guide = projectedRicheseBidGuide(state,id);
+ requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') &&
+ Object.keys(guide.action).every(key => action[key] === guide.action[key]),
+ 'This Once Around bid guide is stale or belongs to another lot.');
+ }
const g = applyActionInner(state, id, action);
if (state.phase === 3 && liveTurnActionPromises(state.turnActionPromises ?? [], id, state.turn).some(row => {
const stage = row.windowCards?.karamaAuction;
@@ -39123,6 +39223,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
reconcileTravelPromises(g, promiseActor, action, state.phase);
reconcileBidPromises(g, voluntary);
reconcilePhaseBids(g, voluntary);
+ reconcileRicheseBids(g, voluntary);
reconcileRevivalPromises(g, voluntary);
reconcileBribePromises(g, voluntary);
reconcileGroundMovementPromises(g, promiseActor, action, state.phase);
@@ -39248,6 +39349,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
reconcileShipmentPromises(g, { actor: id, action, phase: state.phase });
reconcileBidPromises(g, { actor: id, action });
reconcilePhaseBids(g, { actor: id, action });
+ reconcileRicheseBids(g, { actor: id, action });
reconcileRevivalPromises(g, { actor: id, action });
reconcileBribePromises(g, { actor: id, action });
reconcileAlliancePromises(g, { actor: id, action });
@@ -39466,6 +39568,7 @@ function settleAutomaticContinuations(g: Game) {
reconcileShipmentPromises(g);
reconcileBidPromises(g);
reconcilePhaseBids(g);
+ reconcileRicheseBids(g);
reconcileRevivalPromises(g);
reconcileBribePromises(g);
reconcileGroundMovementPromises(g);
@@ -39484,6 +39587,7 @@ function settleAutomaticContinuations(g: Game) {
export function normalizeAutomaticGame(state: Game): Game {
bidPromiseIntegrity(state);
phaseBidIntegrity(state);
+ richeseBidIntegrity(state);
revivalPromiseIntegrity(state);
bribePromiseIntegrity(state);
alliancePromiseIntegrity(state);
@@ -39573,6 +39677,7 @@ export function normalizeAutomaticGame(state: Game): Game {
reconcileShipmentPromises(g);
reconcileBidPromises(g);
reconcilePhaseBids(g);
+ reconcileRicheseBids(g);
reconcileRevivalPromises(g);
reconcileBribePromises(g);
reconcileGroundMovementPromises(g);
@@ -39849,6 +39954,8 @@ function applyActionInner(
bindMixedBid: bindMixedBidTruth,
bidAnswers: bidTruthAnswers,
bindBid: bindBidTruth,
+ richeseBidAnswers: richeseBidTruthAnswers,
+ bindRicheseBid: bindRicheseBidTruth,
phaseBidAnswers: phaseBidTruthAnswers,
bindPhaseBid: bindPhaseBidTruth,
turnActionAnswers: turnActionTruthAnswers,
@@ -39868,7 +39975,7 @@ function applyActionInner(
)
return g;
} catch (error) {
- if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof AllianceExpressionPromiseError || error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof MovementExpressionPromiseError || error instanceof MixedActionQuestionError || error instanceof TraitorDecisionError)
+ if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof AllianceExpressionPromiseError || error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof RicheseBidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof MovementExpressionPromiseError || error instanceof MixedActionQuestionError || error instanceof TraitorDecisionError)
throw new RuleError(error.message);
throw error;
}
@@ -42325,6 +42432,9 @@ function applyActionInner(
if (parties.richeseSaleAmount !== undefined)
checkBureaucratContributors(g,byFaction(g,'emperor')?.id,[{payer:lot.owner,amount:allyPayment}]);
}
+ if (lot.method === 'onceAround' && g.richeseBidPromises?.length)
+ g.richeseBidPromises = richeseBidRule(() => recordRicheseBid(g.richeseBidPromises!,
+ {turn:g.turn,event:lot.event,player:p.id},amount));
g.richeseAuction = submitRicheseBid(
lot,
{ event: stringField(action.event), actor: p.id, amount },
@@ -43849,6 +43959,14 @@ export function viewGame(state: Game, id: string, now?: number) {
}).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,
+ truthRicheseBidTargets: richeseBidWindow(g) ? g.players.filter(player => {
+ const lot = g.richeseAuction!;
+ return lot.eligible.includes(player.id) && !lot.acted.includes(player.id);
+ }).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,
+ richeseBidPromises: (g.richeseBidPromises ?? []).filter(row => row.player === id).map(row => ({...row})),
+ richeseBidPromiseGuide: projectedRicheseBidGuide(g,id),
truthPhaseBidTargets: phaseBidQuestionWindow(g) ? g.players.map(player => player.id) : [],
truthPhaseBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
g.truthtrance.question.kind === 'phaseBid' ? phaseBidTruthAnswers(g,me,g.truthtrance.question) : null,
diff --git a/game/reference.ts b/game/reference.ts
index 3c52a6c4..8d4ff55d 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3652,13 +3652,14 @@ export const RULE_TOPICS: RuleTopic[] = [
},
{
id: 'truthtrance-bid',
- title: 'Truthtrance: ordinary bid commitments',
+ title: 'Truthtrance: bidding commitments',
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
- summary: 'Bind one future minimum ordinary bid in the current lot or remaining Bidding phase, preserving native funding, payment and turn order.',
+ summary: 'Bind a minimum bid in an ordinary lot, the remaining ordinary Bidding phase, or one Richese Once Around lot, 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; special auctions and broader compositions remain unfinished.',
+ '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. Silent and 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.',
'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.',
diff --git a/game/richese-bid-promises.ts b/game/richese-bid-promises.ts
new file mode 100644
index 00000000..e4b8c9fe
--- /dev/null
+++ b/game/richese-bid-promises.ts
@@ -0,0 +1,142 @@
+/** One future declaration in the exact public Richese Once Around lot.
+ * The engine owns native eligibility, timing, hand capacity and funding. */
+export type RicheseBidClaim = { turn: number; event: string; minimum: number };
+export type RicheseBidPromise = RicheseBidClaim & {
+ id: string;
+ player: string;
+ asker: string;
+ answer: boolean;
+ fulfilled?: boolean;
+ released?: boolean;
+};
+export type RicheseBidContext = { turn: number; event: string; player: string };
+/** Inclusive actual funded interval supplied by the engine; never a forecast of
+ * another player's bid or a replacement for the native quote. */
+export type RicheseBidOpportunity = RicheseBidContext & { minimum: number; maximum: number };
+export type RicheseBidRange = { minimum: number; maximum: number };
+export type RicheseBidChange = 'voluntary' | 'external';
+export class RicheseBidPromiseError 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();
+
+export function parseRicheseBidClaim(value: unknown): RicheseBidClaim {
+ if (!value || typeof value !== 'object' || Array.isArray(value))
+ throw new RicheseBidPromiseError('Choose a current Once Around bid question.');
+ const input = value as Record;
+ if (!positive(input.turn) || !identifier(input.event) || !positive(input.minimum))
+ throw new RicheseBidPromiseError('Choose the current turn, auction event and a positive whole bid minimum.');
+ return { turn: input.turn, event: input.event, minimum: input.minimum };
+}
+
+export function richeseBidClaimText(claim: RicheseBidClaim): string {
+ return `place a bid of at least ${claim.minimum} spice in this Once Around auction`;
+}
+
+function checkContext(context: RicheseBidContext): void {
+ if (!context || !positive(context.turn) || !identifier(context.event) || !identifier(context.player))
+ throw new RicheseBidPromiseError('Retain the original turn, auction event and bidder.');
+}
+function checkOpportunity(opportunity: RicheseBidOpportunity): void {
+ checkContext(opportunity);
+ if (!positive(opportunity.minimum) || !positive(opportunity.maximum) || opportunity.minimum > opportunity.maximum)
+ throw new RicheseBidPromiseError('Retain an actual funded Once Around bid interval.');
+}
+function sameContext(a: RicheseBidContext, b: RicheseBidContext): boolean {
+ return a.turn === b.turn && a.event === b.event && a.player === b.player;
+}
+
+export function liveRicheseBidPromises(promises: readonly RicheseBidPromise[], context: RicheseBidContext): RicheseBidPromise[] {
+ checkContext(context);
+ return promises.filter(row => sameContext(row, context) && !row.fulfilled && !row.released);
+}
+
+/** Yes requires a bid; No alone permits passing. Each No limits any bid. */
+export function richeseBidAllowedRange(promises: readonly RicheseBidPromise[], opportunity: RicheseBidOpportunity): RicheseBidRange | null {
+ checkOpportunity(opportunity);
+ let maximum = opportunity.maximum;
+ for (const row of liveRicheseBidPromises(promises, opportunity))
+ if (!row.answer) maximum = Math.min(maximum, row.minimum - 1);
+ return opportunity.minimum <= maximum ? { minimum: opportunity.minimum, maximum } : null;
+}
+export function richeseBidCompletionRange(promises: readonly RicheseBidPromise[], opportunity: RicheseBidOpportunity): RicheseBidRange | null {
+ const allowed = richeseBidAllowedRange(promises, opportunity);
+ if (!allowed) return null;
+ let minimum = allowed.minimum;
+ for (const row of liveRicheseBidPromises(promises, opportunity))
+ if (row.answer) minimum = Math.max(minimum, row.minimum);
+ return minimum <= allowed.maximum ? { minimum, maximum: allowed.maximum } : null;
+}
+export function richeseBidRequired(promises: readonly RicheseBidPromise[], context: RicheseBidContext): boolean {
+ return liveRicheseBidPromises(promises, context).some(row => row.answer);
+}
+
+/** Call inside the detached engine transaction immediately before its native
+ * bid/pass validation. A Once Around actor receives one declaration only; a
+ * small bid cannot rely on a later turn. The native action owns the actual
+ * bid amount, funding, eligibility, hand space and event validity. */
+export function recordRicheseBid(promises: readonly T[], context: RicheseBidContext,
+ amount: number | null): T[] {
+ checkContext(context);
+ if (amount !== null && !positive(amount))
+ throw new RicheseBidPromiseError('Record only a positive bid or a pass.');
+ const live = liveRicheseBidPromises(promises, context);
+ if (live.some(row => row.answer && (amount === null || amount < row.minimum)))
+ throw new RicheseBidPromiseError('Honor your Truthtrance Yes before passing or bidding less.');
+ if (amount !== null && live.some(row => !row.answer && amount >= row.minimum))
+ throw new RicheseBidPromiseError('This bid conflicts with your Truthtrance No answer.');
+ return promises.map(row => live.includes(row) ? { ...row, fulfilled: true } : row);
+}
+
+/** Supply null only after the engine has determined there is no native
+ * opportunity. External events release individually impossible Yes answers;
+ * the respondent cannot voluntarily make their own Yes impossible. */
+export function reconcileRicheseBidPromises(promises: readonly T[],
+ context: RicheseBidContext, opportunity: RicheseBidOpportunity | null, change: RicheseBidChange): T[] {
+ checkContext(context);
+ if (change !== 'voluntary' && change !== 'external')
+ throw new RicheseBidPromiseError('Retain the cause of the auction change.');
+ if (opportunity) {
+ checkOpportunity(opportunity);
+ if (!sameContext(context, opportunity))
+ throw new RicheseBidPromiseError('The bid opportunity belongs to a different auction or bidder.');
+ }
+ const live = liveRicheseBidPromises(promises, context);
+ const range = opportunity ? richeseBidAllowedRange(promises, opportunity) : null;
+ const impossible = live.filter(row => row.answer && (!range || row.minimum > range.maximum));
+ if (impossible.length && change === 'voluntary')
+ throw new RicheseBidPromiseError('This action would remove your last funded Once Around bid opportunity.');
+ return promises.map(row => impossible.includes(row) ? { ...row, released: true } : row);
+}
+
+/** Close only the original lot; outstanding No is established and an
+ * unfulfilled Yes is released only on an external or already-forced close. */
+export function finishRicheseBidLot(promises: readonly T[], context: RicheseBidContext,
+ change: RicheseBidChange): T[] {
+ checkContext(context);
+ if (change !== 'voluntary' && change !== 'external')
+ throw new RicheseBidPromiseError('Retain the cause of the auction close.');
+ const live = liveRicheseBidPromises(promises, context);
+ if (change === 'voluntary' && live.some(row => row.answer))
+ throw new RicheseBidPromiseError('Do not close the auction by abandoning your Truthtrance Yes.');
+ return promises.map(row => live.includes(row)
+ ? row.answer ? { ...row, released: true } : { ...row, fulfilled: true } : row);
+}
+
+/** The public lot event is intentionally shared by multiple questions. */
+export function validateRicheseBidPromises(promises: readonly RicheseBidPromise[], players: readonly string[]): void {
+ if (!Array.isArray(promises) || !Array.isArray(players))
+ throw new RicheseBidPromiseError('The Once Around bid commitment record is invalid.');
+ const receipts = new Set();
+ for (const row of promises) {
+ parseRicheseBidClaim(row);
+ if (!identifier(row.id) || receipts.has(row.id) || !identifier(row.player) || !identifier(row.asker) || !players.includes(row.player) ||
+ !players.includes(row.asker) || row.player === row.asker || typeof row.answer !== 'boolean' ||
+ (row.fulfilled !== undefined && typeof row.fulfilled !== 'boolean') ||
+ (row.released !== undefined && typeof row.released !== 'boolean') || row.fulfilled && row.released)
+ throw new RicheseBidPromiseError('The Once Around commitment lost its receipt, original lot or participants.');
+ receipts.add(row.id);
+ }
+}
diff --git a/game/truthtrance.ts b/game/truthtrance.ts
index 2880769a..d7c48f8d 100644
--- a/game/truthtrance.ts
+++ b/game/truthtrance.ts
@@ -71,6 +71,7 @@ import { parseNoFieldValueFact, noFieldValueFactMatches, noFieldValueFactText, t
import { parseFaceDancerFact, faceDancerFactMatches, faceDancerFactText, type FaceDancerFact } from './truthtrance-face-dancer';
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';
export type TruthFact =
| KnowledgeFact
@@ -104,6 +105,7 @@ export type TruthQuestion =
| ({ kind: 'revival'; target: string } & RevivalClaim)
| ({ kind: 'bid'; target: string } & BidClaim)
| ({ kind: 'phaseBid'; target: string } & PhaseBidClaim)
+ | ({ kind: 'richeseBid'; target: string } & RicheseBidClaim)
| { kind: 'traitorCall'; target: string; battle: string; territory: string }
| ({ kind: 'movement'; target: string } & MovementClaim)
| ({ kind: 'shipment'; target: string } & ShipmentClaim)
@@ -356,6 +358,16 @@ 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 === 'richeseBid') {
+ check(Object.keys(v).sort().join(',') === 'event,kind,minimum,target,turn',
+ 'Choose the current Once Around lot and bid minimum.');
+ const claim = parseRicheseBidClaim(v), lot = g.richeseAuction;
+ check(bidPromiseModeSupported({ ...g, semutaPreview: undefined }) && g.status === 'playing' && g.phase === 3 && lot &&
+ lot.method === 'onceAround' && !lot.outcome && lot.event === claim.event && claim.turn === g.turn &&
+ !g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard,
+ 'Choose a quiet supported Once Around auction and its current public event.');
+ return { kind: 'richeseBid', target: v.target, ...claim };
+ }
if (v.kind === 'phaseBid') {
check(Object.keys(v).sort().join(',') === 'kind,minimum,target,turn',
'Supply the current turn, phase bid minimum and respondent.');
@@ -733,6 +745,7 @@ export function truthQuestionText(
if (q.kind === 'revival') return `Will you ${revivalClaimText(q)}?`;
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 === '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?`;
@@ -863,6 +876,8 @@ export function resolveTruthAction(
bindMixedBid?: (g: Game, p: Player, claim: MixedBidClaim, answer: boolean, asker: string) => void;
bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[];
bindBid?: (g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) => void;
+ richeseBidAnswers?: (g: Game, p: Player, claim: RicheseBidClaim) => TruthAnswer[];
+ bindRicheseBid?: (g: Game, p: Player, claim: RicheseBidClaim, 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[];
@@ -1036,6 +1051,9 @@ export function resolveTruthAction(
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 === 'richeseBid')
+ check(effects.richeseBidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
+ 'Choose a bidder with a remaining Once Around 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.');
@@ -1169,6 +1187,11 @@ export function resolveTruthAction(
'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 === 'richeseBid') {
+ check(effects.richeseBidAnswers && effects.bindRicheseBid && effects.richeseBidAnswers(g, p, q).includes(a.answer),
+ 'Choose an answer consistent with this Once Around lot and your legal bid.');
+ if (a.answer !== 'unknown') effects.bindRicheseBid(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/fixture-truth-richese-bid.ts b/tests/fixture-truth-richese-bid.ts
new file mode 100644
index 00000000..cf45d6bb
--- /dev/null
+++ b/tests/fixture-truth-richese-bid.ts
@@ -0,0 +1,70 @@
+import assert from 'node:assert/strict';
+import { viewGame, type Action, type Game } from '../game/engine';
+import {
+ actTruthtranceRichesePromises as act,
+ advanceTruthtranceRichesePromises as advance,
+ createTruthtranceRichesePromisesFixture,
+ truthtranceRichesePromisesClean as clean,
+ truthtranceRichesePromisesInventory as inventory,
+ truthtranceRichesePromisesPlayer as player,
+} from './fixture-truthtrance-richese-promises';
+
+export { act, advance, clean, inventory, player };
+
+/** Actual public R/A/E Basic or Advanced setup. The parent fixture selects only
+ * unopened original deal/Spice positions and native scalar lotteries. Use its
+ * genuine Charity prefix, then declare and start the original cache lot with
+ * native controls. No dealt card, wallet, lot or promise is installed. */
+export function createTruthRicheseBidFixture(advanced = false) {
+ const original = createTruthtranceRichesePromisesFixture({ advanced });
+ const asker = original.asker, target = original.emperor, owner = original.richese;
+ let game: Game | undefined;
+ for (const direction of ['clockwise', 'counterclockwise'] as const) {
+ const candidate = advance(structuredClone(original.earlySource), state => clean(state) &&
+ !!state.richeseAuction && state.richeseAuction.method === 'onceAround' && !state.richeseAuction.outcome,
+ [], { position: 'first', method: 'onceAround', direction });
+ if (candidate.richeseAuction?.active === asker && candidate.richeseAuction.order.includes(target)) {
+ game = candidate;
+ break;
+ }
+ }
+ assert.ok(game, 'One native physical direction opens with the Truth holder before Emperor');
+ const lot = game.richeseAuction!;
+ assert.equal(lot.source, 'cache'); assert.equal(lot.bid, 0);
+ assert.equal(lot.active, asker); assert.equal(lot.order.at(-1), owner);
+ assert.ok(lot.eligible.includes(target));
+ assert.ok(player(game, asker).hand.some(card => card.id === original.truth));
+ assert.deepEqual(inventory(game), original.originalCards);
+ return { original, game, asker, target, owner, lot: lot.event,
+ card: lot.cardId, truth: original.truth, originalCards: original.originalCards,
+ staging: original.staging };
+}
+export type TruthRicheseBidFixture = ReturnType;
+
+export function declareTruthRicheseBid(f: TruthRicheseBidFixture, source = f.game) {
+ let game = act(source, f.asker, { type: 'card', card: f.truth });
+ for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
+ const actor = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(actor);
+ game = act(game, actor.id, { type: 'truthPass' });
+ }
+ assert.equal(game.truthtrance?.stage, 'ask');
+ return game;
+}
+export function askTruthRicheseBid(f: TruthRicheseBidFixture, target = f.target, minimum = 4, source = f.game) {
+ const declared = declareTruthRicheseBid(f, source);
+ return act(declared, f.asker, { type: 'truthAsk', question: {
+ kind: 'richeseBid', target, turn: source.turn, event: f.lot, minimum,
+ } });
+}
+export function bindTruthRicheseBid(f: TruthRicheseBidFixture, answer: 'yes' | 'no' = 'yes', minimum = 4,
+ source = f.game, target = f.target) {
+ const asked = askTruthRicheseBid(f, target, minimum, source);
+ assert.ok(viewGame(asked, target).truthRicheseBidAnswers?.includes(answer));
+ const game = act(asked, target, { type: 'truthAnswer', answer });
+ return { game, asked };
+}
+export function rejectTruthRicheseBid(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game);
+ assert.throws(() => act(game, actor, action));
+ assert.deepEqual(game, before, 'Rejected native action must not mutate original state');
+}
diff --git a/tests/richese-bid-promise-controls.test.tsx b/tests/richese-bid-promise-controls.test.tsx
new file mode 100644
index 00000000..ace5c0d4
--- /dev/null
+++ b/tests/richese-bid-promise-controls.test.tsx
@@ -0,0 +1,88 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { RicheseBidPromiseFields, richeseBidMinimumError } from '../components/richese-bid-promise-fields';
+import { RicheseBidPromiseGuide, RicheseBidPromisePanel, type RicheseBidPromisePanelProps } from '../components/richese-bid-promise-panel';
+import type { Action } from '../game/engine';
+
+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)];
+}
+function edit(props: Parameters[0], minimum: number) {
+ const input = elements(RicheseBidPromiseFields(props)).find(element =>
+ element.props.id === 'truth-richese-bid-minimum' && typeof element.props.onChange === 'function');
+ assert.ok(input);
+ (input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber: minimum } });
+}
+function projected(type: 'bid' | 'pass' = 'bid'): { props: RicheseBidPromisePanelProps; sent: Action[] } {
+ const sent: Action[] = [];
+ const props: RicheseBidPromisePanelProps = {
+ promises: [{ id: 'receipt-1', turn: 2, event: 'lot-1', minimum: 4, answer: type === 'bid' }],
+ guide: { action: { type: 'richeseBid', event: 'lot-1', richeseBidPromiseId: 'receipt-1',
+ amount: type === 'bid' ? 4 : null, allyPayment: 0 } },
+ game: { turn: 2, richeseAuction: { event: 'lot-1' } }, busy: false,
+ act(action) { sent.push(action); },
+ };
+ return { props, sent };
+}
+
+void test('Richese Once Around condition accepts only positive safe integer minima', () => {
+ const changes: number[] = [], props = { minimum: 4, disabled: false, onMinimum: (value: number) => changes.push(value) };
+ const html = renderToStaticMarkup(createElement(RicheseBidPromiseFields, props));
+ assert.match(html, /Once Around/);
+ assert.match(html, /Earlier declarations do not count/);
+ for (const value of [1, 7, Number.MAX_SAFE_INTEGER]) edit(props, value);
+ assert.deepEqual(changes, [1, 7, Number.MAX_SAFE_INTEGER]);
+ for (const value of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
+ assert.ok(richeseBidMinimumError(value)); edit(props, value);
+ }
+ edit({ ...props, disabled: true }, 8);
+ assert.equal(changes.length, 3);
+});
+
+void test('owned receipt panel explains binding, fulfilled and released answers', () => {
+ const { props } = projected();
+ props.promises.push(
+ { id: 'no', event: 'lot-1', turn: 2, minimum: 8, answer: false },
+ { id: 'done', event: 'lot-1', turn: 2, minimum: 2, answer: true, fulfilled: true },
+ { id: 'released', event: 'lot-1', turn: 2, minimum: 12, answer: true, released: true },
+ );
+ const html = renderToStaticMarkup(createElement(RicheseBidPromisePanel, props));
+ assert.match(html, /Your Once Around bid promises/);
+ assert.match(html, /No forbids a qualifying bid/);
+ assert.match(html, /Fulfilled: this commitment is complete/);
+ assert.match(html, /Released: this commitment is no longer possible/);
+ assert.doesNotMatch(html, /receipt-1|lot-1/);
+});
+
+void test('guide sends the exact server action and rejects busy, stale or completed receipts', () => {
+ for (const type of ['bid', 'pass'] as const) {
+ const { props, sent } = projected(type);
+ const button = RicheseBidPromiseGuide(props); assert.ok(button);
+ button.props.onClick();
+ assert.deepEqual(sent, [props.guide!.action]);
+ assert.equal(sent[0], props.guide!.action);
+ const stale = RicheseBidPromiseGuide(props); assert.ok(stale);
+ props.game.richeseAuction!.event = 'later-lot'; stale.props.onClick();
+ assert.equal(sent.length, 1);
+ assert.equal(RicheseBidPromiseGuide(props), null);
+ }
+ const { props, sent } = projected();
+ const button = RicheseBidPromiseGuide(props); assert.ok(button);
+ props.busy = true; button.props.onClick();
+ assert.deepEqual(sent, []);
+ const busyButton = RicheseBidPromiseGuide(props); assert.ok(busyButton);
+ assert.equal(busyButton.props.disabled, true);
+ props.busy = false;
+ const completed = RicheseBidPromiseGuide(props); assert.ok(completed);
+ props.promises[0].fulfilled = true; completed.props.onClick();
+ assert.deepEqual(sent, []);
+ assert.equal(RicheseBidPromiseGuide(props), null);
+ props.promises[0].fulfilled = false;
+ props.game.turn = 3;
+ assert.equal(RicheseBidPromiseGuide(props), null);
+});
diff --git a/tests/richese-bid-promises.test.ts b/tests/richese-bid-promises.test.ts
new file mode 100644
index 00000000..2ba583d3
--- /dev/null
+++ b/tests/richese-bid-promises.test.ts
@@ -0,0 +1,134 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ RicheseBidPromiseError, finishRicheseBidLot, liveRicheseBidPromises,
+ parseRicheseBidClaim, reconcileRicheseBidPromises, recordRicheseBid,
+ richeseBidAllowedRange, richeseBidClaimText, richeseBidCompletionRange,
+ richeseBidRequired, validateRicheseBidPromises,
+ type RicheseBidContext, type RicheseBidOpportunity, type RicheseBidPromise,
+} from '../game/richese-bid-promises';
+
+const context: RicheseBidContext = { turn: 2, event: 'public-lot', player: 'target' };
+const opportunity: RicheseBidOpportunity = { ...context, minimum: 4, maximum: 10 };
+const seats = ['target', 'asker', 'other'];
+const promise = (id: string, minimum: number, answer = true,
+ extra: Partial = {}): RicheseBidPromise => ({
+ id, ...context, asker: 'asker', minimum, answer, ...extra,
+ });
+
+void test('claims retain only a safe integer turn, public lot event and positive bid minimum', () => {
+ assert.deepEqual(parseRicheseBidClaim({ turn: 2, event: 'public-lot', minimum: 6, ignored: true }),
+ { turn: 2, event: 'public-lot', minimum: 6 });
+ for (const value of [0, -1, 1.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '2', null, undefined])
+ for (const field of ['turn', 'minimum'])
+ assert.throws(() => parseRicheseBidClaim({ turn: 2, event: 'public-lot', minimum: 6, [field]: value }), RicheseBidPromiseError);
+ for (const event of ['', ' ', ' lot ', null, 1, undefined])
+ assert.throws(() => parseRicheseBidClaim({ turn: 2, event, minimum: 6 }), RicheseBidPromiseError);
+ for (const value of [null, undefined, [], 3, 'claim'])
+ assert.throws(() => parseRicheseBidClaim(value), RicheseBidPromiseError);
+ const text = richeseBidClaimText({ turn: 2, event: 'public-lot', minimum: 6 });
+ assert.match(text, /bid of at least 6 spice/);
+ assert.match(text, /Once Around/);
+ assert.doesNotMatch(text, /win|purchase|public-lot/);
+});
+
+void test('the engine-supplied interval combines every Yes threshold and No cap', () => {
+ const rows = [promise('yes-6', 6), promise('yes-7', 7), promise('no-9', 9, false), promise('no-8', 8, false)];
+ assert.deepEqual(richeseBidAllowedRange(rows, opportunity), { minimum: 4, maximum: 7 });
+ assert.deepEqual(richeseBidCompletionRange(rows, opportunity), { minimum: 7, maximum: 7 });
+ assert.equal(richeseBidRequired(rows, context), true);
+ assert.equal(richeseBidCompletionRange(rows, { ...opportunity, maximum: 6 }), null);
+ assert.equal(richeseBidCompletionRange(rows, { ...opportunity, minimum: 8 }), null);
+ assert.equal(richeseBidRequired([promise('no-only', 4, false)], context), false);
+ assert.equal(richeseBidAllowedRange([promise('no-only', 4, false)], opportunity), null);
+});
+
+void test('live answers bind the precise bidder, turn and public lot and preserve separate receipts', () => {
+ const original = [promise('yes', 6), promise('no', 9, false),
+ promise('other-player', 8, true, { player: 'other' }),
+ promise('other-turn', 8, true, { turn: 3 }),
+ promise('other-lot', 8, true, { event: 'new-lot' }),
+ promise('past', 8, true, { fulfilled: true })];
+ assert.deepEqual(liveRicheseBidPromises(original, context).map(row => row.id), ['yes', 'no']);
+ validateRicheseBidPromises(original, seats);
+ const parsed = JSON.parse(JSON.stringify(original)) as RicheseBidPromise[];
+ assert.deepEqual(liveRicheseBidPromises(parsed, context).map(row => row.id), ['yes', 'no']);
+});
+
+void test('an actual single qualifying bid fulfills Yes and No without requiring a win', () => {
+ const rows = [promise('yes', 6), promise('no', 9, false), promise('other', 2, true, { event: 'next' })];
+ const frozen = structuredClone(rows);
+ const result = recordRicheseBid(rows, context, 7);
+ assert.equal(result[0].fulfilled, true);
+ assert.equal(result[1].fulfilled, true);
+ assert.equal(result[2].fulfilled, undefined);
+ assert.deepEqual(rows, frozen);
+ assert.equal(richeseBidRequired(result, context), false);
+ for (const amount of [null, 4, 5, 9, 10, 0, -1, 1.5, Number.NaN, Infinity])
+ assert.throws(() => recordRicheseBid(rows, context, amount), RicheseBidPromiseError, String(amount));
+});
+
+void test('passing satisfies No but cannot evade a still-live Yes', () => {
+ const no = [promise('no', 6, false)];
+ assert.equal(recordRicheseBid(no, context, null)[0].fulfilled, true);
+ assert.equal(recordRicheseBid(no, context, 5)[0].fulfilled, true);
+ assert.throws(() => recordRicheseBid(no, context, 6), RicheseBidPromiseError);
+ const yes = [promise('yes', 6)];
+ assert.throws(() => recordRicheseBid(yes, context, null), RicheseBidPromiseError);
+ assert.throws(() => recordRicheseBid(yes, context, 5), RicheseBidPromiseError);
+ assert.equal(recordRicheseBid(yes, context, 6)[0].fulfilled, true);
+});
+
+void test('external funding or competing bid releases only individually impossible Yes', () => {
+ const rows = [promise('yes-8', 8), promise('yes-6', 6), promise('no-9', 9, false)];
+ const actual = { ...opportunity, maximum: 7 };
+ const reduced = reconcileRicheseBidPromises(rows, context, actual, 'external');
+ assert.equal(reduced[0].released, true);
+ assert.equal(reduced[1].released, undefined);
+ assert.equal(reduced[2].released, undefined);
+ assert.deepEqual(richeseBidCompletionRange(reduced, actual), { minimum: 6, maximum: 7 });
+ assert.throws(() => reconcileRicheseBidPromises(rows, context, actual, 'voluntary'), RicheseBidPromiseError);
+ assert.equal(rows[0].released, undefined);
+ const absent = reconcileRicheseBidPromises(rows, context, null, 'external');
+ assert.equal(absent[0].released, true);
+ assert.equal(absent[1].released, true);
+ assert.equal(absent[2].released, undefined);
+ assert.throws(() => reconcileRicheseBidPromises(rows, context, null, 'voluntary'), RicheseBidPromiseError);
+});
+
+void test('a rival bid beyond the No cap forces release, not an unlawful above-cap declaration', () => {
+ const rows = [promise('yes', 6), promise('no', 8, false)];
+ const rival = { ...opportunity, minimum: 8, maximum: 10 };
+ assert.equal(richeseBidCompletionRange(rows, rival), null);
+ const result = reconcileRicheseBidPromises(rows, context, rival, 'external');
+ assert.equal(result[0].released, true);
+ assert.equal(result[1].released, undefined);
+ assert.equal(recordRicheseBid(result, context, null)[1].fulfilled, true);
+});
+
+void test('lot close finishes No and externally releases unfulfilled Yes without affecting other lots', () => {
+ const rows = [promise('yes', 6), promise('no', 9, false), promise('next', 6, true, { event: 'next' })];
+ assert.throws(() => finishRicheseBidLot(rows, context, 'voluntary'), RicheseBidPromiseError);
+ const result = finishRicheseBidLot(rows, context, 'external');
+ assert.equal(result[0].released, true);
+ assert.equal(result[1].fulfilled, true);
+ assert.equal(result[2].released, undefined);
+ assert.equal(finishRicheseBidLot([rows[1]], context, 'voluntary')[0].fulfilled, true);
+});
+
+void test('invalid saved rows and mismatched opportunity contexts fail without mutating promises', () => {
+ const base = promise('id', 6);
+ for (const row of [
+ { ...base, id: '' }, { ...base, id: ' id ' }, { ...base, asker: 'target' },
+ { ...base, player: 'nobody' }, { ...base, answer: 'yes' },
+ { ...base, fulfilled: true, released: true }, { ...base, fulfilled: 'yes' },
+ ]) assert.throws(() => validateRicheseBidPromises([row as RicheseBidPromise], seats), RicheseBidPromiseError);
+ assert.throws(() => validateRicheseBidPromises([base, { ...base, minimum: 7 }], seats), RicheseBidPromiseError);
+ assert.throws(() => reconcileRicheseBidPromises([base], context, { ...opportunity, event: 'other' }, 'external'), RicheseBidPromiseError);
+ assert.throws(() => reconcileRicheseBidPromises([base], context, { ...opportunity, player: 'other' }, 'external'), RicheseBidPromiseError);
+ assert.throws(() => richeseBidAllowedRange([base], { ...opportunity, minimum: 0 }), RicheseBidPromiseError);
+ assert.throws(() => richeseBidAllowedRange([base], { ...opportunity, maximum: Infinity }), RicheseBidPromiseError);
+ assert.throws(() => richeseBidAllowedRange([base], { ...opportunity, maximum: 3 }), RicheseBidPromiseError);
+ assert.equal(base.fulfilled, undefined);
+ assert.equal(base.released, undefined);
+});
diff --git a/tests/truth-richese-bid-integrated-controls.test.tsx b/tests/truth-richese-bid-integrated-controls.test.tsx
new file mode 100644
index 00000000..749d3b03
--- /dev/null
+++ b/tests/truth-richese-bid-integrated-controls.test.tsx
@@ -0,0 +1,85 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { registerHooks } from 'node:module';
+import { readFileSync } from 'node:fs';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { viewGame, type Action, type GameView } from '../game/engine';
+import { RicheseBidPromiseGuide } from '../components/richese-bid-promise-panel';
+import { act, inventory, createTruthRicheseBidFixture, declareTruthRicheseBid,
+ askTruthRicheseBid } from './fixture-truth-richese-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 { GameTable } = await import('../components/game-table');
+const { Truthtrance } = await import('../components/truthtrance');
+const { RicheseAuctionLot } = await import('../components/richese-auctions');
+aliases.deregister();
+
+function table(game: GameView) {
+ return renderToStaticMarkup(createElement(GameTable, { game, busy: false, send: async () => {}, onExit() {} }));
+}
+function truth(game: GameView) {
+ return renderToStaticMarkup(createElement(Truthtrance, { game, busy: false, act() {} }));
+}
+function auction(game: GameView) {
+ return renderToStaticMarkup(createElement(RicheseAuctionLot, { game, busy: false, act() {} }));
+}
+
+for (const advanced of [false, true]) void test(
+ `${advanced ? 'Advanced' : 'Basic'} real Once Around question, answers and guide connect to the original Richese table`, () => {
+ // Original public R/A/E setup, actual dealt Truth and native cache lot.
+ // The fixture selects only unopened original deal/Spice positions and scalar lotteries.
+ const f = createTruthRicheseBidFixture(advanced);
+ const source = act(f.game, f.asker, { type: 'richeseBid', event: f.lot, amount: null });
+ assert.equal(source.richeseAuction?.active, f.target);
+ const declared = declareTruthRicheseBid(f, source);
+ const asker = viewGame(declared, f.asker);
+ assert.ok(asker.truthRicheseBidTargets.includes(f.target));
+ assert.match(truth(asker), /Bind a bid in this Once Around lot<\/option>/);
+ assert.match(table(asker), /Bind a bid in this Once Around lot/);
+ // Server rendering does not operate the stateful question selector; the
+ // original question is submitted through the real engine fixture.
+ const asked = askTruthRicheseBid(f, f.target, 4, source);
+ const respondent = viewGame(asked, f.target);
+ assert.equal(respondent.truthtrance?.question?.kind, 'richeseBid');
+ assert.ok(respondent.truthRicheseBidAnswers?.includes('yes'));
+ assert.ok(respondent.truthRicheseBidAnswers?.includes('no'));
+ assert.equal(viewGame(asked, f.asker).truthRicheseBidAnswers, null);
+ assert.match(truth(respondent), /Answer Yes/);
+ assert.match(truth(respondent), /Answer No/);
+ assert.match(table(respondent), /Answer Yes/);
+ assert.match(auction(respondent), /Richese auction/);
+
+ for (const answer of ['yes', 'no'] as const) {
+ let game = act(asked, f.target, { type: 'truthAnswer', answer });
+ const own = viewGame(game, f.target), sent: Action[] = [];
+ assert.ok(own.richeseBidPromises.some(promise => promise.player === f.target && promise.answer === (answer === 'yes')));
+ assert.ok(own.richeseBidPromiseGuide);
+ const html = table(own);
+ assert.match(html, /Your Once Around bid promises/);
+ assert.match(html, /data-testid="richese-bid-promise-guide"/);
+ assert.match(auction(own), /Richese auction/);
+ const button = RicheseBidPromiseGuide({ promises: own.richeseBidPromises, guide: own.richeseBidPromiseGuide,
+ game: own, busy: false, act(action) { sent.push(action); } });
+ assert.ok(button); assert.equal(button.props.disabled, false);
+ button.props.onClick();
+ assert.deepEqual(sent, [own.richeseBidPromiseGuide.action]);
+ assert.equal(sent[0].type, 'richeseBid');
+ assert.equal(sent[0].event, f.lot);
+ assert.equal(typeof sent[0].richeseBidPromiseId, 'string');
+ game = act(game, f.target, sent[0]);
+ assert.equal(game.richeseBidPromises!.find(promise => promise.id === sent[0].richeseBidPromiseId)?.fulfilled, true);
+ assert.deepEqual(inventory(game), f.originalCards);
+ }
+ },
+);
diff --git a/tests/truth-richese-bid-runtime.test.ts b/tests/truth-richese-bid-runtime.test.ts
new file mode 100644
index 00000000..12b90815
--- /dev/null
+++ b/tests/truth-richese-bid-runtime.test.ts
@@ -0,0 +1,142 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import {
+ act, inventory, player, createTruthRicheseBidFixture, askTruthRicheseBid,
+ bindTruthRicheseBid, rejectTruthRicheseBid as reject,
+} from './fixture-truth-richese-bid';
+
+for (const advanced of [false, true]) {
+ const mode = advanced ? 'Advanced' : 'Basic';
+ void test(`${mode} Once Around Yes requires a real qualifying funded bid`, () => {
+ const f = createTruthRicheseBidFixture(advanced);
+ const source = act(f.game, f.asker, { type: 'richeseBid', event: f.lot, amount: null });
+ assert.equal(source.richeseAuction?.active, f.target);
+ const { game: bound } = bindTruthRicheseBid(f, 'yes', 4, source);
+ assert.equal(bound.discard.filter(card => card.id === f.truth).length, 1);
+ assert.equal(bound.richeseAuction?.event, f.lot);
+ assert.equal(bound.richeseAuction?.cardId, f.card);
+ reject(bound, f.target, { type: 'richeseBid', event: f.lot, amount: null });
+ reject(bound, f.target, { type: 'richeseBid', event: f.lot, amount: 3, allyPayment: 0 });
+ const guide = viewGame(bound, f.target).richeseBidPromiseGuide; assert.ok(guide);
+ assert.equal(guide.action.type, 'richeseBid'); assert.equal(guide.action.amount, 4);
+ const bid = act(bound, f.target, guide.action);
+ assert.ok(bid.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
+ assert.equal(player(bid, f.target).spice, 10, 'Declaring a bid does not pay the eventual invoice');
+ assert.deepEqual(inventory(bid), f.originalCards);
+ });
+
+ void test(`${mode} Once Around No caps a real bid and preserves the lot`, () => {
+ const f = createTruthRicheseBidFixture(advanced);
+ const source = act(f.game, f.asker, { type: 'richeseBid', event: f.lot, amount: null });
+ assert.equal(source.richeseAuction?.active, f.target);
+ const { game: bound } = bindTruthRicheseBid(f, 'no', 4, source);
+ reject(bound, f.target, { type: 'richeseBid', event: f.lot, amount: 4, allyPayment: 0 });
+ const bid = act(bound, f.target, { type: 'richeseBid', event: f.lot, amount: 2, allyPayment: 0 });
+ assert.equal(bid.richeseAuction?.event, f.lot); assert.equal(bid.richeseAuction?.cardId, f.card);
+ assert.equal(bid.richeseAuction?.bid, 2); assert.equal(bid.richeseAuction?.bidder, f.target);
+ assert.deepEqual(inventory(bid), f.originalCards);
+ });
+
+ void test(`${mode} Richese cannot promise to bid when its final owner turn has no earlier sale`, () => {
+ const f = createTruthRicheseBidFixture(advanced);
+ const asked = askTruthRicheseBid(f, f.owner, 1);
+ assert.ok(!viewGame(asked, f.owner).truthRicheseBidAnswers?.includes('yes'));
+ reject(asked, f.owner, { type: 'truthAnswer', answer: 'yes' });
+ assert.deepEqual(inventory(asked), f.originalCards);
+ });
+
+ void test(`${mode} an actual opposing high bid releases an impossible waiting Yes`, () => {
+ const f = createTruthRicheseBidFixture(advanced);
+ const { game: bound } = bindTruthRicheseBid(f, 'yes', 4);
+ assert.equal(bound.richeseAuction?.active, f.asker);
+ assert.equal(viewGame(bound, f.target).richeseBidPromiseGuide, null);
+ const raised = act(bound, f.asker, { type: 'richeseBid', event: f.lot, amount: 10, allyPayment: 0 });
+ assert.equal(raised.richeseAuction?.active, f.target);
+ assert.ok(raised.richeseBidPromises?.some(row => row.player === f.target && row.released));
+ const pass = act(raised, f.target, { type: 'richeseBid', event: f.lot, amount: null });
+ assert.deepEqual(inventory(pass), f.originalCards);
+ });
+}
+
+for (const advanced of [false, true]) {
+ const mode = advanced ? 'Advanced' : 'Basic';
+ void test(`${mode} stale Once Around guided callbacks are rejected without changing the lot`, () => {
+ const f = createTruthRicheseBidFixture(advanced);
+ const source = act(f.game, f.asker, { type: 'richeseBid', event: f.lot, amount: null });
+ const { game: bound } = bindTruthRicheseBid(f, 'yes', 4, source);
+ const guide = viewGame(bound, f.target).richeseBidPromiseGuide; assert.ok(guide);
+ reject(bound, f.target, { ...guide.action, richeseBidPromiseId: 'stale-promise' });
+ reject(bound, f.target, { ...guide.action, event: 'stale-lot' });
+ const accepted = act(bound, f.target, guide.action);
+ assert.ok(accepted.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
+ assert.deepEqual(inventory(accepted), f.originalCards);
+ });
+
+ void test(`${mode} four legal bot profiles answer and follow the actual Once Around guide`, () => {
+ const f = createTruthRicheseBidFixture(advanced);
+ const source = act(f.game, f.asker, { type: 'richeseBid', event: f.lot, amount: null });
+ const asked = askTruthRicheseBid(f, f.target, 4, source);
+ for (const difficulty of DIFFICULTIES) {
+ const answerView = viewGame(asked, f.target);
+ answerView.players.find(row => row.id === f.target)!.bot = difficulty;
+ const answer = botActions(answerView)[0]; assert.ok(answer, difficulty);
+ assert.equal(answer.type, 'truthAnswer');
+ assert.ok(['yes', 'no'].includes(String(answer.answer)));
+ const bound = act(structuredClone(asked), f.target, answer);
+ const bidView = viewGame(bound, f.target);
+ bidView.players.find(row => row.id === f.target)!.bot = difficulty;
+ const guide = bidView.richeseBidPromiseGuide; assert.ok(guide, difficulty);
+ const chosen = botActions(bidView)[0]; assert.deepEqual(chosen, guide.action);
+ const completed = act(bound, f.target, chosen);
+ assert.ok(completed.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
+ assert.deepEqual(inventory(completed), f.originalCards);
+ }
+ });
+}
+
+// This Basic Richese fixture enables the Semuta preview, whose existing
+// bribe promise profile does not support a Basic bribe question. The Advanced
+// native fixture supports this independent-bribe admission path.
+for (const advanced of [true]) {
+ const mode = advanced ? 'Advanced' : 'Basic';
+ void test(`${mode} a Once Around Yes excludes an independently unfunded later bribe Yes`, () => {
+ const f = createTruthRicheseBidFixture(advanced);
+ let source = act(f.game, f.asker, { type: 'richeseBid', event: f.lot, amount: null });
+ assert.equal(source.richeseAuction?.active, f.target);
+ assert.equal(player(source, f.target).spice, 10);
+ // Controlled physical supplement: move the second original Truthtrance
+ // from the unopened native main deck to the questioner's hand. No funds,
+ // promise, lot, phase or other card is installed; card custody is conserved.
+ source = structuredClone(source);
+ const at = source.deck.findIndex(card => card.effect === 'truthtrance' && card.id !== f.truth);
+ assert.ok(at >= 0);
+ const [second] = source.deck.splice(at, 1);
+ player(source, f.asker).hand.push(second);
+ assert.deepEqual(inventory(source), f.originalCards);
+ let game = bindTruthRicheseBid(f, 'yes', 8, source).game;
+ const prior = structuredClone(game.richeseBidPromises![0]);
+ game = act(game, f.asker, { type: 'card', card: second.id });
+ for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
+ const actor = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(actor);
+ game = act(game, actor.id, { type: 'truthPass' });
+ }
+ assert.equal(game.truthtrance?.stage, 'ask');
+ game = act(game, f.asker, { type: 'truthAsk', question: {
+ kind: 'bribe', target: f.target, turn: game.turn, recipient: f.asker, minimum: 5,
+ } });
+ const answers = viewGame(game, f.target).truthBribeAnswers;
+ assert.ok(!answers?.includes('yes')); assert.ok(answers?.includes('no'));
+ reject(game, f.target, { type: 'truthAnswer', answer: 'yes' });
+ assert.deepEqual(game.richeseBidPromises![0], prior);
+ game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
+ assert.deepEqual(game.richeseBidPromises![0], prior);
+ const guide = viewGame(game, f.target).richeseBidPromiseGuide; assert.ok(guide);
+ assert.equal(guide.action.amount, 8);
+ game = act(game, f.target, guide.action);
+ assert.ok(game.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
+ assert.deepEqual(inventory(game), f.originalCards);
+ });
+}