diff --git a/components/richese-bid-promise-fields.tsx b/components/richese-bid-promise-fields.tsx index 740698dc..86347015 100644 --- a/components/richese-bid-promise-fields.tsx +++ b/components/richese-bid-promise-fields.tsx @@ -12,10 +12,12 @@ export function RicheseBidPromiseFields(props: { minimum: number; onMinimum: (value: number) => void; disabled: boolean; + method?: 'onceAround' | 'silent'; }) { + const silent = props.method === 'silent'; const error = richeseBidMinimumError(props.minimum); - return
- Bid in this Once Around auction + return
+ Bid in this {silent ? 'Silent' : 'Once Around'} auction {error &&

{error}

}

- This asks about the bidder’s remaining opportunity in this Once Around lot. - Earlier declarations do not count; a qualifying bid does not promise a win - or change the winning payment. + {silent + ? 'This asks about the bidder’s unsubmitted sealed bid in this Silent lot. A zero submission is permitted when compatible with the answer; submitted amounts remain private until bids are revealed.' + : 'This asks about the bidder’s remaining opportunity in this Once Around lot. Earlier declarations do not count.'} + {' '}A qualifying bid does not promise a win or change the winning payment.

; } diff --git a/components/richese-bid-promise-panel.tsx b/components/richese-bid-promise-panel.tsx index b935f6f9..7dd24abb 100644 --- a/components/richese-bid-promise-panel.tsx +++ b/components/richese-bid-promise-panel.tsx @@ -15,7 +15,7 @@ export type RicheseBidPromiseReceipt = { export type RicheseBidPromisePanelProps = { promises: RicheseBidPromiseReceipt[]; guide: { action: Action } | null; - game: { turn: number; richeseAuction: { event: string } | null }; + game: { turn: number; richeseAuction: { event: string; method?: 'onceAround' | 'silent' | 'normal' } | null }; act: (action: Action) => void; busy: boolean; }; @@ -40,7 +40,9 @@ export function RicheseBidPromiseGuide(props: RicheseBidPromisePanelProps) { 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'} + : action.amount === 0 + ? 'Submit zero spice while honoring your answer' + : 'Pass this lot while honoring your answer'} ; } @@ -49,15 +51,18 @@ export function RicheseBidPromisePanel(props: RicheseBidPromisePanelProps) { if (!props.promises.length) return null; const waiting = props.promises.some(promise => promise.turn === props.game.turn && promise.event === props.game.richeseAuction?.event && !promise.fulfilled && !promise.released); - return
-

Your Once Around bid promises

+ const silent = props.game.richeseAuction?.method === 'silent'; + const auction = silent ? 'Silent' : 'Once Around'; + return
+

Your {auction} bid promises

    {props.promises.map(promise =>
  • - Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to bidding at least {promise.minimum} spice in this Once Around lot. + Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to bidding at least {promise.minimum} spice in this Richese lot. {promise.released ? ' Released: this commitment is no longer possible.' : promise.fulfilled ? ' Fulfilled: this commitment is complete.' : ' This answer is binding for your remaining opportunity.'}
  • )}

Yes requires a qualifying bid while possible. No forbids a qualifying bid. - The original auction order and payment rules still apply.

+ The original auction and payment rules still apply. + {silent && ' Your submitted amount remains private until all bids are revealed.'}

{waiting && }
; } diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 6fe25d4a..2f5147e3 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -430,7 +430,7 @@ export function Truthtrance({ ) : w.question!.kind === 'richeseBid' ? ( <> -

Yes requires a qualifying bid in your one remaining Once Around opportunity while possible. No forbids a qualifying bid. Winning still requires payment.

+

Yes requires a qualifying bid in your one remaining opportunity while possible. No forbids a qualifying bid. Silent amounts remain sealed until all submit; winning still requires payment.

{(g.truthRicheseBidAnswers ?? []).map(answer =>
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
)} @@ -598,7 +598,7 @@ export function Truthtrance({ - + @@ -744,9 +744,9 @@ export function Truthtrance({ ) : kind === 'richeseBid' ? ( <> - - {!g.truthRicheseBidTargets.includes(target) &&

Choose a bidder with a remaining Once Around opportunity.

} -

{truthQuestionText(question, leaderName, locationName, name)}

+ + {!g.truthRicheseBidTargets.includes(target) &&

Choose a bidder with a remaining Richese auction opportunity.

} +

{truthQuestionText(question, leaderName, locationName, name)}

) : kind === 'phaseBid' ? ( <> diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index c4cf7236..9ee4c967 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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 Silent Truthtrance bid — 27 October. Prototyped / Partial.** +The event-bound Richese question also supports one sealed Silent submission. +Yes requires a qualifying bid; No permits zero or a lower bid. Feasibility uses +only the respondent's unsubmitted opportunity, hand and available funding, not +other sealed amounts. Forced zero from a full hand releases an impossible Yes +or completes No; its public notice no longer reveals the zero before reveal. +Ten focused files pass 85/85 and the build passes. Type/lint passed before the +final test-only wording correction. Stateful browser and deployed play remain +unverified. +[Scope and provisional interpretation](TRUTHTRANCE_BID_PROMISES.md#richese-silent-bid-prototype--27-october-2026). + **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 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 6807576c..df3e0d98 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,20 @@ # Dune implementation status +## 27 October 2026 — Richese Silent bid Truthtrance + +Extend the current public-event bid question to a single sealed Silent +submission. Zero is the native decline, not an invented pass; Yes and No use +only the respondent's own funds, committed aid and unsubmitted opportunity. +Original escrow, tie order and simultaneous reveal remain native. A forced +full-hand zero releases an impossible Yes or completes No, and its automatic +public text and tag now remain amount-neutral until reveal. The general FAQ +supports turn-action commitments but does not prescribe this specific protocol; +it remains provisional. Normal Black Market and broader combinations remain +open. Ten focused files pass 85/85 and build passes; type/lint passed before +the final test-only wording correction. Private proof records are +`20261027-richese-silent-truth-proof-v1/` and `20261027-richese-silent-truth-proof-v3/`. +[Contract](TRUTHTRANCE_BID_PROMISES.md#richese-silent-bid-prototype--27-october-2026). + ## 27 October 2026 — Richese Once Around bid Truthtrance Connect a separate current-lot minimum-bid question and owner-only receipt and diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 91224faa..ae287dcb 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,14 @@ # Rule decision index +## Richese Silent bid Truthtrance — 27 October 2026 + +Provisionally allow an event-bound minimum for the respondent's single sealed +Silent bid. Zero declines; other players' concealed amounts are not considered +when quoting the answer. Keep native funding, escrow, reveal and automatic +full-hand behavior. The FAQ authorizes current-turn action commitments while +possible but does not prescribe this sealed-auction implementation. Normal +Black Market and combined commitments retain separate boundaries. [Contract](TRUTHTRANCE_BID_PROMISES.md#richese-silent-bid-prototype--27-october-2026). + ## Richese Once Around bid Truthtrance — 27 October 2026 Provisionally allow an exact minimum-bid question for the one remaining Once diff --git a/docs/TRUTHTRANCE_BID_PROMISES.md b/docs/TRUTHTRANCE_BID_PROMISES.md index c4ea68ea..71d47120 100644 --- a/docs/TRUTHTRANCE_BID_PROMISES.md +++ b/docs/TRUTHTRANCE_BID_PROMISES.md @@ -1,5 +1,26 @@ # Truthtrance ordinary bid commitments +## Richese Silent bid prototype — 27 October 2026 + +**Prototyped / Partial.** The existing event-bound Richese minimum-bid question +also applies to the respondent's one unsubmitted Silent bid. A qualifying +positive sealed bid fulfills Yes; No permits zero or a bid below the threshold. +The answer and guide use only the respondent's actual available funds and aid, +not other sealed amounts. All eligible submissions retain native escrow and +reveal simultaneously in original tie order; only the winner pays. When a hand +becomes full, the native automatic zero releases an impossible Yes or completes +No. Its public text and action tag do not announce the amount before reveal. + +The [GF9 November 2020 FAQ p8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8) +supports action commitments while possible, but does not supply this specific +sealed-auction procedure. This remains a provisional interpretation. Normal +Black Market bidding, mixed questions and wider combined promises remain open. +Native Basic and Advanced R/A/E fixtures exercise original Silent lots, real +Truth cards, zero, positive bids, reveal and minimal bot guides. Separate +full-hand cases explicitly stage conserved unopened cards; selector/browser and +deployed acceptance remain open. Ten focused files pass 85/85 and build passes; +type/lint passed before the final test-only wording correction. + ## Richese Once Around bid prototype — 27 October 2026 **Prototyped / Partial.** The distinct `{kind:'richeseBid', target, turn, event, diff --git a/game/engine.ts b/game/engine.ts index bef5d93a..674955cb 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -16082,18 +16082,22 @@ function richeseBidRule(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 }, diff --git a/game/reference.ts b/game/reference.ts index 8d4ff55d..6f674e5d 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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.', diff --git a/game/richese-bid-promises.ts b/game/richese-bid-promises.ts index e4b8c9fe..98ecc1fc 100644 --- a/game/richese-bid-promises.ts +++ b/game/richese-bid-promises.ts @@ -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; 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(promises: readonly T[], context: RicheseBidContext, @@ -107,7 +107,7 @@ export function reconcileRicheseBidPromises(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(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(); 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); } } diff --git a/game/truthtrance.ts b/game/truthtrance.ts index d7c48f8d..8fa11694 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -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') { diff --git a/tests/fixture-truth-richese-silent.ts b/tests/fixture-truth-richese-silent.ts new file mode 100644 index 00000000..276bf708 --- /dev/null +++ b/tests/fixture-truth-richese-silent.ts @@ -0,0 +1,54 @@ +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, inventory, player }; + +/** Original public Basic/Advanced Richese/Atreides/Emperor setup, genuine + * Charity prefix and actual first Silent cache declaration. The parent fixture + * selects unopened deal and Spice positions and native scalar lotteries; no + * hand, spice, auction, answer or receipt is installed. */ +export function createTruthRicheseSilentFixture(advanced = false) { + const original = createTruthtranceRichesePromisesFixture({ advanced }); + const game = advance(structuredClone(original.earlySource), state => clean(state) && + !!state.richeseAuction && state.richeseAuction.method === 'silent' && !state.richeseAuction.outcome, + [], { position: 'first', method: 'silent' }); + const lot = game.richeseAuction!; + const asker = original.asker, target = original.emperor, owner = original.richese; + assert.equal(lot.source, 'cache'); assert.equal(lot.active, null); + assert.ok(lot.eligible.includes(asker) && lot.eligible.includes(target) && lot.eligible.includes(owner)); + assert.deepEqual(lot.sealed, {}); + 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 TruthRicheseSilentFixture = ReturnType; + +export function askTruthRicheseSilent(f: TruthRicheseSilentFixture, minimum = 4, 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 seat = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(seat); + game = act(game, seat.id, { type:'truthPass' }); + } + assert.equal(game.truthtrance?.stage, 'ask'); + return act(game, f.asker, { type:'truthAsk', question:{kind:'richeseBid', target:f.target, + turn:source.turn, event:f.lot, minimum} }); +} +export function bindTruthRicheseSilent(f: TruthRicheseSilentFixture, answer: 'yes'|'no', minimum = 4, source = f.game) { + const asked = askTruthRicheseSilent(f, minimum, source); + assert.ok(viewGame(asked, f.target).truthRicheseBidAnswers?.includes(answer)); + return { asked, game:act(asked, f.target, {type:'truthAnswer', answer}) }; +} +export function rejectTruthRicheseSilent(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => act(game, actor, action)); + assert.deepEqual(game, before, 'A rejected action cannot mutate its input'); +} diff --git a/tests/richese-bid-promise-controls.test.tsx b/tests/richese-bid-promise-controls.test.tsx index ace5c0d4..dd6dae8d 100644 --- a/tests/richese-bid-promise-controls.test.tsx +++ b/tests/richese-bid-promise-controls.test.tsx @@ -18,13 +18,13 @@ function edit(props: Parameters[0], minimum: num 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[] } { +function projected(type: 'bid' | 'pass' | 'zero' = '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, + amount: type === 'bid' ? 4 : type === 'zero' ? 0 : null, allyPayment: 0 } }, + game: { turn: 2, richeseAuction: { event: 'lot-1', method: type === 'zero' ? 'silent' : 'onceAround' } }, busy: false, act(action) { sent.push(action); }, }; return { props, sent }; @@ -86,3 +86,31 @@ void test('guide sends the exact server action and rejects busy, stale or comple props.game.turn = 3; assert.equal(RicheseBidPromiseGuide(props), null); }); + +void test('Silent question and private receipt explain sealed submissions without showing lot or receipt identifiers', () => { + const fields = renderToStaticMarkup(createElement(RicheseBidPromiseFields, { + minimum: 4, disabled: false, method: 'silent', onMinimum() {}, + })); + assert.match(fields, /Silent auction/); + assert.match(fields, /unsubmitted sealed bid/); + assert.match(fields, /submitted amounts remain private until bids are revealed/); + const { props } = projected('zero'); + const panel = renderToStaticMarkup(createElement(RicheseBidPromisePanel, props)); + assert.match(panel, /Your Silent bid promises/); + assert.match(panel, /Submit zero spice while honoring your answer/); + assert.match(panel, /Your submitted amount remains private until all bids are revealed/); + assert.doesNotMatch(panel, /receipt-1|lot-1/); +}); + +void test('Silent zero guide submits the exact quoted action and refuses a stale event', () => { + const { props, sent } = projected('zero'); + const button = RicheseBidPromiseGuide(props); assert.ok(button); + button.props.onClick(); + assert.deepEqual(sent, [props.guide!.action]); + assert.equal(sent[0].amount, 0); + 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); +}); diff --git a/tests/richese-bid-promises.test.ts b/tests/richese-bid-promises.test.ts index 2ba583d3..84e874c3 100644 --- a/tests/richese-bid-promises.test.ts +++ b/tests/richese-bid-promises.test.ts @@ -28,7 +28,7 @@ void test('claims retain only a safe integer turn, public lot event and positive 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.match(text, /Richese auction/); assert.doesNotMatch(text, /win|purchase|public-lot/); }); diff --git a/tests/richese-engine.test.ts b/tests/richese-engine.test.ts index a483c73e..5c9c4287 100644 --- a/tests/richese-engine.test.ts +++ b/tests/richese-engine.test.ts @@ -325,7 +325,7 @@ void test('restored full hands automatically submit zero or remove an unbid cach assert.equal(g.players[0].spice, 22); assert.equal(g.players[0].hand.length, 4); assert.ok( - g.log.some((entry) => entry.text.includes('automatically submitted zero')), + g.log.some((entry) => entry.text.includes('automatically submitted a sealed bid')), ); let unbid = cache(); for (const id of ['h', 'e', 'r']) unbid = bid(unbid, id, 0); diff --git a/tests/truth-richese-bid-integrated-controls.test.tsx b/tests/truth-richese-bid-integrated-controls.test.tsx index 749d3b03..89311b7f 100644 --- a/tests/truth-richese-bid-integrated-controls.test.tsx +++ b/tests/truth-richese-bid-integrated-controls.test.tsx @@ -45,8 +45,8 @@ for (const advanced of [false, true]) void test( const declared = declareTruthRicheseBid(f, source); const asker = viewGame(declared, f.asker); assert.ok(asker.truthRicheseBidTargets.includes(f.target)); - assert.match(truth(asker), /