Support Truthtrance in Richese Once Around bidding

This commit is contained in:
Quintine committed 2026-10-27 17:56:20 +11:00
1 parent 65a1936b94
commit 090d764f6f
18 files changed
+1003 -7

No files matched your search

+4
View File
@@ -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 && <CharityClaim game={g} act={act} busy={busy} />}
{g.phase === 3 && g.richeseAuction && (
<>
<RicheseBidPromisePanel promises={g.richeseBidPromises} guide={g.richeseBidPromiseGuide} game={g} act={act} busy={busy} />
<RicheseAuctionLot
key={`${g.richeseAuction.event}-${g.me}`}
game={g}
act={act}
busy={busy}
/>
</>
)}
{g.phase === 3 && !g.richeseAuction && g.auction && (
<>
+34
View File
@@ -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 <fieldset className="grid gap-2" disabled={props.disabled} aria-label="Richese Once Around bid condition">
<legend>Bid in this Once Around auction</legend>
<label htmlFor="truth-richese-bid-minimum">Minimum bid in spice</label>
<Input id="truth-richese-bid-minimum" type="number" min={1} max={Number.MAX_SAFE_INTEGER} step={1} required
disabled={props.disabled} value={Number.isFinite(props.minimum) ? props.minimum : ''}
aria-invalid={!!error} aria-describedby="truth-richese-bid-help"
onChange={event => {
const value = event.currentTarget.valueAsNumber;
if (!props.disabled && !richeseBidMinimumError(value)) props.onMinimum(value);
}} />
{error && <p role="alert">{error}</p>}
<p className="fine" id="truth-richese-bid-help">
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.
</p>
</fieldset>;
}
+63
View File
@@ -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 <Button className="game-action min-h-11 whitespace-normal" data-testid="richese-bid-promise-guide"
disabled={!canAct()} onClick={() => { 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'}
</Button>;
}
/** 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 <section className="notice space-y-3" aria-label="Your Richese Once Around bid promises">
<h3>Your Once Around bid promises</h3>
<ul>{props.promises.map(promise => <li key={promise.id}>
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.'}
</li>)}</ul>
<p>Yes requires a qualifying bid while possible. No forbids a qualifying bid.
The original auction order and payment rules still apply.</p>
{waiting && <RicheseBidPromiseGuide {...props} />}
</section>;
}
+18 -1
View File
@@ -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 })}
</div>)}
</>
) : w.question!.kind === 'richeseBid' ? (
<>
<p className="notice">Yes requires a qualifying bid in your one remaining Once Around opportunity while possible. No forbids a qualifying bid. Winning still requires payment.</p>
{(g.truthRicheseBidAnswers ?? []).map(answer => <div key={answer}>
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
</div>)}
</>
) : w.question!.kind === 'phaseBid' ? (
<>
<p className="notice">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.</p>
@@ -589,6 +598,7 @@ export function Truthtrance({
<option value="revival">Bind ordinary force revivals this turn</option>
<option value="bid">Bind a bid in this ordinary lot</option>
<option value="phaseBid">Bind a bid this Bidding phase</option>
<option value="richeseBid">Bind a bid in this Once Around lot</option>
<option value="mixedBid">Combine current facts and ordinary bids</option>
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
<option value="mixedTraitorCall">Combine current facts and this Traitor declaration</option>
@@ -732,6 +742,12 @@ export function Truthtrance({
{!g.truthRevivalTargets.includes(target) && <p className="notice">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.</p>}
<p className="notice" aria-label="Public revival question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'richeseBid' ? (
<>
<RicheseBidPromiseFields minimum={bidMinimum} onMinimum={setBidMinimum} disabled={busy || !g.richeseAuction} />
{!g.truthRicheseBidTargets.includes(target) && <p className="notice">Choose a bidder with a remaining Once Around opportunity.</p>}
<p className="notice" aria-label="Public Once Around bid question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'phaseBid' ? (
<>
<PhaseBidClaimFields value={phaseBidClaim} disabled={busy}
@@ -877,6 +893,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 === 'richeseBid' && (!!richeseBidMinimumError(bidMinimum) || !g.richeseAuction || !g.truthRicheseBidTargets.includes(target))) ||
(kind === 'phaseBid' && (!!phaseBidClaimInputError(phaseBidClaim) || !g.truthPhaseBidTargets.includes(target))) ||
(kind === 'turnActions' && (!!turnActionsError || !g.truthTurnActionTargets.includes(target))) ||
(kind === 'travel' && (!!travelError || !g.truthTravelTargets.includes(target))) ||
+11
View File
@@ -20,6 +20,17 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**Richese Once Around Truthtrance bid — 27 October. Prototyped / Partial.**
A player can answer a minimum-bid question bound to the current public Once
Around lot, then use a private guide for the original bid or pass. The real
single opportunity, eligible order, current hand and native funds remain
binding; a waiting Richese owner is not assumed to get a turn after all pass.
An opposing raise can release an impossible Yes. Independent financial answers
are conservatively limited until a joint planner exists. Ten focused files pass
120/120; type/lint and build pass. Server-rendered controls and guide callbacks
are exercised, but stateful selector and browser/deployed play are unverified.
[Scope and source](TRUTHTRANCE_BID_PROMISES.md#richese-once-around-bid-prototype--27-october-2026).
**Phase-bid admission repair — 27 October. Prototyped / Partial.**
A displayed shipment, movement, revival or travel answer now preserves an
existing phase-bid Yes when a known compatible route exists. The unfinished
+18
View File
@@ -1,5 +1,23 @@
# Dune implementation status
## 27 October 2026 — Richese Once Around bid Truthtrance
Connect a separate current-lot minimum-bid question and owner-only receipt and
guide to the original public Once Around event. The bidder acts only once;
existing order, physical cards, hand space, current funds and ally pledge stay
native. External impossibility releases a Yes, and a waiting Richese owner
cannot rely on a hypothetical earlier bid. Basic's native Semuta preview is
permitted but its other discard combinations remain uncertified. Simultaneous
Silent bids, Normal Black Market bidding, broader modules and a joint future
planner remain open. Basic/Advanced original setup sources exercise physical
Truth cards, native bidding, four minimal bot profiles and stale-guide rejection;
an Advanced conflicting-bribe supplement explicitly stages one original card.
Ten focused files pass 120/120, with type/lint and build. Server-rendered
controls and guide callbacks are exercised; stateful browser, full-mode and
deployed acceptance remain open. Private proof is
`20261027-richese-truth-bid-proof-v1/`.
[Contract](TRUTHTRANCE_BID_PROMISES.md#richese-once-around-bid-prototype--27-october-2026).
## 27 October 2026 — Phase-bid admission repair
An independently projected movement, shipment, travel or revival answer now
+11
View File
@@ -1,5 +1,16 @@
# Rule decision index
## Richese Once Around bid Truthtrance — 27 October 2026
Provisionally allow an exact minimum-bid question for the one remaining Once
Around declaration, bound to its public event. An accepted bid fulfills Yes;
No caps the single bid and permits passing. No future rival bid is assumed for
the owner's final opportunity. Preserve native funding, hand limits and actual
settlement; an opposing raise can release an impossible answer. The general GF9
FAQ supports current-turn commitments while possible but does not prescribe
this specific special-auction procedure. Independent combination forecasts and
other auction methods remain open. [Contract](TRUTHTRANCE_BID_PROMISES.md#richese-once-around-bid-prototype--27-october-2026).
## Phase-wide ordinary bid Truthtrance — 27 October 2026
Prototypically support a current-turn Yes/No minimum ordinary bid across
+30
View File
@@ -1,5 +1,35 @@
# Truthtrance ordinary bid commitments
## Richese Once Around bid prototype — 27 October 2026
**Prototyped / Partial.** The distinct `{kind:'richeseBid', target, turn, event,
minimum}` question binds the current public Once Around lot, not its private
card identity. The original physical Truth card, one bid or pass, bid order,
capacity, currently available spice, pledged ally funding and native winner's
payment remain unchanged. An accepted qualifying bid fulfills Yes even if it
loses; No excludes qualifying declarations. The last-acting Richese owner gets
no opportunity if all other bidders pass, so Yes is not forecast without an
existing positive bid. A real opposing raise or loss of funds can release an
impossible Yes; the respondent cannot deliberately abandon it.
The public question editor, private answer, own receipt, event-bound guide and
four minimal legal bot profiles are connected. Existing positive independent
financial/transport commitments restrict admission in both answer orders until
a combined planner is implemented. Silent and normal special auctions, mixed
expressions, optional module combinations, broad Semuta discard interactions,
complete modes and browser/deployed acceptance remain open. Basic's native
Semuta preview may enter the quiet lot path, without certifying its combinations.
The [GF9 November 2020 FAQ p8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8)
supports current-turn action questions and compliance while possible but does
not prescribe this Once Around procedure. The online opportunity and release
policy is a provisional software interpretation, not a dedicated publisher
ruling. Native Basic/Advanced R/A/E starting deals and auction actions exercise
answers, costs, lot continuation, rival raises and minimal bots. A separate
Advanced bribe conflict uses a labeled card-conserving supplement. Server-rendered
controls and guide callbacks are exercised, without operating the stateful
selector. Ten focused files pass 120/120; type/lint and build pass.
## Phase-wide ordinary bid prototype — 27 October 2026
**Prototyped / Partial.** A distinct `{kind:'phaseBid', target, turn, minimum}`
+5
View File
@@ -1319,6 +1319,7 @@ function policyActions(g: GameView): Action[] {
if (g.revivalExpressionPromiseGuide) return [g.revivalExpressionPromiseGuide.action];
if (g.leaderRevivalPromiseGuide) return [g.leaderRevivalPromiseGuide.action];
if (g.revivalPromiseGuide) return [g.revivalPromiseGuide.action];
if (g.richeseBidPromiseGuide) return [g.richeseBidPromiseGuide.action];
if (g.phaseBidPromiseGuide) return [g.phaseBidPromiseGuide.action];
if (g.bidPromiseGuide) return [g.bidPromiseGuide.action];
if (g.truthtrance) {
@@ -1376,6 +1377,10 @@ function policyActions(g: GameView): Action[] {
const answer = g.truthBidAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'richeseBid') {
const answer = g.truthRicheseBidAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'phaseBid') {
const answer = g.truthPhaseBidAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
+121 -3
View File
@@ -1,3 +1,6 @@
import { RicheseBidPromiseError, richeseBidCompletionRange, richeseBidRequired, recordRicheseBid,
liveRicheseBidPromises, reconcileRicheseBidPromises, finishRicheseBidLot, validateRicheseBidPromises,
type RicheseBidClaim, type RicheseBidPromise, type RicheseBidOpportunity } from './richese-bid-promises';
import { AuctionClockError, AUCTION_BID_MS, quoteAuctionClock, viewAuctionClock,
type AuctionClock, type AuctionClockView } from './auction-clock';
import { PrintedCardPlayError, printedCardPlayCount, recordPrintedCardPlay,
@@ -2248,6 +2251,7 @@ export type Game = {
shipmentPromises?: ShipmentPromise[];
bidPromises?: BidPromise[];
phaseBidPromises?: PhaseBidPromise[];
richeseBidPromises?: RicheseBidPromise[];
/** Raw mixed question linked to one private native current-lot obligation. */
mixedBidQuestions?: (MixedBidClaim & {
event: string; player: string; asker: string; answer: boolean; binding: boolean;
@@ -15661,7 +15665,8 @@ function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no'
const promises = [...(g.bribePromises ?? []), { ...claim, event: 'candidate', player: p.id,
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: 0 }];
return !!bribePromiseCompletion(source, p, promises) &&
phaseBidCompatibleBribeAnswer({ ...source, bribePromises: promises }, p);
phaseBidCompatibleBribeAnswer({ ...source, bribePromises: promises }, p) &&
richeseBidCompatibleLaterPromises({ ...source, bribePromises: promises },p);
});
}
function bribeExpressionTruthAnswers(g: Game, p: Player, claim: BribeExpressionClaim): ('yes' | 'no' | 'unknown')[] {
@@ -15672,7 +15677,8 @@ function bribeExpressionTruthAnswers(g: Game, p: Player, claim: BribeExpressionC
const expressions = [...(g.bribeExpressionPromises ?? []), { ...claim, event: 'candidate', player: p.id,
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: [] }];
return !!bribePromiseCompletion(source, p, g.bribePromises ?? [], expressions) &&
phaseBidCompatibleBribeAnswer({ ...source, bribeExpressionPromises: expressions }, p);
phaseBidCompatibleBribeAnswer({ ...source, bribeExpressionPromises: expressions }, p) &&
richeseBidCompatibleLaterPromises({ ...source, bribeExpressionPromises: expressions },p);
});
}
function reopenBribePayer(g: Game, p: Player, answer: boolean) {
@@ -16071,6 +16077,91 @@ function phaseBidWitnesses(g: Game, p: Player): PhaseBidOpportunity[] {
}
return result;
}
function richeseBidRule<T>(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,
+4 -3
View File
@@ -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.',
+142
View File
@@ -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<string, unknown>;
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<T extends RicheseBidPromise>(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<T extends RicheseBidPromise>(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<T extends RicheseBidPromise>(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<string>();
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);
}
}
+23
View File
@@ -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.');
+70
View File
@@ -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<typeof createTruthRicheseBidFixture>;
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');
}
@@ -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<ElementProps>[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<ElementProps>(node)) return [];
return [node, ...elements(node.props.children)];
}
function edit(props: Parameters<typeof RicheseBidPromiseFields>[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);
});
+134
View File
@@ -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> = {}): 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);
});
@@ -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), /<option value="richeseBid">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);
}
},
);
+142
View File
@@ -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);
});
}