Support Truthtrance in Richese Silent bidding

This commit is contained in:
Quintine committed 2026-10-27 18:01:55 +11:00
1 parent 090d764f6f
commit 3061dc5004
18 files changed
+442 -50

No files matched your search

+24 -12
View File
@@ -16082,18 +16082,22 @@ function richeseBidRule<T>(run: () => T): T {
}
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.richeseAuction && ['onceAround','silent'].includes(g.richeseAuction.method) && !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;
(lot.method === 'onceAround' && p.id === lot.owner && lot.active !== p.id && !lot.bidder) ||
(lot.method === 'onceAround' && lot.bid >= Number.MAX_SAFE_INTEGER)) return null;
// Silent submissions have no visible competing price; quote only the
// respondent's own funding and an unsubmitted positive bid.
const minimum = lot.method === 'silent' ? 1 : lot.bid + 1;
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};
if (!Number.isSafeInteger(max) || max < minimum) return null;
return {turn:g.turn,event:lot.event,player:p.id,minimum,maximum:max};
}
function richeseBidOtherPromises(g: Game, p: Player): boolean {
return liveShipmentPromises(g.shipmentPromises ?? [],p.id,g.turn).some(row => row.answer) ||
@@ -16129,7 +16133,7 @@ function richeseBidTruthAnswers(g: Game, p: Player, claim: RicheseBidClaim): Tru
}
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.');
'Choose an answer compatible with this Richese auction bid.');
(g.richeseBidPromises ??= []).push({...claim,id:crypto.randomUUID(),player:p.id,asker,answer});
}
function richeseBidIntegrity(g: Game) {
@@ -16152,10 +16156,11 @@ function reconcileRicheseBids(g: Game,voluntary?: {actor:string;action:Action})
}
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;
if (!lot || (lot.method === 'silent' ? Object.hasOwn(lot.sealed,player) : 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}};
if (!richeseBidRequired(live,context)) return {action:{type:'richeseBid',event:lot.event,amount:lot.method === 'silent' ? 0 : 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);
@@ -18550,6 +18555,13 @@ function finishAutomaticDecision(g: Game): boolean {
? active.id
: null;
if (full) {
if ((lot.method === 'onceAround' || lot.method === 'silent') && g.richeseBidPromises?.length) {
const context = {turn:g.turn,event:lot.event,player:full};
// Capacity loss forces a pass/zero. Release an impossible Yes first,
// then settle No against the automatic single declaration.
g.richeseBidPromises = richeseBidRule(() => reconcileRicheseBidPromises(g.richeseBidPromises!,context,null,'external'));
g.richeseBidPromises = richeseBidRule(() => recordRicheseBid(g.richeseBidPromises!,context,null));
}
g.richeseAuction = submitRicheseBid(
lot,
{
@@ -18569,8 +18581,8 @@ function finishAutomaticDecision(g: Game): boolean {
const player = getPlayer(g, full);
log(
g,
`${player.name} automatically ${lot.method === 'silent' ? 'submitted zero' : 'passed'} because their hand became full before bidding.`,
{ faction: player.faction, name: 'Full hand: no bid' },
`${player.name} automatically ${lot.method === 'silent' ? 'submitted a sealed bid' : 'passed'} because their hand became full before bidding.`,
{ faction: player.faction, name: lot.method === 'silent' ? 'Full hand: sealed submission' : 'Full hand: no bid' },
);
if (lot.method === 'silent' && g.richeseAuction.outcome)
log(
@@ -39207,7 +39219,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
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.');
'This Richese 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 => {
@@ -42432,9 +42444,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)
if ((lot.method === 'onceAround' || lot.method === 'silent') && g.richeseBidPromises?.length)
g.richeseBidPromises = richeseBidRule(() => recordRicheseBid(g.richeseBidPromises!,
{turn:g.turn,event:lot.event,player:p.id},amount));
{turn:g.turn,event:lot.event,player:p.id},lot.method === 'silent' && amount === 0 ? null : amount));
g.richeseAuction = submitRicheseBid(
lot,
{ event: stringField(action.event), actor: p.id, amount },
+3 -2
View File
@@ -3656,11 +3656,12 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Bind a minimum bid in an ordinary lot, the remaining ordinary Bidding phase, or one Richese Once Around lot, preserving native funding and order.',
summary: 'Bind a minimum bid in an ordinary lot, the remaining ordinary Bidding phase, or a Richese Once Around or Silent lot, preserving native funding and order.',
steps: [
'Choose Bind a bid in this ordinary lot, another remaining eligible bidder and a positive whole spice minimum. The turn and lot are fixed. Registered native Basic and Advanced tables without optional modules are supported first; broader compositions remain unfinished.',
'In a quiet Richese Once Around auction, choose Bind a bid in this Once Around lot to bind the respondent’s one remaining bid or pass. A waiting Richese owner cannot promise Yes until an actual bid ensures an owner turn. Current hand space, funds and pledged aid remain required; an opposing raise can release an impossible Yes. Silent and Normal special auctions remain outside this prototype.',
'In a quiet Richese Once Around auction, choose Bind a bid in this Once Around lot to bind the respondent’s one remaining bid or pass. A waiting Richese owner cannot promise Yes until an actual bid ensures an owner turn. Current hand space, funds and pledged aid remain required; an opposing raise can release an impossible Yes. Normal special auctions remain outside this prototype.',
'Alternatively choose Bind a bid this Bidding phase. Yes requires one qualifying accepted ordinary bid after the answer and before Bidding closes; No forbids qualifying later ordinary bids across lots. Earlier bids do not count, and a bid does not promise a win.',
'In a Silent Richese lot the question binds the respondent’s single sealed submission. Zero declines; a positive bid remains hidden until the native simultaneous reveal. Available answers use that bidder’s own funding, without using opponents’ concealed amounts.',
'The phase-wide guide may wait for a later lot only when the existing funded sale, prepared next card and post-sale space and funds support it. An unbid lot, hypothetical rival raise, cancelable unfunded overbid or Harkonnen bonus slot is not invented as a future opportunity. Before Bidding, current funding and available card count provide a provisional forecast; special auction preparation and combined future commitments remain limited.',
'Yes requires one new accepted bid at or above the minimum while possible. An earlier bid does not count. A pass or lower bid cannot rely on another player raising later. No forbids every later qualifying bid until this lot closes.',
'The declaration fulfills Yes even if another player later wins. If you win, the original winning invoice and Karama payment rules still apply. A promise creates no spice or free purchase. Current-highest, already-passed and full-hand players have no new opportunity for this structured question.',
+8 -8
View File
@@ -1,4 +1,4 @@
/** One future declaration in the exact public Richese Once Around lot.
/** One future declaration in the exact public Richese Once Around or Silent lot.
* The engine owns native eligibility, timing, hand capacity and funding. */
export type RicheseBidClaim = { turn: number; event: string; minimum: number };
export type RicheseBidPromise = RicheseBidClaim & {
@@ -24,7 +24,7 @@ const identifier = (value: unknown): value is string =>
export function parseRicheseBidClaim(value: unknown): RicheseBidClaim {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new RicheseBidPromiseError('Choose a current Once Around bid question.');
throw new RicheseBidPromiseError('Choose a current Once Around or Silent 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.');
@@ -32,7 +32,7 @@ export function parseRicheseBidClaim(value: unknown): RicheseBidClaim {
}
export function richeseBidClaimText(claim: RicheseBidClaim): string {
return `place a bid of at least ${claim.minimum} spice in this Once Around auction`;
return `place a bid of at least ${claim.minimum} spice in this Richese auction`;
}
function checkContext(context: RicheseBidContext): void {
@@ -42,7 +42,7 @@ function checkContext(context: RicheseBidContext): void {
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.');
throw new RicheseBidPromiseError('Retain an actual funded Once Around or Silent bid interval.');
}
function sameContext(a: RicheseBidContext, b: RicheseBidContext): boolean {
return a.turn === b.turn && a.event === b.event && a.player === b.player;
@@ -74,7 +74,7 @@ export function richeseBidRequired(promises: readonly RicheseBidPromise[], conte
}
/** Call inside the detached engine transaction immediately before its native
* bid/pass validation. A Once Around actor receives one declaration only; a
* bid/pass validation. A Once Around or Silent 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,
@@ -107,7 +107,7 @@ export function reconcileRicheseBidPromises<T extends RicheseBidPromise>(promise
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.');
throw new RicheseBidPromiseError('This action would remove your last funded Once Around or Silent bid opportunity.');
return promises.map(row => impossible.includes(row) ? { ...row, released: true } : row);
}
@@ -128,7 +128,7 @@ export function finishRicheseBidLot<T extends RicheseBidPromise>(promises: reado
/** 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.');
throw new RicheseBidPromiseError('The Once Around or Silent bid commitment record is invalid.');
const receipts = new Set<string>();
for (const row of promises) {
parseRicheseBidClaim(row);
@@ -136,7 +136,7 @@ export function validateRicheseBidPromises(promises: readonly RicheseBidPromise[
!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.');
throw new RicheseBidPromiseError('The Once Around or Silent commitment lost its receipt, original lot or participants.');
receipts.add(row.id);
}
}
+5 -5
View File
@@ -360,12 +360,12 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
}
if (v.kind === 'richeseBid') {
check(Object.keys(v).sort().join(',') === 'event,kind,minimum,target,turn',
'Choose the current Once Around lot and bid minimum.');
'Choose the current Richese 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 &&
(lot.method === 'onceAround' || lot.method === 'silent') && !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.');
'Choose a quiet supported Once Around or Silent auction and its current public event.');
return { kind: 'richeseBid', target: v.target, ...claim };
}
if (v.kind === 'phaseBid') {
@@ -1053,7 +1053,7 @@ export function resolveTruthAction(
'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.');
'Choose a bidder with a remaining Richese auction opportunity.');
if (w.question.kind === 'phaseBid')
check(effects.phaseBidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
'Choose a phase bid question compatible with supported native opportunities and earlier answers.');
@@ -1189,7 +1189,7 @@ export function resolveTruthAction(
}
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.');
'Choose an answer consistent with this Richese lot and your legal bid.');
if (a.answer !== 'unknown') effects.bindRicheseBid(g, p, q, a.answer === 'yes', current.player);
}
if (q.kind === 'phaseBid') {