From bd6e5bbf7f88549075457f3004986f666be72ae6 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:44:00 +1100 Subject: [PATCH] Add current-lot Truthtrance bid commitments --- components/bid-claim-fields.tsx | 38 ++++ components/bid-promises.tsx | 62 +++++ components/game-table.tsx | 2 + components/truthtrance.tsx | 29 ++- docs/CURRENT_STATUS.md | 30 +++ docs/IMPLEMENTATION_STATUS.md | 30 +++ docs/RULE_DECISIONS.md | 16 ++ docs/TRUTHTRANCE_BID_PROMISES.md | 109 +++++++++ ...RUTHTRANCE_NONBATTLE_READINESS_20260907.md | 6 + game/bid-promises.ts | 93 ++++++++ game/bots.ts | 5 + game/engine.ts | 177 ++++++++++++-- game/reference.ts | 29 ++- game/truthtrance.ts | 24 ++ tests/bid-promises.test.ts | 183 +++++++++++++++ tests/fixture-truth-bid.ts | 142 ++++++++++++ tests/truth-bid-controls.test.tsx | 183 +++++++++++++++ tests/truth-bid-runtime.test.ts | 215 ++++++++++++++++++ 18 files changed, 1346 insertions(+), 27 deletions(-) create mode 100644 components/bid-claim-fields.tsx create mode 100644 components/bid-promises.tsx create mode 100644 docs/TRUTHTRANCE_BID_PROMISES.md create mode 100644 game/bid-promises.ts create mode 100644 tests/bid-promises.test.ts create mode 100644 tests/fixture-truth-bid.ts create mode 100644 tests/truth-bid-controls.test.tsx create mode 100644 tests/truth-bid-runtime.test.ts diff --git a/components/bid-claim-fields.tsx b/components/bid-claim-fields.tsx new file mode 100644 index 00000000..b3c2afdd --- /dev/null +++ b/components/bid-claim-fields.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { parseBidClaim, type BidClaim } from '@/game/bid-promises'; +import { Input } from './ui/input'; + +export function bidClaimInputError(value: unknown): string | null { + try { parseBidClaim(value); return null; } + catch (error) { return error instanceof Error ? error.message : 'Choose a positive whole bid minimum.'; } +} + +/** The parent supplies the public current lot; only the minimum is editable. */ +export function BidClaimFields(props: { + value: BidClaim; + onChange: (claim: BidClaim) => void; + disabled?: boolean; +}) { + const error = bidClaimInputError(props.value); + return
+ Current ordinary-lot bid +

Turn {props.value.turn} · Ordinary lot {props.value.lot}

+ + { + if (props.disabled) return; + const next = { ...props.value, minimum: event.currentTarget.valueAsNumber }; + if (bidClaimInputError(next)) return; + props.onChange(parseBidClaim(next)); + }} /> + {error &&

{error}

} +

+ This asks whether you will place at least one bid of this size from now until this lot closes. + It does not promise a win. If you win, the original payment rules still apply. Ordinary bank auctions only; + optional modules and native special auctions are excluded. +

