Support phase-wide Truthtrance bid commitments

This commit is contained in:
Quintine committed 2026-10-27 17:44:53 +11:00
1 parent 474674f7fe
commit 2916b4d48f
19 files changed
+1512 -15

No files matched your search

+5
View File
@@ -1319,6 +1319,7 @@ function policyActions(g: GameView): Action[] {
if (g.revivalExpressionPromiseGuide) return [g.revivalExpressionPromiseGuide.action];
if (g.leaderRevivalPromiseGuide) return [g.leaderRevivalPromiseGuide.action];
if (g.revivalPromiseGuide) return [g.revivalPromiseGuide.action];
if (g.phaseBidPromiseGuide) return [g.phaseBidPromiseGuide.action];
if (g.bidPromiseGuide) return [g.bidPromiseGuide.action];
if (g.truthtrance) {
const w = g.truthtrance;
@@ -1375,6 +1376,10 @@ function policyActions(g: GameView): Action[] {
const answer = g.truthBidAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'phaseBid') {
const answer = g.truthPhaseBidAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
}
if (w.question.kind === 'turnActions') {
const answer = g.truthTurnActionAnswers?.[0];
return answer ? [{ type: 'truthAnswer', answer }] : [];
+301 -9
View File
@@ -67,6 +67,10 @@ import { RevivalPromiseError, revivalPromiseModeSupported, liveRevivalPromises,
revivalPromisesSatisfied, validateRevivalPromises, type RevivalClaim, type RevivalPromise } from './revival-promises';
import { BidPromiseError, bidPromiseModeSupported, bidPromiseRange, bidRequired, liveBidPromises,
recordPromisedBid, validateBidPromises, type BidClaim, type BidOpportunity, type BidPromise } from './bid-promises';
import { PhaseBidPromiseError, phaseBidPromiseModeSupported, livePhaseBidPromises,
phaseBidCompletionRange, recordPhaseBid, reconcilePhaseBidPromises,
finishPhaseBidPhase, validatePhaseBidPromises, type PhaseBidClaim, type PhaseBidOpportunity,
type PhaseBidPromise } from './turn-bid-promises';
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3LeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { bribeTimingBlock, bribesCloseBeforeNextMentat, maximumBribe, type BribeOptions } from './bribe-options';
@@ -2243,6 +2247,7 @@ export type Game = {
})[];
shipmentPromises?: ShipmentPromise[];
bidPromises?: BidPromise[];
phaseBidPromises?: PhaseBidPromise[];
/** Raw mixed question linked to one private native current-lot obligation. */
mixedBidQuestions?: (MixedBidClaim & {
event: string; player: string; asker: string; answer: boolean; binding: boolean;
@@ -15652,17 +15657,23 @@ function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no'
if (!bribeQuestionWindow(g) || claim.turn !== g.turn || claim.recipient === p.id ||
claim.recipient === p.ally || !g.players.some(player => 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<T>(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 &&
+7 -5
View File
@@ -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'] },
+25
View File
@@ -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.');
+149
View File
@@ -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<string, unknown>;
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<T extends PhaseBidPromise>(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<T extends PhaseBidPromise>(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<T extends PhaseBidPromise>(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<string>();
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);
}
}