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

+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.');