From 090d764f6f9b824b2faccf830ce6e80573a67ab8 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:56:20 +1100 Subject: [PATCH] Support Truthtrance in Richese Once Around bidding --- components/game-table.tsx | 4 + components/richese-bid-promise-fields.tsx | 34 +++++ components/richese-bid-promise-panel.tsx | 63 ++++++++ components/truthtrance.tsx | 19 ++- docs/CURRENT_STATUS.md | 11 ++ docs/IMPLEMENTATION_STATUS.md | 18 +++ docs/RULE_DECISIONS.md | 11 ++ docs/TRUTHTRANCE_BID_PROMISES.md | 30 ++++ game/bots.ts | 5 + game/engine.ts | 124 ++++++++++++++- game/reference.ts | 7 +- game/richese-bid-promises.ts | 142 ++++++++++++++++++ game/truthtrance.ts | 23 +++ tests/fixture-truth-richese-bid.ts | 70 +++++++++ tests/richese-bid-promise-controls.test.tsx | 88 +++++++++++ tests/richese-bid-promises.test.ts | 134 +++++++++++++++++ ...h-richese-bid-integrated-controls.test.tsx | 85 +++++++++++ tests/truth-richese-bid-runtime.test.ts | 142 ++++++++++++++++++ 18 files changed, 1003 insertions(+), 7 deletions(-) create mode 100644 components/richese-bid-promise-fields.tsx create mode 100644 components/richese-bid-promise-panel.tsx create mode 100644 game/richese-bid-promises.ts create mode 100644 tests/fixture-truth-richese-bid.ts create mode 100644 tests/richese-bid-promise-controls.test.tsx create mode 100644 tests/richese-bid-promises.test.ts create mode 100644 tests/truth-richese-bid-integrated-controls.test.tsx create mode 100644 tests/truth-richese-bid-runtime.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index 7d17da00..4b5b998b 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -1,4 +1,5 @@ 'use client'; +import { RicheseBidPromisePanel } from './richese-bid-promise-panel'; import { TravelPromises } from './travel-promises'; import { TurnActionPromises } from './turn-action-promises'; import { MixedTraitorPromises } from './mixed-traitor-promises'; @@ -4332,12 +4333,15 @@ export function GameTable({ )} {g.phase === 2 && } {g.phase === 3 && g.richeseAuction && ( + <> + + )} {g.phase === 3 && !g.richeseAuction && g.auction && ( <> diff --git a/components/richese-bid-promise-fields.tsx b/components/richese-bid-promise-fields.tsx new file mode 100644 index 00000000..740698dc --- /dev/null +++ b/components/richese-bid-promise-fields.tsx @@ -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
+ Bid in this Once Around auction + + { + const value = event.currentTarget.valueAsNumber; + if (!props.disabled && !richeseBidMinimumError(value)) props.onMinimum(value); + }} /> + {error &&

{error}

} +

+ This asks about the bidder’s remaining opportunity in this Once Around lot. + Earlier declarations do not count; a qualifying bid does not promise a win + or change the winning payment. +

+
; +} diff --git a/components/richese-bid-promise-panel.tsx b/components/richese-bid-promise-panel.tsx new file mode 100644 index 00000000..b935f6f9 --- /dev/null +++ b/components/richese-bid-promise-panel.tsx @@ -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 ; +} + +/** 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
+

Your Once Around bid promises

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

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

+ {waiting && } +
; +} diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 9043a2b7..6fe25d4a 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -1,4 +1,5 @@ 'use client'; +import { RicheseBidPromiseFields, richeseBidMinimumError } from './richese-bid-promise-fields'; import { TravelClaimFields, travelClaimInputError } from './travel-claim-fields'; import { TravelAnswers } from './travel-promises'; @@ -97,7 +98,7 @@ export function Truthtrance({ g.players.find((p) => p.id !== me.id)!.id, ); 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'); const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader => 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 === 'bid' ? { kind, target, ...bidClaim } : kind === 'phaseBid' ? { kind, target, ...phaseBidClaim } + : kind === 'richeseBid' ? { kind, target, turn:g.turn, event:g.richeseAuction?.event ?? '', minimum:bidMinimum } : kind === 'shipment' ? { kind, @@ -426,6 +428,13 @@ export function Truthtrance({ {button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })} )} + ) : w.question!.kind === 'richeseBid' ? ( + <> +

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

+ {(g.truthRicheseBidAnswers ?? []).map(answer =>
+ {button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })} +
)} + ) : w.question!.kind === 'phaseBid' ? ( <>

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.

@@ -589,6 +598,7 @@ export function Truthtrance({ + @@ -732,6 +742,12 @@ export function Truthtrance({ {!g.truthRevivalTargets.includes(target) &&

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.

}

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

+ ) : kind === 'richeseBid' ? ( + <> + + {!g.truthRicheseBidTargets.includes(target) &&

Choose a bidder with a remaining Once Around opportunity.

} +

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

+ ) : kind === 'phaseBid' ? ( <> (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 { return g.status === 'playing' && g.phase >= 0 && g.phase <= 3 && phaseBidPromiseModeSupported(g) && !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 * answer is withheld rather than offered and rejected after binding. */ function phaseBidCompatibleLaterPromises(g: Game, p: Player): boolean { + if (!richeseBidCompatibleLaterPromises(g,p)) return false; const live = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn); if (!live.some(row => row.answer)) return true; 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; const trial = { ...turnActionAnswerSource(g, p.id, !!promise.alliance, !!promise.windowCards, { claim, answer }), 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 lot = bidOpportunity(trial); if (phases.length && lot) { @@ -38937,6 +39030,7 @@ function normalizeCardNames(g: Game) { export function applyAction(state: Game, id: string, action: Action): Game { bidPromiseIntegrity(state); phaseBidIntegrity(state); + richeseBidIntegrity(state); revivalPromiseIntegrity(state); bribePromiseIntegrity(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]), '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); if (state.phase === 3 && liveTurnActionPromises(state.turnActionPromises ?? [], id, state.turn).some(row => { 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); reconcileBidPromises(g, voluntary); reconcilePhaseBids(g, voluntary); + reconcileRicheseBids(g, voluntary); reconcileRevivalPromises(g, voluntary); reconcileBribePromises(g, voluntary); 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 }); reconcileBidPromises(g, { actor: id, action }); reconcilePhaseBids(g, { actor: id, action }); + reconcileRicheseBids(g, { actor: id, action }); reconcileRevivalPromises(g, { actor: id, action }); reconcileBribePromises(g, { actor: id, action }); reconcileAlliancePromises(g, { actor: id, action }); @@ -39466,6 +39568,7 @@ function settleAutomaticContinuations(g: Game) { reconcileShipmentPromises(g); reconcileBidPromises(g); reconcilePhaseBids(g); + reconcileRicheseBids(g); reconcileRevivalPromises(g); reconcileBribePromises(g); reconcileGroundMovementPromises(g); @@ -39484,6 +39587,7 @@ function settleAutomaticContinuations(g: Game) { export function normalizeAutomaticGame(state: Game): Game { bidPromiseIntegrity(state); phaseBidIntegrity(state); + richeseBidIntegrity(state); revivalPromiseIntegrity(state); bribePromiseIntegrity(state); alliancePromiseIntegrity(state); @@ -39573,6 +39677,7 @@ export function normalizeAutomaticGame(state: Game): Game { reconcileShipmentPromises(g); reconcileBidPromises(g); reconcilePhaseBids(g); + reconcileRicheseBids(g); reconcileRevivalPromises(g); reconcileBribePromises(g); reconcileGroundMovementPromises(g); @@ -39849,6 +39954,8 @@ function applyActionInner( bindMixedBid: bindMixedBidTruth, bidAnswers: bidTruthAnswers, bindBid: bindBidTruth, + richeseBidAnswers: richeseBidTruthAnswers, + bindRicheseBid: bindRicheseBidTruth, phaseBidAnswers: phaseBidTruthAnswers, bindPhaseBid: bindPhaseBidTruth, turnActionAnswers: turnActionTruthAnswers, @@ -39868,7 +39975,7 @@ function applyActionInner( ) return g; } 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 error; } @@ -42325,6 +42432,9 @@ function applyActionInner( if (parties.richeseSaleAmount !== undefined) checkBureaucratContributors(g,byFaction(g,'emperor')?.id,[{payer:lot.owner,amount:allyPayment}]); } + if (lot.method === 'onceAround' && g.richeseBidPromises?.length) + g.richeseBidPromises = richeseBidRule(() => recordRicheseBid(g.richeseBidPromises!, + {turn:g.turn,event:lot.event,player:p.id},amount)); g.richeseAuction = submitRicheseBid( lot, { 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) : [], truthBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && 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) : [], truthPhaseBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'phaseBid' ? phaseBidTruthAnswers(g,me,g.truthtrance.question) : null, diff --git a/game/reference.ts b/game/reference.ts index 3c52a6c4..8d4ff55d 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3652,13 +3652,14 @@ export const RULE_TOPICS: RuleTopic[] = [ }, { id: 'truthtrance-bid', - title: 'Truthtrance: ordinary bid commitments', + title: 'Truthtrance: bidding commitments', category: 'Cards', coverage: 'Partial', 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: [ - '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.', '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.', diff --git a/game/richese-bid-promises.ts b/game/richese-bid-promises.ts new file mode 100644 index 00000000..e4b8c9fe --- /dev/null +++ b/game/richese-bid-promises.ts @@ -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; + 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(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(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(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(); + 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); + } +} diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 2880769a..d7c48f8d 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -71,6 +71,7 @@ import { parseNoFieldValueFact, noFieldValueFactMatches, noFieldValueFactText, t import { parseFaceDancerFact, faceDancerFactMatches, faceDancerFactText, type FaceDancerFact } from './truthtrance-face-dancer'; import { parseBidClaim, bidClaimText, bidPromiseModeSupported, type BidClaim } from './bid-promises'; import { parsePhaseBidClaim, phaseBidClaimText, phaseBidPromiseModeSupported, type PhaseBidClaim } from './turn-bid-promises'; +import { parseRicheseBidClaim, richeseBidClaimText, type RicheseBidClaim } from './richese-bid-promises'; export type TruthFact = | KnowledgeFact @@ -104,6 +105,7 @@ export type TruthQuestion = | ({ kind: 'revival'; target: string } & RevivalClaim) | ({ kind: 'bid'; target: string } & BidClaim) | ({ kind: 'phaseBid'; target: string } & PhaseBidClaim) + | ({ kind: 'richeseBid'; target: string } & RicheseBidClaim) | { kind: 'traitorCall'; target: string; battle: string; territory: string } | ({ kind: 'movement'; target: string } & MovementClaim) | ({ 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.'); 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') { check(Object.keys(v).sort().join(',') === 'kind,minimum,target,turn', '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 === 'bid') return `Will you ${bidClaimText(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 === '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?`; @@ -863,6 +876,8 @@ export function resolveTruthAction( bindMixedBid?: (g: Game, p: Player, claim: MixedBidClaim, answer: boolean, asker: string) => void; bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[]; 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[]; bindPhaseBid?: (g: Game, p: Player, claim: PhaseBidClaim, answer: boolean, asker: string) => void; travelAnswers?: (g: Game, p: Player, claim: TravelClaim) => TruthAnswer[]; @@ -1036,6 +1051,9 @@ export function resolveTruthAction( if (w.question.kind === 'bid') 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.'); + 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') 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.'); @@ -1169,6 +1187,11 @@ export function resolveTruthAction( '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 (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') { check(effects.phaseBidAnswers && effects.bindPhaseBid && effects.phaseBidAnswers(g, p, q).includes(a.answer), 'Choose an answer consistent with native bids and earlier phase commitments.'); diff --git a/tests/fixture-truth-richese-bid.ts b/tests/fixture-truth-richese-bid.ts new file mode 100644 index 00000000..cf45d6bb --- /dev/null +++ b/tests/fixture-truth-richese-bid.ts @@ -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; + +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'); +} diff --git a/tests/richese-bid-promise-controls.test.tsx b/tests/richese-bid-promise-controls.test.tsx new file mode 100644 index 00000000..ace5c0d4 --- /dev/null +++ b/tests/richese-bid-promise-controls.test.tsx @@ -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[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!isValidElement(node)) return []; + return [node, ...elements(node.props.children)]; +} +function edit(props: Parameters[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); +}); diff --git a/tests/richese-bid-promises.test.ts b/tests/richese-bid-promises.test.ts new file mode 100644 index 00000000..2ba583d3 --- /dev/null +++ b/tests/richese-bid-promises.test.ts @@ -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 => ({ + 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); +}); diff --git a/tests/truth-richese-bid-integrated-controls.test.tsx b/tests/truth-richese-bid-integrated-controls.test.tsx new file mode 100644 index 00000000..749d3b03 --- /dev/null +++ b/tests/truth-richese-bid-integrated-controls.test.tsx @@ -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), /