+
; +} diff --git a/components/bid-promises.tsx b/components/bid-promises.tsx new file mode 100644 index 00000000..1506f077 --- /dev/null +++ b/components/bid-promises.tsx @@ -0,0 +1,62 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { bidClaimText, bidPromiseModeSupported } from '@/game/bid-promises'; +import { Button } from './ui/button'; + +type BidPromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void }; + +/** The server's current guide owns bid feasibility and funding. This leaf only + * checks that its personalized event and public action opportunity stay live. */ +export function BidPromiseGuideButton(props: BidPromiseProps) { + const game = props.game, guide = game.bidPromiseGuide, lot = game.bidPromiseLot; + if (!guide || !lot) return null; + const actor = game.me, turn = lot.turn, number = lot.lot; + const action = guide.action, event = action.bidPromiseEvent, signature = JSON.stringify(action); + const canAct = () => { + const current = props.game, me = current.players.find(player => player.id === current.me); + return !props.busy && current === game && bidPromiseModeSupported(current) && current.status === 'playing' && current.phase === 3 && + current.me === actor && current.auction?.active === actor && + current.turn === turn && current.bidPromiseLot?.turn === turn && current.bidPromiseLot.lot === number && + current.bidPromiseGuide === guide && current.bidPromiseGuide.action === action && JSON.stringify(action) === signature && + typeof event === 'string' && (action.type === 'bid' || action.type === 'passBid') && + current.bidPromises.some(promise => promise.event === event && promise.player === actor && + promise.turn === turn && promise.lot === number && !promise.fulfilled && !promise.released) && + !!me && !me.autopilot && !current.truthtrance && !current.response && !current.decision && + !current.phaseOpening && !current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed; + }; + return ; +} + +export function BidPromises(props: BidPromiseProps) { + const own = props.game.bidPromises.filter(promise => promise.player === props.game.me); + if (!own.length) return null; + const waiting = own.some(promise => !promise.fulfilled && !promise.released && + promise.turn === props.game.turn && promise.lot === props.game.bidPromiseLot?.lot); + return
+

Your bid promises

+ +

+ Yes requires one qualifying bid from your answer until this lot closes, while it remains possible. + No forbids every later bid at or above that minimum through the close of this lot. + A qualifying declaration fulfills this answer even if another player wins. + If you win, the original payment rules still apply. +

+

+ Bid promise rules.{' '} + These commitments concern the current ordinary bank lot. Optional modules and native special auctions are excluded. + Ordinary bids keep their existing funding and payment rules. +

+ {waiting && props.game.auction?.active !== props.game.me &&

Your next bid opportunity is still ahead; this promise remains visible while others bid.

} + +
; +} diff --git a/components/game-table.tsx b/components/game-table.tsx index b00b104f..89c447d3 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -24,6 +24,7 @@ import { liveShipmentPromises, } from '@/game/shipment-promises'; import { ShipmentPromises } from './shipment-promises'; +import { BidPromises } from './bid-promises'; import { HomeworldShipment } from './homeworld-shipment'; import { HomeworldRevivalDeployment } from './homeworld-revival-deployment'; import { CaladanReinforcement } from './caladan-reinforcement'; @@ -2030,6 +2031,7 @@ export function GameTable({ {g.status === 'playing' && g.phase < 5 && ( )} + {g.status === 'playing' && } {g.guildBetrayalReaction ? ( ) : g.richeseBetrayalReaction ? ( diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 039818f4..09a1c76f 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -1,5 +1,7 @@ 'use client'; import { MovementClaimFields } from './movement-claim-fields'; +import { BidClaimFields, bidClaimInputError } from './bid-claim-fields'; +import type { BidClaim } from '@/game/bid-promises'; import { TraitorDecisionAnswers } from './traitor-decision-promises'; import { validMovementClaim, type MovementClaim } from '@/game/movement-promises'; import { TruthShipmentQuestion } from './truth-shipment-question'; @@ -53,8 +55,11 @@ export function Truthtrance({ g.players.find((p) => p.id !== me.id)!.id, ); const [kind, setKind] = useState< - 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' + 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' >('fact'); + const [bidMinimum, setBidMinimum] = useState(1); + const bidClaim: BidClaim = { turn: g.bidPromiseLot?.turn ?? g.turn, + lot: g.bidPromiseLot?.lot ?? 1, minimum: bidMinimum }; const [movement, setMovement] = useState({ territory: 'carthag', minimum: 6 }); const [shipment, setShipment] = useState({ territory: 'carthag', minimum: 6 }); const [mixed, setMixed] = useState({ @@ -93,6 +98,7 @@ export function Truthtrance({ } : kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' } : kind === 'movement' ? { kind, target, ...movement } + : kind === 'bid' ? { kind, target, ...bidClaim } : kind === 'shipment' ? { kind, @@ -200,6 +206,13 @@ export function Truthtrance({ ))} + ) : w.question!.kind === 'bid' ? ( + <> +

Yes requires one qualifying bid in this ordinary lot while possible. No forbids qualifying bids until it closes. A bid does not promise a win; winning still requires the original payment. Keep the funding needed by your answer.

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

Yes requires one matching ground movement before your turn ends, while possible. No forbids every matching move through later Hajr or Ornithopter opportunities. Separate smaller groups do not add together. The No-Field marker itself is not a physical force counter.

@@ -317,6 +330,7 @@ export function Truthtrance({ Verified current facts +