Carry phase bid commitments through expansion auctions

This commit is contained in:
Quintine committed 2026-10-28 18:54:28 +11:00
1 parent b64b732e50
commit 974b7fd99f
10 files changed
+568 -13

No files matched your search

+42 -9
View File
@@ -16458,16 +16458,19 @@ function normalBidQuote(g: Game, p: Player, amount: unknown, requestedAid?: unkn
function bidOpportunity(g: Game): BidOpportunity | null {
const a = g.auction;
if (g.status !== 'playing' || g.phase !== 3 || !a || g.currentAuctionSale ||
!a.cards[a.index] || !bidPromiseModeSupported(g)) return null;
!a.cards[a.index] || (!bidPromiseModeSupported(g) && !phaseBidPromiseModeSupported(g))) return null;
return { turn: g.turn, lot: a.index + 1,
source: JSON.stringify(['ordinaryBid', g.turn, a.index, a.cards[a.index].id]),
bid: a.bid, bidder: a.bidder, passed: a.passed,
eligible: g.players.filter(player => player.hand.length < handLimit(player)).map(player => player.id) };
}
function bidQuestionWindow(g: Game): boolean {
function phaseBidCurrentWindow(g: Game): boolean {
return !!bidOpportunity(g) && !g.response && !g.decision && !g.phaseOpening && !g.richeseAuction &&
!g.ixAuction && !g.pendingKarama && !g.pendingTreacheryDiscard && !g.pendingKull;
}
function bidQuestionWindow(g: Game): boolean {
return bidPromiseModeSupported(g) && phaseBidCurrentWindow(g);
}
/** 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. */
@@ -16478,14 +16481,42 @@ function phaseBidWitnesses(g: Game, p: Player): PhaseBidOpportunity[] {
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') ||
// Ixian inspection returns at most one physical card; if it draws only
// one, the actual native continuation places that card in the auction.
// Richese's compulsory cache replaces one ordinary lot when available.
const cache = !!byFaction(g, 'richese') && (g.richeseCache?.length ?? 0) > 0;
// A definite answer spends the actual held Truthtrance before Bidding.
// Count its holder's newly freed slot only when that reserved card is
// still in a full hand; an unknown answer retains it and binds nothing.
const committedTruth = g.truthtrance?.question?.kind === 'phaseBid' ? g.truthtrance.queue[0] : undefined;
const holder = committedTruth && g.players.find(seat => seat.id === committedTruth.player);
const freedSlot = !!holder && holder.hand.length === handLimit(holder) &&
holder.hand.some(card => card.id === committedTruth!.card);
if (auctionEligible(g).length + Number(freedSlot) - Number(cache) < 1 ||
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 [];
if (!lot || !auction) {
// A real first-position cache lot still precedes its frozen positive
// ordinary count. Reserve only this respondent's own potentially
// winning cache bid and its own pledged aid; never inspect rival sealed
// amounts or assume anyone else's future bid.
const round = g.richeseBidding, cacheLot = g.richeseAuction;
if (!round || round.turn !== g.turn || round.position !== 'first' ||
!Number.isSafeInteger(round.normalCount) || round.normalCount! < 1 ||
round.stage === 'complete' || !(g.deck.length || g.discard.length)) return [];
const ownCache = cacheLot?.source === 'cache' &&
!p.hand.some(card => card.id === cacheLot.cardId) &&
(cacheLot.outcome?.kind === 'sold' ? cacheLot.outcome.winner === p.id :
!cacheLot.outcome && (cacheLot.method === 'silent' ? (cacheLot.sealed[p.id] ?? 0) > 0 : cacheLot.bidder === p.id));
const futureCards = ownCache ? 1 + Number(p.faction === 'harkonnen') : 0;
if (p.hand.length + futureCards >= handLimit(p)) return [];
const spentAid = ownCache ? (g.richeseFunding?.[p.id]?.allyPayment ?? 0) : 0;
const max = maximum(0, spentAid);
return max > 0 ? [{turn:g.turn,player:p.id,kind:'future',minimum:1,maximum:max}] : [];
}
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});
@@ -16785,7 +16816,8 @@ function phaseBidHonorsTurnActions(g: Game, p: Player, lot: BidOpportunity, acti
* current-lot commitments. Future bidding with other financial/transport
* promises awaits their combined planner; do not invent those resources. */
function phaseBidFuturePlannerUnsupported(g: Game, p: Player): boolean {
return liveShipmentPromises(g.shipmentPromises ?? [],p.id,g.turn).some(row => row.answer) ||
return liveEarlyRicheseBidPromises(g.earlyRicheseBidPromises ?? [],p.id,g.turn).some(row => row.answer) ||
liveShipmentPromises(g.shipmentPromises ?? [],p.id,g.turn).some(row => row.answer) ||
hasMovementPromises(g,p.id,true) || liveRevivalPromises(g.revivalPromises ?? [],p.id,g.turn).some(row => row.answer) ||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [],p.id,g.turn).some(row => row.answer) ||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [],p.id,g.turn).some(row => row.answer) ||
@@ -16924,7 +16956,7 @@ function projectedPhaseBidPromises(g: Game, player: string) {
}
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;
if (!opportunity || !phaseBidCurrentWindow(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);
@@ -17232,8 +17264,9 @@ export function advanceAuctionClock(source: Game, now: number, controlEpoch: str
? turnActionCompletion(game, actor)?.bid : undefined;
const opportunity = bidOpportunity(game);
const required = opportunity && bidRequired(game.bidPromises ?? [], actor, opportunity);
if (!whole && !required) throw error;
const completion = minimumCompelledAuctionBid(game, actor);
const phaseGuide = projectedPhaseBidGuide(game, actor)?.action;
if (!whole && !required && phaseGuide?.type !== 'bid') throw error;
const completion = phaseGuide?.type === 'bid' ? phaseGuide : minimumCompelledAuctionBid(game, actor);
requireRule(completion, 'The expired ordinary bid needs a legal completion of its binding Truthtrance answer.');
next = applyAction(game, actor, completion);
compelled = true;
+2 -1
View File
@@ -3701,6 +3701,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Before Basic Bidding, ask about a minimum bid in the first actual Richese cache auction of this turn. The answer waits for the real event and does not predict its card or method. Current uncommitted personal funds and hand space supply a conservative Yes witness; cancellation or an opposing action can release an impossible commitment. The Richese owner’s Once Around turn may vanish if everyone else passes.',
'Your waiting cache commitment remains visible until its auction opens or it ends. Once the real lot opens, the ordinary Richese bid guide takes over.',
'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.',
'In supported Basic Ixian or Richese games, a phase-wide ordinary bid can wait through Ixian inspection or a first-position Richese cache. Only ordinary bids count; a potential cache purchase reserves your funds and hand space. If the ordinary auction clock expires and your Yes forbids passing, the normal guide supplies the required legal bid.',
'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.',
@@ -3714,7 +3715,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ 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'] },
{ area: 'Verification', status: 'Partial', detail: 'Original public sources and callbacks cover Yes/No, payments, release, Ixian/Richese preparation, clock expiry, multiple original cards 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', 'tests/truth-phase-bid-ix-runtime.test.ts', 'tests/truth-phase-bid-richese-runtime.test.ts', 'tests/truth-phase-bid-richese-limits.test.ts', 'tests/truth-phase-bid-clock.test.ts'] },
],
},
{
+5 -1
View File
@@ -45,7 +45,11 @@ export function parsePhaseBidClaim(value: unknown): PhaseBidClaim {
/** A phase-wide Yes can depend on an unopened later lot; its Kull purchase
* interruption needs a separate continuation proof from a current-lot Yes. */
export function phaseBidPromiseModeSupported(game: Parameters<typeof bidPromiseModeSupported>[0]): boolean {
return !game.kullPreview && bidPromiseModeSupported(game);
// Basic native Richese opens Semuta while preparing its cache auction.
// This phase-wide contract has its own continuation through that window;
// other current-lot bid contracts retain their separate Semuta guard.
const basicRichese = !game.advanced && game.players.some(player => player.faction === 'richese');
return !game.kullPreview && bidPromiseModeSupported(basicRichese ? { ...game, semutaPreview: undefined } : game);
}
export function phaseBidClaimText(claim: PhaseBidClaim): string {