Support Truthtrance in Richese Silent bidding
This commit is contained in:
1 parent
090d764f6f
commit
3061dc5004
18 files changed
+442
-50
No files matched your search
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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' ? (
|
||||
<>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.',
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user