Support phase-wide Truthtrance bid commitments
This commit is contained in:
1 parent
474674f7fe
commit
2916b4d48f
19 files changed
+1512
-15
No files matched your search
@@ -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
@@ -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
@@ -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'] },
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user