From 2916b4d48f24bd19df959f40cda8d5556c279b44 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:44:53 +1100 Subject: [PATCH] Support phase-wide Truthtrance bid commitments --- components/game-table.tsx | 2 + components/phase-bid-claim-fields.tsx | 38 ++ components/phase-bid-promises.tsx | 67 +++ components/truthtrance.tsx | 21 +- docs/CURRENT_STATUS.md | 12 + docs/IMPLEMENTATION_STATUS.md | 17 + docs/RULE_DECISIONS.md | 10 + docs/TRUTHTRANCE_BID_PROMISES.md | 39 ++ game/bots.ts | 5 + game/engine.ts | 310 +++++++++++++- game/reference.ts | 12 +- game/truthtrance.ts | 25 ++ game/turn-bid-promises.ts | 149 +++++++ tests/fixture-truth-turn-bid.ts | 62 +++ tests/truth-phase-bid-controls.test.tsx | 95 +++++ ...uth-phase-bid-integrated-controls.test.tsx | 75 ++++ tests/truth-turn-bid-composition.test.ts | 383 ++++++++++++++++++ tests/truth-turn-bid-runtime.test.ts | 88 ++++ tests/turn-bid-promises.test.ts | 117 ++++++ 19 files changed, 1512 insertions(+), 15 deletions(-) create mode 100644 components/phase-bid-claim-fields.tsx create mode 100644 components/phase-bid-promises.tsx create mode 100644 game/turn-bid-promises.ts create mode 100644 tests/fixture-truth-turn-bid.ts create mode 100644 tests/truth-phase-bid-controls.test.tsx create mode 100644 tests/truth-phase-bid-integrated-controls.test.tsx create mode 100644 tests/truth-turn-bid-composition.test.ts create mode 100644 tests/truth-turn-bid-runtime.test.ts create mode 100644 tests/turn-bid-promises.test.ts diff --git a/components/game-table.tsx b/components/game-table.tsx index e132d64c..7d17da00 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -43,6 +43,7 @@ import { RevivalPromises } from './revival-promises'; import { RevivalExpressionPromises } from './revival-expression-promises'; import { LeaderRevivalPromises } from './leader-revival-promises'; import { BidPromises } from './bid-promises'; +import { PhaseBidPromises } from './phase-bid-promises'; import { HomeworldShipment } from './homeworld-shipment'; import { HomeworldRevivalDeployment } from './homeworld-revival-deployment'; import { CaladanReinforcement } from './caladan-reinforcement'; @@ -2086,6 +2087,7 @@ export function GameTable({ {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } + {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } {g.status === 'playing' && } diff --git a/components/phase-bid-claim-fields.tsx b/components/phase-bid-claim-fields.tsx new file mode 100644 index 00000000..3941ac7e --- /dev/null +++ b/components/phase-bid-claim-fields.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { parsePhaseBidClaim, type PhaseBidClaim } from '@/game/turn-bid-promises'; +import { Input } from './ui/input'; + +export function phaseBidClaimInputError(value: unknown): string | null { + try { parsePhaseBidClaim(value); return null; } + catch (error) { return error instanceof Error ? error.message : 'Choose a positive whole bid minimum.'; } +} + +/** Turn is fixed by the authoritative question context; the asker chooses the threshold. */ +export function PhaseBidClaimFields(props: { + value: PhaseBidClaim; + onChange: (claim: PhaseBidClaim) => void; + disabled?: boolean; +}) { + const error = phaseBidClaimInputError(props.value); + return
+ Bid during this turn +

Turn {props.value.turn} · Remaining ordinary Bidding phase

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

{error}

} +

+ This asks about one accepted bid of at least this size after the answer, + before Bidding ends this turn. Earlier bids do not count; it does not + promise a win or change the winning payment. +

+
; +} diff --git a/components/phase-bid-promises.tsx b/components/phase-bid-promises.tsx new file mode 100644 index 00000000..0f6553de --- /dev/null +++ b/components/phase-bid-promises.tsx @@ -0,0 +1,67 @@ +'use client'; + +import type { Action, GameView } from '@/game/engine'; +import { phaseBidClaimText, phaseBidPromiseModeSupported } from '@/game/turn-bid-promises'; +import { Button } from './ui/button'; + +type PhaseBidPromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void }; + +/** Only operate the exact server-selected bid/pass for a live owned promise. + * The native auction remains responsible for its own card, funding and turn. */ +export function PhaseBidPromiseGuideButton(props: PhaseBidPromiseProps) { + const game = props.game, guide = game.phaseBidPromiseGuide; + if (!guide) return null; + const actor = game.me, turn = game.turn, lot = game.auction; + const lotSignature = lot && JSON.stringify([lot.bid, lot.bidder, lot.active, lot.remaining]); + const action = guide.action, event = action.phaseBidPromiseEvent, lotNumber = action.phaseBidPromiseLot; + const signature = JSON.stringify(action); + const canAct = () => { + const current = props.game, me = current.players.find(player => player.id === current.me); + return !props.busy && current === game && phaseBidPromiseModeSupported(current) && + current.status === 'playing' && current.phase === 3 && + current.me === actor && current.turn === turn && !!lot && current.auction === lot && + current.auction.active === actor && + JSON.stringify([lot.bid, lot.bidder, lot.active, lot.remaining]) === lotSignature && + typeof lotNumber === 'number' && Number.isSafeInteger(lotNumber) && lotNumber > 0 && + current.bidPromiseLot?.turn === turn && current.bidPromiseLot.lot === lotNumber && + current.phaseBidPromiseGuide === guide && current.phaseBidPromiseGuide.action === action && + JSON.stringify(action) === signature && typeof event === 'string' && + (action.type === 'bid' || action.type === 'passBid') && + current.phaseBidPromises.some(promise => promise.event === event && promise.player === actor && + promise.turn === turn && !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 PhaseBidPromises(props: PhaseBidPromiseProps) { + const own = props.game.phaseBidPromises.filter(promise => promise.player === props.game.me); + if (!own.length) return null; + const waiting = own.some(promise => promise.turn === props.game.turn && !promise.fulfilled && !promise.released); + return
+

Your Bidding phase promises

+
    {own.map(promise =>
  • + Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {phaseBidClaimText(promise)}. + {promise.released ? ' Released: this commitment is no longer possible.' + : promise.fulfilled ? ' Fulfilled: this commitment is complete.' : ' This answer is binding through this Bidding phase.'} +
  • )}
+

+ Yes requires a qualifying future bid while it remains possible. No forbids + qualifying bids through Bidding close. A bid can fulfill Yes even if another + player wins; the original payment rules still apply. +

+

+ Earlier bids do not count. Ordinary auctions retain their original order and + funding; the answer gives no extra opportunity, spice or free purchase. +

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

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

} + {waiting && } +
; +} diff --git a/components/truthtrance.tsx b/components/truthtrance.tsx index 79ce8da8..9043a2b7 100644 --- a/components/truthtrance.tsx +++ b/components/truthtrance.tsx @@ -42,6 +42,7 @@ import type { BribeClaim } from '@/game/bribe-promises'; import { RevivalClaimFields, revivalClaimInputError } from './revival-claim-fields'; import type { RevivalClaim } from '@/game/revival-promises'; import { BidClaimFields, bidClaimInputError } from './bid-claim-fields'; +import { PhaseBidClaimFields, phaseBidClaimInputError } from './phase-bid-claim-fields'; import type { BidClaim } from '@/game/bid-promises'; import { TraitorDecisionAnswers } from './traitor-decision-promises'; import { validMovementClaim, type MovementClaim } from '@/game/movement-promises'; @@ -96,7 +97,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' | 'mixedBid' | 'allianceExpression' | 'mixedAlliance' | 'mixedBattlePlan' | 'mixedTraitorCall' | 'travel' | 'turnActions' + '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'); const [revivalLeader, setRevivalLeader] = useState(g.allLeaders.find(leader => leader.faction === g.players.find(player => player.id === target)?.faction)?.id ?? ''); @@ -138,6 +139,7 @@ export function Truthtrance({ const [bidMinimum, setBidMinimum] = useState(1); const bidClaim: BidClaim = { turn: g.bidPromiseLot?.turn ?? g.turn, lot: g.bidPromiseLot?.lot ?? 1, minimum: bidMinimum }; + const phaseBidClaim = {turn:g.turn,minimum:bidMinimum}; const [movement, setMovement] = useState({ territory: 'carthag', minimum: 6 }); const [movementExpression, setMovementExpression] = useState({ kind: 'and', terms: [ { kind: 'move', territory: 'imperial_basin', minimum: 1 }, { kind: 'move', territory: 'carthag', minimum: 1 }, @@ -239,6 +241,7 @@ export function Truthtrance({ : kind === 'revival' ? { kind, target, ...revivalClaim } : kind === 'mixedBid' ? { kind, target, turn: bidClaim.turn, lot: bidClaim.lot, mixed: mixedBid } : kind === 'bid' ? { kind, target, ...bidClaim } + : kind === 'phaseBid' ? { kind, target, ...phaseBidClaim } : kind === 'shipment' ? { kind, @@ -423,6 +426,13 @@ export function Truthtrance({ {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.

+ {(g.truthPhaseBidAnswers ?? []).map(answer =>
+ {button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })} +
)} + ) : 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.

