Support Truthtrance in Richese Once Around bidding
This commit is contained in:
1 parent
65a1936b94
commit
090d764f6f
18 files changed
+1003
-7
No files matched your search
@@ -1,4 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { RicheseBidPromisePanel } from './richese-bid-promise-panel';
|
||||||
import { TravelPromises } from './travel-promises';
|
import { TravelPromises } from './travel-promises';
|
||||||
import { TurnActionPromises } from './turn-action-promises';
|
import { TurnActionPromises } from './turn-action-promises';
|
||||||
import { MixedTraitorPromises } from './mixed-traitor-promises';
|
import { MixedTraitorPromises } from './mixed-traitor-promises';
|
||||||
@@ -4332,12 +4333,15 @@ export function GameTable({
|
|||||||
)}
|
)}
|
||||||
{g.phase === 2 && <CharityClaim game={g} act={act} busy={busy} />}
|
{g.phase === 2 && <CharityClaim game={g} act={act} busy={busy} />}
|
||||||
{g.phase === 3 && g.richeseAuction && (
|
{g.phase === 3 && g.richeseAuction && (
|
||||||
|
<>
|
||||||
|
<RicheseBidPromisePanel promises={g.richeseBidPromises} guide={g.richeseBidPromiseGuide} game={g} act={act} busy={busy} />
|
||||||
<RicheseAuctionLot
|
<RicheseAuctionLot
|
||||||
key={`${g.richeseAuction.event}-${g.me}`}
|
key={`${g.richeseAuction.event}-${g.me}`}
|
||||||
game={g}
|
game={g}
|
||||||
act={act}
|
act={act}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{g.phase === 3 && !g.richeseAuction && g.auction && (
|
{g.phase === 3 && !g.richeseAuction && g.auction && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
|
||||||
|
/** Minimum is public, positive and precisely representable in the action. */
|
||||||
|
export function richeseBidMinimumError(value: unknown): string | null {
|
||||||
|
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
|
||||||
|
? null : 'Choose a positive whole bid minimum.';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RicheseBidPromiseFields(props: {
|
||||||
|
minimum: number;
|
||||||
|
onMinimum: (value: number) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}) {
|
||||||
|
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>
|
||||||
|
<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 : ''}
|
||||||
|
aria-invalid={!!error} aria-describedby="truth-richese-bid-help"
|
||||||
|
onChange={event => {
|
||||||
|
const value = event.currentTarget.valueAsNumber;
|
||||||
|
if (!props.disabled && !richeseBidMinimumError(value)) props.onMinimum(value);
|
||||||
|
}} />
|
||||||
|
{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.
|
||||||
|
</p>
|
||||||
|
</fieldset>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { Action } from '@/game/engine';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
|
export type RicheseBidPromiseReceipt = {
|
||||||
|
id: string;
|
||||||
|
turn: number;
|
||||||
|
event: string;
|
||||||
|
minimum: number;
|
||||||
|
answer: boolean;
|
||||||
|
fulfilled?: boolean;
|
||||||
|
released?: boolean;
|
||||||
|
};
|
||||||
|
export type RicheseBidPromisePanelProps = {
|
||||||
|
promises: RicheseBidPromiseReceipt[];
|
||||||
|
guide: { action: Action } | null;
|
||||||
|
game: { turn: number; richeseAuction: { event: string } | null };
|
||||||
|
act: (action: Action) => void;
|
||||||
|
busy: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The auction owns the physical card, actor, funding and final legality. */
|
||||||
|
export function RicheseBidPromiseGuide(props: RicheseBidPromisePanelProps) {
|
||||||
|
const { game, guide } = props;
|
||||||
|
if (!guide) return null;
|
||||||
|
const auction = game.richeseAuction, turn = game.turn, action = guide.action;
|
||||||
|
const auctionEvent = action.event, id = action.richeseBidPromiseId;
|
||||||
|
const signature = JSON.stringify(action);
|
||||||
|
const isCurrent = () => props.game === game && props.guide === guide &&
|
||||||
|
props.guide.action === action && JSON.stringify(action) === signature &&
|
||||||
|
game.turn === turn && !!auction && game.richeseAuction === auction &&
|
||||||
|
typeof auctionEvent === 'string' && auction.event === auctionEvent &&
|
||||||
|
typeof id === 'string' && action.type === 'richeseBid' &&
|
||||||
|
props.promises.some(promise => promise.id === id && promise.turn === turn &&
|
||||||
|
promise.event === auctionEvent && !promise.fulfilled && !promise.released);
|
||||||
|
const canAct = () => !props.busy && isCurrent();
|
||||||
|
if (!isCurrent()) return null;
|
||||||
|
return <Button className="game-action min-h-11 whitespace-normal" data-testid="richese-bid-promise-guide"
|
||||||
|
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'}
|
||||||
|
</Button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server supplies only this player's own receipts and a legal guide. */
|
||||||
|
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>
|
||||||
|
<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.
|
||||||
|
{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>
|
||||||
|
{waiting && <RicheseBidPromiseGuide {...props} />}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { RicheseBidPromiseFields, richeseBidMinimumError } from './richese-bid-promise-fields';
|
||||||
|
|
||||||
import { TravelClaimFields, travelClaimInputError } from './travel-claim-fields';
|
import { TravelClaimFields, travelClaimInputError } from './travel-claim-fields';
|
||||||
import { TravelAnswers } from './travel-promises';
|
import { TravelAnswers } from './travel-promises';
|
||||||
@@ -97,7 +98,7 @@ export function Truthtrance({
|
|||||||
g.players.find((p) => p.id !== me.id)!.id,
|
g.players.find((p) => p.id !== me.id)!.id,
|
||||||
);
|
);
|
||||||
const [kind, setKind] = useState<
|
const [kind, setKind] = useState<
|
||||||
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'phaseBid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
|
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'phaseBid' | 'richeseBid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions'
|
||||||
>('fact');
|
>('fact');
|
||||||
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
|
const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader =>
|
||||||
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
|
leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? '');
|
||||||
@@ -242,6 +243,7 @@ export function Truthtrance({
|
|||||||
: kind === 'mixedBid' ? { kind, target, turn: bidClaim.turn, lot: bidClaim.lot, mixed: mixedBid }
|
: kind === 'mixedBid' ? { kind, target, turn: bidClaim.turn, lot: bidClaim.lot, mixed: mixedBid }
|
||||||
: kind === 'bid' ? { kind, target, ...bidClaim }
|
: kind === 'bid' ? { kind, target, ...bidClaim }
|
||||||
: kind === 'phaseBid' ? { kind, target, ...phaseBidClaim }
|
: kind === 'phaseBid' ? { kind, target, ...phaseBidClaim }
|
||||||
|
: kind === 'richeseBid' ? { kind, target, turn:g.turn, event:g.richeseAuction?.event ?? '', minimum:bidMinimum }
|
||||||
: kind === 'shipment'
|
: kind === 'shipment'
|
||||||
? {
|
? {
|
||||||
kind,
|
kind,
|
||||||
@@ -426,6 +428,13 @@ export function Truthtrance({
|
|||||||
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
|
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
|
||||||
</div>)}
|
</div>)}
|
||||||
</>
|
</>
|
||||||
|
) : 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>
|
||||||
|
{(g.truthRicheseBidAnswers ?? []).map(answer => <div key={answer}>
|
||||||
|
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
|
||||||
|
</div>)}
|
||||||
|
</>
|
||||||
) : w.question!.kind === 'phaseBid' ? (
|
) : w.question!.kind === 'phaseBid' ? (
|
||||||
<>
|
<>
|
||||||
<p className="notice">Yes requires one qualifying ordinary bid before this turn’s Bidding closes while possible. No forbids all qualifying later bids. A bid does not promise a win; winning still requires payment.</p>
|
<p className="notice">Yes requires one qualifying ordinary bid before this turn’s Bidding closes while possible. No forbids all qualifying later bids. A bid does not promise a win; winning still requires payment.</p>
|
||||||
@@ -589,6 +598,7 @@ export function Truthtrance({
|
|||||||
<option value="revival">Bind ordinary force revivals this turn</option>
|
<option value="revival">Bind ordinary force revivals this turn</option>
|
||||||
<option value="bid">Bind a bid in this ordinary lot</option>
|
<option value="bid">Bind a bid in this ordinary lot</option>
|
||||||
<option value="phaseBid">Bind a bid this Bidding phase</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="mixedBid">Combine current facts and ordinary bids</option>
|
<option value="mixedBid">Combine current facts and ordinary bids</option>
|
||||||
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
|
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
|
||||||
<option value="mixedTraitorCall">Combine current facts and this Traitor declaration</option>
|
<option value="mixedTraitorCall">Combine current facts and this Traitor declaration</option>
|
||||||
@@ -732,6 +742,12 @@ export function Truthtrance({
|
|||||||
{!g.truthRevivalTargets.includes(target) && <p className="notice">Choose a respondent whose native Revival opportunity remains this turn and finish pending decisions. Registered native Basic and Advanced tables without optional modules are supported first.</p>}
|
{!g.truthRevivalTargets.includes(target) && <p className="notice">Choose a respondent whose native Revival opportunity remains this turn and finish pending decisions. Registered native Basic and Advanced tables without optional modules are supported first.</p>}
|
||||||
<p className="notice" aria-label="Public revival question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
|
<p className="notice" aria-label="Public revival question preview">{truthQuestionText(question, leaderName, locationName, name)}</p>
|
||||||
</>
|
</>
|
||||||
|
) : 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>
|
||||||
|
</>
|
||||||
) : kind === 'phaseBid' ? (
|
) : kind === 'phaseBid' ? (
|
||||||
<>
|
<>
|
||||||
<PhaseBidClaimFields value={phaseBidClaim} disabled={busy}
|
<PhaseBidClaimFields value={phaseBidClaim} disabled={busy}
|
||||||
@@ -877,6 +893,7 @@ export function Truthtrance({
|
|||||||
(kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) ||
|
(kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) ||
|
||||||
(kind === 'mixedBid' && (!!mixedBidError || !g.truthMixedBidTargets.includes(target))) ||
|
(kind === 'mixedBid' && (!!mixedBidError || !g.truthMixedBidTargets.includes(target))) ||
|
||||||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
|
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
|
||||||
|
(kind === 'richeseBid' && (!!richeseBidMinimumError(bidMinimum) || !g.richeseAuction || !g.truthRicheseBidTargets.includes(target))) ||
|
||||||
(kind === 'phaseBid' && (!!phaseBidClaimInputError(phaseBidClaim) || !g.truthPhaseBidTargets.includes(target))) ||
|
(kind === 'phaseBid' && (!!phaseBidClaimInputError(phaseBidClaim) || !g.truthPhaseBidTargets.includes(target))) ||
|
||||||
(kind === 'turnActions' && (!!turnActionsError || !g.truthTurnActionTargets.includes(target))) ||
|
(kind === 'turnActions' && (!!turnActionsError || !g.truthTurnActionTargets.includes(target))) ||
|
||||||
(kind === 'travel' && (!!travelError || !g.truthTravelTargets.includes(target))) ||
|
(kind === 'travel' && (!!travelError || !g.truthTravelTargets.includes(target))) ||
|
||||||
|
|||||||
@@ -20,6 +20,17 @@ open mode gates.
|
|||||||
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
||||||
Prototype labels and final acceptance gates remain.
|
Prototype labels and final acceptance gates remain.
|
||||||
|
|
||||||
|
**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
|
||||||
|
single opportunity, eligible order, current hand and native funds remain
|
||||||
|
binding; a waiting Richese owner is not assumed to get a turn after all pass.
|
||||||
|
An opposing raise can release an impossible Yes. Independent financial answers
|
||||||
|
are conservatively limited until a joint planner exists. Ten focused files pass
|
||||||
|
120/120; type/lint and build pass. Server-rendered controls and guide callbacks
|
||||||
|
are exercised, but stateful selector and browser/deployed play are unverified.
|
||||||
|
[Scope and source](TRUTHTRANCE_BID_PROMISES.md#richese-once-around-bid-prototype--27-october-2026).
|
||||||
|
|
||||||
**Phase-bid admission repair — 27 October. Prototyped / Partial.**
|
**Phase-bid admission repair — 27 October. Prototyped / Partial.**
|
||||||
A displayed shipment, movement, revival or travel answer now preserves an
|
A displayed shipment, movement, revival or travel answer now preserves an
|
||||||
existing phase-bid Yes when a known compatible route exists. The unfinished
|
existing phase-bid Yes when a known compatible route exists. The unfinished
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 27 October 2026 — Richese Once Around bid Truthtrance
|
||||||
|
|
||||||
|
Connect a separate current-lot minimum-bid question and owner-only receipt and
|
||||||
|
guide to the original public Once Around event. The bidder acts only once;
|
||||||
|
existing order, physical cards, hand space, current funds and ally pledge stay
|
||||||
|
native. External impossibility releases a Yes, and a waiting Richese owner
|
||||||
|
cannot rely on a hypothetical earlier bid. Basic's native Semuta preview is
|
||||||
|
permitted but its other discard combinations remain uncertified. Simultaneous
|
||||||
|
Silent bids, Normal Black Market bidding, broader modules and a joint future
|
||||||
|
planner remain open. Basic/Advanced original setup sources exercise physical
|
||||||
|
Truth cards, native bidding, four minimal bot profiles and stale-guide rejection;
|
||||||
|
an Advanced conflicting-bribe supplement explicitly stages one original card.
|
||||||
|
Ten focused files pass 120/120, with type/lint and build. Server-rendered
|
||||||
|
controls and guide callbacks are exercised; stateful browser, full-mode and
|
||||||
|
deployed acceptance remain open. Private proof is
|
||||||
|
`20261027-richese-truth-bid-proof-v1/`.
|
||||||
|
[Contract](TRUTHTRANCE_BID_PROMISES.md#richese-once-around-bid-prototype--27-october-2026).
|
||||||
|
|
||||||
## 27 October 2026 — Phase-bid admission repair
|
## 27 October 2026 — Phase-bid admission repair
|
||||||
|
|
||||||
An independently projected movement, shipment, travel or revival answer now
|
An independently projected movement, shipment, travel or revival answer now
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# Rule decision index
|
# Rule decision index
|
||||||
|
|
||||||
|
## Richese Once Around bid Truthtrance — 27 October 2026
|
||||||
|
|
||||||
|
Provisionally allow an exact minimum-bid question for the one remaining Once
|
||||||
|
Around declaration, bound to its public event. An accepted bid fulfills Yes;
|
||||||
|
No caps the single bid and permits passing. No future rival bid is assumed for
|
||||||
|
the owner's final opportunity. Preserve native funding, hand limits and actual
|
||||||
|
settlement; an opposing raise can release an impossible answer. The general GF9
|
||||||
|
FAQ supports current-turn commitments while possible but does not prescribe
|
||||||
|
this specific special-auction procedure. Independent combination forecasts and
|
||||||
|
other auction methods remain open. [Contract](TRUTHTRANCE_BID_PROMISES.md#richese-once-around-bid-prototype--27-october-2026).
|
||||||
|
|
||||||
## Phase-wide ordinary bid Truthtrance — 27 October 2026
|
## Phase-wide ordinary bid Truthtrance — 27 October 2026
|
||||||
|
|
||||||
Prototypically support a current-turn Yes/No minimum ordinary bid across
|
Prototypically support a current-turn Yes/No minimum ordinary bid across
|
||||||
|
|||||||
@@ -1,5 +1,35 @@
|
|||||||
# Truthtrance ordinary bid commitments
|
# Truthtrance ordinary bid commitments
|
||||||
|
|
||||||
|
## Richese Once Around bid prototype — 27 October 2026
|
||||||
|
|
||||||
|
**Prototyped / Partial.** The distinct `{kind:'richeseBid', target, turn, event,
|
||||||
|
minimum}` question binds the current public Once Around lot, not its private
|
||||||
|
card identity. The original physical Truth card, one bid or pass, bid order,
|
||||||
|
capacity, currently available spice, pledged ally funding and native winner's
|
||||||
|
payment remain unchanged. An accepted qualifying bid fulfills Yes even if it
|
||||||
|
loses; No excludes qualifying declarations. The last-acting Richese owner gets
|
||||||
|
no opportunity if all other bidders pass, so Yes is not forecast without an
|
||||||
|
existing positive bid. A real opposing raise or loss of funds can release an
|
||||||
|
impossible Yes; the respondent cannot deliberately abandon it.
|
||||||
|
|
||||||
|
The public question editor, private answer, own receipt, event-bound guide and
|
||||||
|
four minimal legal bot profiles are connected. Existing positive independent
|
||||||
|
financial/transport commitments restrict admission in both answer orders until
|
||||||
|
a combined planner is implemented. Silent and normal special auctions, mixed
|
||||||
|
expressions, optional module combinations, broad Semuta discard interactions,
|
||||||
|
complete modes and browser/deployed acceptance remain open. Basic's native
|
||||||
|
Semuta preview may enter the quiet lot path, without certifying its combinations.
|
||||||
|
|
||||||
|
The [GF9 November 2020 FAQ p8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8)
|
||||||
|
supports current-turn action questions and compliance while possible but does
|
||||||
|
not prescribe this Once Around procedure. The online opportunity and release
|
||||||
|
policy is a provisional software interpretation, not a dedicated publisher
|
||||||
|
ruling. Native Basic/Advanced R/A/E starting deals and auction actions exercise
|
||||||
|
answers, costs, lot continuation, rival raises and minimal bots. A separate
|
||||||
|
Advanced bribe conflict uses a labeled card-conserving supplement. Server-rendered
|
||||||
|
controls and guide callbacks are exercised, without operating the stateful
|
||||||
|
selector. Ten focused files pass 120/120; type/lint and build pass.
|
||||||
|
|
||||||
## Phase-wide ordinary bid prototype — 27 October 2026
|
## Phase-wide ordinary bid prototype — 27 October 2026
|
||||||
|
|
||||||
**Prototyped / Partial.** A distinct `{kind:'phaseBid', target, turn, minimum}`
|
**Prototyped / Partial.** A distinct `{kind:'phaseBid', target, turn, minimum}`
|
||||||
|
|||||||
@@ -1319,6 +1319,7 @@ function policyActions(g: GameView): Action[] {
|
|||||||
if (g.revivalExpressionPromiseGuide) return [g.revivalExpressionPromiseGuide.action];
|
if (g.revivalExpressionPromiseGuide) return [g.revivalExpressionPromiseGuide.action];
|
||||||
if (g.leaderRevivalPromiseGuide) return [g.leaderRevivalPromiseGuide.action];
|
if (g.leaderRevivalPromiseGuide) return [g.leaderRevivalPromiseGuide.action];
|
||||||
if (g.revivalPromiseGuide) return [g.revivalPromiseGuide.action];
|
if (g.revivalPromiseGuide) return [g.revivalPromiseGuide.action];
|
||||||
|
if (g.richeseBidPromiseGuide) return [g.richeseBidPromiseGuide.action];
|
||||||
if (g.phaseBidPromiseGuide) return [g.phaseBidPromiseGuide.action];
|
if (g.phaseBidPromiseGuide) return [g.phaseBidPromiseGuide.action];
|
||||||
if (g.bidPromiseGuide) return [g.bidPromiseGuide.action];
|
if (g.bidPromiseGuide) return [g.bidPromiseGuide.action];
|
||||||
if (g.truthtrance) {
|
if (g.truthtrance) {
|
||||||
@@ -1376,6 +1377,10 @@ function policyActions(g: GameView): Action[] {
|
|||||||
const answer = g.truthBidAnswers?.[0];
|
const answer = g.truthBidAnswers?.[0];
|
||||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||||
}
|
}
|
||||||
|
if (w.question.kind === 'richeseBid') {
|
||||||
|
const answer = g.truthRicheseBidAnswers?.[0];
|
||||||
|
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||||
|
}
|
||||||
if (w.question.kind === 'phaseBid') {
|
if (w.question.kind === 'phaseBid') {
|
||||||
const answer = g.truthPhaseBidAnswers?.[0];
|
const answer = g.truthPhaseBidAnswers?.[0];
|
||||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||||
|
|||||||
+121
-3
@@ -1,3 +1,6 @@
|
|||||||
|
import { RicheseBidPromiseError, richeseBidCompletionRange, richeseBidRequired, recordRicheseBid,
|
||||||
|
liveRicheseBidPromises, reconcileRicheseBidPromises, finishRicheseBidLot, validateRicheseBidPromises,
|
||||||
|
type RicheseBidClaim, type RicheseBidPromise, type RicheseBidOpportunity } from './richese-bid-promises';
|
||||||
import { AuctionClockError, AUCTION_BID_MS, quoteAuctionClock, viewAuctionClock,
|
import { AuctionClockError, AUCTION_BID_MS, quoteAuctionClock, viewAuctionClock,
|
||||||
type AuctionClock, type AuctionClockView } from './auction-clock';
|
type AuctionClock, type AuctionClockView } from './auction-clock';
|
||||||
import { PrintedCardPlayError, printedCardPlayCount, recordPrintedCardPlay,
|
import { PrintedCardPlayError, printedCardPlayCount, recordPrintedCardPlay,
|
||||||
@@ -2248,6 +2251,7 @@ export type Game = {
|
|||||||
shipmentPromises?: ShipmentPromise[];
|
shipmentPromises?: ShipmentPromise[];
|
||||||
bidPromises?: BidPromise[];
|
bidPromises?: BidPromise[];
|
||||||
phaseBidPromises?: PhaseBidPromise[];
|
phaseBidPromises?: PhaseBidPromise[];
|
||||||
|
richeseBidPromises?: RicheseBidPromise[];
|
||||||
/** Raw mixed question linked to one private native current-lot obligation. */
|
/** Raw mixed question linked to one private native current-lot obligation. */
|
||||||
mixedBidQuestions?: (MixedBidClaim & {
|
mixedBidQuestions?: (MixedBidClaim & {
|
||||||
event: string; player: string; asker: string; answer: boolean; binding: boolean;
|
event: string; player: string; asker: string; answer: boolean; binding: boolean;
|
||||||
@@ -15661,7 +15665,8 @@ function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no'
|
|||||||
const promises = [...(g.bribePromises ?? []), { ...claim, event: 'candidate', player: p.id,
|
const promises = [...(g.bribePromises ?? []), { ...claim, event: 'candidate', player: p.id,
|
||||||
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: 0 }];
|
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: 0 }];
|
||||||
return !!bribePromiseCompletion(source, p, promises) &&
|
return !!bribePromiseCompletion(source, p, promises) &&
|
||||||
phaseBidCompatibleBribeAnswer({ ...source, bribePromises: promises }, p);
|
phaseBidCompatibleBribeAnswer({ ...source, bribePromises: promises }, p) &&
|
||||||
|
richeseBidCompatibleLaterPromises({ ...source, bribePromises: promises },p);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function bribeExpressionTruthAnswers(g: Game, p: Player, claim: BribeExpressionClaim): ('yes' | 'no' | 'unknown')[] {
|
function bribeExpressionTruthAnswers(g: Game, p: Player, claim: BribeExpressionClaim): ('yes' | 'no' | 'unknown')[] {
|
||||||
@@ -15672,7 +15677,8 @@ function bribeExpressionTruthAnswers(g: Game, p: Player, claim: BribeExpressionC
|
|||||||
const expressions = [...(g.bribeExpressionPromises ?? []), { ...claim, event: 'candidate', player: p.id,
|
const expressions = [...(g.bribeExpressionPromises ?? []), { ...claim, event: 'candidate', player: p.id,
|
||||||
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: [] }];
|
asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: [] }];
|
||||||
return !!bribePromiseCompletion(source, p, g.bribePromises ?? [], expressions) &&
|
return !!bribePromiseCompletion(source, p, g.bribePromises ?? [], expressions) &&
|
||||||
phaseBidCompatibleBribeAnswer({ ...source, bribeExpressionPromises: expressions }, p);
|
phaseBidCompatibleBribeAnswer({ ...source, bribeExpressionPromises: expressions }, p) &&
|
||||||
|
richeseBidCompatibleLaterPromises({ ...source, bribeExpressionPromises: expressions },p);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function reopenBribePayer(g: Game, p: Player, answer: boolean) {
|
function reopenBribePayer(g: Game, p: Player, answer: boolean) {
|
||||||
@@ -16071,6 +16077,91 @@ function phaseBidWitnesses(g: Game, p: Player): PhaseBidOpportunity[] {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
function richeseBidRule<T>(run: () => T): T {
|
||||||
|
try { return run(); } catch (error) { if (error instanceof RicheseBidPromiseError) throw new RuleError(error.message); throw error; }
|
||||||
|
}
|
||||||
|
function richeseBidWindow(g: Game): boolean {
|
||||||
|
return bidPromiseModeSupported({ ...g, semutaPreview: undefined }) && g.status === 'playing' && g.phase === 3 &&
|
||||||
|
!!g.richeseAuction && g.richeseAuction.method === 'onceAround' && !g.richeseAuction.outcome &&
|
||||||
|
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard;
|
||||||
|
}
|
||||||
|
function richeseBidOpportunity(g: Game, p: Player): RicheseBidOpportunity | null {
|
||||||
|
const lot = g.richeseAuction;
|
||||||
|
if (!lot || !richeseBidWindow(g) || !lot.eligible.includes(p.id) || lot.acted.includes(p.id) ||
|
||||||
|
!richeseBidFitsHand(lot.source,lot.owner,p.id,p.hand.length,handLimit(p)) ||
|
||||||
|
(p.id === lot.owner && lot.active !== p.id && !lot.bidder) || lot.bid >= Number.MAX_SAFE_INTEGER) return null;
|
||||||
|
const own = uncommittedSpice(g,p) + richeseOwnCommitment(lot,g.richeseFunding ?? {},p.id);
|
||||||
|
const max = Math.min(Number.MAX_SAFE_INTEGER,own + (aidFor(g,p)?.amount ?? 0));
|
||||||
|
if (!Number.isSafeInteger(max) || max < lot.bid + 1) return null;
|
||||||
|
return {turn:g.turn,event:lot.event,player:p.id,minimum:lot.bid + 1,maximum:max};
|
||||||
|
}
|
||||||
|
function richeseBidOtherPromises(g: Game, p: Player): boolean {
|
||||||
|
return liveShipmentPromises(g.shipmentPromises ?? [],p.id,g.turn).some(row => row.answer) ||
|
||||||
|
hasMovementPromises(g,p.id,true) || liveRevivalPromises(g.revivalPromises ?? [],p.id,g.turn).some(row => row.answer) ||
|
||||||
|
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [],p.id,g.turn).some(row => row.answer) ||
|
||||||
|
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [],p.id,g.turn).some(row => row.answer) ||
|
||||||
|
hasBribePromises(g,p.id,true) || liveTurnActionPromises(g.turnActionPromises ?? [],p.id,g.turn).length > 0 ||
|
||||||
|
livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn).some(row => row.answer);
|
||||||
|
}
|
||||||
|
/** Other financial/action answers need a joint planner before they can
|
||||||
|
* coexist with a live Once Around Yes. Reject the unsupported combination at
|
||||||
|
* admission, not later when neither promised action can be completed. */
|
||||||
|
function richeseBidCompatibleLaterPromises(g: Game,p: Player): boolean {
|
||||||
|
const lot = g.richeseAuction;
|
||||||
|
if (!lot) return true;
|
||||||
|
const context = {turn:g.turn,event:lot.event,player:p.id};
|
||||||
|
return !richeseBidRequired(g.richeseBidPromises ?? [],context) || !richeseBidOtherPromises(g,p);
|
||||||
|
}
|
||||||
|
function richeseBidTruthAnswers(g: Game, p: Player, claim: RicheseBidClaim): TruthAnswer[] {
|
||||||
|
const lot = g.richeseAuction;
|
||||||
|
if (!richeseBidWindow(g) || !lot || claim.turn !== g.turn || claim.event !== lot.event ||
|
||||||
|
!lot.eligible.includes(p.id) || lot.acted.includes(p.id) ||
|
||||||
|
!richeseBidFitsHand(lot.source,lot.owner,p.id,p.hand.length,handLimit(p))) return [];
|
||||||
|
const base = g.richeseBidPromises ?? [], opportunity = richeseBidOpportunity(g,p);
|
||||||
|
const context = {turn:g.turn,event:lot.event,player:p.id};
|
||||||
|
const asker = g.truthtrance?.queue[0]?.player ?? g.players.find(row => row.id !== p.id)?.id ?? g.host;
|
||||||
|
return (['yes','no'] as const).filter(answer => {
|
||||||
|
const candidate: RicheseBidPromise = {...claim,id:'candidate',player:p.id,asker,answer:answer === 'yes'};
|
||||||
|
const all = [...base,candidate];
|
||||||
|
if (answer === 'yes' && richeseBidOtherPromises(g,p)) return false;
|
||||||
|
return !richeseBidRequired(all,context) || !!opportunity && !!richeseBidCompletionRange(all,opportunity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function bindRicheseBidTruth(g: Game,p: Player,claim: RicheseBidClaim,answer: boolean,asker: string) {
|
||||||
|
requireRule(richeseBidTruthAnswers(g,p,claim).includes(answer ? 'yes' : 'no'),
|
||||||
|
'Choose an answer compatible with this Once Around bid.');
|
||||||
|
(g.richeseBidPromises ??= []).push({...claim,id:crypto.randomUUID(),player:p.id,asker,answer});
|
||||||
|
}
|
||||||
|
function richeseBidIntegrity(g: Game) {
|
||||||
|
richeseBidRule(() => validateRicheseBidPromises(g.richeseBidPromises ?? [],g.players.map(row => row.id)));
|
||||||
|
}
|
||||||
|
function reconcileRicheseBids(g: Game,voluntary?: {actor:string;action:Action}) {
|
||||||
|
richeseBidIntegrity(g);
|
||||||
|
if (!g.richeseBidPromises?.length || g.truthtrance || g.response || g.decision || g.phaseOpening ||
|
||||||
|
g.pendingKarama || g.pendingTreacheryDiscard) return;
|
||||||
|
for (const row of g.richeseBidPromises) {
|
||||||
|
if (row.fulfilled || row.released) continue;
|
||||||
|
const context = {turn:row.turn,event:row.event,player:row.player};
|
||||||
|
const change = voluntary?.actor === row.player ? 'voluntary' : 'external';
|
||||||
|
const lot = g.richeseAuction;
|
||||||
|
if (g.turn !== row.turn || !lot || lot.event !== row.event || lot.outcome)
|
||||||
|
g.richeseBidPromises = richeseBidRule(() => finishRicheseBidLot(g.richeseBidPromises!,context,change));
|
||||||
|
else g.richeseBidPromises = richeseBidRule(() => reconcileRicheseBidPromises(g.richeseBidPromises!,context,
|
||||||
|
richeseBidOpportunity(g,getPlayer(g,row.player)),change));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function projectedRicheseBidGuide(g: Game, player: string): {action:Action} | null {
|
||||||
|
const lot = g.richeseAuction, p = getPlayer(g,player);
|
||||||
|
if (!lot || lot.active !== player || g.truthtrance || !richeseBidWindow(g)) return null;
|
||||||
|
const context = {turn:g.turn,event:lot.event,player}, live = liveRicheseBidPromises(g.richeseBidPromises ?? [],context);
|
||||||
|
if (!live.length) return null;
|
||||||
|
if (!richeseBidRequired(live,context)) return {action:{type:'richeseBid',event:lot.event,amount:null,allyPayment:0,richeseBidPromiseId:live[0].id}};
|
||||||
|
const opportunity = richeseBidOpportunity(g,p), range = opportunity && richeseBidCompletionRange(live,opportunity);
|
||||||
|
if (!range) return null;
|
||||||
|
const own = uncommittedSpice(g,p) + richeseOwnCommitment(lot,g.richeseFunding ?? {},player);
|
||||||
|
return {action:{type:'richeseBid',event:lot.event,amount:range.minimum,
|
||||||
|
allyPayment:Math.max(0,range.minimum-own),richeseBidPromiseId:live[0].id}};
|
||||||
|
}
|
||||||
function phaseBidQuestionWindow(g: Game): boolean {
|
function phaseBidQuestionWindow(g: Game): boolean {
|
||||||
return g.status === 'playing' && g.phase >= 0 && g.phase <= 3 && phaseBidPromiseModeSupported(g) &&
|
return g.status === 'playing' && g.phase >= 0 && g.phase <= 3 && phaseBidPromiseModeSupported(g) &&
|
||||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard &&
|
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard &&
|
||||||
@@ -16159,6 +16250,7 @@ function viablePhaseBidWitnesses(g: Game, p: Player, promises: readonly PhaseBid
|
|||||||
* bid impossible. If the future joint planner has no witness, a definite
|
* bid impossible. If the future joint planner has no witness, a definite
|
||||||
* answer is withheld rather than offered and rejected after binding. */
|
* answer is withheld rather than offered and rejected after binding. */
|
||||||
function phaseBidCompatibleLaterPromises(g: Game, p: Player): boolean {
|
function phaseBidCompatibleLaterPromises(g: Game, p: Player): boolean {
|
||||||
|
if (!richeseBidCompatibleLaterPromises(g,p)) return false;
|
||||||
const live = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn);
|
const live = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn);
|
||||||
if (!live.some(row => row.answer)) return true;
|
if (!live.some(row => row.answer)) return true;
|
||||||
return viablePhaseBidWitnesses(g,p,g.phaseBidPromises ?? [])
|
return viablePhaseBidWitnesses(g,p,g.phaseBidPromises ?? [])
|
||||||
@@ -20695,6 +20787,7 @@ function turnActionTruthAnswers(g: Game, p: Player, claim: TurnActionClaim): Tru
|
|||||||
if (promise.compiled.kind === 'constant' && promise.compiled.value !== answer) continue;
|
if (promise.compiled.kind === 'constant' && promise.compiled.value !== answer) continue;
|
||||||
const trial = { ...turnActionAnswerSource(g, p.id, !!promise.alliance, !!promise.windowCards, { claim, answer }),
|
const trial = { ...turnActionAnswerSource(g, p.id, !!promise.alliance, !!promise.windowCards, { claim, answer }),
|
||||||
turnActionPromises: [...(g.turnActionPromises ?? []), promise], travelPromiseOrder: undefined };
|
turnActionPromises: [...(g.turnActionPromises ?? []), promise], travelPromiseOrder: undefined };
|
||||||
|
if (!richeseBidCompatibleLaterPromises(trial,p) && promise.compiled.kind !== 'constant') continue;
|
||||||
const phases = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn);
|
const phases = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn);
|
||||||
const lot = bidOpportunity(trial);
|
const lot = bidOpportunity(trial);
|
||||||
if (phases.length && lot) {
|
if (phases.length && lot) {
|
||||||
@@ -38937,6 +39030,7 @@ function normalizeCardNames(g: Game) {
|
|||||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||||
bidPromiseIntegrity(state);
|
bidPromiseIntegrity(state);
|
||||||
phaseBidIntegrity(state);
|
phaseBidIntegrity(state);
|
||||||
|
richeseBidIntegrity(state);
|
||||||
revivalPromiseIntegrity(state);
|
revivalPromiseIntegrity(state);
|
||||||
bribePromiseIntegrity(state);
|
bribePromiseIntegrity(state);
|
||||||
alliancePromiseIntegrity(state);
|
alliancePromiseIntegrity(state);
|
||||||
@@ -39109,6 +39203,12 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
|||||||
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
|
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
|
||||||
'This phase bid guide is stale or belongs to another auction lot.');
|
'This phase bid guide is stale or belongs to another auction lot.');
|
||||||
}
|
}
|
||||||
|
if (action.richeseBidPromiseId !== undefined) {
|
||||||
|
const guide = projectedRicheseBidGuide(state,id);
|
||||||
|
requireRule(guide && Object.keys(action).sort().join(',') === Object.keys(guide.action).sort().join(',') &&
|
||||||
|
Object.keys(guide.action).every(key => action[key] === guide.action[key]),
|
||||||
|
'This Once Around bid guide is stale or belongs to another lot.');
|
||||||
|
}
|
||||||
const g = applyActionInner(state, id, action);
|
const g = applyActionInner(state, id, action);
|
||||||
if (state.phase === 3 && liveTurnActionPromises(state.turnActionPromises ?? [], id, state.turn).some(row => {
|
if (state.phase === 3 && liveTurnActionPromises(state.turnActionPromises ?? [], id, state.turn).some(row => {
|
||||||
const stage = row.windowCards?.karamaAuction;
|
const stage = row.windowCards?.karamaAuction;
|
||||||
@@ -39123,6 +39223,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
|||||||
reconcileTravelPromises(g, promiseActor, action, state.phase);
|
reconcileTravelPromises(g, promiseActor, action, state.phase);
|
||||||
reconcileBidPromises(g, voluntary);
|
reconcileBidPromises(g, voluntary);
|
||||||
reconcilePhaseBids(g, voluntary);
|
reconcilePhaseBids(g, voluntary);
|
||||||
|
reconcileRicheseBids(g, voluntary);
|
||||||
reconcileRevivalPromises(g, voluntary);
|
reconcileRevivalPromises(g, voluntary);
|
||||||
reconcileBribePromises(g, voluntary);
|
reconcileBribePromises(g, voluntary);
|
||||||
reconcileGroundMovementPromises(g, promiseActor, action, state.phase);
|
reconcileGroundMovementPromises(g, promiseActor, action, state.phase);
|
||||||
@@ -39248,6 +39349,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
|||||||
reconcileShipmentPromises(g, { actor: id, action, phase: state.phase });
|
reconcileShipmentPromises(g, { actor: id, action, phase: state.phase });
|
||||||
reconcileBidPromises(g, { actor: id, action });
|
reconcileBidPromises(g, { actor: id, action });
|
||||||
reconcilePhaseBids(g, { actor: id, action });
|
reconcilePhaseBids(g, { actor: id, action });
|
||||||
|
reconcileRicheseBids(g, { actor: id, action });
|
||||||
reconcileRevivalPromises(g, { actor: id, action });
|
reconcileRevivalPromises(g, { actor: id, action });
|
||||||
reconcileBribePromises(g, { actor: id, action });
|
reconcileBribePromises(g, { actor: id, action });
|
||||||
reconcileAlliancePromises(g, { actor: id, action });
|
reconcileAlliancePromises(g, { actor: id, action });
|
||||||
@@ -39466,6 +39568,7 @@ function settleAutomaticContinuations(g: Game) {
|
|||||||
reconcileShipmentPromises(g);
|
reconcileShipmentPromises(g);
|
||||||
reconcileBidPromises(g);
|
reconcileBidPromises(g);
|
||||||
reconcilePhaseBids(g);
|
reconcilePhaseBids(g);
|
||||||
|
reconcileRicheseBids(g);
|
||||||
reconcileRevivalPromises(g);
|
reconcileRevivalPromises(g);
|
||||||
reconcileBribePromises(g);
|
reconcileBribePromises(g);
|
||||||
reconcileGroundMovementPromises(g);
|
reconcileGroundMovementPromises(g);
|
||||||
@@ -39484,6 +39587,7 @@ function settleAutomaticContinuations(g: Game) {
|
|||||||
export function normalizeAutomaticGame(state: Game): Game {
|
export function normalizeAutomaticGame(state: Game): Game {
|
||||||
bidPromiseIntegrity(state);
|
bidPromiseIntegrity(state);
|
||||||
phaseBidIntegrity(state);
|
phaseBidIntegrity(state);
|
||||||
|
richeseBidIntegrity(state);
|
||||||
revivalPromiseIntegrity(state);
|
revivalPromiseIntegrity(state);
|
||||||
bribePromiseIntegrity(state);
|
bribePromiseIntegrity(state);
|
||||||
alliancePromiseIntegrity(state);
|
alliancePromiseIntegrity(state);
|
||||||
@@ -39573,6 +39677,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
|||||||
reconcileShipmentPromises(g);
|
reconcileShipmentPromises(g);
|
||||||
reconcileBidPromises(g);
|
reconcileBidPromises(g);
|
||||||
reconcilePhaseBids(g);
|
reconcilePhaseBids(g);
|
||||||
|
reconcileRicheseBids(g);
|
||||||
reconcileRevivalPromises(g);
|
reconcileRevivalPromises(g);
|
||||||
reconcileBribePromises(g);
|
reconcileBribePromises(g);
|
||||||
reconcileGroundMovementPromises(g);
|
reconcileGroundMovementPromises(g);
|
||||||
@@ -39849,6 +39954,8 @@ function applyActionInner(
|
|||||||
bindMixedBid: bindMixedBidTruth,
|
bindMixedBid: bindMixedBidTruth,
|
||||||
bidAnswers: bidTruthAnswers,
|
bidAnswers: bidTruthAnswers,
|
||||||
bindBid: bindBidTruth,
|
bindBid: bindBidTruth,
|
||||||
|
richeseBidAnswers: richeseBidTruthAnswers,
|
||||||
|
bindRicheseBid: bindRicheseBidTruth,
|
||||||
phaseBidAnswers: phaseBidTruthAnswers,
|
phaseBidAnswers: phaseBidTruthAnswers,
|
||||||
bindPhaseBid: bindPhaseBidTruth,
|
bindPhaseBid: bindPhaseBidTruth,
|
||||||
turnActionAnswers: turnActionTruthAnswers,
|
turnActionAnswers: turnActionTruthAnswers,
|
||||||
@@ -39868,7 +39975,7 @@ function applyActionInner(
|
|||||||
)
|
)
|
||||||
return g;
|
return g;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof AllianceExpressionPromiseError || error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof MovementExpressionPromiseError || error instanceof MixedActionQuestionError || error instanceof TraitorDecisionError)
|
if (error instanceof TruthError || error instanceof RevivalExpressionPromiseError || error instanceof LeaderRevivalPromiseError || error instanceof AlliancePromiseError || error instanceof AllianceExpressionPromiseError || error instanceof BribePromiseError || error instanceof BribeExpressionPromiseError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof RicheseBidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof MovementExpressionPromiseError || error instanceof MixedActionQuestionError || error instanceof TraitorDecisionError)
|
||||||
throw new RuleError(error.message);
|
throw new RuleError(error.message);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -42325,6 +42432,9 @@ function applyActionInner(
|
|||||||
if (parties.richeseSaleAmount !== undefined)
|
if (parties.richeseSaleAmount !== undefined)
|
||||||
checkBureaucratContributors(g,byFaction(g,'emperor')?.id,[{payer:lot.owner,amount:allyPayment}]);
|
checkBureaucratContributors(g,byFaction(g,'emperor')?.id,[{payer:lot.owner,amount:allyPayment}]);
|
||||||
}
|
}
|
||||||
|
if (lot.method === 'onceAround' && g.richeseBidPromises?.length)
|
||||||
|
g.richeseBidPromises = richeseBidRule(() => recordRicheseBid(g.richeseBidPromises!,
|
||||||
|
{turn:g.turn,event:lot.event,player:p.id},amount));
|
||||||
g.richeseAuction = submitRicheseBid(
|
g.richeseAuction = submitRicheseBid(
|
||||||
lot,
|
lot,
|
||||||
{ event: stringField(action.event), actor: p.id, amount },
|
{ event: stringField(action.event), actor: p.id, amount },
|
||||||
@@ -43849,6 +43959,14 @@ export function viewGame(state: Game, id: string, now?: number) {
|
|||||||
}).map(player => player.id) : [],
|
}).map(player => player.id) : [],
|
||||||
truthBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
truthBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||||
g.truthtrance.question.kind === 'bid' ? bidTruthAnswers(g, me, g.truthtrance.question) : null,
|
g.truthtrance.question.kind === 'bid' ? bidTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||||
|
truthRicheseBidTargets: richeseBidWindow(g) ? g.players.filter(player => {
|
||||||
|
const lot = g.richeseAuction!;
|
||||||
|
return lot.eligible.includes(player.id) && !lot.acted.includes(player.id);
|
||||||
|
}).map(player => player.id) : [],
|
||||||
|
truthRicheseBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||||
|
g.truthtrance.question.kind === 'richeseBid' ? richeseBidTruthAnswers(g,me,g.truthtrance.question) : null,
|
||||||
|
richeseBidPromises: (g.richeseBidPromises ?? []).filter(row => row.player === id).map(row => ({...row})),
|
||||||
|
richeseBidPromiseGuide: projectedRicheseBidGuide(g,id),
|
||||||
truthPhaseBidTargets: phaseBidQuestionWindow(g) ? g.players.map(player => player.id) : [],
|
truthPhaseBidTargets: phaseBidQuestionWindow(g) ? g.players.map(player => player.id) : [],
|
||||||
truthPhaseBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
truthPhaseBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||||
g.truthtrance.question.kind === 'phaseBid' ? phaseBidTruthAnswers(g,me,g.truthtrance.question) : null,
|
g.truthtrance.question.kind === 'phaseBid' ? phaseBidTruthAnswers(g,me,g.truthtrance.question) : null,
|
||||||
|
|||||||
+4
-3
@@ -3652,13 +3652,14 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'truthtrance-bid',
|
id: 'truthtrance-bid',
|
||||||
title: 'Truthtrance: ordinary bid commitments',
|
title: 'Truthtrance: bidding commitments',
|
||||||
category: 'Cards',
|
category: 'Cards',
|
||||||
coverage: 'Partial',
|
coverage: 'Partial',
|
||||||
developmentStage: 'Prototyped',
|
developmentStage: 'Prototyped',
|
||||||
summary: 'Bind one future minimum ordinary bid in the current lot or remaining Bidding phase, preserving native funding, payment and turn order.',
|
summary: 'Bind a minimum bid in an ordinary lot, the remaining ordinary Bidding phase, or one Richese Once Around lot, preserving native funding and order.',
|
||||||
steps: [
|
steps: [
|
||||||
'Choose Bind a bid in this ordinary lot, another remaining eligible bidder and a positive whole spice minimum. The turn and lot are fixed. Registered native Basic and Advanced tables without optional modules are supported first; special auctions and broader compositions remain unfinished.',
|
'Choose Bind a bid in this ordinary lot, another remaining eligible bidder and a positive whole spice minimum. The turn and lot are fixed. Registered native Basic and Advanced tables without optional modules are supported first; broader compositions remain unfinished.',
|
||||||
|
'In a quiet Richese Once Around auction, choose Bind a bid in this Once Around lot to bind the respondent’s one remaining bid or pass. A waiting Richese owner cannot promise Yes until an actual bid ensures an owner turn. Current hand space, funds and pledged aid remain required; an opposing raise can release an impossible Yes. Silent and Normal special auctions remain outside this prototype.',
|
||||||
'Alternatively choose Bind a bid this Bidding phase. Yes requires one qualifying accepted ordinary bid after the answer and before Bidding closes; No forbids qualifying later ordinary bids across lots. Earlier bids do not count, and a bid does not promise a win.',
|
'Alternatively choose Bind a bid this Bidding phase. Yes requires one qualifying accepted ordinary bid after the answer and before Bidding closes; No forbids qualifying later ordinary bids across lots. Earlier bids do not count, and a bid does not promise a win.',
|
||||||
'The phase-wide guide may wait for a later lot only when the existing funded sale, prepared next card and post-sale space and funds support it. An unbid lot, hypothetical rival raise, cancelable unfunded overbid or Harkonnen bonus slot is not invented as a future opportunity. Before Bidding, current funding and available card count provide a provisional forecast; special auction preparation and combined future commitments remain limited.',
|
'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.',
|
'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.',
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/** One future declaration in the exact public Richese Once Around lot.
|
||||||
|
* The engine owns native eligibility, timing, hand capacity and funding. */
|
||||||
|
export type RicheseBidClaim = { turn: number; event: string; minimum: number };
|
||||||
|
export type RicheseBidPromise = RicheseBidClaim & {
|
||||||
|
id: string;
|
||||||
|
player: string;
|
||||||
|
asker: string;
|
||||||
|
answer: boolean;
|
||||||
|
fulfilled?: boolean;
|
||||||
|
released?: boolean;
|
||||||
|
};
|
||||||
|
export type RicheseBidContext = { turn: number; event: string; player: string };
|
||||||
|
/** Inclusive actual funded interval supplied by the engine; never a forecast of
|
||||||
|
* another player's bid or a replacement for the native quote. */
|
||||||
|
export type RicheseBidOpportunity = RicheseBidContext & { minimum: number; maximum: number };
|
||||||
|
export type RicheseBidRange = { minimum: number; maximum: number };
|
||||||
|
export type RicheseBidChange = 'voluntary' | 'external';
|
||||||
|
export class RicheseBidPromiseError extends Error {}
|
||||||
|
|
||||||
|
const positive = (value: unknown): value is number =>
|
||||||
|
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
||||||
|
const identifier = (value: unknown): value is string =>
|
||||||
|
typeof value === 'string' && value.length > 0 && value === value.trim();
|
||||||
|
|
||||||
|
export function parseRicheseBidClaim(value: unknown): RicheseBidClaim {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||||
|
throw new RicheseBidPromiseError('Choose a current Once Around bid question.');
|
||||||
|
const input = value as Record<string, unknown>;
|
||||||
|
if (!positive(input.turn) || !identifier(input.event) || !positive(input.minimum))
|
||||||
|
throw new RicheseBidPromiseError('Choose the current turn, auction event and a positive whole bid minimum.');
|
||||||
|
return { turn: input.turn, event: input.event, minimum: input.minimum };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function richeseBidClaimText(claim: RicheseBidClaim): string {
|
||||||
|
return `place a bid of at least ${claim.minimum} spice in this Once Around auction`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkContext(context: RicheseBidContext): void {
|
||||||
|
if (!context || !positive(context.turn) || !identifier(context.event) || !identifier(context.player))
|
||||||
|
throw new RicheseBidPromiseError('Retain the original turn, auction event and bidder.');
|
||||||
|
}
|
||||||
|
function checkOpportunity(opportunity: RicheseBidOpportunity): void {
|
||||||
|
checkContext(opportunity);
|
||||||
|
if (!positive(opportunity.minimum) || !positive(opportunity.maximum) || opportunity.minimum > opportunity.maximum)
|
||||||
|
throw new RicheseBidPromiseError('Retain an actual funded Once Around bid interval.');
|
||||||
|
}
|
||||||
|
function sameContext(a: RicheseBidContext, b: RicheseBidContext): boolean {
|
||||||
|
return a.turn === b.turn && a.event === b.event && a.player === b.player;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function liveRicheseBidPromises(promises: readonly RicheseBidPromise[], context: RicheseBidContext): RicheseBidPromise[] {
|
||||||
|
checkContext(context);
|
||||||
|
return promises.filter(row => sameContext(row, context) && !row.fulfilled && !row.released);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yes requires a bid; No alone permits passing. Each No limits any bid. */
|
||||||
|
export function richeseBidAllowedRange(promises: readonly RicheseBidPromise[], opportunity: RicheseBidOpportunity): RicheseBidRange | null {
|
||||||
|
checkOpportunity(opportunity);
|
||||||
|
let maximum = opportunity.maximum;
|
||||||
|
for (const row of liveRicheseBidPromises(promises, opportunity))
|
||||||
|
if (!row.answer) maximum = Math.min(maximum, row.minimum - 1);
|
||||||
|
return opportunity.minimum <= maximum ? { minimum: opportunity.minimum, maximum } : null;
|
||||||
|
}
|
||||||
|
export function richeseBidCompletionRange(promises: readonly RicheseBidPromise[], opportunity: RicheseBidOpportunity): RicheseBidRange | null {
|
||||||
|
const allowed = richeseBidAllowedRange(promises, opportunity);
|
||||||
|
if (!allowed) return null;
|
||||||
|
let minimum = allowed.minimum;
|
||||||
|
for (const row of liveRicheseBidPromises(promises, opportunity))
|
||||||
|
if (row.answer) minimum = Math.max(minimum, row.minimum);
|
||||||
|
return minimum <= allowed.maximum ? { minimum, maximum: allowed.maximum } : null;
|
||||||
|
}
|
||||||
|
export function richeseBidRequired(promises: readonly RicheseBidPromise[], context: RicheseBidContext): boolean {
|
||||||
|
return liveRicheseBidPromises(promises, context).some(row => row.answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call inside the detached engine transaction immediately before its native
|
||||||
|
* bid/pass validation. A Once Around actor receives one declaration only; a
|
||||||
|
* small bid cannot rely on a later turn. The native action owns the actual
|
||||||
|
* bid amount, funding, eligibility, hand space and event validity. */
|
||||||
|
export function recordRicheseBid<T extends RicheseBidPromise>(promises: readonly T[], context: RicheseBidContext,
|
||||||
|
amount: number | null): T[] {
|
||||||
|
checkContext(context);
|
||||||
|
if (amount !== null && !positive(amount))
|
||||||
|
throw new RicheseBidPromiseError('Record only a positive bid or a pass.');
|
||||||
|
const live = liveRicheseBidPromises(promises, context);
|
||||||
|
if (live.some(row => row.answer && (amount === null || amount < row.minimum)))
|
||||||
|
throw new RicheseBidPromiseError('Honor your Truthtrance Yes before passing or bidding less.');
|
||||||
|
if (amount !== null && live.some(row => !row.answer && amount >= row.minimum))
|
||||||
|
throw new RicheseBidPromiseError('This bid conflicts with your Truthtrance No answer.');
|
||||||
|
return promises.map(row => live.includes(row) ? { ...row, fulfilled: true } : row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Supply null only after the engine has determined there is no native
|
||||||
|
* opportunity. External events release individually impossible Yes answers;
|
||||||
|
* the respondent cannot voluntarily make their own Yes impossible. */
|
||||||
|
export function reconcileRicheseBidPromises<T extends RicheseBidPromise>(promises: readonly T[],
|
||||||
|
context: RicheseBidContext, opportunity: RicheseBidOpportunity | null, change: RicheseBidChange): T[] {
|
||||||
|
checkContext(context);
|
||||||
|
if (change !== 'voluntary' && change !== 'external')
|
||||||
|
throw new RicheseBidPromiseError('Retain the cause of the auction change.');
|
||||||
|
if (opportunity) {
|
||||||
|
checkOpportunity(opportunity);
|
||||||
|
if (!sameContext(context, opportunity))
|
||||||
|
throw new RicheseBidPromiseError('The bid opportunity belongs to a different auction or bidder.');
|
||||||
|
}
|
||||||
|
const live = liveRicheseBidPromises(promises, context);
|
||||||
|
const range = opportunity ? richeseBidAllowedRange(promises, opportunity) : null;
|
||||||
|
const impossible = live.filter(row => row.answer && (!range || row.minimum > range.maximum));
|
||||||
|
if (impossible.length && change === 'voluntary')
|
||||||
|
throw new RicheseBidPromiseError('This action would remove your last funded Once Around bid opportunity.');
|
||||||
|
return promises.map(row => impossible.includes(row) ? { ...row, released: true } : row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close only the original lot; outstanding No is established and an
|
||||||
|
* unfulfilled Yes is released only on an external or already-forced close. */
|
||||||
|
export function finishRicheseBidLot<T extends RicheseBidPromise>(promises: readonly T[], context: RicheseBidContext,
|
||||||
|
change: RicheseBidChange): T[] {
|
||||||
|
checkContext(context);
|
||||||
|
if (change !== 'voluntary' && change !== 'external')
|
||||||
|
throw new RicheseBidPromiseError('Retain the cause of the auction close.');
|
||||||
|
const live = liveRicheseBidPromises(promises, context);
|
||||||
|
if (change === 'voluntary' && live.some(row => row.answer))
|
||||||
|
throw new RicheseBidPromiseError('Do not close the auction by abandoning your Truthtrance Yes.');
|
||||||
|
return promises.map(row => live.includes(row)
|
||||||
|
? row.answer ? { ...row, released: true } : { ...row, fulfilled: true } : row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The public lot event is intentionally shared by multiple questions. */
|
||||||
|
export function validateRicheseBidPromises(promises: readonly RicheseBidPromise[], players: readonly string[]): void {
|
||||||
|
if (!Array.isArray(promises) || !Array.isArray(players))
|
||||||
|
throw new RicheseBidPromiseError('The Once Around bid commitment record is invalid.');
|
||||||
|
const receipts = new Set<string>();
|
||||||
|
for (const row of promises) {
|
||||||
|
parseRicheseBidClaim(row);
|
||||||
|
if (!identifier(row.id) || receipts.has(row.id) || !identifier(row.player) || !identifier(row.asker) || !players.includes(row.player) ||
|
||||||
|
!players.includes(row.asker) || row.player === row.asker || typeof row.answer !== 'boolean' ||
|
||||||
|
(row.fulfilled !== undefined && typeof row.fulfilled !== 'boolean') ||
|
||||||
|
(row.released !== undefined && typeof row.released !== 'boolean') || row.fulfilled && row.released)
|
||||||
|
throw new RicheseBidPromiseError('The Once Around commitment lost its receipt, original lot or participants.');
|
||||||
|
receipts.add(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ import { parseNoFieldValueFact, noFieldValueFactMatches, noFieldValueFactText, t
|
|||||||
import { parseFaceDancerFact, faceDancerFactMatches, faceDancerFactText, type FaceDancerFact } from './truthtrance-face-dancer';
|
import { parseFaceDancerFact, faceDancerFactMatches, faceDancerFactText, type FaceDancerFact } from './truthtrance-face-dancer';
|
||||||
import { parseBidClaim, bidClaimText, bidPromiseModeSupported, type BidClaim } from './bid-promises';
|
import { parseBidClaim, bidClaimText, bidPromiseModeSupported, type BidClaim } from './bid-promises';
|
||||||
import { parsePhaseBidClaim, phaseBidClaimText, phaseBidPromiseModeSupported, type PhaseBidClaim } from './turn-bid-promises';
|
import { parsePhaseBidClaim, phaseBidClaimText, phaseBidPromiseModeSupported, type PhaseBidClaim } from './turn-bid-promises';
|
||||||
|
import { parseRicheseBidClaim, richeseBidClaimText, type RicheseBidClaim } from './richese-bid-promises';
|
||||||
|
|
||||||
export type TruthFact =
|
export type TruthFact =
|
||||||
| KnowledgeFact
|
| KnowledgeFact
|
||||||
@@ -104,6 +105,7 @@ export type TruthQuestion =
|
|||||||
| ({ kind: 'revival'; target: string } & RevivalClaim)
|
| ({ kind: 'revival'; target: string } & RevivalClaim)
|
||||||
| ({ kind: 'bid'; target: string } & BidClaim)
|
| ({ kind: 'bid'; target: string } & BidClaim)
|
||||||
| ({ kind: 'phaseBid'; target: string } & PhaseBidClaim)
|
| ({ kind: 'phaseBid'; target: string } & PhaseBidClaim)
|
||||||
|
| ({ kind: 'richeseBid'; target: string } & RicheseBidClaim)
|
||||||
| { kind: 'traitorCall'; target: string; battle: string; territory: string }
|
| { kind: 'traitorCall'; target: string; battle: string; territory: string }
|
||||||
| ({ kind: 'movement'; target: string } & MovementClaim)
|
| ({ kind: 'movement'; target: string } & MovementClaim)
|
||||||
| ({ kind: 'shipment'; target: string } & ShipmentClaim)
|
| ({ kind: 'shipment'; target: string } & ShipmentClaim)
|
||||||
@@ -356,6 +358,16 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
|||||||
'Choose a remaining native Revival opportunity this turn in a supported table and finish pending decisions.');
|
'Choose a remaining native Revival opportunity this turn in a supported table and finish pending decisions.');
|
||||||
return { kind: 'revival', target: v.target, ...claim };
|
return { kind: 'revival', target: v.target, ...claim };
|
||||||
}
|
}
|
||||||
|
if (v.kind === 'richeseBid') {
|
||||||
|
check(Object.keys(v).sort().join(',') === 'event,kind,minimum,target,turn',
|
||||||
|
'Choose the current Once Around lot and bid minimum.');
|
||||||
|
const claim = parseRicheseBidClaim(v), lot = g.richeseAuction;
|
||||||
|
check(bidPromiseModeSupported({ ...g, semutaPreview: undefined }) && g.status === 'playing' && g.phase === 3 && lot &&
|
||||||
|
lot.method === 'onceAround' && !lot.outcome && lot.event === claim.event && claim.turn === g.turn &&
|
||||||
|
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard,
|
||||||
|
'Choose a quiet supported Once Around auction and its current public event.');
|
||||||
|
return { kind: 'richeseBid', target: v.target, ...claim };
|
||||||
|
}
|
||||||
if (v.kind === 'phaseBid') {
|
if (v.kind === 'phaseBid') {
|
||||||
check(Object.keys(v).sort().join(',') === 'kind,minimum,target,turn',
|
check(Object.keys(v).sort().join(',') === 'kind,minimum,target,turn',
|
||||||
'Supply the current turn, phase bid minimum and respondent.');
|
'Supply the current turn, phase bid minimum and respondent.');
|
||||||
@@ -733,6 +745,7 @@ export function truthQuestionText(
|
|||||||
if (q.kind === 'revival') return `Will you ${revivalClaimText(q)}?`;
|
if (q.kind === 'revival') return `Will you ${revivalClaimText(q)}?`;
|
||||||
if (q.kind === 'bid') return `Will you ${bidClaimText(q)}?`;
|
if (q.kind === 'bid') return `Will you ${bidClaimText(q)}?`;
|
||||||
if (q.kind === 'phaseBid') return `Will you ${phaseBidClaimText(q)}?`;
|
if (q.kind === 'phaseBid') return `Will you ${phaseBidClaimText(q)}?`;
|
||||||
|
if (q.kind === 'richeseBid') return `Will you ${richeseBidClaimText(q)}?`;
|
||||||
if (q.kind === 'traitorCall') return `In this revealed battle in ${locationName(q.territory)}, will you declare the opposing leader a Traitor?`;
|
if (q.kind === 'traitorCall') return `In this revealed battle in ${locationName(q.territory)}, will you declare the opposing leader a Traitor?`;
|
||||||
if (q.kind === 'movementExpression') return `Will you ${movementExpressionText(q.claim)} before your Shipment and Movement turn ends?`;
|
if (q.kind === 'movementExpression') return `Will you ${movementExpressionText(q.claim)} before your Shipment and Movement turn ends?`;
|
||||||
if (q.kind === 'movement') return `Will you ${movementClaimText(q)} before your Shipment and Movement turn ends?`;
|
if (q.kind === 'movement') return `Will you ${movementClaimText(q)} before your Shipment and Movement turn ends?`;
|
||||||
@@ -863,6 +876,8 @@ export function resolveTruthAction(
|
|||||||
bindMixedBid?: (g: Game, p: Player, claim: MixedBidClaim, answer: boolean, asker: string) => void;
|
bindMixedBid?: (g: Game, p: Player, claim: MixedBidClaim, answer: boolean, asker: string) => void;
|
||||||
bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[];
|
bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[];
|
||||||
bindBid?: (g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) => void;
|
bindBid?: (g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) => void;
|
||||||
|
richeseBidAnswers?: (g: Game, p: Player, claim: RicheseBidClaim) => TruthAnswer[];
|
||||||
|
bindRicheseBid?: (g: Game, p: Player, claim: RicheseBidClaim, answer: boolean, asker: string) => void;
|
||||||
phaseBidAnswers?: (g: Game, p: Player, claim: PhaseBidClaim) => TruthAnswer[];
|
phaseBidAnswers?: (g: Game, p: Player, claim: PhaseBidClaim) => TruthAnswer[];
|
||||||
bindPhaseBid?: (g: Game, p: Player, claim: PhaseBidClaim, answer: boolean, asker: string) => void;
|
bindPhaseBid?: (g: Game, p: Player, claim: PhaseBidClaim, answer: boolean, asker: string) => void;
|
||||||
travelAnswers?: (g: Game, p: Player, claim: TravelClaim) => TruthAnswer[];
|
travelAnswers?: (g: Game, p: Player, claim: TravelClaim) => TruthAnswer[];
|
||||||
@@ -1036,6 +1051,9 @@ export function resolveTruthAction(
|
|||||||
if (w.question.kind === 'bid')
|
if (w.question.kind === 'bid')
|
||||||
check(effects.bidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
check(effects.bidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||||
'Choose a bidder with a remaining ordinary opportunity in this lot.');
|
'Choose a bidder with a remaining ordinary opportunity in this lot.');
|
||||||
|
if (w.question.kind === 'richeseBid')
|
||||||
|
check(effects.richeseBidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||||
|
'Choose a bidder with a remaining Once Around opportunity.');
|
||||||
if (w.question.kind === 'phaseBid')
|
if (w.question.kind === 'phaseBid')
|
||||||
check(effects.phaseBidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
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.');
|
'Choose a phase bid question compatible with supported native opportunities and earlier answers.');
|
||||||
@@ -1169,6 +1187,11 @@ export function resolveTruthAction(
|
|||||||
'Choose an answer consistent with this lot, your legal bids and earlier answers.');
|
'Choose an answer consistent with this lot, your legal bids and earlier answers.');
|
||||||
if (a.answer !== 'unknown') effects.bindBid(g, p, q, a.answer === 'yes', current.player);
|
if (a.answer !== 'unknown') effects.bindBid(g, p, q, a.answer === 'yes', current.player);
|
||||||
}
|
}
|
||||||
|
if (q.kind === 'richeseBid') {
|
||||||
|
check(effects.richeseBidAnswers && effects.bindRicheseBid && effects.richeseBidAnswers(g, p, q).includes(a.answer),
|
||||||
|
'Choose an answer consistent with this Once Around lot and your legal bid.');
|
||||||
|
if (a.answer !== 'unknown') effects.bindRicheseBid(g, p, q, a.answer === 'yes', current.player);
|
||||||
|
}
|
||||||
if (q.kind === 'phaseBid') {
|
if (q.kind === 'phaseBid') {
|
||||||
check(effects.phaseBidAnswers && effects.bindPhaseBid && effects.phaseBidAnswers(g, p, q).includes(a.answer),
|
check(effects.phaseBidAnswers && effects.bindPhaseBid && effects.phaseBidAnswers(g, p, q).includes(a.answer),
|
||||||
'Choose an answer consistent with native bids and earlier phase commitments.');
|
'Choose an answer consistent with native bids and earlier phase commitments.');
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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, advance, clean, inventory, player };
|
||||||
|
|
||||||
|
/** Actual public R/A/E Basic or Advanced setup. The parent fixture selects only
|
||||||
|
* unopened original deal/Spice positions and native scalar lotteries. Use its
|
||||||
|
* genuine Charity prefix, then declare and start the original cache lot with
|
||||||
|
* native controls. No dealt card, wallet, lot or promise is installed. */
|
||||||
|
export function createTruthRicheseBidFixture(advanced = false) {
|
||||||
|
const original = createTruthtranceRichesePromisesFixture({ advanced });
|
||||||
|
const asker = original.asker, target = original.emperor, owner = original.richese;
|
||||||
|
let game: Game | undefined;
|
||||||
|
for (const direction of ['clockwise', 'counterclockwise'] as const) {
|
||||||
|
const candidate = advance(structuredClone(original.earlySource), state => clean(state) &&
|
||||||
|
!!state.richeseAuction && state.richeseAuction.method === 'onceAround' && !state.richeseAuction.outcome,
|
||||||
|
[], { position: 'first', method: 'onceAround', direction });
|
||||||
|
if (candidate.richeseAuction?.active === asker && candidate.richeseAuction.order.includes(target)) {
|
||||||
|
game = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(game, 'One native physical direction opens with the Truth holder before Emperor');
|
||||||
|
const lot = game.richeseAuction!;
|
||||||
|
assert.equal(lot.source, 'cache'); assert.equal(lot.bid, 0);
|
||||||
|
assert.equal(lot.active, asker); assert.equal(lot.order.at(-1), owner);
|
||||||
|
assert.ok(lot.eligible.includes(target));
|
||||||
|
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 TruthRicheseBidFixture = ReturnType<typeof createTruthRicheseBidFixture>;
|
||||||
|
|
||||||
|
export function declareTruthRicheseBid(f: TruthRicheseBidFixture, 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 actor = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(actor);
|
||||||
|
game = act(game, actor.id, { type: 'truthPass' });
|
||||||
|
}
|
||||||
|
assert.equal(game.truthtrance?.stage, 'ask');
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
export function askTruthRicheseBid(f: TruthRicheseBidFixture, target = f.target, minimum = 4, source = f.game) {
|
||||||
|
const declared = declareTruthRicheseBid(f, source);
|
||||||
|
return act(declared, f.asker, { type: 'truthAsk', question: {
|
||||||
|
kind: 'richeseBid', target, turn: source.turn, event: f.lot, minimum,
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
export function bindTruthRicheseBid(f: TruthRicheseBidFixture, answer: 'yes' | 'no' = 'yes', minimum = 4,
|
||||||
|
source = f.game, target = f.target) {
|
||||||
|
const asked = askTruthRicheseBid(f, target, minimum, source);
|
||||||
|
assert.ok(viewGame(asked, target).truthRicheseBidAnswers?.includes(answer));
|
||||||
|
const game = act(asked, target, { type: 'truthAnswer', answer });
|
||||||
|
return { game, asked };
|
||||||
|
}
|
||||||
|
export function rejectTruthRicheseBid(game: Game, actor: string, action: Action) {
|
||||||
|
const before = structuredClone(game);
|
||||||
|
assert.throws(() => act(game, actor, action));
|
||||||
|
assert.deepEqual(game, before, 'Rejected native action must not mutate original state');
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { RicheseBidPromiseFields, richeseBidMinimumError } from '../components/richese-bid-promise-fields';
|
||||||
|
import { RicheseBidPromiseGuide, RicheseBidPromisePanel, type RicheseBidPromisePanelProps } from '../components/richese-bid-promise-panel';
|
||||||
|
import type { Action } from '../game/engine';
|
||||||
|
|
||||||
|
type ElementProps = { children?: ReactNode; [key: string]: unknown };
|
||||||
|
function elements(node: ReactNode): ReactElement<ElementProps>[] {
|
||||||
|
if (Array.isArray(node)) return node.flatMap(elements);
|
||||||
|
if (!isValidElement<ElementProps>(node)) return [];
|
||||||
|
return [node, ...elements(node.props.children)];
|
||||||
|
}
|
||||||
|
function edit(props: Parameters<typeof RicheseBidPromiseFields>[0], minimum: number) {
|
||||||
|
const input = elements(RicheseBidPromiseFields(props)).find(element =>
|
||||||
|
element.props.id === 'truth-richese-bid-minimum' && typeof element.props.onChange === 'function');
|
||||||
|
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[] } {
|
||||||
|
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,
|
||||||
|
act(action) { sent.push(action); },
|
||||||
|
};
|
||||||
|
return { props, sent };
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('Richese Once Around condition accepts only positive safe integer minima', () => {
|
||||||
|
const changes: number[] = [], props = { minimum: 4, disabled: false, onMinimum: (value: number) => changes.push(value) };
|
||||||
|
const html = renderToStaticMarkup(createElement(RicheseBidPromiseFields, props));
|
||||||
|
assert.match(html, /Once Around/);
|
||||||
|
assert.match(html, /Earlier declarations do not count/);
|
||||||
|
for (const value of [1, 7, Number.MAX_SAFE_INTEGER]) edit(props, value);
|
||||||
|
assert.deepEqual(changes, [1, 7, Number.MAX_SAFE_INTEGER]);
|
||||||
|
for (const value of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||||||
|
assert.ok(richeseBidMinimumError(value)); edit(props, value);
|
||||||
|
}
|
||||||
|
edit({ ...props, disabled: true }, 8);
|
||||||
|
assert.equal(changes.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('owned receipt panel explains binding, fulfilled and released answers', () => {
|
||||||
|
const { props } = projected();
|
||||||
|
props.promises.push(
|
||||||
|
{ id: 'no', event: 'lot-1', turn: 2, minimum: 8, answer: false },
|
||||||
|
{ id: 'done', event: 'lot-1', turn: 2, minimum: 2, answer: true, fulfilled: true },
|
||||||
|
{ id: 'released', event: 'lot-1', turn: 2, minimum: 12, answer: true, released: true },
|
||||||
|
);
|
||||||
|
const html = renderToStaticMarkup(createElement(RicheseBidPromisePanel, props));
|
||||||
|
assert.match(html, /Your Once Around bid promises/);
|
||||||
|
assert.match(html, /No forbids a qualifying bid/);
|
||||||
|
assert.match(html, /Fulfilled: this commitment is complete/);
|
||||||
|
assert.match(html, /Released: this commitment is no longer possible/);
|
||||||
|
assert.doesNotMatch(html, /receipt-1|lot-1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('guide sends the exact server action and rejects busy, stale or completed receipts', () => {
|
||||||
|
for (const type of ['bid', 'pass'] as const) {
|
||||||
|
const { props, sent } = projected(type);
|
||||||
|
const button = RicheseBidPromiseGuide(props); assert.ok(button);
|
||||||
|
button.props.onClick();
|
||||||
|
assert.deepEqual(sent, [props.guide!.action]);
|
||||||
|
assert.equal(sent[0], props.guide!.action);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
const { props, sent } = projected();
|
||||||
|
const button = RicheseBidPromiseGuide(props); assert.ok(button);
|
||||||
|
props.busy = true; button.props.onClick();
|
||||||
|
assert.deepEqual(sent, []);
|
||||||
|
const busyButton = RicheseBidPromiseGuide(props); assert.ok(busyButton);
|
||||||
|
assert.equal(busyButton.props.disabled, true);
|
||||||
|
props.busy = false;
|
||||||
|
const completed = RicheseBidPromiseGuide(props); assert.ok(completed);
|
||||||
|
props.promises[0].fulfilled = true; completed.props.onClick();
|
||||||
|
assert.deepEqual(sent, []);
|
||||||
|
assert.equal(RicheseBidPromiseGuide(props), null);
|
||||||
|
props.promises[0].fulfilled = false;
|
||||||
|
props.game.turn = 3;
|
||||||
|
assert.equal(RicheseBidPromiseGuide(props), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
RicheseBidPromiseError, finishRicheseBidLot, liveRicheseBidPromises,
|
||||||
|
parseRicheseBidClaim, reconcileRicheseBidPromises, recordRicheseBid,
|
||||||
|
richeseBidAllowedRange, richeseBidClaimText, richeseBidCompletionRange,
|
||||||
|
richeseBidRequired, validateRicheseBidPromises,
|
||||||
|
type RicheseBidContext, type RicheseBidOpportunity, type RicheseBidPromise,
|
||||||
|
} from '../game/richese-bid-promises';
|
||||||
|
|
||||||
|
const context: RicheseBidContext = { turn: 2, event: 'public-lot', player: 'target' };
|
||||||
|
const opportunity: RicheseBidOpportunity = { ...context, minimum: 4, maximum: 10 };
|
||||||
|
const seats = ['target', 'asker', 'other'];
|
||||||
|
const promise = (id: string, minimum: number, answer = true,
|
||||||
|
extra: Partial<RicheseBidPromise> = {}): RicheseBidPromise => ({
|
||||||
|
id, ...context, asker: 'asker', minimum, answer, ...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('claims retain only a safe integer turn, public lot event and positive bid minimum', () => {
|
||||||
|
assert.deepEqual(parseRicheseBidClaim({ turn: 2, event: 'public-lot', minimum: 6, ignored: true }),
|
||||||
|
{ turn: 2, event: 'public-lot', minimum: 6 });
|
||||||
|
for (const value of [0, -1, 1.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '2', null, undefined])
|
||||||
|
for (const field of ['turn', 'minimum'])
|
||||||
|
assert.throws(() => parseRicheseBidClaim({ turn: 2, event: 'public-lot', minimum: 6, [field]: value }), RicheseBidPromiseError);
|
||||||
|
for (const event of ['', ' ', ' lot ', null, 1, undefined])
|
||||||
|
assert.throws(() => parseRicheseBidClaim({ turn: 2, event, minimum: 6 }), RicheseBidPromiseError);
|
||||||
|
for (const value of [null, undefined, [], 3, 'claim'])
|
||||||
|
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.doesNotMatch(text, /win|purchase|public-lot/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('the engine-supplied interval combines every Yes threshold and No cap', () => {
|
||||||
|
const rows = [promise('yes-6', 6), promise('yes-7', 7), promise('no-9', 9, false), promise('no-8', 8, false)];
|
||||||
|
assert.deepEqual(richeseBidAllowedRange(rows, opportunity), { minimum: 4, maximum: 7 });
|
||||||
|
assert.deepEqual(richeseBidCompletionRange(rows, opportunity), { minimum: 7, maximum: 7 });
|
||||||
|
assert.equal(richeseBidRequired(rows, context), true);
|
||||||
|
assert.equal(richeseBidCompletionRange(rows, { ...opportunity, maximum: 6 }), null);
|
||||||
|
assert.equal(richeseBidCompletionRange(rows, { ...opportunity, minimum: 8 }), null);
|
||||||
|
assert.equal(richeseBidRequired([promise('no-only', 4, false)], context), false);
|
||||||
|
assert.equal(richeseBidAllowedRange([promise('no-only', 4, false)], opportunity), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('live answers bind the precise bidder, turn and public lot and preserve separate receipts', () => {
|
||||||
|
const original = [promise('yes', 6), promise('no', 9, false),
|
||||||
|
promise('other-player', 8, true, { player: 'other' }),
|
||||||
|
promise('other-turn', 8, true, { turn: 3 }),
|
||||||
|
promise('other-lot', 8, true, { event: 'new-lot' }),
|
||||||
|
promise('past', 8, true, { fulfilled: true })];
|
||||||
|
assert.deepEqual(liveRicheseBidPromises(original, context).map(row => row.id), ['yes', 'no']);
|
||||||
|
validateRicheseBidPromises(original, seats);
|
||||||
|
const parsed = JSON.parse(JSON.stringify(original)) as RicheseBidPromise[];
|
||||||
|
assert.deepEqual(liveRicheseBidPromises(parsed, context).map(row => row.id), ['yes', 'no']);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('an actual single qualifying bid fulfills Yes and No without requiring a win', () => {
|
||||||
|
const rows = [promise('yes', 6), promise('no', 9, false), promise('other', 2, true, { event: 'next' })];
|
||||||
|
const frozen = structuredClone(rows);
|
||||||
|
const result = recordRicheseBid(rows, context, 7);
|
||||||
|
assert.equal(result[0].fulfilled, true);
|
||||||
|
assert.equal(result[1].fulfilled, true);
|
||||||
|
assert.equal(result[2].fulfilled, undefined);
|
||||||
|
assert.deepEqual(rows, frozen);
|
||||||
|
assert.equal(richeseBidRequired(result, context), false);
|
||||||
|
for (const amount of [null, 4, 5, 9, 10, 0, -1, 1.5, Number.NaN, Infinity])
|
||||||
|
assert.throws(() => recordRicheseBid(rows, context, amount), RicheseBidPromiseError, String(amount));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('passing satisfies No but cannot evade a still-live Yes', () => {
|
||||||
|
const no = [promise('no', 6, false)];
|
||||||
|
assert.equal(recordRicheseBid(no, context, null)[0].fulfilled, true);
|
||||||
|
assert.equal(recordRicheseBid(no, context, 5)[0].fulfilled, true);
|
||||||
|
assert.throws(() => recordRicheseBid(no, context, 6), RicheseBidPromiseError);
|
||||||
|
const yes = [promise('yes', 6)];
|
||||||
|
assert.throws(() => recordRicheseBid(yes, context, null), RicheseBidPromiseError);
|
||||||
|
assert.throws(() => recordRicheseBid(yes, context, 5), RicheseBidPromiseError);
|
||||||
|
assert.equal(recordRicheseBid(yes, context, 6)[0].fulfilled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('external funding or competing bid releases only individually impossible Yes', () => {
|
||||||
|
const rows = [promise('yes-8', 8), promise('yes-6', 6), promise('no-9', 9, false)];
|
||||||
|
const actual = { ...opportunity, maximum: 7 };
|
||||||
|
const reduced = reconcileRicheseBidPromises(rows, context, actual, 'external');
|
||||||
|
assert.equal(reduced[0].released, true);
|
||||||
|
assert.equal(reduced[1].released, undefined);
|
||||||
|
assert.equal(reduced[2].released, undefined);
|
||||||
|
assert.deepEqual(richeseBidCompletionRange(reduced, actual), { minimum: 6, maximum: 7 });
|
||||||
|
assert.throws(() => reconcileRicheseBidPromises(rows, context, actual, 'voluntary'), RicheseBidPromiseError);
|
||||||
|
assert.equal(rows[0].released, undefined);
|
||||||
|
const absent = reconcileRicheseBidPromises(rows, context, null, 'external');
|
||||||
|
assert.equal(absent[0].released, true);
|
||||||
|
assert.equal(absent[1].released, true);
|
||||||
|
assert.equal(absent[2].released, undefined);
|
||||||
|
assert.throws(() => reconcileRicheseBidPromises(rows, context, null, 'voluntary'), RicheseBidPromiseError);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a rival bid beyond the No cap forces release, not an unlawful above-cap declaration', () => {
|
||||||
|
const rows = [promise('yes', 6), promise('no', 8, false)];
|
||||||
|
const rival = { ...opportunity, minimum: 8, maximum: 10 };
|
||||||
|
assert.equal(richeseBidCompletionRange(rows, rival), null);
|
||||||
|
const result = reconcileRicheseBidPromises(rows, context, rival, 'external');
|
||||||
|
assert.equal(result[0].released, true);
|
||||||
|
assert.equal(result[1].released, undefined);
|
||||||
|
assert.equal(recordRicheseBid(result, context, null)[1].fulfilled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('lot close finishes No and externally releases unfulfilled Yes without affecting other lots', () => {
|
||||||
|
const rows = [promise('yes', 6), promise('no', 9, false), promise('next', 6, true, { event: 'next' })];
|
||||||
|
assert.throws(() => finishRicheseBidLot(rows, context, 'voluntary'), RicheseBidPromiseError);
|
||||||
|
const result = finishRicheseBidLot(rows, context, 'external');
|
||||||
|
assert.equal(result[0].released, true);
|
||||||
|
assert.equal(result[1].fulfilled, true);
|
||||||
|
assert.equal(result[2].released, undefined);
|
||||||
|
assert.equal(finishRicheseBidLot([rows[1]], context, 'voluntary')[0].fulfilled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('invalid saved rows and mismatched opportunity contexts fail without mutating promises', () => {
|
||||||
|
const base = promise('id', 6);
|
||||||
|
for (const row of [
|
||||||
|
{ ...base, id: '' }, { ...base, id: ' id ' }, { ...base, asker: 'target' },
|
||||||
|
{ ...base, player: 'nobody' }, { ...base, answer: 'yes' },
|
||||||
|
{ ...base, fulfilled: true, released: true }, { ...base, fulfilled: 'yes' },
|
||||||
|
]) assert.throws(() => validateRicheseBidPromises([row as RicheseBidPromise], seats), RicheseBidPromiseError);
|
||||||
|
assert.throws(() => validateRicheseBidPromises([base, { ...base, minimum: 7 }], seats), RicheseBidPromiseError);
|
||||||
|
assert.throws(() => reconcileRicheseBidPromises([base], context, { ...opportunity, event: 'other' }, 'external'), RicheseBidPromiseError);
|
||||||
|
assert.throws(() => reconcileRicheseBidPromises([base], context, { ...opportunity, player: 'other' }, 'external'), RicheseBidPromiseError);
|
||||||
|
assert.throws(() => richeseBidAllowedRange([base], { ...opportunity, minimum: 0 }), RicheseBidPromiseError);
|
||||||
|
assert.throws(() => richeseBidAllowedRange([base], { ...opportunity, maximum: Infinity }), RicheseBidPromiseError);
|
||||||
|
assert.throws(() => richeseBidAllowedRange([base], { ...opportunity, maximum: 3 }), RicheseBidPromiseError);
|
||||||
|
assert.equal(base.fulfilled, undefined);
|
||||||
|
assert.equal(base.released, undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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 GameView } from '../game/engine';
|
||||||
|
import { RicheseBidPromiseGuide } from '../components/richese-bid-promise-panel';
|
||||||
|
import { act, inventory, createTruthRicheseBidFixture, declareTruthRicheseBid,
|
||||||
|
askTruthRicheseBid } from './fixture-truth-richese-bid';
|
||||||
|
|
||||||
|
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() {} }));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const advanced of [false, true]) void test(
|
||||||
|
`${advanced ? 'Advanced' : 'Basic'} real Once Around question, answers and guide connect to the original Richese table`, () => {
|
||||||
|
// Original public R/A/E setup, actual dealt Truth and native cache lot.
|
||||||
|
// The fixture selects only unopened original deal/Spice positions and scalar lotteries.
|
||||||
|
const f = createTruthRicheseBidFixture(advanced);
|
||||||
|
const source = act(f.game, f.asker, { type: 'richeseBid', event: f.lot, amount: null });
|
||||||
|
assert.equal(source.richeseAuction?.active, f.target);
|
||||||
|
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/);
|
||||||
|
// 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);
|
||||||
|
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), /Richese 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(promise => promise.player === f.target && promise.answer === (answer === 'yes')));
|
||||||
|
assert.ok(own.richeseBidPromiseGuide);
|
||||||
|
const html = table(own);
|
||||||
|
assert.match(html, /Your Once Around bid promises/);
|
||||||
|
assert.match(html, /data-testid="richese-bid-promise-guide"/);
|
||||||
|
assert.match(auction(own), /Richese auction/);
|
||||||
|
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(typeof sent[0].richeseBidPromiseId, 'string');
|
||||||
|
game = act(game, f.target, sent[0]);
|
||||||
|
assert.equal(game.richeseBidPromises!.find(promise => promise.id === sent[0].richeseBidPromiseId)?.fulfilled, true);
|
||||||
|
assert.deepEqual(inventory(game), f.originalCards);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { viewGame } from '../game/engine';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||||
|
import {
|
||||||
|
act, inventory, player, createTruthRicheseBidFixture, askTruthRicheseBid,
|
||||||
|
bindTruthRicheseBid, rejectTruthRicheseBid as reject,
|
||||||
|
} from './fixture-truth-richese-bid';
|
||||||
|
|
||||||
|
for (const advanced of [false, true]) {
|
||||||
|
const mode = advanced ? 'Advanced' : 'Basic';
|
||||||
|
void test(`${mode} Once Around Yes requires a real qualifying funded bid`, () => {
|
||||||
|
const f = createTruthRicheseBidFixture(advanced);
|
||||||
|
const source = act(f.game, f.asker, { type: 'richeseBid', event: f.lot, amount: null });
|
||||||
|
assert.equal(source.richeseAuction?.active, f.target);
|
||||||
|
const { game: bound } = bindTruthRicheseBid(f, 'yes', 4, source);
|
||||||
|
assert.equal(bound.discard.filter(card => card.id === f.truth).length, 1);
|
||||||
|
assert.equal(bound.richeseAuction?.event, f.lot);
|
||||||
|
assert.equal(bound.richeseAuction?.cardId, f.card);
|
||||||
|
reject(bound, f.target, { type: 'richeseBid', event: f.lot, amount: null });
|
||||||
|
reject(bound, f.target, { type: 'richeseBid', event: f.lot, amount: 3, allyPayment: 0 });
|
||||||
|
const guide = viewGame(bound, f.target).richeseBidPromiseGuide; assert.ok(guide);
|
||||||
|
assert.equal(guide.action.type, 'richeseBid'); assert.equal(guide.action.amount, 4);
|
||||||
|
const bid = act(bound, f.target, guide.action);
|
||||||
|
assert.ok(bid.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
|
||||||
|
assert.equal(player(bid, f.target).spice, 10, 'Declaring a bid does not pay the eventual invoice');
|
||||||
|
assert.deepEqual(inventory(bid), f.originalCards);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test(`${mode} Once Around No caps a real bid and preserves the lot`, () => {
|
||||||
|
const f = createTruthRicheseBidFixture(advanced);
|
||||||
|
const source = act(f.game, f.asker, { type: 'richeseBid', event: f.lot, amount: null });
|
||||||
|
assert.equal(source.richeseAuction?.active, f.target);
|
||||||
|
const { game: bound } = bindTruthRicheseBid(f, 'no', 4, source);
|
||||||
|
reject(bound, f.target, { type: 'richeseBid', event: f.lot, amount: 4, allyPayment: 0 });
|
||||||
|
const bid = act(bound, f.target, { type: 'richeseBid', event: f.lot, amount: 2, allyPayment: 0 });
|
||||||
|
assert.equal(bid.richeseAuction?.event, f.lot); assert.equal(bid.richeseAuction?.cardId, f.card);
|
||||||
|
assert.equal(bid.richeseAuction?.bid, 2); assert.equal(bid.richeseAuction?.bidder, f.target);
|
||||||
|
assert.deepEqual(inventory(bid), f.originalCards);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test(`${mode} Richese cannot promise to bid when its final owner turn has no earlier sale`, () => {
|
||||||
|
const f = createTruthRicheseBidFixture(advanced);
|
||||||
|
const asked = askTruthRicheseBid(f, f.owner, 1);
|
||||||
|
assert.ok(!viewGame(asked, f.owner).truthRicheseBidAnswers?.includes('yes'));
|
||||||
|
reject(asked, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||||
|
assert.deepEqual(inventory(asked), f.originalCards);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test(`${mode} an actual opposing high bid releases an impossible waiting Yes`, () => {
|
||||||
|
const f = createTruthRicheseBidFixture(advanced);
|
||||||
|
const { game: bound } = bindTruthRicheseBid(f, 'yes', 4);
|
||||||
|
assert.equal(bound.richeseAuction?.active, f.asker);
|
||||||
|
assert.equal(viewGame(bound, f.target).richeseBidPromiseGuide, null);
|
||||||
|
const raised = act(bound, f.asker, { type: 'richeseBid', event: f.lot, amount: 10, allyPayment: 0 });
|
||||||
|
assert.equal(raised.richeseAuction?.active, f.target);
|
||||||
|
assert.ok(raised.richeseBidPromises?.some(row => row.player === f.target && row.released));
|
||||||
|
const pass = act(raised, f.target, { type: 'richeseBid', event: f.lot, amount: null });
|
||||||
|
assert.deepEqual(inventory(pass), f.originalCards);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const advanced of [false, true]) {
|
||||||
|
const mode = advanced ? 'Advanced' : 'Basic';
|
||||||
|
void test(`${mode} stale Once Around guided callbacks are rejected without changing the lot`, () => {
|
||||||
|
const f = createTruthRicheseBidFixture(advanced);
|
||||||
|
const source = act(f.game, f.asker, { type: 'richeseBid', event: f.lot, amount: null });
|
||||||
|
const { game: bound } = bindTruthRicheseBid(f, 'yes', 4, source);
|
||||||
|
const guide = viewGame(bound, f.target).richeseBidPromiseGuide; assert.ok(guide);
|
||||||
|
reject(bound, f.target, { ...guide.action, richeseBidPromiseId: 'stale-promise' });
|
||||||
|
reject(bound, f.target, { ...guide.action, event: 'stale-lot' });
|
||||||
|
const accepted = act(bound, f.target, guide.action);
|
||||||
|
assert.ok(accepted.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
|
||||||
|
assert.deepEqual(inventory(accepted), f.originalCards);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test(`${mode} four legal bot profiles answer and follow the actual Once Around guide`, () => {
|
||||||
|
const f = createTruthRicheseBidFixture(advanced);
|
||||||
|
const source = act(f.game, f.asker, { type: 'richeseBid', event: f.lot, amount: null });
|
||||||
|
const asked = askTruthRicheseBid(f, f.target, 4, source);
|
||||||
|
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);
|
||||||
|
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));
|
||||||
|
assert.deepEqual(inventory(completed), f.originalCards);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// This Basic Richese fixture enables the Semuta preview, whose existing
|
||||||
|
// bribe promise profile does not support a Basic bribe question. The Advanced
|
||||||
|
// native fixture supports this independent-bribe admission path.
|
||||||
|
for (const advanced of [true]) {
|
||||||
|
const mode = advanced ? 'Advanced' : 'Basic';
|
||||||
|
void test(`${mode} a Once Around Yes excludes an independently unfunded later bribe Yes`, () => {
|
||||||
|
const f = createTruthRicheseBidFixture(advanced);
|
||||||
|
let source = act(f.game, f.asker, { type: 'richeseBid', event: f.lot, amount: null });
|
||||||
|
assert.equal(source.richeseAuction?.active, f.target);
|
||||||
|
assert.equal(player(source, f.target).spice, 10);
|
||||||
|
// Controlled physical supplement: move the second original Truthtrance
|
||||||
|
// from the unopened native main deck to the questioner's hand. No funds,
|
||||||
|
// promise, lot, phase or other card is installed; card custody is conserved.
|
||||||
|
source = structuredClone(source);
|
||||||
|
const at = source.deck.findIndex(card => card.effect === 'truthtrance' && card.id !== f.truth);
|
||||||
|
assert.ok(at >= 0);
|
||||||
|
const [second] = source.deck.splice(at, 1);
|
||||||
|
player(source, f.asker).hand.push(second);
|
||||||
|
assert.deepEqual(inventory(source), f.originalCards);
|
||||||
|
let game = bindTruthRicheseBid(f, 'yes', 8, source).game;
|
||||||
|
const prior = structuredClone(game.richeseBidPromises![0]);
|
||||||
|
game = act(game, f.asker, { type: 'card', card: second.id });
|
||||||
|
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
|
||||||
|
const actor = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(actor);
|
||||||
|
game = act(game, actor.id, { type: 'truthPass' });
|
||||||
|
}
|
||||||
|
assert.equal(game.truthtrance?.stage, 'ask');
|
||||||
|
game = act(game, f.asker, { type: 'truthAsk', question: {
|
||||||
|
kind: 'bribe', target: f.target, turn: game.turn, recipient: f.asker, minimum: 5,
|
||||||
|
} });
|
||||||
|
const answers = viewGame(game, f.target).truthBribeAnswers;
|
||||||
|
assert.ok(!answers?.includes('yes')); assert.ok(answers?.includes('no'));
|
||||||
|
reject(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||||
|
assert.deepEqual(game.richeseBidPromises![0], prior);
|
||||||
|
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||||
|
assert.deepEqual(game.richeseBidPromises![0], prior);
|
||||||
|
const guide = viewGame(game, f.target).richeseBidPromiseGuide; assert.ok(guide);
|
||||||
|
assert.equal(guide.action.amount, 8);
|
||||||
|
game = act(game, f.target, guide.action);
|
||||||
|
assert.ok(game.richeseBidPromises?.some(row => row.player === f.target && row.fulfilled));
|
||||||
|
assert.deepEqual(inventory(game), f.originalCards);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user