Allow Basic bribes during selected pending decisions
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 06:42:50 +11:00
1 parent 171bc3a636
commit 72f6ec2b12
11 files changed
+435 -29

No files matched your search

+7
View File
@@ -3,6 +3,8 @@ import type { Action, Game } from './engine';
export interface BribeOptions {
available: number;
incoming: number;
/** When an ordinary payment made now becomes spendable. */
receiptTiming?: 'upcoming' | 'nextTurn';
blocked: string | null;
targets: { id: string; maximum: number; limitation: string | null }[];
}
@@ -21,6 +23,11 @@ export function bribeTimingBlock(g: Game): string | null {
return null;
}
/** A phase-8 opening may still precede the phase's actual collection. */
export function bribeReceiptTiming(g: Pick<Game, 'phase' | 'phaseOpening'>): 'upcoming' | 'nextTurn' {
return g.phase === 8 && !g.phaseOpening?.initialize ? 'nextTurn' : 'upcoming';
}
/** The existing automatic token lifecycle will turn an unflipped Cancel into
* Double at this opening. An owed payment must precede that known closure. */
export function bribesCloseBeforeNextMentat(g: Pick<Game, 'turn' | 'inflation'>): boolean {
+59 -20
View File
@@ -109,7 +109,7 @@ import { PhaseBidPromiseError, phaseBidPromiseModeSupported, livePhaseBidPromise
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';
import { bribeReceiptTiming, bribeTimingBlock, bribesCloseBeforeNextMentat, maximumBribe, type BribeOptions } from './bribe-options';
import { quoteAtreidesFullPlan } from './atreides-full-plan';
import { quoteTleilaxuSpecialKarama } from './tleilaxu-special-karama';
import { quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError, type AdvisorStance, type AutomaticAdvisorFlipSource, type AutomaticAdvisorTransport, type AutomaticAdvisorGamont, type AutomaticAdvisorBattle, type AutomaticAdvisorHomeworldTransport, type AutomaticAdvisorFaceDance, type AutomaticAdvisorStorm, type AutomaticAdvisorRelocation } from './advisor-automatic-flip';
@@ -17299,8 +17299,38 @@ function bribePaymentPreservesPromises(g: Game, p: Player, recipient: string, am
? !!turnActionCompletion(trial, p.id) : !hasBribePromises(trial, p.id) || !!bribePromiseCompletion(trial, actor);
} catch (error) { if (error instanceof BribeExpressionPromiseError || error instanceof BribePromiseError || error instanceof RuleError) return false; throw error; }
}
/** Selected Basic parents accept a direct payment without replacing or
* advancing their pending state. Existing whole-promise planners retain their
* original timing boundary until those compositions are implemented. */
function basicBribeInterrupt(g: Game, p: Player): boolean {
if (g.status !== 'playing' || g.advanced || g.expansions.length !== 1 ||
g.inflation?.side === 'double' || g.techTokens || g.strongholdCards || g.leaderSkills ||
g.homeworlds || g.nexusCards || g.discoveryEnabled || g.sandtrout || g.coalitionState ||
g.ecazTreachery || g.semutaPreview || g.semutaRecruitsPreview || g.truthtrance || g.response ||
g.pendingKarama || g.pendingKull || g.pendingTreacheryDiscard || g.pendingNullentropy ||
g.bureaucratPayments?.pending || g.battle || g.pendingRicheseGift || g.pendingShipment ||
g.pendingGuildTransport || g.pendingArrivalOverlap || g.pendingAmbassador || g.pendingEmperorAuctionGift ||
liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
hasMovementPromises(g, p.id) || hasBribePromises(g, p.id) ||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length ||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length ||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).length ||
liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length ||
livePhaseBidPromises(g.phaseBidPromises ?? [], p.id, g.turn).length ||
(g.pledgePromises ?? []).some(row => row.player === p.id && row.turn === g.turn && !row.fulfilled && !row.released) ||
(g.deals ?? []).some(row => row.status === 'active' && row.payment?.payer === p.id && row.paymentStatus === 'pending')) return false;
const classic = ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'];
if (g.expansions[0] === 'ix') return !!g.phaseOpening && !!g.amalWindowOpen && !g.decision &&
g.players.every(seat => [...classic, 'ixians', 'tleilaxu'].includes(seat.faction));
const extortion = g.moritaniExtortion, decision = g.decision;
return g.expansions[0] === 'ecaz' && !g.phaseOpening && g.phase === 8 &&
g.players.every(seat => [...classic, 'moritani'].includes(seat.faction)) &&
extortion?.stage === 'payment' && decision?.kind === 'moritaniExtortion' &&
decision.event === extortionEvent(extortion) && decision.player === extortion.queue[extortion.cursor];
}
function projectedBribes(g: Game, p: Player): BribeOptions {
const available = Math.max(0, uncommittedSpice(g, p)), blocked = bribeTimingBlock(g);
const available = Math.max(0, uncommittedSpice(g, p)),
blocked = basicBribeInterrupt(g, p) ? null : bribeTimingBlock(g);
const constrained = liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length > 0 || hasBribePromises(g, p.id) ||
g.battle?.truthPromises?.some(promise => promise.player === p.id && !promise.released && !g.battle?.revealed && !g.battle?.plans[p.id]) ||
liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
@@ -17308,7 +17338,7 @@ function projectedBribes(g: Game, p: Player): BribeOptions {
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(promise => promise.answer) ||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).some(promise => promise.answer);
return { available, incoming: p.bribes, blocked,
return { available, incoming: p.bribes, receiptTiming: bribeReceiptTiming(g), blocked,
targets: g.players.filter(target => target.id !== p.id && !bribeAllied(g, p, target.id)).map(target => {
let limit = !blocked && constrained ? maximumBribe(available,
amount => bribePaymentPreservesPromises(g, p, target.id, amount)) : available;
@@ -42205,6 +42235,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
if (action?.type === 'dealPay') {
requireRule(state.status === 'playing' && Object.keys(action).sort().join(',') === 'deal,type' &&
typeof action.deal === 'string', 'Choose your pending deal payment.');
requireRule(!bribeTimingBlock(state), 'Finish the current decision or response before paying a deal.');
const g = structuredClone(state), index = (g.deals ?? []).findIndex(deal => deal.id === action.deal);
requireRule(index >= 0, 'This deal is no longer available.');
const current = g.deals![index], payment = current.payment;
@@ -42275,6 +42306,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
// The payer consented to these immutable terms. Run the native action
// on the same cloned state so a failed payment also rolls back consent.
try {
requireRule(!bribeTimingBlock(g), 'Finish the current decision or response before paying a deal.');
requireRule(!getPlayer(g, next.payment.payer).autopilot, 'The payer is not controlling this seat.');
return applyActionWithInitiator(g, next.payment.payer, {
type: 'bribe', target: next.payment.recipient, amount: next.payment.amount,
@@ -43009,6 +43041,25 @@ export function normalizeAutomaticGame(state: Game): Game {
return g;
}
function payOrdinaryBribe(g: Game, p: Player, action: Action): void {
const blocked = bribeTimingBlock(g);
requireRule(!blocked || basicBribeInterrupt(g, p), blocked ?? 'Bribes are unavailable.');
const target = getPlayer(g, stringField(action.target));
requireRule(
target.id !== p.id && !bribeAllied(g, p, target.id),
'Bribes are between different, non-allied factions.',
);
const n = integer(action.amount, 1, uncommittedSpice(g, p), 'Spice');
checkBureaucratContributors(g,target.id,[{payer:p.id,amount:n}]);
recordBribePayment(g, p.id, target.id, n);
p.spice -= n;
log(
g,
`${p.name} paid a ${n}-spice bribe to ${target.name}; its recipient share becomes spendable ${bribeReceiptTiming(g) === 'nextTurn' ? 'at the start of next turn’s Mentat Pause' : 'at the next Mentat Pause opening'}.`,
);
const source = stampBureaucratPayment(g,'bribe',p.id,target.id,n).bureaucratPayment;
if (!offerBureaucratPayment(g,source,{kind:'bribe'})) target.bribes += n;
}
function applyActionInner(
state: Game,
id: string,
@@ -43553,6 +43604,10 @@ function applyActionInner(
decideBiddingEnd(g, p, action);
return g;
}
if (t === 'bribe' && basicBribeInterrupt(g, p)) {
payOrdinaryBribe(g, p, action);
return g;
}
if (t === 'emperorGift' && g.phaseOpening && !g.response) {
const quote = emperorPhaseOpeningGiftQuote(g, p);
requireRule(quote && Object.keys(action).sort().join(',') === 'amount,type',
@@ -45834,23 +45889,7 @@ function applyActionInner(
return g;
}
if (t === 'bribe') {
const blocked = bribeTimingBlock(g);
requireRule(!blocked, blocked ?? 'Bribes are unavailable.');
const target = getPlayer(g, stringField(action.target));
requireRule(
target.id !== id && !bribeAllied(g, p, target.id),
'Bribes are between different, non-allied factions.',
);
const n = integer(action.amount, 1, uncommittedSpice(g, p), 'Spice');
checkBureaucratContributors(g,target.id,[{payer:p.id,amount:n}]);
recordBribePayment(g, p.id, target.id, n);
p.spice -= n;
log(
g,
`${p.name} paid a ${n}-spice bribe to ${target.name}; its recipient share becomes spendable ${g.phase === 8 ? 'at the start of next turn’s Mentat Pause' : 'at the next Mentat Pause opening'}.`,
);
const source = stampBureaucratPayment(g,'bribe',p.id,target.id,n).bureaucratPayment;
if (!offerBureaucratPayment(g,source,{kind:'bribe'})) target.bribes += n;
payOrdinaryBribe(g, p, action);
return g;
}
if (t === 'stormDial') {
+2 -2
View File
@@ -296,7 +296,7 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'Open Bribes beneath the table. Choose a non-allied faction and a positive whole amount, then use the named Pay button. The amount is public and leaves your supply immediately. A message or promise alone does not pay spice.',
'The recipient keeps paid spice separately until the next Mentat opening. A payment after the current opening waits for next turn’s opening. Your incoming bribes are shown separately from your spendable supply; they cannot fund bids, shipments or another bribe yet.',
'You may pay out of turn when no decision or response is pending, including during Mentat after opening collection. You cannot bribe yourself or your ally, or while Inflation shows Double. Emperor gifts to allies use their own power controls.',
'You may pay out of turn when no decision or response is pending, including during Mentat after opening collection. In supported Basic Ix-deck games, you can also pay during the Amal opening; in supported Basic Moritani games, you can pay during an Extortion choice. The original opening or choice remains pending. You cannot bribe yourself or your ally, or while Inflation shows Double. Emperor gifts to allies use their own power controls.',
'Your payment limit excludes reserved spending and preserves binding Truthtrance answers. If an alliance, pending decision or available supply changes, recheck the recipient and amount. A rejected payment does not change the game.',
'In the Basic group preview, group allies are excluded from ordinary bribes and nonallied deals. Existing agreements remain recorded and can be released; a deferred bribe to a newly allied recipient stays pending while it is unavailable, subject to its original deadline.',
'A qualifying Bureaucrat may divert two spice to the Bank without reducing the payer’s cost. The remainder stays deferred. Unsupported combinations remain unavailable and do not imply an official rule prohibition.',
@@ -312,7 +312,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Public deal proposals, responses and mutual release, named bribe recipient, amount, explicit payment, deferred incoming supply and current availability guidance.', evidence: ['components/public-deals.tsx', 'components/bribes.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Passive receipt/collection and existing Bureaucrat responses work. Strategic bribe initiation and negotiation wait for feature completion.' },
{ area: 'Documentation', status: 'Partial', detail: 'Public deal consent, player-managed terms, payment, escrow, restrictions and remaining boundaries are recorded.', evidence: ['docs/PUBLIC_DEALS.md', 'docs/BRIBE_CONTROLS.md', 'docs/MENTAT_BRIBES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Public deal consent, release, immediate and deferred native payments, authenticated visibility and legal bot continuation have focused coverage, including staged group and legacy records. Original late Mentat/next-turn receipt journeys supplement existing bribe projection, budgets and Bureaucrat coverage. Full integrated acceptance remains open.', evidence: ['tests/coalition-bribe-deals.test.ts', 'tests/coalition-bribe-truth.test.ts', 'tests/public-deals.test.ts', 'tests/public-deals-engine.test.ts', 'tests/public-deals-multiparty-engine.test.ts', 'tests/public-deals-payment-engine.test.ts', 'tests/public-deals-deferred-runtime.test.ts', 'tests/public-deals-deferred-truthtrance.test.ts', 'tests/public-deals-deferred-charity.test.ts', 'tests/public-deals-deferred-income.test.ts', 'tests/public-deals-deferred-http.test.ts', 'tests/public-deals-controls.test.tsx', 'tests/public-deals-http.test.ts', 'tests/public-deals-multiparty-http.test.ts', 'tests/public-deals-payment-http.test.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Supported Basic pending opening and Extortion payments retain their original parent and deferred collection; the actual opening and next-turn collection have focused runtime and rendered-control coverage. Public deal consent, release, immediate and deferred native payments, authenticated visibility and legal bot continuation have focused coverage, including staged group and legacy records. Original late Mentat/next-turn receipt journeys supplement existing bribe projection, budgets and Bureaucrat coverage. Full integrated acceptance remains open.', evidence: ['tests/bribe-pending-basic.test.ts', 'tests/bribe-pending-basic-controls.test.tsx', 'tests/coalition-bribe-deals.test.ts', 'tests/coalition-bribe-truth.test.ts', 'tests/public-deals.test.ts', 'tests/public-deals-engine.test.ts', 'tests/public-deals-multiparty-engine.test.ts', 'tests/public-deals-payment-engine.test.ts', 'tests/public-deals-deferred-runtime.test.ts', 'tests/public-deals-deferred-truthtrance.test.ts', 'tests/public-deals-deferred-charity.test.ts', 'tests/public-deals-deferred-income.test.ts', 'tests/public-deals-deferred-http.test.ts', 'tests/public-deals-controls.test.tsx', 'tests/public-deals-http.test.ts', 'tests/public-deals-multiparty-http.test.ts', 'tests/public-deals-payment-http.test.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts'] },
],
},
{