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
+ Minimum bid in spice
+ {
+ 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 { if (canAct()) props.send(action); }}>
+ {action.type === 'bid'
+ ? `Bid ${typeof action.amount === 'number' ? action.amount : 'the suggested amount'} spice while honoring your phase answers`
+ : 'Pass this lot while honoring your phase answers'}
+ ;
+}
+
+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({
Bind a named leader revival this turn
Bind ordinary force revivals this turn
Bind a bid in this ordinary lot
+ Bind a bid this Bidding phase
Combine current facts and ordinary bids
Bind this battle’s Traitor declaration
Combine current facts and this Traitor declaration
@@ -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, /Bind a bid this Bidding phase<\/option>/);
+ assert.match(table(asker), /Bind a bid this Bidding phase/);
+ // Server rendering does not operate the stateful question selector; the
+ // fixture's actual native question is submitted by its engine action.
+ const respondent = viewGame(b.asked, f.target);
+ assert.deepEqual(respondent.truthtrance?.question, f.phaseQuestion);
+ assert.ok(respondent.truthPhaseBidAnswers?.includes('yes'));
+ assert.ok(respondent.truthPhaseBidAnswers?.includes('no'));
+ assert.equal(viewGame(b.asked, f.asker).truthPhaseBidAnswers, null);
+ const answers = truth(respondent);
+ assert.match(answers, /Answer Yes/);
+ assert.match(answers, /Answer No/);
+ assert.match(table(respondent), /Answer Yes/);
+ let game = advanceTruthTurnBid(b.game,
+ state => truthTurnBidClean(state) && state.phase === 3 && state.auction?.active === f.target);
+ const own = viewGame(game, f.target), sent: Action[] = [];
+ assert.ok(own.phaseBidPromises.some(promise => promise.player === f.target && promise.answer));
+ assert.ok(own.phaseBidPromiseGuide);
+ const html = table(own);
+ assert.match(html, /Your Bidding phase promises/);
+ assert.match(html, /data-testid="phase-bid-promise-guide"/);
+ const button = PhaseBidPromiseGuideButton({ game: own, busy: false, send(action) { sent.push(action); } });
+ assert.ok(button);
+ assert.equal(button.props.disabled, false);
+ button.props.onClick();
+ assert.deepEqual(sent, [own.phaseBidPromiseGuide.action]);
+ assert.equal(typeof sent[0].phaseBidPromiseEvent, 'string');
+ assert.equal(sent[0].phaseBidPromiseLot, own.bidPromiseLot!.lot);
+ game = actTruthTurnBid(game, f.target, sent[0]);
+ assert.equal(game.phaseBidPromises!.find(promise => promise.event === sent[0].phaseBidPromiseEvent)?.fulfilled, true);
+ assert.deepEqual(truthBidInventory(game), f.originalInventory);
+ },
+);
diff --git a/tests/truth-turn-bid-composition.test.ts b/tests/truth-turn-bid-composition.test.ts
new file mode 100644
index 00000000..4278cd6e
--- /dev/null
+++ b/tests/truth-turn-bid-composition.test.ts
@@ -0,0 +1,383 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { actTruthBid as act, advanceTruthBid as advance, askTruthBid, answerTruthBid,
+ declareTruthBid, truthBidClean as clean, truthBidInventory as inventory } from './fixture-truth-bid';
+import { bindTruthTurnBid, createTruthTurnBidFixture, finishTruthTurnBidLot,
+ purchaseTruthTurnBidLot } from './fixture-truth-turn-bid';
+
+function reject(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game); assert.throws(() => act(game,actor,action)); assert.deepEqual(game,before);
+}
+/** Fork the genuine public setup before its selected unopened Spice positions,
+ * reapply only that original selection, and follow native controls to Charity.
+ * No played purse, hand, phase or promise is staged. */
+function early(advanced: boolean) {
+ const f = createTruthTurnBidFixture({advanced});
+ let game = structuredClone(f.afterSetup);
+ for (const {position,from,card} of f.spiceOrder) {
+ const [selected] = game.spiceDeck.splice(from,1);
+ assert.deepEqual(selected,card); game.spiceDeck.splice(position,0,selected);
+ }
+ game = advance(game,g => g.turn === 1 && g.phase === 2 && clean(g));
+ return {f,game};
+}
+function phaseAsk(game: Game, asker: string, target: string, minimum = 4) {
+ return act(game,asker,{type:'truthAsk',question:{kind:'phaseBid',target,turn:game.turn,minimum}});
+}
+for (const advanced of [false,true]) {
+ const mode = advanced ? 'Advanced' : 'Basic';
+ for (const answer of ['yes','no'] as const) void test(`${mode} pre-Bidding phase ${answer} binds a real later auction declaration`, () => {
+ const {f,game:source} = early(advanced);
+ assert.equal(source.phase,2); assert.equal(source.auction,null);
+ const bound = bindTruthTurnBid(f,answer,4,source);
+ assert.ok(viewGame(bound.game,f.target).phaseBidPromises.some(p => p.minimum === 4 && p.answer === (answer === 'yes')));
+ let game = advance(bound.game,g => clean(g) && g.phase === 3 && g.auction?.active === f.target);
+ if (answer === 'yes') {
+ reject(game,f.target,{type:'passBid'});
+ game = act(game,f.target,{type:'bid',amount:4,allyPayment:0});
+ assert.ok(viewGame(game,f.target).phaseBidPromises.some(p => p.fulfilled));
+ } else {
+ reject(game,f.target,{type:'bid',amount:4,allyPayment:0});
+ game = act(game,f.target,{type:'bid',amount:1,allyPayment:0});
+ }
+ assert.deepEqual(inventory(game),f.originalCards);
+ });
+ void test(`${mode} existing current-lot Yes prevents a contradictory phase-wide No`, () => {
+ const f = createTruthTurnBidFixture({advanced});
+ let game = declareTruthBid(f,f.game,{copies:2}).game;
+ game = askTruthBid(game,f.asker,f.target,4);
+ game = answerTruthBid(game,f.target,'yes');
+ game = phaseAsk(game,f.asker,f.target,4);
+ assert.ok(!viewGame(game,f.target).truthPhaseBidAnswers?.includes('no'));
+ reject(game,f.target,{type:'truthAnswer',answer:'no'});
+ game = answerTruthBid(game,f.target,'yes');
+ game = advance(game,g => clean(g) && g.auction?.active === f.target);
+ game = act(game,f.target,{type:'bid',amount:4,allyPayment:0});
+ assert.ok(game.bidPromises?.some(p => p.player === f.target && p.fulfilled));
+ assert.ok(game.phaseBidPromises?.some(p => p.player === f.target && p.fulfilled));
+ assert.deepEqual(inventory(game),f.originalCards);
+ });
+ void test(`${mode} phase Yes and current-lot No coexist when a real committed sale opens a later lot`, () => {
+ const f = createTruthTurnBidFixture({advanced});
+ let game = act(f.game,f.asker,{type:'bid',amount:1,allyPayment:0});
+ game = act(game,f.opponent,{type:'passBid'});
+ assert.equal(game.auction?.active,f.target); assert.equal(game.auction?.bidder,f.asker);
+ game = declareTruthBid(f,game,{copies:2}).game;
+ game = phaseAsk(game,f.asker,f.target,4);
+ game = answerTruthBid(game,f.target,'yes');
+ game = askTruthBid(game,f.asker,f.target,4);
+ assert.ok(viewGame(game,f.target).truthBidAnswers?.includes('no'));
+ game = answerTruthBid(game,f.target,'no');
+ reject(game,f.target,{type:'bid',amount:4,allyPayment:0});
+ game = act(game,f.target,{type:'passBid'});
+ if (game.auction?.index === 0) game = finishTruthTurnBidLot(game);
+ assert.equal(game.auction?.index,1);
+ game = advance(game,g => clean(g) && g.auction?.active === f.target);
+ game = act(game,f.target,{type:'bid',amount:4,allyPayment:0});
+ assert.ok(game.phaseBidPromises?.some(p => p.player === f.target && p.fulfilled));
+ assert.deepEqual(inventory(game),f.originalCards);
+ });
+ void test(`${mode} phase Yes keeps its funded bid and cannot rely on a future buyer after everyone passes`, () => {
+ const f = createTruthTurnBidFixture({advanced});
+ let game = bindTruthTurnBid(f,'yes',4).game;
+ reject(game,f.target,{type:'bribe',target:f.asker,amount:7});
+ game = act(game,f.target,{type:'bribe',target:f.asker,amount:6});
+ assert.equal(game.players.find(p => p.id === f.target)!.spice,4);
+ game = act(game,f.asker,{type:'passBid'});
+ game = act(game,f.opponent,{type:'passBid'});
+ assert.equal(game.auction?.bidder,null); assert.equal(game.auction?.active,f.target);
+ reject(game,f.target,{type:'passBid'});
+ game = act(game,f.target,{type:'bid',amount:4,allyPayment:0});
+ assert.ok(game.phaseBidPromises?.some(p => p.player === f.target && p.fulfilled));
+ assert.deepEqual(inventory(game),f.originalCards);
+ });
+ void test(`${mode} an original phase bid guide is stale after its lot actually retires`, () => {
+ const f = createTruthTurnBidFixture({advanced});
+ let game = act(f.game,f.asker,{type:'bid',amount:1,allyPayment:0});
+ game = act(game,f.opponent,{type:'passBid'});
+ game = bindTruthTurnBid(f,'no',4,game).game;
+ const guide = viewGame(game,f.target).phaseBidPromiseGuide; assert.ok(guide);
+ const packet = structuredClone(guide.action);
+ assert.equal(packet.type,'passBid');
+ game = act(game,f.target,packet);
+ if (game.auction?.index === 0) game = finishTruthTurnBidLot(game);
+ assert.equal(game.auction?.index,1);
+ game = advance(game,g => clean(g) && g.auction?.active === f.target);
+ reject(game,f.target,packet);
+ assert.ok(viewGame(game,f.target).phaseBidPromiseGuide);
+ });
+}
+
+/** Controlled supplement only: install the two already-answered, canonical
+ * current-lot receipts on the genuine live auction, with its exact original
+ * physical lot binding. No card/force/lot is manufactured. */
+function stagedLotBounds(game: Game, target: string, asker: string, yesMinimum = 5, noMinimum = 6) {
+ assert.ok(game.auction);
+ const source = JSON.stringify(['ordinaryBid',game.turn,game.auction.index,game.auction.cards[game.auction.index].id]);
+ game.bidPromises = [
+ {turn:game.turn,lot:game.auction.index+1,minimum:yesMinimum,event:'controlled-lot-yes',source,player:target,asker,answer:true},
+ {turn:game.turn,lot:game.auction.index+1,minimum:noMinimum,event:'controlled-lot-no',source,player:target,asker,answer:false},
+ ];
+}
+function askAs(game: Game, asker: string, card: string, target: string, minimum: number) {
+ game = act(game,asker,{type:'card',card});
+ for (let count=0; game.truthtrance?.stage === 'priority' && count !game.truthtrance!.passed.includes(seat.id)); assert.ok(p);
+ game = act(game,p.id,{type:'truthPass'});
+ }
+ assert.equal(game.truthtrance?.stage,'ask');
+ return phaseAsk(game,asker,target,minimum);
+}
+
+for (const advanced of [false,true]) {
+ const mode = advanced ? 'Advanced' : 'Basic';
+ void test(`${mode} a new lot Yes cannot erase the funded future needed by an existing phase Yes`, () => {
+ const f = createTruthTurnBidFixture({advanced});
+ let game = act(f.game,f.asker,{type:'bid',amount:4,allyPayment:0});
+ game = act(game,f.opponent,{type:'passBid'});
+ assert.equal(game.auction?.active,f.target); assert.ok(game.auction?.cards[1]);
+ game = structuredClone(game);
+ // Controlled existing answers and wallet: H's real committed4 sale leaves
+ // E's six spice for its existing phase Yes6. Current-lot No6 prevents E6;
+ // a proposed lot Yes5 would compel a purchase and leave only one spice.
+ game.players.find(p => p.id === f.target)!.spice = 6;
+ stagedLotBounds(game,f.target,f.asker);
+ game.bidPromises = game.bidPromises!.filter(promise => !promise.answer);
+ game.phaseBidPromises = [{turn:game.turn,minimum:6,event:'controlled-phase-yes',
+ player:f.target,asker:f.asker,answer:true}];
+ game = declareTruthBid(f,game).game;
+ game = askTruthBid(game,f.asker,f.target,5);
+ assert.ok(!viewGame(game,f.target).truthBidAnswers?.includes('yes'));
+ reject(game,f.target,{type:'truthAnswer',answer:'yes'});
+ game = answerTruthBid(game,f.target,'no');
+ assert.deepEqual(inventory(game),f.originalCards);
+ });
+ void test(`${mode} a live whole-turn No for the last lot excludes the same phase-wide Yes`, () => {
+ const f = createTruthTurnBidFixture({advanced}); let game = f.game;
+ // Both purchases and the two independent Truth cards are native; no
+ // question, wallet or auction state is installed for this source.
+ for (let n=0;n<2;n++) game = purchaseTruthTurnBidLot(game,f.opponent).game;
+ assert.equal(game.auction?.index,2);
+ game = advance(game,g => clean(g) && g.auction?.active === f.target);
+ game = declareTruthBid(f,game).game;
+ game = act(game,f.asker,{type:'truthAsk',question:{kind:'turnActions',target:f.target,
+ turn:game.turn,mixed:{kind:'action',action:{kind:'bid',lot:3,minimum:6}}}});
+ assert.ok(viewGame(game,f.target).truthTurnActionAnswers?.includes('no'));
+ game = answerTruthBid(game,f.target,'no');
+ assert.ok(game.turnActionPromises?.some(p => p.player === f.target && !p.answer && !p.fulfilled && !p.released));
+ game = askAs(game,f.asker,f.truths[1],f.target,6);
+ assert.ok(!viewGame(game,f.target).truthPhaseBidAnswers?.includes('yes'));
+ reject(game,f.target,{type:'truthAnswer',answer:'yes'});
+ game = answerTruthBid(game,f.target,'no');
+ assert.deepEqual(inventory(game),f.originalCards);
+ });
+ void test(`${mode} phase Yes cannot ignore a forced current-lot payment that consumes future funding`, () => {
+ const f = createTruthTurnBidFixture({advanced});
+ let game = act(f.game,f.asker,{type:'bid',amount:1,allyPayment:0});
+ game = act(game,f.opponent,{type:'passBid'});
+ assert.equal(game.auction?.active,f.target); assert.ok(game.auction?.cards[1]);
+ // Explicit controlled wallet/answers; the physical auction and H1 bid are
+ // actual native actions. E6 must bid exactly5 under its prior Yes5/No6.
+ game = structuredClone(game);
+ game.players.find(p => p.id === f.target)!.spice = 6;
+ stagedLotBounds(game,f.target,f.asker);
+ game = askAs(game,f.asker,f.truth,f.target,6);
+ assert.ok(!viewGame(game,f.target).truthPhaseBidAnswers?.includes('yes'));
+ reject(game,f.target,{type:'truthAnswer',answer:'yes'});
+ game = answerTruthBid(game,f.target,'no');
+ assert.deepEqual(inventory(game),f.originalCards);
+ });
+ void test(`${mode} phase Yes cannot assume Harkonnen will avoid its purchase bonus to make a later slot`, () => {
+ const f = createTruthTurnBidFixture({advanced});
+ let game = structuredClone(f.game);
+ const hark = game.players.find(p => p.id === f.asker)!;
+ const atreides = game.players.find(p => p.id === f.opponent)!;
+ // Explicit conserved prior-card transfers: Atreides holds the original
+ // Truthtrance to ask; Harkonnen has six held cards (two open slots). The
+ // original undealt deck retains a possible Harkonnen bonus card.
+ const index = hark.hand.findIndex(c => c.id === f.truth); assert.ok(index >= 0);
+ atreides.hand.push(hark.hand.splice(index,1)[0]);
+ while (hark.hand.length < 6) {
+ const from = game.deck.findIndex(c => ['projectile','poison','shield','snooper','worthless'].includes(c.kind));
+ assert.ok(from >= 0); hark.hand.push(game.deck.splice(from,1)[0]);
+ }
+ assert.ok(game.deck.length > 0); assert.equal(hark.hand.length,6);
+ game = act(game,f.asker,{type:'passBid'});
+ game = act(game,f.opponent,{type:'passBid'});
+ game = act(game,f.target,{type:'bid',amount:1,allyPayment:0});
+ assert.equal(game.auction?.active,f.asker); assert.ok(game.auction?.cards[1]);
+ // Harkonnen has ten spice. Its existing Yes2/No4 permits a bid2,
+ // leaving eight spice after payment; capacity alone removes the future.
+ assert.equal(game.players.find(p => p.id === f.asker)!.spice,10);
+ stagedLotBounds(game,f.asker,f.opponent,2,4);
+ game = askAs(game,f.opponent,f.truth,f.asker,4);
+ assert.ok(!viewGame(game,f.asker).truthPhaseBidAnswers?.includes('yes'));
+ reject(game,f.asker,{type:'truthAnswer',answer:'yes'});
+ game = answerTruthBid(game,f.asker,'no');
+ assert.deepEqual(inventory(game),f.originalCards);
+ });
+ void test(`${mode} an opponent's unfunded Karama-only high bid cannot certify a future lot`, () => {
+ const f = createTruthTurnBidFixture({advanced});
+ let game = structuredClone(f.game);
+ // Explicit conserved prior acquisition: Harkonnen holds an undealt real
+ // Karama, and can legally declare11 on its actual ten-spice purse. The
+ // unrelated bidder's future choice to spend that card is not assumed.
+ const from = game.deck.findIndex(c => c.effect === 'karama'); assert.ok(from >= 0);
+ game.players.find(p => p.id === f.asker)!.hand.push(game.deck.splice(from,1)[0]);
+ game = act(game,f.asker,{type:'bid',amount:11,allyPayment:0});
+ game = act(game,f.opponent,{type:'passBid'});
+ assert.equal(game.auction?.active,f.target); assert.equal(game.auction?.bidder,f.asker);
+ assert.ok(game.auction?.cards[1]);
+ game = askAs(game,f.asker,f.truth,f.target,6);
+ assert.ok(!viewGame(game,f.target).truthPhaseBidAnswers?.includes('yes'));
+ reject(game,f.target,{type:'truthAnswer',answer:'yes'});
+ game = answerTruthBid(game,f.target,'no');
+ assert.deepEqual(inventory(game),f.originalCards);
+ });
+}
+
+for (const advanced of [false,true]) {
+ const mode = advanced ? 'Advanced' : 'Basic';
+ void test(`${mode} a phase bid Yes cannot spend the funds required by a live whole-turn bribe`, () => {
+ const f = createTruthTurnBidFixture({advanced}); let game = f.game;
+ // Actual A1 purchases pay E one spice each; E then really commits a
+ // separate bribe2, leaving ten. Both Truth cards and the last lot are
+ // original components. No wallet, promise or auction state is staged.
+ for (let n=0;n<2;n++) game = purchaseTruthTurnBidLot(game,f.opponent).game;
+ assert.equal(game.auction?.index,2);
+ game = act(game,f.target,{type:'bribe',target:f.asker,amount:2});
+ assert.equal(game.players.find(p => p.id === f.target)!.spice,10);
+ game = advance(game,g => clean(g) && g.auction?.active === f.target);
+ game = declareTruthBid(f,game).game;
+ game = act(game,f.asker,{type:'truthAsk',question:{kind:'turnActions',target:f.target,
+ turn:game.turn,mixed:{kind:'action',action:{kind:'payment',recipient:f.asker,minimum:5}}}});
+ assert.ok(viewGame(game,f.target).truthTurnActionAnswers?.includes('yes'));
+ game = answerTruthBid(game,f.target,'yes');
+ const prior = structuredClone(game.turnActionPromises![0]);
+ game = askAs(game,f.asker,f.truths[1],f.target,6);
+ assert.ok(!viewGame(game,f.target).truthPhaseBidAnswers?.includes('yes'));
+ reject(game,f.target,{type:'truthAnswer',answer:'yes'});
+ assert.deepEqual(game.turnActionPromises![0],prior);
+ game = answerTruthBid(game,f.target,'no');
+ assert.deepEqual(game.turnActionPromises![0],prior);
+ assert.deepEqual(inventory(game),f.originalCards);
+ });
+}
+
+/** Use the second real physical Truth card to ask one whole-turn action after
+ * a phase-bid answer. Keep the normal declaration and priority actions. */
+function askTurnActionAs(game: Game, asker: string, card: string, target: string,
+ action: {kind:'payment';recipient:string;minimum:number} | {kind:'bid';lot:number;minimum:number}) {
+ game = act(game,asker,{type:'card',card});
+ for (let count=0; game.truthtrance?.stage === 'priority' && count !game.truthtrance!.passed.includes(seat.id)); assert.ok(p);
+ game = act(game,p.id,{type:'truthPass'});
+ }
+ assert.equal(game.truthtrance?.stage,'ask');
+ return act(game,asker,{type:'truthAsk',question:{kind:'turnActions',target,turn:game.turn,
+ mixed:{kind:'action',action}}});
+}
+for (const advanced of [false,true]) {
+ const mode = advanced ? 'Advanced' : 'Basic';
+ void test(`${mode} an existing phase bid Yes excludes an unfunded later whole-turn bribe Yes`, () => {
+ const f = createTruthTurnBidFixture({advanced}); let game = f.game;
+ // Real A1 purchases twice give E two income; E's real bribe2 leaves ten.
+ // The still-unresolved original last lot and both Truth cards are native.
+ for (let n=0;n<2;n++) game = purchaseTruthTurnBidLot(game,f.opponent).game;
+ assert.equal(game.auction?.index,2);
+ game = act(game,f.target,{type:'bribe',target:f.asker,amount:2});
+ assert.equal(game.players.find(p => p.id === f.target)!.spice,10);
+ game = advance(game,g => clean(g) && g.auction?.active === f.target);
+ game = bindTruthTurnBid(f,'yes',6,game).game;
+ const prior = structuredClone(game.phaseBidPromises![0]);
+ game = askTurnActionAs(game,f.asker,f.truths[1],f.target,
+ {kind:'payment',recipient:f.asker,minimum:5});
+ assert.ok(!viewGame(game,f.target).truthTurnActionAnswers?.includes('yes'));
+ reject(game,f.target,{type:'truthAnswer',answer:'yes'});
+ assert.deepEqual(game.phaseBidPromises![0],prior);
+ game = answerTruthBid(game,f.target,'no');
+ assert.deepEqual(game.phaseBidPromises![0],prior);
+ assert.deepEqual(inventory(game),f.originalCards);
+ });
+ void test(`${mode} an existing phase bid No excludes a later whole-turn bid Yes on the final lot`, () => {
+ const f = createTruthTurnBidFixture({advanced}); let game = f.game;
+ // Two actual purchases leave the actual third and last prepared lot.
+ for (let n=0;n<2;n++) game = purchaseTruthTurnBidLot(game,f.opponent).game;
+ assert.equal(game.auction?.index,2);
+ game = advance(game,g => clean(g) && g.auction?.active === f.target);
+ game = bindTruthTurnBid(f,'no',6,game).game;
+ const prior = structuredClone(game.phaseBidPromises![0]);
+ game = askTurnActionAs(game,f.asker,f.truths[1],f.target,
+ {kind:'bid',lot:game.auction!.index+1,minimum:6});
+ assert.ok(!viewGame(game,f.target).truthTurnActionAnswers?.includes('yes'));
+ reject(game,f.target,{type:'truthAnswer',answer:'yes'});
+ assert.deepEqual(game.phaseBidPromises![0],prior);
+ game = answerTruthBid(game,f.target,'no');
+ assert.deepEqual(game.phaseBidPromises![0],prior);
+ assert.deepEqual(inventory(game),f.originalCards);
+ });
+}
+
+for (const advanced of [false,true]) {
+ const mode = advanced ? 'Advanced' : 'Basic';
+ void test(`${mode} an offered independent bribe Yes can be accepted before Bidding alongside a funded phase bid Yes`, () => {
+ const {f,game:source} = early(advanced);
+ // Genuine Charity source: E has ten spice; original H/A/E setup and the
+ // two actual Truth cards are retained. The two requirements cost nine.
+ assert.equal(source.phase,2);
+ assert.equal(source.players.find(p => p.id === f.target)!.spice,10);
+ let game = bindTruthTurnBid(f,'yes',6,source).game;
+ const phasePromise = structuredClone(game.phaseBidPromises![0]);
+ game = act(game,f.asker,{type:'card',card:f.truths[1]});
+ for (let count=0; game.truthtrance?.stage === 'priority' && count !game.truthtrance!.passed.includes(seat.id)); assert.ok(p);
+ game = act(game,p.id,{type:'truthPass'});
+ }
+ assert.equal(game.truthtrance?.stage,'ask');
+ game = act(game,f.asker,{type:'truthAsk',question:{kind:'bribe',target:f.target,
+ turn:game.turn,recipient:f.asker,minimum:3}});
+ assert.ok(viewGame(game,f.target).truthBribeAnswers?.includes('yes'));
+ game = answerTruthBid(game,f.target,'yes');
+ assert.equal(game.truthtrance,null);
+ assert.deepEqual(game.phaseBidPromises![0],phasePromise);
+ assert.ok(game.bribePromises?.some(p => p.player === f.target && p.recipient === f.asker && p.answer));
+ game = act(game,f.target,{type:'bribe',target:f.asker,amount:3});
+ assert.equal(game.players.find(p => p.id === f.target)!.spice,7);
+ assert.ok(game.bribePromises?.some(p => p.player === f.target && p.fulfilled));
+ game = advance(game,g => clean(g) && g.phase === 3 && g.auction?.active === f.target);
+ game = act(game,f.target,{type:'bid',amount:6,allyPayment:0});
+ assert.ok(game.phaseBidPromises?.some(p => p.player === f.target && p.fulfilled));
+ assert.deepEqual(inventory(game),f.originalCards);
+ });
+}
+
+for (const advanced of [false,true]) {
+ const mode = advanced ? 'Advanced' : 'Basic';
+ void test(`${mode} an underfunded independent bribe Yes is unavailable before Bidding while phase bid Yes survives`, () => {
+ const {f,game:source} = early(advanced);
+ assert.equal(source.phase,2);
+ assert.equal(source.players.find(p => p.id === f.target)!.spice,10);
+ let game = bindTruthTurnBid(f,'yes',6,source).game;
+ const phasePromise = structuredClone(game.phaseBidPromises![0]);
+ game = act(game,f.asker,{type:'card',card:f.truths[1]});
+ for (let count=0; game.truthtrance?.stage === 'priority' && count !game.truthtrance!.passed.includes(seat.id)); assert.ok(p);
+ game = act(game,p.id,{type:'truthPass'});
+ }
+ assert.equal(game.truthtrance?.stage,'ask');
+ game = act(game,f.asker,{type:'truthAsk',question:{kind:'bribe',target:f.target,
+ turn:game.turn,recipient:f.asker,minimum:5}});
+ assert.ok(!viewGame(game,f.target).truthBribeAnswers?.includes('yes'));
+ assert.ok(viewGame(game,f.target).truthBribeAnswers?.includes('no'));
+ reject(game,f.target,{type:'truthAnswer',answer:'yes'});
+ assert.deepEqual(game.phaseBidPromises![0],phasePromise);
+ game = answerTruthBid(game,f.target,'no');
+ assert.equal(game.truthtrance,null);
+ assert.deepEqual(game.phaseBidPromises![0],phasePromise);
+ game = advance(game,g => clean(g) && g.phase === 3 && g.auction?.active === f.target);
+ game = act(game,f.target,{type:'bid',amount:6,allyPayment:0});
+ assert.ok(game.phaseBidPromises?.some(p => p.player === f.target && p.fulfilled));
+ assert.deepEqual(inventory(game),f.originalCards);
+ });
+}
diff --git a/tests/truth-turn-bid-runtime.test.ts b/tests/truth-turn-bid-runtime.test.ts
new file mode 100644
index 00000000..340c0f01
--- /dev/null
+++ b/tests/truth-turn-bid-runtime.test.ts
@@ -0,0 +1,88 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { truthBidInventory } from './fixture-truth-bid';
+import { actTruthTurnBid as act, advanceTruthTurnBid as advance, bindTruthTurnBid,
+ createTruthTurnBidFixture, finishTruthTurnBidLot, purchaseTruthTurnBidLot,
+ truthTurnBidClean as clean, truthTurnBidPlayer as player, type TruthTurnBidStep } from './fixture-truth-turn-bid';
+
+function reject(game: Game, actor: string, action: Action) {
+ const before = structuredClone(game);
+ assert.throws(() => act(game,actor,action));
+ assert.deepEqual(game,before);
+}
+function firstContested(game: Game, asker: string, rival: string, target: string, amount: number | null) {
+ assert.equal(game.auction?.index,0); assert.equal(game.auction?.active,asker);
+ const actions: TruthTurnBidStep[] = [];
+ game = act(game,asker,{type:'passBid'},actions);
+ assert.equal(game.auction?.active,rival);
+ game = act(game,rival,{type:'bid',amount:1,allyPayment:0},actions);
+ assert.equal(game.auction?.active,target);
+ game = act(game,target,amount === null ? {type:'passBid'} : {type:'bid',amount,allyPayment:0},actions);
+ return {game,actions};
+}
+for (const advanced of [false,true]) {
+ const prefix = advanced ? 'Advanced' : 'Basic';
+ void test(`${prefix} phase bid Yes survives a real first-lot pass then fulfills on a later accepted bid`, () => {
+ const f = createTruthTurnBidFixture({advanced});
+ const bound = bindTruthTurnBid(f,'yes',4);
+ const first = firstContested(bound.game,f.asker,f.opponent,f.target,null);
+ let game = finishTruthTurnBidLot(first.game,first.actions);
+ assert.equal(game.auction?.index,1);
+ assert.ok(player(game,f.opponent).hand.some(c => c.id === f.lotCards[0]));
+ const promise = viewGame(game,f.target).phaseBidPromises.find(p => p.minimum === 4)!;
+ assert.ok(promise); assert.equal(promise.fulfilled ?? false,false); assert.equal(promise.released ?? false,false);
+ game = advance(game,state => clean(state) && state.auction?.active === f.target);
+ for (const difficulty of DIFFICULTIES) {
+ const view = viewGame(game,f.target);
+ view.players.find(p => p.id === f.target)!.bot = difficulty;
+ const action = botActions(view)[0]; assert.ok(action);
+ const accepted = act(game,f.target,action);
+ assert.ok(accepted,`${difficulty} supplies a legal current action`);
+ }
+ game = act(game,f.target,{type:'bid',amount:4,allyPayment:0});
+ assert.ok(viewGame(game,f.target).phaseBidPromises.some(p => p.minimum === 4 && p.fulfilled));
+ assert.deepEqual(truthBidInventory(game),f.originalCards);
+ });
+ void test(`${prefix} phase bid No forbids qualifying bids across both original lots`, () => {
+ const f = createTruthTurnBidFixture({advanced});
+ const bound = bindTruthTurnBid(f,'no',4);
+ let game = firstContested(bound.game,f.asker,f.opponent,f.target,3).game;
+ game = finishTruthTurnBidLot(game);
+ assert.equal(game.auction?.index,1);
+ assert.ok(player(game,f.target).hand.some(c => c.id === f.lotCards[0]));
+ game = advance(game,state => clean(state) && state.auction?.active === f.target);
+ assert.ok(viewGame(game,f.target).phaseBidPromises.some(p => p.minimum === 4 && !p.fulfilled && !p.released));
+ reject(game,f.target,{type:'bid',amount:4,allyPayment:0});
+ game = act(game,f.target,{type:'bid',amount:3,allyPayment:0});
+ assert.deepEqual(truthBidInventory(game),f.originalCards);
+ });
+ void test(`${prefix} a live phase bid Yes cannot be abandoned on the final original lot`, () => {
+ const f = createTruthTurnBidFixture({advanced}); let game = f.game;
+ for (let n=0;n<2;n++) game = purchaseTruthTurnBidLot(game,f.opponent).game;
+ assert.equal(game.auction?.index,2);
+ game = advance(game,state => clean(state) && state.auction?.active === f.target);
+ game = bindTruthTurnBid(f,'yes',4,game).game;
+ reject(game,f.target,{type:'passBid'});
+ reject(game,f.target,{type:'bid',amount:3,allyPayment:0});
+ game = act(game,f.target,{type:'bid',amount:4,allyPayment:0});
+ assert.ok(viewGame(game,f.target).phaseBidPromises.some(p => p.minimum === 4 && p.fulfilled));
+ assert.deepEqual(truthBidInventory(game),f.originalCards);
+ });
+ void test(`${prefix} an actual opposing last-lot raise releases an unaffordable phase bid Yes`, () => {
+ const f = createTruthTurnBidFixture({advanced,extraGuild:true}); let game = f.game;
+ // Two real E purchases cost him two; a real A purchase pays E one. The
+ // original fourth lot remains, E has one hand slot and nine spice, and H
+ // has its real ten spice. This is no staged budget or future rival bid.
+ for (const buyer of [f.target,f.target,f.opponent]) game = purchaseTruthTurnBidLot(game,buyer).game;
+ assert.equal(game.auction?.index,3); assert.equal(player(game,f.target).spice,9);
+ game = advance(game,state => clean(state) && state.auction?.active === f.asker);
+ assert.ok(!game.auction!.passed.includes(f.target));
+ game = bindTruthTurnBid(f,'yes',8,game).game;
+ game = act(game,f.asker,{type:'bid',amount:10,allyPayment:0});
+ assert.ok(viewGame(game,f.target).phaseBidPromises.some(p => p.minimum === 8 && p.released));
+ assert.deepEqual(truthBidInventory(game),f.originalCards);
+ });
+}
diff --git a/tests/turn-bid-promises.test.ts b/tests/turn-bid-promises.test.ts
new file mode 100644
index 00000000..4e0b73b0
--- /dev/null
+++ b/tests/turn-bid-promises.test.ts
@@ -0,0 +1,117 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ PhaseBidPromiseError,
+ finishPhaseBidPhase,
+ livePhaseBidPromises,
+ parsePhaseBidClaim,
+ phaseBidAllowedRange,
+ phaseBidClaimText,
+ phaseBidCompletionRange,
+ reconcilePhaseBidPromises,
+ recordPhaseBid,
+ validatePhaseBidPromises,
+ type PhaseBidOpportunity,
+ type PhaseBidPromise,
+} from '../game/turn-bid-promises';
+
+const seats = ['respondent', 'asker', 'rival'];
+const promise = (event: string, minimum: number, answer = true,
+ extra: Partial = {}): PhaseBidPromise =>
+ ({ event, turn: 2, minimum, player: 'respondent', asker: 'asker', answer, ...extra });
+const opportunity = (extra: Partial = {}): PhaseBidOpportunity =>
+ ({ turn: 2, player: 'respondent', kind: 'current', minimum: 1, maximum: 9, ...extra });
+
+void test('phase bid claim is one positive safe turn and threshold, with no lot or cumulative payment', () => {
+ const claim = parsePhaseBidClaim({ turn: 2, minimum: 6 });
+ assert.deepEqual(claim, { turn: 2, minimum: 6 });
+ assert.match(phaseBidClaimText(claim), /bid of at least 6 spice/);
+ assert.match(phaseBidClaimText(claim), /Bidding phase closes/);
+ for (const field of ['turn', 'minimum'] as const)
+ for (const value of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '6', null, undefined])
+ assert.throws(() => parsePhaseBidClaim({ turn: 2, minimum: 6, [field]: value }), PhaseBidPromiseError);
+ for (const value of [null, undefined, [], '6', 6])
+ assert.throws(() => parsePhaseBidClaim(value), PhaseBidPromiseError);
+});
+
+void test('Yes survives a smaller accepted bid and lot close when a funded later lot is genuinely witnessed', () => {
+ const start = Object.freeze([Object.freeze(promise('yes-six', 6))]);
+ let rows = recordPhaseBid(start, 'respondent', 2, 3);
+ assert.equal(rows[0].fulfilled, undefined);
+ const future = opportunity({ kind: 'future', minimum: 1, maximum: 7 });
+ rows = reconcilePhaseBidPromises(rows, 'respondent', 2, [future], 'voluntary');
+ assert.equal(rows[0].released, undefined);
+ rows = recordPhaseBid(rows, 'respondent', 2, 5);
+ assert.equal(rows[0].fulfilled, undefined, 'Separate bids are not summed');
+ rows = recordPhaseBid(rows, 'respondent', 2, 6);
+ assert.equal(rows[0].fulfilled, true, 'An accepted declaration suffices even without winning the card');
+ assert.equal(start[0].fulfilled, undefined);
+});
+
+void test('No caps all later native lots and remains binding after Yes is fulfilled', () => {
+ const yes = promise('yes-six', 6), no = promise('no-eight', 8, false);
+ const source = Object.freeze([Object.freeze(yes), Object.freeze(no)]);
+ const before = structuredClone(source);
+ assert.deepEqual(phaseBidAllowedRange(source, opportunity()), { minimum: 1, maximum: 7 });
+ assert.deepEqual(phaseBidCompletionRange(source, opportunity()), { minimum: 6, maximum: 7 });
+ const rows = recordPhaseBid(source, 'respondent', 2, 7);
+ assert.equal(rows[0].fulfilled, true); assert.equal(rows[1].fulfilled, undefined);
+ assert.throws(() => recordPhaseBid(rows, 'respondent', 2, 8), PhaseBidPromiseError);
+ assert.deepEqual(source, before);
+ assert.equal(recordPhaseBid(rows, 'rival', 2, 8)[1], no);
+ assert.equal(recordPhaseBid(rows, 'respondent', 3, 8)[1], no);
+ const finished = finishPhaseBidPhase(rows, 'respondent', 2, 'external');
+ assert.equal(finished[1].fulfilled, true);
+});
+
+void test('funded witnesses and other No caps constrain joint completion without forcing each bid to qualify', () => {
+ const rows = [promise('yes-six', 6), promise('yes-seven', 7), promise('no-eight', 8, false)];
+ assert.deepEqual(phaseBidAllowedRange(rows, opportunity()), { minimum: 1, maximum: 7 });
+ assert.deepEqual(phaseBidCompletionRange(rows, opportunity()), { minimum: 7, maximum: 7 });
+ assert.equal(phaseBidCompletionRange(rows, opportunity({ maximum: 6 })), null);
+ assert.equal(phaseBidCompletionRange(rows, opportunity({ minimum: 8 })), null);
+ assert.equal(phaseBidCompletionRange([promise('yes-eight', 8), promise('no-eight', 8, false)], opportunity()), null);
+ assert.deepEqual(phaseBidAllowedRange([promise('no-eight', 8, false)], opportunity()), { minimum: 1, maximum: 7 });
+});
+
+void test('own action cannot abandon the last real witness, while outside impossibility releases only affected Yes rows', () => {
+ const rows = Object.freeze([Object.freeze(promise('yes-five', 5)), Object.freeze(promise('yes-eight', 8)),
+ Object.freeze(promise('no-ten', 10, false))]);
+ const before = structuredClone(rows);
+ const limited = [opportunity({ maximum: 5 })];
+ assert.throws(() => reconcilePhaseBidPromises(rows, 'respondent', 2, limited, 'voluntary'), PhaseBidPromiseError);
+ assert.throws(() => reconcilePhaseBidPromises(rows, 'respondent', 2, [], 'voluntary'), PhaseBidPromiseError);
+ const after = reconcilePhaseBidPromises(rows, 'respondent', 2, limited, 'external');
+ assert.equal(after[0].released, undefined); assert.equal(after[1].released, true);
+ assert.equal(after[2].released, undefined, 'An impossible Yes does not erase a No restriction');
+ assert.deepEqual(rows, before);
+ assert.throws(() => finishPhaseBidPhase(rows, 'respondent', 2, 'voluntary'), PhaseBidPromiseError);
+ const closed = finishPhaseBidPhase(rows, 'respondent', 2, 'external');
+ assert.equal(closed[0].released, true); assert.equal(closed[1].released, true); assert.equal(closed[2].fulfilled, true);
+});
+
+void test('only actual verified intervals for the respondent and turn can witness a promise', () => {
+ const rows = [promise('yes-six', 6)];
+ for (const wrong of [opportunity({ turn: 3 }), opportunity({ player: 'rival' })])
+ assert.throws(() => reconcilePhaseBidPromises(rows, 'respondent', 2, [wrong], 'voluntary'), PhaseBidPromiseError);
+ for (const invalid of [opportunity({ maximum: 0 }), opportunity({ minimum: 4, maximum: 3 }),
+ opportunity({ maximum: Infinity }), opportunity({ kind: 'invented' as 'future' })])
+ assert.throws(() => phaseBidAllowedRange(rows, invalid), PhaseBidPromiseError);
+ assert.throws(() => recordPhaseBid(rows, 'respondent', 2, 0), PhaseBidPromiseError);
+ assert.deepEqual(livePhaseBidPromises([rows[0], promise('old', 8, true, { turn: 1 }),
+ promise('someone-else', 9, true, { player: 'rival' })], 'respondent', 2), [rows[0]]);
+});
+
+void test('phase records preserve event identities, actual participants and mutually exclusive terminal status', () => {
+ const valid = [promise('live', 6), promise('fulfilled', 7, true, { fulfilled: true }),
+ promise('released', 8, true, { released: true })];
+ assert.doesNotThrow(() => validatePhaseBidPromises(valid, seats));
+ const invalid: unknown[] = [null, [null], [valid[0], { ...valid[1], event: 'live' }],
+ [{ ...valid[0], event: '' }], [{ ...valid[0], asker: 'outsider' }],
+ [{ ...valid[0], player: 'outsider' }], [{ ...valid[0], asker: 'respondent' }],
+ [{ ...valid[0], minimum: 0 }], [{ ...valid[0], turn: 0 }], [{ ...valid[0], answer: 'yes' }],
+ [{ ...valid[0], fulfilled: 'true' }], [{ ...valid[0], released: 1 }],
+ [{ ...valid[0], fulfilled: true, released: true }]];
+ for (const input of invalid)
+ assert.throws(() => validatePhaseBidPromises(input as PhaseBidPromise[], seats), PhaseBidPromiseError);
+});