From 0d979e4f8b2372f5f3ac56d2ac0e6c65eddf450b Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:05:16 +1100 Subject: [PATCH] Combine current facts with native bid declarations --- components/bid-promises.tsx | 10 +- components/game-table.tsx | 2 + components/mixed-bid-fields.tsx | 48 +++ components/mixed-bid-promises.tsx | 41 +++ components/truthtrance.tsx | 28 +- docs/CURRENT_STATUS.md | 33 +++ docs/IMPLEMENTATION_STATUS.md | 36 +++ docs/RULE_DECISIONS.md | 14 + docs/TRUTHTRANCE_MIXED_BIDS.md | 132 +++++++++ ...RUTHTRANCE_NONBATTLE_READINESS_20260907.md | 5 + game/bots.ts | 4 + game/engine.ts | 63 +++- game/mixed-bid-question.ts | 26 ++ game/reference.ts | 25 ++ game/truthtrance.ts | 27 ++ tests/fixture-truth-mixed-bid.ts | 81 ++++++ tests/mixed-bid-question.test.ts | 57 ++++ tests/truth-mixed-bid-controls.test.tsx | 274 ++++++++++++++++++ tests/truth-mixed-bid-funding.test.ts | 68 +++++ tests/truth-mixed-bid-runtime.test.ts | 247 ++++++++++++++++ 20 files changed, 1214 insertions(+), 7 deletions(-) create mode 100644 components/mixed-bid-fields.tsx create mode 100644 components/mixed-bid-promises.tsx create mode 100644 docs/TRUTHTRANCE_MIXED_BIDS.md create mode 100644 game/mixed-bid-question.ts create mode 100644 tests/fixture-truth-mixed-bid.ts create mode 100644 tests/mixed-bid-question.test.ts create mode 100644 tests/truth-mixed-bid-controls.test.tsx create mode 100644 tests/truth-mixed-bid-funding.test.ts create mode 100644 tests/truth-mixed-bid-runtime.test.ts diff --git a/components/bid-promises.tsx b/components/bid-promises.tsx index 1506f077..3d937bbd 100644 --- a/components/bid-promises.tsx +++ b/components/bid-promises.tsx @@ -20,8 +20,10 @@ export function BidPromiseGuideButton(props: BidPromiseProps) { 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) && + (current.bidPromises.some(promise => promise.event === event && promise.player === actor && + promise.turn === turn && promise.lot === number && !promise.fulfilled && !promise.released) || + current.mixedBidPromises.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; }; @@ -37,7 +39,7 @@ 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); + props.game.bidPromiseLot?.turn === props.game.turn && promise.turn === props.game.turn && promise.lot === props.game.bidPromiseLot.lot); return

Your bid promises