@@ -578,6 +588,7 @@ export function Truthtrance({ + @@ -721,6 +732,13 @@ 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 === 'phaseBid' ? ( + <> + setBidMinimum(claim.minimum)} /> + {!g.truthPhaseBidTargets.includes(target) &&

Choose a supported current-turn Bidding question and finish pending decisions.

} +

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

+ ) : kind === 'bid' ? ( <> player.id === claim.recipient)) return []; const source = g.phase === 2 && !g.charityOrder ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; - return (['yes', 'no'] as const).filter(answer => !!bribePromiseCompletion(source, p, [...(g.bribePromises ?? []), - { ...claim, event: 'candidate', player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host, - answer: answer === 'yes', progress: 0 }])); + return (['yes', 'no'] as const).filter(answer => { + const promises = [...(g.bribePromises ?? []), { ...claim, event: 'candidate', player: p.id, + asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: 0 }]; + return !!bribePromiseCompletion(source, p, promises) && + phaseBidCompatibleBribeAnswer({ ...source, bribePromises: promises }, p); + }); } function bribeExpressionTruthAnswers(g: Game, p: Player, claim: BribeExpressionClaim): ('yes' | 'no' | 'unknown')[] { if (!bribeQuestionWindow(g) || claim.turn !== g.turn || bribeExpressionRecipients(claim.claim) .some(recipient => recipient === p.id || recipient === p.ally || !g.players.some(player => player.id === recipient))) return []; const source = g.phase === 2 && !g.charityOrder ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g; - return (['yes', 'no'] as const).filter(answer => !!bribePromiseCompletion(source, p, g.bribePromises ?? [], - [...(g.bribeExpressionPromises ?? []), { ...claim, event: 'candidate', player: p.id, - asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: [] }])); + return (['yes', 'no'] as const).filter(answer => { + const expressions = [...(g.bribeExpressionPromises ?? []), { ...claim, event: 'candidate', player: p.id, + asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes', progress: [] }]; + return !!bribePromiseCompletion(source, p, g.bribePromises ?? [], expressions) && + phaseBidCompatibleBribeAnswer({ ...source, bribeExpressionPromises: expressions }, p); + }); } function reopenBribePayer(g: Game, p: Player, answer: boolean) { if (answer && (g.phase === 2 && !g.charityOrder || g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g)) && g.ready.includes(p.id)) { @@ -16021,6 +16032,216 @@ function bidQuestionWindow(g: Game): boolean { return !!bidOpportunity(g) && !g.response && !g.decision && !g.phaseOpening && !g.richeseAuction && !g.ixAuction && !g.pendingKarama && !g.pendingTreacheryDiscard; } +/** A future lot is a witness only after a real funded bid has committed the + * current lot to sale. An unbid lot ends ordinary Bidding instead of opening + * its remaining cards. The source is kept private in the native auction. */ +function phaseBidWitnesses(g: Game, p: Player): PhaseBidOpportunity[] { + if (g.status !== 'playing' || !phaseBidPromiseModeSupported(g) || g.phase > 3) return []; + const maximum = (spent = 0, spentAid = 0, canUseKarama = true) => { + if (canUseKarama && karamaCard(g, p, undefined, 'auction-free-purchase')) return Number.MAX_SAFE_INTEGER; + return Math.max(0, uncommittedSpice(g, p) - spent + Math.max(0, (aidFor(g, p)?.amount ?? 0) - spentAid)); + }; + if (g.phase < 3) { + // No special-auction preparation or future purchase is manufactured. + if (byFaction(g, 'ixians') || byFaction(g, 'richese') || + p.hand.length >= handLimit(p) || !(g.deck.length || g.discard.length)) return []; + const max = maximum(); + return max > 0 ? [{turn:g.turn,player:p.id,kind:'future',minimum:1,maximum:max}] : []; + } + const lot = bidOpportunity(g), auction = g.auction; + if (!lot || !auction) return []; + const result: PhaseBidOpportunity[] = []; + const current = bidPromiseRange(g.bidPromises ?? [], p.id, lot, normalBidMaximum(g, p)); + if (current) result.push({turn:g.turn,player:p.id,kind:'current',...current}); + const buyer = auction.bidder ? g.players.find(player => player.id === auction.bidder) : undefined; + const paidAid = auction.allyPayment ?? 0; + const funded = !!buyer && paidAid >= 0 && paidAid <= auction.bid && + buyer.spice >= auction.bid - paidAid && (aidFor(g,buyer)?.amount ?? 0) >= paidAid; + // A native Harkonnen winner may receive a bonus card after the + // purchase. Reserve that slot rather than assuming an opponent cancels it. + const futureCards = buyer?.id === p.id + ? 1 + Number(p.faction === 'harkonnen') : 0; + if (buyer && funded && auction.bid > 0 && auction.cards[auction.index + 1] && + p.hand.length + futureCards < handLimit(p)) { + // uncommittedSpice already reserves the current winner's own invoice. + const spentAid = buyer.id === p.id ? paidAid : 0; + const mustSpendKarama = buyer.id === p.id && auction.bid > p.spice + (aidFor(g,p)?.amount ?? 0); + const max = maximum(0, spentAid, !mustSpendKarama); + if (max > 0) result.push({turn:g.turn,player:p.id,kind:'future',minimum:1,maximum:max}); + } + return result; +} +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 && + !g.richeseAuction && !g.ixAuction && !g.currentAuctionSale && (g.phase < 3 || !!bidOpportunity(g)); +} +function phaseBidHonorsTurnActions(g: Game, p: Player, lot: BidOpportunity, action: Action): boolean { + if (!liveTurnActionPromises(g.turnActionPromises ?? [],p.id,g.turn).length) return true; + const trial = structuredClone(g); + trial.truthtrance = null; + try { + if (action.type === 'bid') { + trial.turnActionPromises = turnActionRule(() => recordTurnActionBid(trial.turnActionPromises!,p.id,g.turn, + {lot:lot.lot,source:lot.source,amount:Number(action.amount)})); + trial.bidPromises = recordPromisedBid(trial.bidPromises ?? [],p.id,lot,Number(action.amount)); + trial.auction!.bid = Number(action.amount); + trial.auction!.bidder = p.id; + trial.auction!.allyPayment = Number(action.allyPayment ?? 0); + } else trial.turnActionPromises = turnActionRule(() => closeTurnActionBidding(trial.turnActionPromises!,g.turn,lot.source,p.id)); + return !!turnActionCompletion(trial,p.id); + } catch (error) { if (error instanceof RuleError || error instanceof BidPromiseError) return false; throw error; } +} +/** Current witnesses must also satisfy the existing native funding and + * current-lot commitments. Future bidding with other financial/transport + * promises awaits their combined planner; do not invent those resources. */ +function viablePhaseBidWitnesses(g: Game, p: Player, promises: readonly PhaseBidPromise[]) { + const lot = bidOpportunity(g); + const live = livePhaseBidPromises(promises,p.id,g.turn); + const otherCommitments = liveShipmentPromises(g.shipmentPromises ?? [],p.id,g.turn).some(row => row.answer) || + hasMovementPromises(g,p.id) || 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) || + liveTurnActionPromises(g.turnActionPromises ?? [],p.id,g.turn).length > 0; + return phaseBidWitnesses(g,p).flatMap(witness => { + if (witness.kind === 'future') { + if (otherCommitments) return []; + if (hasBribePromises(g,p.id,true)) { + // Quote only currently funded payments; no future income is invented. + // Current-lot combined forecasts remain unsupported. + if (lot) return []; + const source = structuredClone(g); source.truthtrance = null; + const fundedWitnesses: PhaseBidOpportunity[] = []; + for (const payments of bribeCompletionCandidates(source.bribePromises ?? [], source.bribeExpressionPromises ?? [], p.id, g.turn)) { + const total = payments.reduce((sum, payment) => sum + payment.amount, 0); + if (!Number.isSafeInteger(total) || total > uncommittedSpice(source,getPlayer(source,p.id))) continue; + for (const seed of ordinaryPaymentSeeds(source, p.id, payments)) { + if (seed.actions.some(action => action.type !== 'bribe')) continue; + const after = phaseBidWitnesses(seed.game,getPlayer(seed.game,p.id)).find(row => row.kind === 'future'); + if (after) fundedWitnesses.push(after); + } + } + return fundedWitnesses; + } + if (lot && bidRequired(g.bidPromises ?? [],p.id,lot)) { + // A later lot is viable only after satisfying this lot's existing + // obligations. Its actual invoice and extra hand slot remain reserved. + const caps: BidPromise[] = live.filter(row => !row.answer).map((row,index) => ({ + turn:g.turn,lot:lot.lot,minimum:row.minimum,event:`phase-future-cap-${index}`, + source:lot.source,player:p.id,asker:row.asker,answer:false, + })); + const required = bidPromiseCompletion(g,p,[...(g.bidPromises ?? []),...caps]); + if (!required || required.type !== 'bid') return []; + const trial = structuredClone(g); + trial.auction!.bid = Number(required.amount); + trial.auction!.bidder = p.id; + trial.auction!.allyPayment = Number(required.allyPayment ?? 0); + const after = phaseBidWitnesses(trial,getPlayer(trial,p.id)).find(row => row.kind === 'future'); + return after ? [after] : []; + } + return [witness]; + } + if (!lot) return []; + const bounds: BidPromise[] = live.map((row,index) => ({turn:g.turn,lot:lot.lot,minimum:row.minimum, + event:`phase-witness-${index}`,source:lot.source,player:p.id,asker:row.asker,answer:row.answer})); + const action = bidPromiseCompletion(g,p,[...(g.bidPromises ?? []),...bounds]); + return action && phaseBidHonorsTurnActions(g,p,lot,action) ? [witness] : []; + }); +} +/** A new independent bribe must preserve an already-binding phase Yes. + * Before Bidding, reserve currently fundable payment first; current-lot + * independent combinations wait for a joint planner. */ +function phaseBidCompatibleBribeAnswer(g: Game, p: Player): boolean { + const live = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn); + if (!live.some(row => row.answer)) return true; + if (g.phase === 3 && hasBribePromises(g,p.id,true)) return false; + return viablePhaseBidWitnesses(g,p,g.phaseBidPromises ?? []) + .some(witness => !!phaseBidCompletionRange(g.phaseBidPromises ?? [],witness)); +} +function phaseBidRule(run: () => T): T { + try { return run(); } + catch (error) { if (error instanceof PhaseBidPromiseError) throw new RuleError(error.message); throw error; } +} +function phaseBidTruthAnswers(g: Game, p: Player, claim: PhaseBidClaim): TruthAnswer[] { + if (!phaseBidQuestionWindow(g) || claim.turn !== g.turn) return []; + const base = g.phaseBidPromises ?? []; + const answers: TruthAnswer[] = []; + for (const answer of [true, false]) { + const asker = g.truthtrance?.queue[0]?.player ?? g.players.find(player => player.id !== p.id)?.id ?? g.host; + const candidate: PhaseBidPromise = {turn:claim.turn,minimum:claim.minimum,event:'candidate',player:p.id,asker,answer}; + const all = [...base,candidate]; + const witnesses = viablePhaseBidWitnesses(g,p,all); + const yesPossible = !all.some(row => row.player === p.id && row.turn === g.turn && row.answer && !row.fulfilled && !row.released) || + witnesses.some(witness => !!phaseBidCompletionRange(all,witness)); + // Existing current-lot Yes commitments must also survive this new No. + const lot = bidOpportunity(g); + let lotPossible = true; + if (lot && bidRequired(g.bidPromises ?? [],p.id,lot)) { + const caps: BidPromise[] = all.filter(row => row.player === p.id && row.turn === g.turn && !row.answer && + !row.fulfilled && !row.released).map((row,index) => ({turn:g.turn,lot:lot.lot,minimum:row.minimum, + event:`phase-cap-${index}`,source:lot.source,player:p.id,asker:row.asker,answer:false})); + const action = bidPromiseCompletion(g,p,[...(g.bidPromises ?? []),...caps]); + lotPossible = !!action && phaseBidHonorsTurnActions(g,p,lot,action); + } + if (yesPossible && lotPossible) answers.push(answer ? 'yes' : 'no'); + } + return answers; +} +function bindPhaseBidTruth(g: Game, p: Player, claim: PhaseBidClaim, answer: boolean, asker: string) { + requireRule(phaseBidTruthAnswers(g,p,claim).includes(answer ? 'yes' : 'no'), + 'This phase-wide bid answer no longer has a legal completion.'); + (g.phaseBidPromises ??= []).push({turn:claim.turn,minimum:claim.minimum,event:crypto.randomUUID(),player:p.id,asker,answer}); +} +function phaseBidIntegrity(g: Game) { + phaseBidRule(() => validatePhaseBidPromises(g.phaseBidPromises ?? [],g.players.map(player => player.id))); +} +function reconcilePhaseBids(g: Game, voluntary?: {actor:string;action:Action}) { + phaseBidIntegrity(g); + if (!g.phaseBidPromises?.length || g.truthtrance || g.response || g.decision || g.phaseOpening || + g.pendingTreacheryDiscard || g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal) return; + for (const player of g.players) { + const live = livePhaseBidPromises(g.phaseBidPromises,player.id,g.turn); + if (!live.length) continue; + const change = voluntary?.actor === player.id ? 'voluntary' : 'external'; + if (g.phase > 3) g.phaseBidPromises = phaseBidRule(() => finishPhaseBidPhase(g.phaseBidPromises!,player.id,g.turn,change)); + else { + const witnesses = viablePhaseBidWitnesses(g,player,g.phaseBidPromises); + // An external event must not release an answer merely because the + // combined future-obligation planner is not implemented yet. + const unknownFuture = change === 'external' && !witnesses.some(row => row.kind === 'future') && + phaseBidWitnesses(g,player).some(row => row.kind === 'future'); + if (unknownFuture && live.some(row => row.answer) && + !witnesses.some(row => !!phaseBidCompletionRange(g.phaseBidPromises!,row))) continue; + g.phaseBidPromises = phaseBidRule(() => reconcilePhaseBidPromises(g.phaseBidPromises!,player.id,g.turn,witnesses,change)); + } + } +} +function projectedPhaseBidPromises(g: Game, player: string) { + return (g.phaseBidPromises ?? []).filter(promise => promise.player === player).map(promise => ({...promise})); +} +function projectedPhaseBidGuide(g: Game, player: string): {action:Action} | null { + const opportunity = bidOpportunity(g), seat = getPlayer(g,player); + if (!opportunity || !bidQuestionWindow(g) || g.truthtrance || g.auction?.active !== player) return null; + const live = livePhaseBidPromises(g.phaseBidPromises ?? [],player,g.turn); + if (!live.length) return null; + const required = live.some(promise => promise.answer); + let action: Action = {type:'passBid'}; + if (required || bidRequired(g.bidPromises ?? [],player,opportunity)) { + const bounds: BidPromise[] = live.map((promise,index) => ({ + turn:g.turn,lot:opportunity.lot,minimum:promise.minimum,event:`phase-guide-${index}`, + source:opportunity.source,player,asker:promise.asker,answer:promise.answer, + })); + const bid = bidPromiseCompletion(g,seat,[...(g.bidPromises ?? []),...bounds]); + if (bid && phaseBidHonorsTurnActions(g,seat,opportunity,bid)) action = bid; + else { + const future = viablePhaseBidWitnesses(g,seat,g.phaseBidPromises ?? []).find(witness => witness.kind === 'future' && + !!phaseBidCompletionRange(g.phaseBidPromises ?? [],witness)); + if (!future || bidRequired(g.bidPromises ?? [],player,opportunity) || + !phaseBidHonorsTurnActions(g,seat,opportunity,action)) return null; + } + } + return {action:{...action,phaseBidPromiseEvent:live[0].event,phaseBidPromiseLot:opportunity.lot}}; +} function bidPromiseCompletion(g: Game, p: Player, promises = g.bidPromises ?? []): Action | null { const opportunity = bidOpportunity(g); if (!opportunity) return null; @@ -16057,9 +16278,29 @@ function bidTruthAnswers(g: Game, p: Player, claim: BidClaim): ('yes' | 'no' | ' const opportunity = bidOpportunity(g); if (!opportunity || !bidQuestionWindow(g) || claim.turn !== opportunity.turn || claim.lot !== opportunity.lot || !opportunity.eligible.includes(p.id) || opportunity.passed.includes(p.id) || opportunity.bidder === p.id) return []; - return (['yes', 'no'] as const).filter(answer => !!bidPromiseCompletion(g, p, [...(g.bidPromises ?? []), - { ...claim, event: 'candidate', source: opportunity.source, player: p.id, - asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes' }])); + return (['yes', 'no'] as const).filter(answer => { + const phases = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn); + const caps: BidPromise[] = phases.filter(row => !row.answer).map((row,index) => ({ + turn:g.turn,lot:opportunity.lot,minimum:row.minimum,event:`phase-cap-${index}`, + source:opportunity.source,player:p.id,asker:row.asker,answer:false, + })); + const claims: BidPromise[] = [...(g.bidPromises ?? []),...caps, + { ...claim, event: 'candidate', source: opportunity.source, player: p.id, + asker: g.truthtrance?.queue[0]?.player ?? g.host, answer: answer === 'yes' }]; + const candidateAction = bidPromiseCompletion(g,p,claims); + if (!candidateAction || !phaseBidHonorsTurnActions(g,p,opportunity,candidateAction)) return false; + const phaseYes = phases.filter(row => row.answer); + const trial = structuredClone(g); + trial.bidPromises = claims; + if (!phaseYes.length || viablePhaseBidWitnesses(trial,getPlayer(trial,p.id),trial.phaseBidPromises ?? []).some(w => w.kind === 'future' && + !!phaseBidCompletionRange(g.phaseBidPromises ?? [],w))) return true; + const required: BidPromise[] = phaseYes.map((row,index) => ({ + turn:g.turn,lot:opportunity.lot,minimum:row.minimum,event:`phase-yes-${index}`, + source:opportunity.source,player:p.id,asker:row.asker,answer:true, + })); + const requiredAction = bidPromiseCompletion(g,p,[...claims,...required]); + return !!requiredAction && phaseBidHonorsTurnActions(g,p,opportunity,requiredAction); + }); } function bindBidTruth(g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) { const opportunity = bidOpportunity(g); @@ -16147,6 +16388,8 @@ function projectedBidPromises(g: Game, player: string) { })); } function projectedBidPromiseGuide(g: Game, player: string): { action: Action } | null { + // The phase-wide guide combines both sets of live bounds into one packet. + if (livePhaseBidPromises(g.phaseBidPromises ?? [],player,g.turn).length) return null; const opportunity = bidOpportunity(g); if (!opportunity || !bidQuestionWindow(g) || g.truthtrance || g.auction?.active !== player) return null; const promise = liveBidPromises(g.bidPromises ?? [], player, opportunity)[0]; @@ -16160,6 +16403,22 @@ function guardPromisedBid(g: Game, p: Player, action: Action, amount?: number) { if (action.bidPromiseEvent !== undefined) requireRule(typeof action.bidPromiseEvent === 'string' && live.some(promise => promise.event === action.bidPromiseEvent), 'This bid commitment guide is stale.'); if (!opportunity) return; + const phaseLive = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn); + if (amount !== undefined && phaseLive.length) + g.phaseBidPromises = phaseBidRule(() => recordPhaseBid(g.phaseBidPromises!,p.id,g.turn,amount)); + if (livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn).some(promise => promise.answer)) { + const trial = structuredClone(g); + if (amount !== undefined) { + trial.auction!.bid = amount; + trial.auction!.bidder = p.id; + trial.auction!.allyPayment = normalBidQuote(g,p,amount,action.allyPayment).allyPayment; + try { trial.bidPromises = recordPromisedBid(trial.bidPromises ?? [],p.id,opportunity,amount); } + catch (error) { if (error instanceof BidPromiseError) throw new RuleError(error.message); throw error; } + } + const future = viablePhaseBidWitnesses(trial,getPlayer(trial,p.id),trial.phaseBidPromises ?? []).find(witness => witness.kind === 'future' && + !!phaseBidCompletionRange(trial.phaseBidPromises ?? [],witness)); + requireRule(future, 'Keep a funded later lot or place the bid required by your Truthtrance Yes.'); + } if (g.turnActionPromises) { if (amount !== undefined) g.turnActionPromises = turnActionRule(() => recordTurnActionBid(g.turnActionPromises!, p.id, g.turn, { @@ -20423,6 +20682,20 @@ 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 }; + const phases = livePhaseBidPromises(g.phaseBidPromises ?? [],p.id,g.turn); + const lot = bidOpportunity(trial); + if (phases.length && lot) { + // The combined planner may use this actual lot, never a guessed later + // invoice. Encode both phase Yes and No in its existing native quote. + const bounds: BidPromise[] = phases.map((row,index) => ({ + turn:g.turn,lot:lot.lot,minimum:row.minimum,event:`phase-turn-${index}`, + source:lot.source,player:p.id,asker:row.asker,answer:row.answer, + })); + trial.bidPromises = [...(g.bidPromises ?? []),...bounds]; + } else if (phases.some(row => row.answer) && promise.compiled.kind !== 'constant') { + // Future mixed payment/transport composition has no verified planner. + continue; + } if (turnActionCompletion(trial, p.id, answer && promise.alliance ? consent : undefined)) answers.push(answer ? 'yes' : 'no'); } if (!answers.includes('yes') && consent.value) answers.push('unknown'); @@ -38645,6 +38918,7 @@ function normalizeCardNames(g: Game) { } export function applyAction(state: Game, id: string, action: Action): Game { bidPromiseIntegrity(state); + phaseBidIntegrity(state); revivalPromiseIntegrity(state); bribePromiseIntegrity(state); alliancePromiseIntegrity(state); @@ -38811,6 +39085,12 @@ export function applyAction(state: Game, id: string, action: Action): Game { Object.keys(guide.action).every(key => action[key] === guide.action[key]), 'This revival commitment guide is stale or belongs to another native opportunity.'); } + if (action.phaseBidPromiseEvent !== undefined || action.phaseBidPromiseLot !== undefined) { + const guide = projectedPhaseBidGuide(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 phase bid guide is stale or belongs to another auction lot.'); + } const g = applyActionInner(state, id, action); if (state.phase === 3 && liveTurnActionPromises(state.turnActionPromises ?? [], id, state.turn).some(row => { const stage = row.windowCards?.karamaAuction; @@ -38824,6 +39104,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { const voluntary = forcedThumperDraw || action.type === 'passResponse' ? undefined : { actor: id, action }; reconcileTravelPromises(g, promiseActor, action, state.phase); reconcileBidPromises(g, voluntary); + reconcilePhaseBids(g, voluntary); reconcileRevivalPromises(g, voluntary); reconcileBribePromises(g, voluntary); reconcileGroundMovementPromises(g, promiseActor, action, state.phase); @@ -38948,6 +39229,7 @@ export function applyAction(state: Game, id: string, action: Action): Game { reconcileBattlePromises(g, { actor: id, action }); reconcileShipmentPromises(g, { actor: id, action, phase: state.phase }); reconcileBidPromises(g, { actor: id, action }); + reconcilePhaseBids(g, { actor: id, action }); reconcileRevivalPromises(g, { actor: id, action }); reconcileBribePromises(g, { actor: id, action }); reconcileAlliancePromises(g, { actor: id, action }); @@ -39165,6 +39447,7 @@ function settleAutomaticContinuations(g: Game) { reconcileBattlePromises(g); reconcileShipmentPromises(g); reconcileBidPromises(g); + reconcilePhaseBids(g); reconcileRevivalPromises(g); reconcileBribePromises(g); reconcileGroundMovementPromises(g); @@ -39182,6 +39465,7 @@ function settleAutomaticContinuations(g: Game) { /** Internal authoritative continuation. Callers must persist with their usual CAS fence. */ export function normalizeAutomaticGame(state: Game): Game { bidPromiseIntegrity(state); + phaseBidIntegrity(state); revivalPromiseIntegrity(state); bribePromiseIntegrity(state); alliancePromiseIntegrity(state); @@ -39270,6 +39554,7 @@ export function normalizeAutomaticGame(state: Game): Game { reconcileBattlePromises(g); reconcileShipmentPromises(g); reconcileBidPromises(g); + reconcilePhaseBids(g); reconcileRevivalPromises(g); reconcileBribePromises(g); reconcileGroundMovementPromises(g); @@ -39546,6 +39831,8 @@ function applyActionInner( bindMixedBid: bindMixedBidTruth, bidAnswers: bidTruthAnswers, bindBid: bindBidTruth, + phaseBidAnswers: phaseBidTruthAnswers, + bindPhaseBid: bindPhaseBidTruth, turnActionAnswers: turnActionTruthAnswers, bindTurnAction: bindTurnActionTruth, travelAnswers: travelTruthAnswers, @@ -43544,6 +43831,11 @@ 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, + 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, + phaseBidPromises: projectedPhaseBidPromises(g,id), + phaseBidPromiseGuide: projectedPhaseBidGuide(g,id), alliancePromiseNexus: (() => { const nexus = allianceOpportunity(g); return nexus ? { turn: nexus.turn } : null; })(), truthAllianceTargets: allianceQuestionWindow(g) ? g.players.map(player => player.id) : [], truthAllianceAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id && diff --git a/game/reference.ts b/game/reference.ts index 49d272b6..16dda29f 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3652,24 +3652,26 @@ export const RULE_TOPICS: RuleTopic[] = [ }, { id: 'truthtrance-bid', - title: 'Truthtrance: a bid in the current ordinary lot', + title: 'Truthtrance: ordinary bid commitments', category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped', - summary: 'Bind one future minimum bid in the current ordinary bank lot, while preserving its original funding, payment and turn order.', + summary: 'Bind one future minimum ordinary bid in the current lot or remaining Bidding phase, preserving native funding, payment and turn 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.', + '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.', 'The declaration fulfills Yes even if another player later wins. If you win, the original winning invoice and Karama payment rules still apply. A promise creates no spice or free purchase. Current-highest, already-passed and full-hand players have no new opportunity for this structured question.', 'Available answers use current native funding, prepaid allied aid and a spendable Karama. Earlier shipment and movement obligations retain their funding. Unknown future purchases or rival income are not assumed. Several answers must remain jointly possible: Yes sets the required minimum and No can cap a later bid.', 'Your own voluntary spending cannot evade Yes. A completed opposing raise may make it impossible and release that answer; still-possible answers and No constraints remain. Closed lots finish No and cannot bind the next lot or turn.', 'Your persistent bid-promise panel waits for your actual active seat, then offers a legal bid or pass. It shows fulfilled and released status without timing out. Only the questioned player receives its feasible answers and private guide; the question and reply are public.', - 'This current-lot and next-opportunity protocol is a provisional online composition of the general current-turn Truthtrance rule. Native special auctions, optional modules, grouped/phase-wide bidding commitments, broader force-revival commitments and complete-mode acceptance remain unfinished.', + 'These current-lot and phase-wide protocols are provisional online compositions of the general current-turn Truthtrance rule. Native special auctions, optional modules, grouped phase-wide bidding commitments, broader force-revival commitments and complete-mode acceptance remain unfinished.', ], related: ['card-truthtrance', 'bidding', 'truthtrance-shipment', 'implementation-checklist'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Exact current-lot minimum bids use the native quote, real declaration, joint Yes/No interval, accepted-order forced release and lot expiry. Original registered bare native Basic/Advanced scope; no new game-start gate.', evidence: ['game/bid-promises.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/bid-promises.test.ts', 'tests/truth-bid-runtime.test.ts'] }, - { area: 'Player controls', status: 'Partial', detail: 'Original question selector, public preview, target-only answers and persistent event-bound guide use the native bid packet. Winning payments remain separate.', evidence: ['components/bid-claim-fields.tsx', 'components/bid-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-bid-controls.test.tsx'] }, + { area: 'Implementation', status: 'Partial', detail: 'Current-lot and separate phase-wide minimum bids use native declarations, funded witnesses, joint Yes/No constraints and their own expiry. No new game-start gate.', evidence: ['game/bid-promises.ts', 'game/turn-bid-promises.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/turn-bid-promises.test.ts', 'tests/truth-turn-bid-runtime.test.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Separate editors, public previews, target-only answers and persistent event-bound native guides keep winning payments separate.', evidence: ['components/bid-claim-fields.tsx', 'components/bid-promises.tsx', 'components/phase-bid-claim-fields.tsx', 'components/phase-bid-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-phase-bid-controls.test.tsx'] }, { area: 'AI', status: 'Partial', detail: 'All four existing profiles use the projected feasible answer and ordinary guide. Minimal legal participation only; strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-bid-runtime.test.ts'] }, { area: 'Documentation', status: 'Implemented', detail: 'General FAQ authority, provisional current-lot meaning, physical declaration/payment distinction and remaining scope are explicit.', evidence: ['docs/TRUTHTRANCE_BID_PROMISES.md', 'docs/RULE_DECISIONS.md'] }, { area: 'Verification', status: 'Partial', detail: 'Original public H/A/E sources and actual callbacks cover Yes/No, payments, release, multiple original cards, stale ownership and earlier shipment funding. Broader rule/module, recovery and deployed acceptance remain required.', evidence: ['tests/fixture-truth-bid.ts', 'tests/truth-bid-runtime.test.ts', 'tests/truth-bid-controls.test.tsx'] }, diff --git a/game/truthtrance.ts b/game/truthtrance.ts index 8b905ede..2880769a 100644 --- a/game/truthtrance.ts +++ b/game/truthtrance.ts @@ -70,6 +70,7 @@ import { isKnowledgeFact, parseKnowledgeFact, knowledgeFactAnswer, knowledgeFact import { parseNoFieldValueFact, noFieldValueFactMatches, noFieldValueFactText, type NoFieldValueFact } from './truthtrance-no-field-value'; 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'; export type TruthFact = | KnowledgeFact @@ -102,6 +103,7 @@ export type TruthQuestion = | ({ kind: 'bribe'; target: string } & BribeClaim) | ({ kind: 'revival'; target: string } & RevivalClaim) | ({ kind: 'bid'; target: string } & BidClaim) + | ({ kind: 'phaseBid'; target: string } & PhaseBidClaim) | { kind: 'traitorCall'; target: string; battle: string; territory: string } | ({ kind: 'movement'; target: string } & MovementClaim) | ({ kind: 'shipment'; target: string } & ShipmentClaim) @@ -354,6 +356,18 @@ 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 === 'phaseBid') { + check(Object.keys(v).sort().join(',') === 'kind,minimum,target,turn', + 'Supply the current turn, phase bid minimum and respondent.'); + const claim = parsePhaseBidClaim(v); + check(phaseBidPromiseModeSupported(g) && g.status === 'playing' && Number.isInteger(g.phase) && + g.phase >= 0 && g.phase <= 3 && claim.turn === g.turn && + (g.phase < 3 || !!g.auction && !g.currentAuctionSale && !g.richeseAuction && !g.ixAuction) && + !g.response && !g.decision && !g.phaseOpening && !g.pendingRevival && + !g.pendingKarama && !g.pendingTreacheryDiscard, + 'Choose the current turn and a quiet supported ordinary Bidding opportunity.'); + return { kind: 'phaseBid', target: v.target, ...claim }; + } if (v.kind === 'mixedBid') { check(Object.keys(v).sort().join(',') === 'kind,lot,mixed,target,turn', 'Supply the current ordinary lot, turn, complete mixed bid question and respondent.'); @@ -718,6 +732,7 @@ export function truthQuestionText( if (q.kind === 'bribe') return `Will you ${bribeClaimText(q, playerName)}?`; 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 === '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?`; @@ -848,6 +863,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; + 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[]; bindTravel?: (g: Game, p: Player, claim: TravelClaim, answer: boolean, asker: string) => void; turnActionAnswers?: (g: Game, p: Player, claim: TurnActionClaim) => TruthAnswer[]; @@ -1019,6 +1036,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 === '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.'); if (w.question.kind === 'travel') check(effects.travelAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length, 'Choose an unused or active native Shipment and Movement opportunity.'); @@ -1149,6 +1169,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 === '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.'); + if (a.answer !== 'unknown') effects.bindPhaseBid(g, p, q, a.answer === 'yes', current.player); + } if (q.kind === 'travel') { check(effects.travelAnswers && effects.bindTravel && effects.travelAnswers(g, p, q).includes(a.answer), 'Choose a whole travel answer compatible with native actions and earlier commitments.'); diff --git a/game/turn-bid-promises.ts b/game/turn-bid-promises.ts new file mode 100644 index 00000000..134ea055 --- /dev/null +++ b/game/turn-bid-promises.ts @@ -0,0 +1,149 @@ +import { bidPromiseModeSupported } from './bid-promises'; + +/** One accepted ordinary bid after the answer, in this turn's Bidding phase. */ +export type PhaseBidClaim = { turn: number; minimum: number }; +export type PhaseBidPromise = PhaseBidClaim & { + event: string; + player: string; + asker: string; + answer: boolean; + fulfilled?: boolean; + released?: boolean; +}; + +/** An engine-verified inclusive interval for a native, funded declaration. + * A future interval is a conservative witness: the engine must prove the next + * lot can be reached and paid for from the actual state, including committed + * invoices and capacity. An unbid lot and speculative rival actions do not + * establish a later lot. This helper cannot verify those native facts itself. */ +export type PhaseBidOpportunity = { + turn: number; + player: string; + kind: 'current' | 'future'; + minimum: number; + maximum: number; +}; +export type PhaseBidRange = { minimum: number; maximum: number }; +export type PhaseBidChange = 'voluntary' | 'external'; + +export class PhaseBidPromiseError 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 parsePhaseBidClaim(value: unknown): PhaseBidClaim { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new PhaseBidPromiseError('Choose a turn-wide ordinary bid question.'); + const input = value as Record; + if (!positive(input.turn) || !positive(input.minimum)) + throw new PhaseBidPromiseError('Choose the current turn and a positive whole bid minimum.'); + return { turn: input.turn, minimum: input.minimum }; +} + +export const phaseBidPromiseModeSupported = bidPromiseModeSupported; + +export function phaseBidClaimText(claim: PhaseBidClaim): string { + return `place a bid of at least ${claim.minimum} spice from now until this turn's Bidding phase closes`; +} + +export function livePhaseBidPromises(promises: readonly PhaseBidPromise[], player: string, turn: number): PhaseBidPromise[] { + return promises.filter(promise => promise.player === player && promise.turn === turn && + !promise.fulfilled && !promise.released); +} + +function checkedOpportunity(opportunity: PhaseBidOpportunity): void { + if (!opportunity || !positive(opportunity.turn) || !identifier(opportunity.player) || + opportunity.kind !== 'current' && opportunity.kind !== 'future' || + !positive(opportunity.minimum) || !positive(opportunity.maximum) || opportunity.minimum > opportunity.maximum) + throw new PhaseBidPromiseError('Retain an actual funded native bid opportunity.'); +} + +/** Allowed native bids respect every live No; a smaller bid may remain legal + * while a Yes remains achievable in a later verified native opportunity. */ +export function phaseBidAllowedRange(promises: readonly PhaseBidPromise[], opportunity: PhaseBidOpportunity): PhaseBidRange | null { + checkedOpportunity(opportunity); + let maximum = opportunity.maximum; + for (const promise of livePhaseBidPromises(promises, opportunity.player, opportunity.turn)) + if (!promise.answer) maximum = Math.min(maximum, promise.minimum - 1); + return opportunity.minimum <= maximum ? { minimum: opportunity.minimum, maximum } : null; +} + +/** A single qualifying declaration can fulfill all remaining Yes answers. */ +export function phaseBidCompletionRange(promises: readonly PhaseBidPromise[], opportunity: PhaseBidOpportunity): PhaseBidRange | null { + const range = phaseBidAllowedRange(promises, opportunity); + if (!range) return null; + let minimum = range.minimum; + for (const promise of livePhaseBidPromises(promises, opportunity.player, opportunity.turn)) + if (promise.answer) minimum = Math.max(minimum, promise.minimum); + return minimum <= range.maximum ? { minimum, maximum: range.maximum } : null; +} + +/** Call only for a real accepted native bid, inside the detached engine + * transaction. A previous bid, final invoice or sum of bids is not a receipt. */ +export function recordPhaseBid(promises: readonly T[], player: string, + turn: number, amount: number): T[] { + if (!identifier(player) || !positive(turn) || !positive(amount)) + throw new PhaseBidPromiseError('Record only an accepted positive whole native bid.'); + const live = livePhaseBidPromises(promises, player, turn); + if (live.some(promise => !promise.answer && amount >= promise.minimum)) + throw new PhaseBidPromiseError('This bid conflicts with your Truthtrance No answer for this Bidding phase.'); + return promises.map(promise => live.includes(promise) && promise.answer && amount >= promise.minimum + ? { ...promise, fulfilled: true } : promise); +} + +/** Reconcile only after the engine has supplied a complete set of truthful + * post-action witnesses. Missing or uncomputed quotes are not impossibility. + * A respondent may not voluntarily eliminate their last feasible Yes route; + * another player's action can release only the Yes answers it makes impossible. + * This is a conservative implementation policy, not a printed timing rule. */ +export function reconcilePhaseBidPromises(promises: readonly T[], player: string, + turn: number, opportunities: readonly PhaseBidOpportunity[], change: PhaseBidChange): T[] { + if (!identifier(player) || !positive(turn) || !Array.isArray(opportunities) || + change !== 'voluntary' && change !== 'external') + throw new PhaseBidPromiseError('Retain the complete native bid opportunities and cause.'); + for (const opportunity of opportunities) { + checkedOpportunity(opportunity); + if (opportunity.player !== player || opportunity.turn !== turn) + throw new PhaseBidPromiseError('Bid opportunities must belong to the original respondent and turn.'); + } + const live = livePhaseBidPromises(promises, player, turn); + const impossible = live.filter(promise => promise.answer && !opportunities.some(opportunity => { + const range = phaseBidAllowedRange(promises, opportunity); + return range !== null && Math.max(range.minimum, promise.minimum) <= range.maximum; + })); + if (change === 'voluntary' && impossible.length) + throw new PhaseBidPromiseError('This action would remove the last funded bid opportunity for your Truthtrance Yes answer.'); + return promises.map(promise => impossible.includes(promise) ? { ...promise, released: true } : promise); +} + +/** Call at actual phase close, after own-action preflight. No is established + * by the absence of a qualifying accepted bid; unfulfilled Yes is released. */ +export function finishPhaseBidPhase(promises: readonly T[], player: string, + turn: number, change: PhaseBidChange): T[] { + if (!identifier(player) || !positive(turn) || change !== 'voluntary' && change !== 'external') + throw new PhaseBidPromiseError('Retain the actual phase close and its cause.'); + const live = livePhaseBidPromises(promises, player, turn); + if (change === 'voluntary' && live.some(promise => promise.answer)) + throw new PhaseBidPromiseError('The Bidding phase cannot be closed by abandoning your Truthtrance Yes answer.'); + return promises.map(promise => live.includes(promise) + ? promise.answer ? { ...promise, released: true } : { ...promise, fulfilled: true } + : promise); +} + +export function validatePhaseBidPromises(promises: readonly PhaseBidPromise[], players: readonly string[]): void { + if (!Array.isArray(promises) || !Array.isArray(players)) + throw new PhaseBidPromiseError('The phase bid commitment record is invalid.'); + const events = new Set(); + for (const promise of promises) { + parsePhaseBidClaim(promise); + if (!identifier(promise.event) || events.has(promise.event) || !players.includes(promise.player) || + !players.includes(promise.asker) || promise.player === promise.asker || typeof promise.answer !== 'boolean' || + (promise.fulfilled !== undefined && typeof promise.fulfilled !== 'boolean') || + (promise.released !== undefined && typeof promise.released !== 'boolean') || + promise.fulfilled && promise.released) + throw new PhaseBidPromiseError('The phase bid commitment lost its original event or participants.'); + events.add(promise.event); + } +} diff --git a/tests/fixture-truth-turn-bid.ts b/tests/fixture-truth-turn-bid.ts new file mode 100644 index 00000000..f4278a39 --- /dev/null +++ b/tests/fixture-truth-turn-bid.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import type { Game } from '../game/engine'; +import { actTruthBid, advanceTruthBid, createTruthBidFixture, declareTruthBid, + nextTruthBid, truthBidClean, truthBidPlayer, type TruthBidStep } from './fixture-truth-bid'; + +export type TruthTurnBidStep = TruthBidStep; +export const actTruthTurnBid = actTruthBid; +export const truthTurnBidPlayer = truthBidPlayer; +export const truthTurnBidClean = truthBidClean; +export const advanceTruthTurnBid = advanceTruthBid; + +/** Original H/A/E (optional G) setup, starting deal, ordinary lot creation, + * and native controls from the shared fixture; only unopened original deal, + * lot/Spice positions and scalar lotteries are selected. */ +export function createTruthTurnBidFixture(options: { advanced?: boolean; extraGuild?: boolean } = {}) { + const original = createTruthBidFixture({ advanced: options.advanced, extraGuild: options.extraGuild, window:'waiting' }); + return { ...original, phaseQuestion: { kind:'phaseBid' as const, target:original.target, + turn:original.game.turn, minimum:4 }, + sourceDisclosure: `${original.sourceDisclosure} Phase-wide question, answer and all subsequent bids remain caller actions.` }; +} +export type TruthTurnBidFixture = ReturnType; + +export function bindTruthTurnBid(f: TruthTurnBidFixture, answer: 'yes'|'no', minimum = 4, state = f.game) { + const declared = declareTruthBid(f,state), actions = [...declared.actions]; + let game = actTruthBid(declared.game,f.asker,{ type:'truthAsk', question: { + kind:'phaseBid', target:f.target, turn:declared.game.turn, minimum, + } },actions); + const asked = structuredClone(game); + game = actTruthBid(game,f.target,{type:'truthAnswer',answer},actions); + return { game, declared, asked, actions }; +} + +/** Finish exactly the existing lot after a genuine winning bid; no future bid + * or pool is installed. Neutral native continuations pay and advance it. */ +export function finishTruthTurnBidLot(game: Game, actions: TruthBidStep[] = []) { + assert.ok(game.auction && game.auction.bidder && game.auction.bid > 0); + const turn = game.turn, index = game.auction.index; + return advanceTruthBid(game,state => truthBidClean(state) && + (state.turn !== turn || state.phase !== 3 || !state.auction || state.auction.index !== index),actions); +} + +/** Purchase this actual lot for one spice by the named player. Other bidders + * pass using native controls; native payment and Emperor income settle. */ +export function purchaseTruthTurnBidLot(game: Game, winner: string, actions: TruthBidStep[] = []) { + assert.ok(game.auction); const turn = game.turn, index = game.auction.index; + const card = game.auction.cards[index].id, before = structuredClone(game); + for (let n = 0; n < 150; n++) { + if (truthBidClean(game) && (game.turn !== turn || game.phase !== 3 || !game.auction || game.auction.index !== index)) { + assert.ok(truthBidPlayer(game,winner).hand.some(c => c.id === card)); + return {game,before,card}; + } + if (truthBidClean(game) && game.auction && game.auction.index === index && !game.currentAuctionSale) { + const actor = game.auction.active; + game = actTruthBid(game,actor,actor === winner && !game.auction.bidder + ? {type:'bid',amount:1,allyPayment:0} : {type:'passBid'},actions); + } else { + const step = nextTruthBid(game); assert.ok(step); + game = actTruthBid(game,step.actor,step.action,actions); + } + } + throw Error('The actual funded ordinary purchase did not finish'); +} diff --git a/tests/truth-phase-bid-controls.test.tsx b/tests/truth-phase-bid-controls.test.tsx new file mode 100644 index 00000000..b8fd421d --- /dev/null +++ b/tests/truth-phase-bid-controls.test.tsx @@ -0,0 +1,95 @@ +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 { PhaseBidClaimFields, phaseBidClaimInputError } from '../components/phase-bid-claim-fields'; +import { PhaseBidPromises, PhaseBidPromiseGuideButton } from '../components/phase-bid-promises'; +import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine'; +import type { PhaseBidClaim } from '../game/turn-bid-promises'; + +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(PhaseBidClaimFields(props)).find(element => + element.props.id === 'truth-phase-bid-minimum' && typeof element.props.onChange === 'function'); + assert.ok(input); + (input.props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)({ currentTarget: { valueAsNumber: minimum } }); +} +/** Controlled projections exercise the rendered owned control, not a real question. */ +function projected(type: 'bid' | 'passBid' = 'bid'): GameView { + const source = createGame('TRUTHPHASEBIDCONTROLS', newPlayer('p', 'Respondent', 'emperor')); + joinGame(source, newPlayer('a', 'Asker', 'atreides')); + const game = viewGame(source, 'p'); + game.status = 'playing'; game.phase = 3; game.turn = 2; + game.auction = { bid: 1, bidder: 'a', active: 'p', remaining: 2, inspectionBlocked: null, card: null }; + game.bidPromiseLot = { turn: 2, lot: 1 }; + game.phaseBidPromises = [{ event: 'owned-event', player: 'p', asker: 'a', turn: 2, minimum: 4, answer: type === 'bid' }]; + game.phaseBidPromiseGuide = { action: type === 'bid' + ? { type: 'bid', amount: 4, allyPayment: 1, phaseBidPromiseEvent: 'owned-event', phaseBidPromiseLot: 1 } + : { type: 'passBid', phaseBidPromiseEvent: 'owned-event', phaseBidPromiseLot: 1 } }; + return game; +} + +void test('phase bid editor preserves the current turn and accepts only whole positive minima', () => { + const value: PhaseBidClaim = { turn: 2, minimum: 4 }, changes: PhaseBidClaim[] = []; + const props = { value, onChange(next: PhaseBidClaim) { changes.push(next); } }; + const html = renderToStaticMarkup(createElement(PhaseBidClaimFields, props)); + assert.match(html, /Turn 2/); + assert.match(html, /before Bidding ends this turn/); + assert.match(html, /Earlier bids do not count/); + for (const minimum of [1, 7, Number.MAX_SAFE_INTEGER]) edit(props, minimum); + assert.deepEqual(changes, [1, 7, Number.MAX_SAFE_INTEGER].map(minimum => ({ ...value, minimum }))); + for (const minimum of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + assert.ok(phaseBidClaimInputError({ ...value, minimum })); edit(props, minimum); + } + edit({ ...props, disabled: true }, 8); + assert.equal(changes.length, 3); +}); + +void test('phase promises remain visible and do not expose another player’s promises', () => { + const game = projected(); + game.phaseBidPromises.push( + { event: 'no', player: 'p', asker: 'a', turn: 2, minimum: 8, answer: false }, + { event: 'done', player: 'p', asker: 'a', turn: 2, minimum: 2, answer: true, fulfilled: true }, + { event: 'released', player: 'p', asker: 'a', turn: 2, minimum: 12, answer: true, released: true }, + { event: 'foreign', player: 'a', asker: 'p', turn: 2, minimum: 99, answer: true }, + ); + game.auction!.active = 'a'; game.phaseBidPromiseGuide = null; + const before = structuredClone(game); + const html = renderToStaticMarkup(createElement(PhaseBidPromises, { game, busy: false, send() {} })); + assert.match(html, /Your Bidding phase promises/); + assert.match(html, /No forbids qualifying bids through Bidding close/); + assert.match(html, /Fulfilled: this commitment is complete/); + assert.match(html, /Released: this commitment is no longer possible/); + assert.match(html, /Your next bid opportunity is still ahead/); + assert.doesNotMatch(html, /99 spice|foreign/); + assert.deepEqual(game, before); +}); + +void test('the real guide callback preserves its event and rejects stale or busy projections', () => { + for (const type of ['bid', 'passBid'] as const) { + const game = projected(type), sent: Action[] = []; + const props = { game, busy: false, send(action: Action) { sent.push(action); } }; + const button = PhaseBidPromiseGuideButton(props); assert.ok(button); + button.props.onClick(); + assert.deepEqual(sent, [game.phaseBidPromiseGuide!.action]); + assert.equal(sent[0], game.phaseBidPromiseGuide!.action); + const stale = PhaseBidPromiseGuideButton(props); assert.ok(stale); + game.phaseBidPromises[0].event = 'different-event'; stale.props.onClick(); + assert.equal(sent.length, 1); + } + const game = projected(), sent: Action[] = []; + const props = { game, busy: false, send(action: Action) { sent.push(action); } }; + const button = PhaseBidPromiseGuideButton(props); assert.ok(button); + props.busy = true; button.props.onClick(); + assert.deepEqual(sent, []); + props.busy = false; + const priorLotButton = PhaseBidPromiseGuideButton(props); assert.ok(priorLotButton); + game.bidPromiseLot = { turn: 2, lot: 2 }; + priorLotButton.props.onClick(); + assert.deepEqual(sent, []); +}); diff --git a/tests/truth-phase-bid-integrated-controls.test.tsx b/tests/truth-phase-bid-integrated-controls.test.tsx new file mode 100644 index 00000000..6dc661f8 --- /dev/null +++ b/tests/truth-phase-bid-integrated-controls.test.tsx @@ -0,0 +1,75 @@ +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 { PhaseBidPromiseGuideButton } from '../components/phase-bid-promises'; +import { actTruthTurnBid, advanceTruthTurnBid, bindTruthTurnBid, + createTruthTurnBidFixture, truthTurnBidClean } from './fixture-truth-turn-bid'; +import { truthBidInventory } from './fixture-truth-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'); +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() {} })); +} + +for (const advanced of [false, true]) void test( + `${advanced ? 'Advanced' : 'Basic'} actual phase-bid question, private answer and current owned guide connect to the original table`, () => { + // Genuine original public setup/card deal and actual question/answer; the + // selected unopened deck/Spice positions are documented by the source fixture. + const f = createTruthTurnBidFixture({ advanced }), b = bindTruthTurnBid(f, 'yes'); + const asker = viewGame(b.declared.game, f.asker); + assert.ok(asker.truthPhaseBidTargets.includes(f.target)); + const authored = truth(asker); + assert.match(authored, /