Support future-turn public deal payments
This commit is contained in:
1 parent
bb193d8f8c
commit
7d71647a90
16 files changed
+641
-35
No files matched your search
+4
-1
@@ -4299,7 +4299,10 @@ export function botActions(g: GameView): Action[] {
|
||||
const dealPayer = g.players.find(player => player.id === g.me);
|
||||
if ((dealPayer?.bot || dealPayer?.autopilot) && g.status === 'playing' &&
|
||||
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending) {
|
||||
const payable = Object.entries(g.deferredDealPaymentOptions ?? {}).find(([,option]) => !option.blocked);
|
||||
const payable = Object.entries(g.deferredDealPaymentOptions ?? {}).find(([id,option]) => {
|
||||
const payment = g.deals.find(deal => deal.id === id)?.payment;
|
||||
return !option.blocked && payment?.kind === 'deferredBribe' && payment.dueTurn <= g.turn;
|
||||
});
|
||||
if (payable) return [{type:'dealPay',deal:payable[0]}];
|
||||
}
|
||||
if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup)
|
||||
|
||||
+26
-10
@@ -6,7 +6,7 @@ import { createFactionSwap, validateFactionSwap, answerFactionSwap, cancelFactio
|
||||
FactionSwapError, type FactionSwap } from './faction-swap';
|
||||
import { createPublicDeal, createMultipartyPublicDeal, validatePublicDeals, respondPublicDeal, withdrawPublicDeal,
|
||||
releasePublicDeal, markPublicDealPaid, voidPublicDealPayment, PublicDealError, type PublicDeal, type PublicDealPayment } from './public-deals';
|
||||
import { pendingPublicDealPayments, publicDealPaymentTotal, PublicDealObligationError } from './public-deal-obligations';
|
||||
import { outstandingPublicDealPayments, pendingPublicDealPayments, publicDealPaymentTotal, PublicDealObligationError, type PendingPublicDealPayment } from './public-deal-obligations';
|
||||
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
|
||||
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
|
||||
import { createVictoryContinuation, validateVictoryContinuation, voteVictoryContinuation,
|
||||
@@ -5929,15 +5929,26 @@ function publicDealInsolventAtBaseMentat(g: Game, player: string): boolean {
|
||||
publicDealObligationRule(() => publicDealPaymentTotal(g.deals ?? [],player,g.turn)) >
|
||||
Math.max(0,uncommittedSpice(g,getPlayer(g,player)));
|
||||
}
|
||||
/** An insolvent payer may choose among currently affordable payments, but an
|
||||
* early future payment must not remove that choice at the current deadline. */
|
||||
function protectInsolventDueDeal(g: Game, payer: string, payment: PendingPublicDealPayment): void {
|
||||
if (payment.dueTurn === g.turn || !publicDealInsolventAtBaseMentat(g,payer)) return;
|
||||
const seat = getPlayer(g,payer), available = Math.max(0,uncommittedSpice(g,seat));
|
||||
const affordable = publicDealObligationRule(() => pendingPublicDealPayments(g.deals ?? [],payer,g.turn));
|
||||
requireRule(!affordable.some(debt => debt.recipient !== seat.ally && debt.amount <= available &&
|
||||
debt.amount > available - payment.amount),
|
||||
'Keep the funding for a currently payable deal before paying a later deal.');
|
||||
}
|
||||
/** Only the payer receives its exact native payment quote. */
|
||||
function projectedDeferredDealPaymentOptions(g: Game, player: string): Record<string,{blocked:string|null}> {
|
||||
const result: Record<string,{blocked:string|null}> = {};
|
||||
for (const debt of pendingPublicDealPayments(g.deals ?? [],player,g.turn)) {
|
||||
for (const debt of outstandingPublicDealPayments(g.deals ?? [],player,g.turn)) {
|
||||
try {
|
||||
protectInsolventDueDeal(g,player,debt);
|
||||
const trial = structuredClone(g), index = trial.deals!.findIndex(deal => deal.id === debt.deal);
|
||||
trial.deals![index] = publicDealRule(() => markPublicDealPaid(trial.deals![index],trial.players,player));
|
||||
applyActionWithInitiator(trial,player,{type:'bribe',target:debt.recipient,amount:debt.amount},player,
|
||||
{insolventDealPayment:publicDealInsolventAtBaseMentat(g,player)});
|
||||
{insolventDealPayment:debt.dueTurn === g.turn && publicDealInsolventAtBaseMentat(g,player)});
|
||||
result[debt.deal] = {blocked:null};
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
@@ -40343,11 +40354,12 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
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;
|
||||
requireRule(payment?.kind === 'deferredBribe' && payment.dueTurn === g.turn,
|
||||
'This deferred payment is not due in the current turn.');
|
||||
requireRule(payment?.kind === 'deferredBribe' && payment.dueTurn >= g.turn,
|
||||
'This deferred payment is no longer available.');
|
||||
protectInsolventDueDeal(state,id,{deal:current.id,...payment});
|
||||
g.deals![index] = publicDealRule(() => markPublicDealPaid(current,g.players,id));
|
||||
return applyActionWithInitiator(g,id,{type:'bribe',target:payment.recipient,amount:payment.amount},id,
|
||||
{insolventDealPayment:publicDealInsolventAtBaseMentat(state,id)});
|
||||
{insolventDealPayment:payment.dueTurn === state.turn && publicDealInsolventAtBaseMentat(state,id)});
|
||||
}
|
||||
if (['dealOffer', 'dealRespond', 'dealWithdraw', 'dealRelease'].includes(action?.type)) {
|
||||
requireRule(state.status === 'playing', 'Public deals require a game in progress.');
|
||||
@@ -40386,6 +40398,8 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
let next: PublicDeal;
|
||||
if (action.type === 'dealRespond') {
|
||||
requireRule(typeof action.accept === 'boolean', 'Choose whether to accept or decline.');
|
||||
requireRule(!action.accept || current.payment?.kind !== 'deferredBribe' || current.payment.dueTurn >= g.turn,
|
||||
'The deferred payment deadline has passed; decline or withdraw this offer.');
|
||||
next = publicDealRule(() => respondPublicDeal(current, g.players, id, action.accept as boolean));
|
||||
} else if (action.type === 'dealRelease') {
|
||||
requireRule(typeof action.agree === 'boolean', 'Choose whether to request cancellation.');
|
||||
@@ -40394,11 +40408,13 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
|
||||
g.deals = deals.map((deal, position) => position === index ? next : deal);
|
||||
if (current.status === 'offered' && next.status === 'active' && next.payment?.kind === 'deferredBribe') {
|
||||
const payer = getPlayer(g,next.payment.payer);
|
||||
requireRule(next.payment.dueTurn === g.turn && publicDealCompletion(g,payer),
|
||||
'The agreed payment needs a legal funded completion in this turn.');
|
||||
if ((g.phase === 2 && !g.charityOrder || g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g)) &&
|
||||
requireRule(next.payment.dueTurn >= g.turn &&
|
||||
(next.payment.dueTurn > g.turn || publicDealCompletion(g,payer)),
|
||||
'The agreed payment needs an unexpired deadline and, when due now, a legal funded completion.');
|
||||
if (next.payment.dueTurn === g.turn &&
|
||||
(g.phase === 2 && !g.charityOrder || g.phase === 8 || g.phase === 7 && bribesCloseBeforeNextMentat(g)) &&
|
||||
g.ready.includes(payer.id)) g.ready = g.ready.filter(playerId => playerId !== payer.id);
|
||||
log(g, `${payer.name} agreed to pay ${next.payment.amount} spice to ${getPlayer(g,next.payment.recipient).name} by the end of turn ${g.turn}.`);
|
||||
log(g, `${payer.name} agreed to pay ${next.payment.amount} spice to ${getPlayer(g,next.payment.recipient).name} by the end of turn ${next.payment.dueTurn}.`);
|
||||
}
|
||||
if (current.status === 'offered' && next.status === 'active' && next.payment?.kind === 'immediateBribe') {
|
||||
// The payer consented to these immutable terms. Run the native action
|
||||
|
||||
@@ -23,9 +23,10 @@ const identifier = (value: unknown): value is string =>
|
||||
const nonnegative = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
|
||||
/** Input ledger order is acceptance order; callers validate persisted deals
|
||||
* against seated players before using a quote. */
|
||||
export function pendingPublicDealPayments(
|
||||
/** Return a payer's unsettled active terms that can still be paid now,
|
||||
* including future deadlines. Past-due rows stay in the authoritative ledger
|
||||
* for the engine's expiry reconciliation; this helper never voids them. */
|
||||
export function outstandingPublicDealPayments(
|
||||
deals: readonly PublicDeal[], payer: string, turn: number,
|
||||
): PendingPublicDealPayment[] {
|
||||
if (!Array.isArray(deals) || !identifier(payer) || !Number.isSafeInteger(turn) || turn < 1)
|
||||
@@ -33,11 +34,19 @@ export function pendingPublicDealPayments(
|
||||
return deals.flatMap(deal => {
|
||||
const payment = deal.payment;
|
||||
if (deal.status !== 'active' || deal.paymentStatus !== 'pending' ||
|
||||
payment?.kind !== 'deferredBribe' || payment.payer !== payer || payment.dueTurn !== turn) return [];
|
||||
payment?.kind !== 'deferredBribe' || payment.payer !== payer || payment.dueTurn < turn) return [];
|
||||
return [{ deal: deal.id, payer, recipient: payment.recipient, dueTurn: payment.dueTurn, amount: payment.amount }];
|
||||
});
|
||||
}
|
||||
|
||||
/** Existing turn-specific planners and funding totals consider only payments
|
||||
* due in that turn. They must not reserve every future promise immediately. */
|
||||
export function pendingPublicDealPayments(
|
||||
deals: readonly PublicDeal[], payer: string, turn: number,
|
||||
): PendingPublicDealPayment[] {
|
||||
return outstandingPublicDealPayments(deals,payer,turn).filter(payment => payment.dueTurn === turn);
|
||||
}
|
||||
|
||||
/** Separate deals add, including two deals for the same recipient. */
|
||||
export function publicDealPaymentTotal(deals: readonly PublicDeal[], payer: string, turn: number): number {
|
||||
let total = 0;
|
||||
|
||||
@@ -94,10 +94,11 @@ export function parsePublicDealPayment(value: unknown, parties: readonly string[
|
||||
}
|
||||
requireDeal(value.kind === 'deferredBribe' &&
|
||||
Object.keys(value).sort().join(',') === 'amount,dueTurn,kind,payer,recipient' &&
|
||||
Number.isSafeInteger(turn) && (turn as number) > 0 && value.dueTurn === turn,
|
||||
'Choose a deferred bribe due in the deal’s current turn.');
|
||||
Number.isSafeInteger(turn) && (turn as number) > 0 &&
|
||||
typeof value.dueTurn === 'number' && Number.isSafeInteger(value.dueTurn) && value.dueTurn >= (turn as number),
|
||||
'Choose a deferred bribe due no earlier than the deal’s turn.');
|
||||
return { kind: 'deferredBribe', payer: value.payer, recipient: value.recipient,
|
||||
amount: value.amount, dueTurn: turn as number };
|
||||
amount: value.amount, dueTurn: value.dueTurn };
|
||||
}
|
||||
|
||||
/** Validate persisted ledger shape and lifecycle without applying current
|
||||
|
||||
+2
-2
@@ -301,13 +301,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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.',
|
||||
'In Public deals, propose written terms to one or more human seats. Every pair of participants must be nonallied when the offer is made and accepted, a provisional interpretation. Every recipient must accept; any recipient can decline, and the proposer can withdraw the outstanding offer. Any party may request release; all must agree. The record remains active until mutually released, even when players consider it fulfilled.',
|
||||
'A deal may include one immediate spice payment between two participants. The last acceptance invokes the normal bribe action: the payer loses the gross amount immediately, Bureaucrat may divert two, and the recipient collects the remaining share at the next Mentat opening. Nothing is reserved before acceptance, and an unavailable payment leaves the offer open.',
|
||||
'A deal may instead specify one exact payment due this turn. After everyone accepts, the payer sees a persistent payment request and uses that deal’s Pay button. Separate deals require separate payments. A compatible Truthtrance bribe answer can be satisfied by the same payment. Mutual release cancels an unpaid term; a paid amount is not refunded.',
|
||||
'A deal may instead specify one exact payment due this turn or a later turn. After everyone accepts, the payer can use that deal’s Pay button early or by the deadline. Future payments do not reserve current funds; obligations due now must still be respected. Separate deals require separate payments. A compatible Truthtrance bribe answer can be satisfied by the same payment. Mutual release cancels an unpaid term; a paid amount is not refunded.',
|
||||
'Players must honor legal deal terms; an impossible part is void. The game does not interpret or enforce free-text promises or determine impossibility. Free text alone pays no spice. Deals must not transfer cards, leaders, forces or faction advantages or contradict the rules.',
|
||||
'Minimal AI follows supported Truthtrance ordinary-payment answers and their legal guides. AI deal negotiation and automatic enforcement of prose remain unfinished.',
|
||||
],
|
||||
related: ['table-discussion', 'alliance-funding', 'card-truthtrance', 'privacy'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Public two-to-six-human offer, unanimous consent and mutual release are recorded. Immediate and current-turn deferred deal payments use the native bribe action; prose compliance remains player managed. Funding, escrow and Bureaucrat continuation remain native. Complete negotiation and all module combinations remain open.', evidence: ['game/public-deals.ts', 'game/public-deal-obligations.ts', 'game/bribe-options.ts', 'game/engine.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Public two-to-six-human offer, unanimous consent and mutual release are recorded. Immediate and deferred deal payments, including later deadlines, use the native bribe action; prose compliance remains player managed. Funding, escrow and Bureaucrat continuation remain native. Complete negotiation and all module combinations remain open.', evidence: ['game/public-deals.ts', 'game/public-deal-obligations.ts', 'game/bribe-options.ts', 'game/engine.ts'] },
|
||||
{ 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'] },
|
||||
|
||||
Reference in new issue
Block a user