; } diff --git a/components/game-table.tsx b/components/game-table.tsx index 9d52a00c..1f04467b 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -31,6 +31,7 @@ import { BribePromises } from './bribe-promises'; import { BribeExpressionPromises } from './bribe-expression-promises'; import { MixedBribePromises } from './mixed-bribe-promises'; import { MixedRevivalPromises } from './mixed-revival-promises'; +import { MixedBidPromises } from './mixed-bid-promises'; import { RevivalPromises } from './revival-promises'; import { RevivalExpressionPromises } from './revival-expression-promises'; import { LeaderRevivalPromises } from './leader-revival-promises'; @@ -2052,6 +2053,7 @@ export function GameTable({ {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } + {g.status === 'playing' && } {g.guildBetrayalReaction ? ( ) : g.richeseBetrayalReaction ? ( diff --git a/components/mixed-bid-fields.tsx b/components/mixed-bid-fields.tsx new file mode 100644 index 00000000..9c9f5c8a --- /dev/null +++ b/components/mixed-bid-fields.tsx @@ -0,0 +1,48 @@ +'use client'; + +import type { GameView } from '@/game/engine'; +import type { MixedActionExpression } from '@/game/mixed-action-question'; +import { MIXED_BID_MAX_DEPTH, MIXED_BID_MAX_LEAVES, parseBidActionClaim, type BidActionClaim } from '@/game/mixed-bid-question'; +import { MixedActionFields, mixedActionInputError, type MixedActionConfig } from './mixed-action-fields'; +import { Input } from './ui/input'; + +const config: MixedActionConfig = { + prefix: 'mixed-bid', + legend: 'Mixed current-fact / future ordinary-lot bid claim', + actionLabel: 'ordinary-lot bid condition', + actionTiming: 'Future bid · this ordinary lot', + actionHelp: <>Count one future accepted native bid declaration after the answer and before this ordinary lot closes. + Smaller bids do not add together. A qualifying declaration counts even if another player wins; this does not promise a purchase. + If you win, the original payment rules still apply. Existing funding, Karama and prepaid ally aid rules still apply. + Ordinary bank auctions only; optional modules and native special auctions are excluded. + An unknown current fact uses the conservative future commitment that guarantees the whole answer. + If no such value is available, the answer is I don’t know., + fallbackError: 'Choose a current fact and a future ordinary-lot bid condition.', + maxLeaves: MIXED_BID_MAX_LEAVES, + maxDepth: MIXED_BID_MAX_DEPTH, + newAction: () => ({ minimum: 1 }), + parseAction: value => parseBidActionClaim(value), + renderAction: ({ value, id, disabled, onChange }) => <> + + onChange({ ...value, minimum: event.currentTarget.valueAsNumber })} /> + , +}; + +export function mixedBidInputError(value: unknown, game: GameView, target: string): string | null { + return mixedActionInputError(value, game, target, config); +} +export function MixedBidFields(props: { + value: MixedActionExpression; + game: GameView; + target: string; + disabled: boolean; + onChange: (expression: MixedActionExpression) => void; +}) { + const lot = props.game.bidPromiseLot; + return MixedActionFields({ ...props, config: lot + ? { ...config, legend: `${config.legend} · Turn ${lot.turn} · Ordinary lot ${lot.lot}` } + : config }); +} diff --git a/components/mixed-bid-promises.tsx b/components/mixed-bid-promises.tsx new file mode 100644 index 00000000..71270b84 --- /dev/null +++ b/components/mixed-bid-promises.tsx @@ -0,0 +1,41 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { truthQuestionText } from '@/game/truthtrance'; +import { CHEAP_HERO_TRAITOR } from '@/game/traitors'; +import { BidPromiseGuideButton } from './bid-promises'; + +/** Display the original question and whole answer only. Frozen facts, reduced + * thresholds and physical lot sources never belong in this panel. */ +export function MixedBidPromises(props: { game: GameView; busy: boolean; send: (action: Action) => void }) { + const game = props.game, own = game.mixedBidPromises.filter(promise => promise.player === game.me); + if (!own.length) return null; + const leaderName = (id: string) => id === 'kwisatz' ? 'Kwisatz Haderach' + : id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : game.allLeaders.find(leader => leader.id === id)?.name ?? id; + const lot = game.bidPromiseLot; + const live = !!lot && lot.turn === game.turn && own.some(promise => promise.turn === lot.turn && promise.lot === lot.lot && + !promise.fulfilled && !promise.released); + const sharedGuideElsewhere = !!lot && lot.turn === game.turn && game.bidPromises.some(promise => promise.player === game.me && + promise.turn === lot.turn && promise.lot === lot.lot && !promise.fulfilled && !promise.released); + return
+

Your mixed bid promises

+
    {own.map(promise =>
  • + Turn {promise.turn}, ordinary lot {promise.lot}: You answered {promise.answer ? 'Yes' : 'No'} to {truthQuestionText({ kind: 'mixedBid', target: promise.player, + turn: promise.turn, lot: promise.lot, mixed: promise.mixed }, leaderName, + id => game.combatLocations.find(location => location.id === id)?.name ?? id, + id => game.players.find(player => player.id === id)?.name ?? id)} + {promise.released ? ' Released: this whole commitment is no longer possible.' + : promise.fulfilled ? ' Fulfilled: this whole commitment is complete.' : ' This whole answer is binding for this lot.'} +
  • )}
+

Current facts are evaluated at the answer. They create no promise to keep cards, spice or forces afterward. + Only the aggregate answer is shared. Individual fact answers and any reduced future threshold remain private. + AND requires every condition; OR requires at least one. No negates the whole expression, not each condition separately.

+

A Yes answer that still requires a future bid is fulfilled immediately by one qualifying native declaration, + even if another player wins. Smaller bids do not add together. A binding No remains in force until this ordinary lot actually closes. + If you win, the original payment rules still apply. Existing funding, Karama and prepaid ally aid rules still apply. + Ordinary bank auctions only; optional modules and native special auctions are excluded. + {' '}Mixed bid rules.

+ {live && game.auction?.active !== game.me &&

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

} + {live && !sharedGuideElsewhere && } +
; +} diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 407aeed2..066a8baa 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -2,6 +2,8 @@ import { MixedBribeFields, mixedBribeInputError } from './mixed-bribe-fields'; import { MixedRevivalFields, mixedRevivalInputError } from './mixed-revival-fields'; +import { MixedBidFields, mixedBidInputError } from './mixed-bid-fields'; +import type { BidActionClaim } from '@/game/mixed-bid-question'; import type { RevivalActionClaim } from '@/game/mixed-revival-question'; import type { BribePaymentClaim } from '@/game/mixed-bribe-question'; import { MixedMovementFields, mixedMovementInputError } from './mixed-movement-fields'; @@ -77,7 +79,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' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' + 'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival' | 'bribe' | 'alliance' | 'leaderRevival' | 'revivalExpression' | 'bribeExpression' | 'movementExpression' | 'mixedMovement' | 'mixedBribe' | 'mixedRevival' | 'mixedBid' >('fact'); const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader => leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? ''); @@ -130,6 +132,11 @@ export function Truthtrance({ { kind: 'action', action: { kind: 'forces', counter: 'total', minimum: 1 } }, ] }); const mixedRevivalError = mixedRevivalInputError(mixedRevival, g, target); + const [mixedBid, setMixedBid] = useState>({ kind: 'and', terms: [ + { kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 10 } }, + { kind: 'action', action: { minimum: 4 } }, + ] }); + const mixedBidError = mixedBidInputError(mixedBid, g, target); const [shipment, setShipment] = useState({ territory: 'carthag', minimum: 6 }); const [mixed, setMixed] = useState({ kind: 'and', @@ -177,6 +184,7 @@ export function Truthtrance({ : kind === 'bribeExpression' ? { kind, target, turn: g.turn, claim: bribeExpression } : kind === 'bribe' ? { kind, target, ...bribeClaim } : kind === 'revival' ? { kind, target, ...revivalClaim } + : kind === 'mixedBid' ? { kind, target, turn: bidClaim.turn, lot: bidClaim.lot, mixed: mixedBid } : kind === 'bid' ? { kind, target, ...bidClaim } : kind === 'shipment' ? { @@ -285,6 +293,13 @@ export function Truthtrance({ ))} + ) : w.question!.kind === 'mixedBid' ? ( + <> +

Answer the whole mixed question. Current facts freeze now; any remaining bid conditions concern future accepted declarations in this ordinary lot. No keeps the whole expression false. Passing, funding, Karama and the original winning payment still follow the auction rules. An unknown fact requires a commitment that guarantees the whole answer; otherwise choose I don’t know.

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

Answer the complete mixed question. Current facts freeze now; only future ordinary returns can fulfill any remaining Revival conditions. No keeps the whole expression false. Native eligibility, costs, limits and turn order still apply. An unknown fact requires a future commitment that guarantees your answer; otherwise choose I don’t know.

@@ -490,6 +505,7 @@ export function Truthtrance({ +