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

+8 -5
View File
@@ -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 <fieldset className="grid gap-2" disabled={props.disabled} aria-label="Richese Once Around bid condition">
<legend>Bid in this Once Around auction</legend>
return <fieldset className="grid gap-2" disabled={props.disabled} aria-label={`Richese ${silent ? 'Silent' : 'Once Around'} bid condition`}>
<legend>Bid in this {silent ? 'Silent' : '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 : ''}
@@ -26,9 +28,10 @@ export function RicheseBidPromiseFields(props: {
}} />
{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.
{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.
</p>
</fieldset>;
}
+11 -6
View File
@@ -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'}
</Button>;
}
@@ -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 <section className="notice space-y-3" aria-label="Your Richese Once Around bid promises">
<h3>Your Once Around bid promises</h3>
const silent = props.game.richeseAuction?.method === 'silent';
const auction = silent ? 'Silent' : 'Once Around';
return <section className="notice space-y-3" aria-label={`Your Richese ${auction} bid promises`}>
<h3>Your {auction} 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.
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.'}
</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>
The original auction and payment rules still apply.
{silent && ' Your submitted amount remains private until all bids are revealed.'}</p>
{waiting && <RicheseBidPromiseGuide {...props} />}
</section>;
}
+5 -5
View File
@@ -430,7 +430,7 @@ export function Truthtrance({
</>
) : 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>
<p className="notice">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.</p>
{(g.truthRicheseBidAnswers ?? []).map(answer => <div key={answer}>
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
</div>)}
@@ -598,7 +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="richeseBid">Bind a bid in this Richese 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>
@@ -744,9 +744,9 @@ export function Truthtrance({
</>
) : 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>
<RicheseBidPromiseFields minimum={bidMinimum} onMinimum={setBidMinimum} disabled={busy || !g.richeseAuction} method={g.richeseAuction?.method === 'silent' ? 'silent' : 'onceAround'} />
{!g.truthRicheseBidTargets.includes(target) && <p className="notice">Choose a bidder with a remaining Richese auction opportunity.</p>}
<p className="notice" aria-label="Public Richese bid question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
</>
) : kind === 'phaseBid' ? (
<>
+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 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
+15
View File
@@ -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
+9
View File
@@ -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
+21
View File
@@ -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,
+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') {
+54
View File
@@ -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<typeof createTruthRicheseSilentFixture>;
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');
}
+31 -3
View File
@@ -18,13 +18,13 @@ function edit(props: Parameters<typeof RicheseBidPromiseFields>[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);
});
+1 -1
View File
@@ -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/);
});
+1 -1
View File
@@ -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);
@@ -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), /<option value="richeseBid">Bind a bid in this Once Around lot<\/option>/);
assert.match(table(asker), /Bind a bid in this Once Around lot/);
assert.match(truth(asker), /<option value="richeseBid">Bind a bid in this Richese lot<\/option>/);
assert.match(table(asker), /Bind a bid in this Richese 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);
@@ -0,0 +1,102 @@
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 Game, type GameView } from '../game/engine';
import { RicheseBidPromiseGuide } from '../components/richese-bid-promise-panel';
import { act, inventory, createTruthRicheseSilentFixture, askTruthRicheseSilent } from './fixture-truth-richese-silent';
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() {} }));
}
function declare(game: Game, asker: string, truthCard: string): Game {
let current = act(game, asker, { type:'card', card:truthCard });
for (let count = 0; current.truthtrance?.stage === 'priority' && count < current.players.length; count++) {
const seat = current.players.find(row => !current.truthtrance!.passed.includes(row.id)); assert.ok(seat);
current = act(current, seat.id, {type:'truthPass'});
}
assert.equal(current.truthtrance?.stage, 'ask');
return current;
}
for (const advanced of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} real Silent question, answer and guide connect to the original private auction`, () => {
// Native public R/A/E setup, originally dealt Truth card and actual Silent
// cache; the fixture selects unopened deal/Spice positions and lotteries.
const f = createTruthRicheseSilentFixture(advanced);
const declared = declare(f.game, f.asker, f.truth);
const asker = viewGame(declared, f.asker);
assert.equal(asker.richeseAuction?.method, 'silent');
assert.ok(asker.truthRicheseBidTargets.includes(f.target));
assert.match(truth(asker), /<option value="richeseBid">Bind a bid in this Richese lot<\/option>/);
assert.match(table(asker), /Bind a bid in this Richese lot/);
// Server rendering does not operate the stateful question selector or
// browser hydration; submit the original question through the engine.
const asked = askTruthRicheseSilent(f, 4);
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), /Silent 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(row => row.player === f.target && row.answer === (answer === 'yes')));
assert.ok(own.richeseBidPromiseGuide);
const html = table(own);
assert.match(html, /Your Silent bid promises/);
assert.match(html, /data-testid="richese-bid-promise-guide"/);
if (answer === 'no') assert.match(html, /Submit zero spice while honoring your answer/);
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(sent[0].amount, answer === 'yes' ? 4 : 0);
assert.equal(typeof sent[0].richeseBidPromiseId, 'string');
game = act(game, f.target, sent[0]);
assert.equal(game.richeseBidPromises!.find(row => row.id === sent[0].richeseBidPromiseId)?.fulfilled, true);
const submitted = viewGame(game, f.target), observer = viewGame(game, f.owner);
assert.equal(submitted.richeseAuction?.ownBid, answer === 'yes' ? 4 : 0);
assert.equal(observer.richeseAuction?.ownBid, null);
assert.equal(observer.richeseAuction?.revealedBids, null);
assert.ok(observer.richeseAuction?.submitted.includes(f.target));
assert.equal(Object.hasOwn(observer.richeseAuction!, 'sealed'), false);
assert.match(auction(submitted), /Your sealed bid:/);
assert.doesNotMatch(auction(observer), /Your sealed bid:/);
assert.doesNotMatch(table(observer), /Your sealed bid:/);
assert.deepEqual(inventory(game), f.originalCards);
}
},
);
+131
View File
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handLimit, normalizeAutomaticGame, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { act, inventory, player, createTruthRicheseSilentFixture, askTruthRicheseSilent,
bindTruthRicheseSilent, rejectTruthRicheseSilent as reject } from './fixture-truth-richese-silent';
for (const advanced of [false, true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode} Silent Yes requires a funded sealed bid and reveals only after all submissions`, () => {
const f = createTruthRicheseSilentFixture(advanced);
const {game:bound} = bindTruthRicheseSilent(f, 'yes', 4);
assert.equal(bound.discard.filter(card => card.id === f.truth).length, 1);
assert.equal(bound.richeseAuction?.event, f.lot);
reject(bound, f.target, {type:'richeseBid', event:f.lot, amount:0, allyPayment:0});
reject(bound, f.target, {type:'richeseBid', event:f.lot, amount:3, allyPayment:0});
reject(bound, f.target, {type:'richeseBid', event:f.lot, amount:11, allyPayment:0});
const guide = viewGame(bound, f.target).richeseBidPromiseGuide; assert.ok(guide);
assert.equal(guide.action.amount, 4);
let game = act(bound, f.target, guide.action);
assert.ok(game.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
assert.equal(player(game, f.target).spice, 10, 'A sealed submission is not settlement');
const own = viewGame(game, f.target).richeseAuction!, other = viewGame(game, f.owner).richeseAuction!;
assert.equal(own.ownBid, 4); assert.equal(other.ownBid, null);
assert.equal(own.revealedBids, null); assert.equal(other.revealedBids, null);
assert.ok(other.submitted.includes(f.target));
assert.ok(!game.log.some(entry => entry.text.includes('Silent bids revealed together')));
game = act(game, f.asker, {type:'richeseBid', event:f.lot, amount:2, allyPayment:0});
assert.equal(viewGame(game, f.owner).richeseAuction?.revealedBids, null);
game = act(game, f.owner, {type:'richeseBid', event:f.lot, amount:0, allyPayment:0});
assert.ok(game.log.some(entry => entry.text.includes('Silent bids revealed together')));
assert.ok(player(game, f.target).hand.some(card => card.id === f.card));
assert.equal(player(game, f.target).spice, 6, 'The winning invoice is paid once');
assert.deepEqual(inventory(game), f.originalCards);
});
void test(`${mode} Silent No permits zero or a smaller sealed bid and rejects its threshold`, () => {
const f = createTruthRicheseSilentFixture(advanced), {game:bound} = bindTruthRicheseSilent(f, 'no', 4);
reject(bound, f.target, {type:'richeseBid', event:f.lot, amount:4, allyPayment:0});
reject(bound, f.target, {type:'richeseBid', event:f.lot, amount:null, allyPayment:0});
const guide = viewGame(bound, f.target).richeseBidPromiseGuide; assert.ok(guide);
assert.equal(guide.action.amount, 0, 'Silent decline is a physical sealed zero');
const zero = act(bound, f.target, guide.action);
assert.equal(viewGame(zero, f.target).richeseAuction?.ownBid, 0);
assert.equal(viewGame(zero, f.owner).richeseAuction?.ownBid, null);
assert.ok(zero.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
const low = act(bound, f.target, {type:'richeseBid', event:f.lot, amount:3, allyPayment:0});
assert.equal(viewGame(low, f.target).richeseAuction?.ownBid, 3);
assert.equal(viewGame(low, f.owner).richeseAuction?.revealedBids, null);
assert.ok(low.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
assert.deepEqual(inventory(zero), f.originalCards); assert.deepEqual(inventory(low), f.originalCards);
});
void test(`${mode} hidden opposing Silent bid does not alter the target's legal answer or guide`, () => {
const f = createTruthRicheseSilentFixture(advanced);
const source = act(f.game, f.asker, {type:'richeseBid',event:f.lot,amount:9,allyPayment:0});
assert.equal(viewGame(source, f.target).richeseAuction?.revealedBids, null);
assert.equal(viewGame(source, f.target).richeseAuction?.ownBid, null);
const asked = askTruthRicheseSilent(f, 4, source);
assert.ok(viewGame(asked, f.target).truthRicheseBidAnswers?.includes('yes'));
const bound = act(asked, f.target, {type:'truthAnswer', answer:'yes'});
const guide = viewGame(bound, f.target).richeseBidPromiseGuide; assert.ok(guide);
assert.equal(guide.action.amount, 4);
const submitted = act(bound, f.target, guide.action);
assert.ok(submitted.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
assert.equal(viewGame(submitted, f.owner).richeseAuction?.revealedBids, null);
const settled = act(submitted, f.owner, {type:'richeseBid',event:f.lot,amount:0,allyPayment:0});
assert.ok(player(settled, f.asker).hand.some(card => card.id === f.card));
assert.equal(player(settled, f.target).spice, 10, 'Losing submissions spend no spice');
assert.deepEqual(inventory(settled), f.originalCards);
});
void test(`${mode} Silent funded answers, stale guide rejection and four legal bot profiles`, () => {
const f = createTruthRicheseSilentFixture(advanced);
const unaffordable = askTruthRicheseSilent(f, 11);
assert.ok(!viewGame(unaffordable, f.target).truthRicheseBidAnswers?.includes('yes'));
assert.ok(viewGame(unaffordable, f.target).truthRicheseBidAnswers?.includes('no'));
reject(unaffordable, f.target, {type:'truthAnswer',answer:'yes'});
const asked = askTruthRicheseSilent(f, 4);
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);
reject(bound, f.target, {...guide.action,richeseBidPromiseId:'stale-receipt'});
reject(bound, f.target, {...guide.action,event:'stale-lot'});
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));
reject(completed, f.target, chosen);
assert.deepEqual(inventory(completed), f.originalCards);
}
});
}
for (const advanced of [false, true]) for (const answer of ['yes','no'] as const) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode} a full hand forces a sealed zero and resolves the prior Silent ${answer}`, () => {
const f = createTruthRicheseSilentFixture(advanced), {game:bound} = bindTruthRicheseSilent(f, answer, 4);
// Controlled external acquisition supplement: move only existing unopened
// main-deck cards to this respondent until the normal hand limit is met.
// Preserve every physical card, the live lot, funding, answer and phase.
const harmed = structuredClone(bound), respondent = player(harmed,f.target);
while (respondent.hand.length < handLimit(respondent)) {
const card = harmed.deck.shift(); assert.ok(card);
respondent.hand.push(card);
}
assert.deepEqual(inventory(harmed),f.originalCards);
assert.equal(Object.hasOwn(harmed.richeseAuction!.sealed,f.target),false);
const beforeSequence = harmed.log.at(-1)?.seq ?? 0;
const settled = normalizeAutomaticGame(harmed);
const receipt = settled.richeseBidPromises!.find(row => row.player === f.target); assert.ok(receipt);
if (answer === 'yes') { assert.equal(receipt.released,true); assert.ok(!receipt.fulfilled); }
else { assert.equal(receipt.fulfilled,true); assert.ok(!receipt.released); }
assert.equal(viewGame(settled,f.target).richeseAuction?.ownBid,0);
const observer = viewGame(settled,f.owner).richeseAuction!;
assert.equal(observer.ownBid,null); assert.equal(observer.revealedBids,null);
assert.ok(observer.submitted.includes(f.target));
const fresh = settled.log.filter(entry => entry.seq > beforeSequence);
assert.ok(fresh.some(entry => entry.text.includes('submitted a sealed bid')));
assert.ok(fresh.every(entry => !/revealed together|zero|0 spice|no bid/i.test(`${entry.text} ${entry.automatic?.name ?? ''}`)),
'Automatic public notices must not disclose the sealed zero before reveal');
assert.deepEqual(inventory(settled),f.originalCards);
});
}