Support current-turn payments in public deals

This commit is contained in:
Quintine committed 2026-10-28 19:13:17 +11:00
1 parent 974b7fd99f
commit bb193d8f8c
19 files changed
+1203 -44

No files matched your search

+6
View File
@@ -4296,6 +4296,12 @@ export function botActions(g: GameView): Action[] {
return g.status === 'playing' && !g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending
? policyActions(g) : [];
if (g.homeworldTupileBlocked) return [];
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);
if (payable) return [{type:'dealPay',deal:payable[0]}];
}
if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup)
return tupileCleanupActions(g);
if (g.turnActionGuide && g.status === 'playing' &&
+162 -3
View File
@@ -5,7 +5,8 @@ import { createFactionDraft, validateFactionDraft, currentFactionDrafter, choose
import { createFactionSwap, validateFactionSwap, answerFactionSwap, cancelFactionSwap,
FactionSwapError, type FactionSwap } from './faction-swap';
import { createPublicDeal, createMultipartyPublicDeal, validatePublicDeals, respondPublicDeal, withdrawPublicDeal,
releasePublicDeal, PublicDealError, type PublicDeal, type PublicDealPayment } from './public-deals';
releasePublicDeal, markPublicDealPaid, voidPublicDealPayment, PublicDealError, type PublicDeal, type PublicDealPayment } from './public-deals';
import { pendingPublicDealPayments, publicDealPaymentTotal, PublicDealObligationError } 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,
@@ -5815,6 +5816,136 @@ function publicDealRule<T>(operation: () => T): T {
function publicDealIntegrity(g: Game): void {
if (g.deals !== undefined) publicDealRule(() => validatePublicDeals(g.deals, g.players));
}
function publicDealObligationRule<T>(operation: () => T): T {
try { return operation(); }
catch (error) { if (error instanceof PublicDealObligationError) throw new RuleError(error.message); throw error; }
}
/** A detached quote spends each deal separately, so two agreements cannot be
* satisfied by one transfer. It also lets a real deal payment satisfy an
* overlapping Truthtrance bribe while retaining other live obligations. */
function publicDealCompletion(g: Game, payer: Player): boolean {
const allDebts = publicDealObligationRule(() => pendingPublicDealPayments(g.deals ?? [],payer.id,g.turn));
// An allied recipient cannot currently receive a bribe. Keep that term
// pending, but do not let it mask other independently payable debts.
const debts = allDebts.filter(debt => debt.recipient !== payer.ally);
if (!debts.length) return true;
publicDealObligationRule(() => publicDealPaymentTotal(g.deals ?? [],payer.id,g.turn));
if (g.status !== 'playing') return false;
const source = structuredClone(g);
source.truthtrance = null; source.response = null; source.decision = null; source.phaseOpening = null;
source.pendingKarama = null; source.pendingTreacheryDiscard = null;
let visited = 0;
const visit = (candidate: Game, remaining: typeof debts): boolean => {
// Limit adversarially large ledgers. Exhaustion is not proof that an
// externally changed deal is impossible; no automatic void uses it.
if (++visited > 2048) return false;
try {
if (candidate.inflation?.side === 'double') return false;
if (remaining.length) {
// Each agreement is one exact transfer. Try real whole payments in
// different orders; a payer may become eligible for Charity between
// them, but a partial transfer never settles a separate deal.
for (const [index,debt] of remaining.entries()) {
const trial = structuredClone(candidate);
try {
quoteOrdinaryPayments(trial,payer.id,[{recipient:debt.recipient,amount:debt.amount}]);
if (visit(trial,remaining.filter((_,position) => position !== index))) return true;
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
const charity = forecastOrdinaryCharity(candidate,payer.id);
if (charity && visit(charity,remaining)) return true;
const income = forecastChoamOpeningIncome(candidate,payer.id);
if (income && visit(income,remaining)) return true;
// Existing bribes are already earned and arrive at this turn's
// Mentat opening; no other player's future payment is invented.
if (candidate.phase < 8 && getPlayer(candidate,payer.id).bribes > 0 &&
!bribesCloseBeforeNextMentat(candidate)) {
const opening = structuredClone(candidate), recipient = getPlayer(opening,payer.id);
opening.phase = 8; opening.ready = [];
recipient.spice += recipient.bribes; recipient.bribes = 0;
if (visit(opening,remaining)) return true;
}
return false;
}
const trial = candidate, seat = getPlayer(trial,payer.id);
if (hasBribePromises(trial,payer.id,true) && !bribePromiseCompletion(trial,seat)) return false;
if (liveTurnActionPromises(trial.turnActionPromises ?? [],payer.id,trial.turn).length &&
!turnActionCompletion(trial,payer.id)) return false;
const voluntary = {actor:payer.id,action:{type:'bribe'}};
reconcileBattlePromises(trial,voluntary);
reconcileShipmentPromises(trial,voluntary);
reconcileGroundMovementPromises(trial,payer.id,voluntary.action,g.phase);
reconcileBidPromises(trial,voluntary);
reconcilePhaseBids(trial,voluntary);
reconcileEarlyRicheseTruth(trial,voluntary);
reconcileRicheseBids(trial,voluntary);
reconcileRevivalPromises(trial,voluntary);
return true;
} catch (error) { if (error instanceof RuleError) return false; throw error; }
};
return visit(source,debts);
}
/** Hold accepted obligations while their current-turn continuation remains
* possible. Only settled, proven expiry or a base-game Mentat shortfall voids
* a term; temporary pending decisions do not establish impossibility. */
function reconcilePublicDealTruth(g: Game, voluntary?: {actor:string;action:Action}, before?: Game): void {
if (!g.deals?.some(deal => deal.payment?.kind === 'deferredBribe' && deal.paymentStatus === 'pending')) return;
for (const p of g.players) {
const pending: PublicDeal[] = (g.deals ?? []).filter(deal => deal.status === 'active' && deal.payment?.kind === 'deferredBribe' &&
deal.payment.payer === p.id && deal.paymentStatus === 'pending');
if (!pending.length) continue;
const beforePayer = before && getPlayer(before,p.id);
const wasPossible = !!before && !!beforePayer && voluntary?.actor === p.id &&
pendingPublicDealPayments(before.deals ?? [],p.id,before.turn).some(debt => debt.recipient !== beforePayer.ally) &&
publicDealCompletion(before,beforePayer);
const expiredByAction = pending.some(deal => deal.payment?.kind === 'deferredBribe' &&
(g.turn > deal.payment.dueTurn || g.status === 'finished'));
const closesPaymentWindow = wasPossible && voluntary?.action.type === 'ready' &&
(before!.phase === 8 || before!.phase === 7 && bribesCloseBeforeNextMentat(before!));
if (wasPossible && (closesPaymentWindow || expiredByAction || !publicDealCompletion(g,p)))
throw new RuleError('Keep a legal completion for your accepted public deal payments.');
const quiet = !g.truthtrance && !g.response && !g.decision && !g.phaseOpening &&
!g.pendingKarama && !g.pendingTreacheryDiscard && !g.bureaucratPayments?.pending;
const available = Math.max(0,uncommittedSpice(g,p));
for (const deal of pending) {
const payment = deal.payment!;
if (payment.kind !== 'deferredBribe') continue;
const expired = g.turn > payment.dueTurn || g.status === 'finished';
if (!expired && payment.recipient === p.ally) continue;
const baseMentatShortfall = quiet && g.turn === payment.dueTurn && g.phase === 8 &&
g.expansions.length === 0 && bribePromiseModeSupported(g) && available < payment.amount;
if (expired || baseMentatShortfall) {
g.deals = g.deals!.map(row => row.id === deal.id ? publicDealRule((): PublicDeal => voidPublicDealPayment(row,g.players)) : row);
log(g, `${p.name}'s agreed ${payment.amount}-spice payment can no longer be completed and is void.`);
}
}
}
}
/** Once Basic Mentat has opened, outstanding promises can exceed funds
* after an external loss. Let the payer choose which still-payable deal to
* settle; the other individually impossible terms are then voided. */
function publicDealInsolventAtBaseMentat(g: Game, player: string): boolean {
return g.phase === 8 && g.expansions.length === 0 && bribePromiseModeSupported(g) &&
publicDealObligationRule(() => publicDealPaymentTotal(g.deals ?? [],player,g.turn)) >
Math.max(0,uncommittedSpice(g,getPlayer(g,player)));
}
/** 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)) {
try {
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)});
result[debt.deal] = {blocked:null};
} catch (error) {
if (!(error instanceof RuleError)) throw error;
result[debt.deal] = {blocked:error.message};
}
}
return result;
}
function assignLobbyFaction(player: Player, chosen: FactionId): void {
player.faction = chosen;
if (player.bot) player.name = `${faction(chosen).name} AI`;
@@ -16091,6 +16222,7 @@ function bribeTruthAnswers(g: Game, p: Player, claim: BribeClaim): ('yes' | 'no'
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) &&
publicDealCompletion({ ...source, bribePromises: promises },p) &&
phaseBidCompatibleBribeAnswer({ ...source, bribePromises: promises }, p) &&
richeseBidCompatibleLaterPromises({ ...source, bribePromises: promises },p);
});
@@ -16103,6 +16235,7 @@ function bribeExpressionTruthAnswers(g: Game, p: Player, claim: BribeExpressionC
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) &&
publicDealCompletion({ ...source, bribeExpressionPromises: expressions },p) &&
phaseBidCompatibleBribeAnswer({ ...source, bribeExpressionPromises: expressions }, p) &&
richeseBidCompatibleLaterPromises({ ...source, bribeExpressionPromises: expressions },p);
});
@@ -40129,7 +40262,8 @@ export function applyAction(state: Game, id: string, action: Action): Game {
}
/** The initiator can differ from the payer only for a previously consented
* automatic deal payment; it is never supplied by a client action. */
function applyActionWithInitiator(state: Game, id: string, action: Action, initiator: string): Game {
function applyActionWithInitiator(state: Game, id: string, action: Action, initiator: string,
options?: {insolventDealPayment?: boolean}): Game {
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
factionSwapIntegrity(state);
@@ -40203,6 +40337,18 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
ambassadorRevivalIncomeIntegrity(state);
ecazCollectionIntegrity(state);
ecazAllianceIntegrity(state);
if (action?.type === 'dealPay') {
requireRule(state.status === 'playing' && Object.keys(action).sort().join(',') === 'deal,type' &&
typeof action.deal === 'string', 'Choose your pending deal payment.');
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.');
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)});
}
if (['dealOffer', 'dealRespond', 'dealWithdraw', 'dealRelease'].includes(action?.type)) {
requireRule(state.status === 'playing', 'Public deals require a game in progress.');
const player = getPlayer(state, id);
@@ -40246,7 +40392,15 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
next = publicDealRule(() => releasePublicDeal(current, g.players, id, action.agree as boolean));
} else next = publicDealRule(() => withdrawPublicDeal(current, g.players, id));
g.deals = deals.map((deal, position) => position === index ? next : deal);
if (current.status === 'offered' && next.status === 'active' && next.payment) {
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)) &&
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}.`);
}
if (current.status === 'offered' && next.status === 'active' && next.payment?.kind === 'immediateBribe') {
// 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 {
@@ -40444,6 +40598,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
reconcileCharityTruth(g, voluntary);
reconcileRevivalPromises(g, voluntary);
reconcileBribePromises(g, voluntary);
reconcilePublicDealTruth(g, voluntary, options?.insolventDealPayment ? undefined : state);
reconcileGroundMovementPromises(g, promiseActor, action, state.phase);
reconcileTraitorTruth(g, id, action);
harkonnenNexusBetrayalIntegrity(g);
@@ -40572,6 +40727,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
reconcileCharityTruth(g, { actor: id, action });
reconcileRevivalPromises(g, { actor: id, action });
reconcileBribePromises(g, { actor: id, action });
reconcilePublicDealTruth(g, voluntary, options?.insolventDealPayment ? undefined : state);
reconcileAlliancePromises(g, { actor: id, action });
settleAutomaticContinuations(g);
observeOccupation(g);
@@ -40807,6 +40963,7 @@ function settleAutomaticContinuations(g: Game) {
reconcileCharityTruth(g);
reconcileRevivalPromises(g);
reconcileBribePromises(g);
reconcilePublicDealTruth(g);
reconcileGroundMovementPromises(g);
reconcileAlliancePromises(g);
reconcileTraitorTruth(g);
@@ -40924,6 +41081,7 @@ export function normalizeAutomaticGame(state: Game): Game {
reconcileCharityTruth(g);
reconcileRevivalPromises(g);
reconcileBribePromises(g);
reconcilePublicDealTruth(g);
reconcileGroundMovementPromises(g);
reconcileAlliancePromises(g);
reconcileTraitorTruth(g);
@@ -45466,6 +45624,7 @@ export function viewGame(state: Game, id: string, now?: number) {
host: g.host,
status: g.status,
deals: g.deals ?? [],
deferredDealPaymentOptions: projectedDeferredDealPaymentOptions(g,id),
factionDraft: g.factionDraft ?? null,
factionSwap: g.factionSwap ?? null,
twoPlayerControllers: g.twoPlayerControllers ?? null,
+84
View File
@@ -0,0 +1,84 @@
import { validatePublicDeals, voidPublicDealPayment, type PublicDeal, type PublicDealSeat } from './public-deals';
/** A deal-linked obligation is independent of every other deal and of
* Truthtrance. It is due once for its exact gross payment, not a threshold
* shared with a different agreement. */
export type PendingPublicDealPayment = {
deal: string;
payer: string;
recipient: string;
dueTurn: number;
amount: number;
};
export class PublicDealObligationError extends Error {
constructor(message: string) {
super(message);
this.name = 'PublicDealObligationError';
}
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value === value.trim();
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(
deals: readonly PublicDeal[], payer: string, turn: number,
): PendingPublicDealPayment[] {
if (!Array.isArray(deals) || !identifier(payer) || !Number.isSafeInteger(turn) || turn < 1)
throw new PublicDealObligationError('Retain the payer and original turn of the deferred payments.');
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 [];
return [{ deal: deal.id, payer, recipient: payment.recipient, dueTurn: payment.dueTurn, amount: payment.amount }];
});
}
/** Separate deals add, including two deals for the same recipient. */
export function publicDealPaymentTotal(deals: readonly PublicDeal[], payer: string, turn: number): number {
let total = 0;
for (const payment of pendingPublicDealPayments(deals, payer, turn)) {
if (!Number.isSafeInteger(payment.amount) || payment.amount <= 0 ||
!Number.isSafeInteger(total + payment.amount))
throw new PublicDealObligationError('The outstanding deal payments exceed the safe spice range.');
total += payment.amount;
}
return total;
}
export type PublicDealPaymentWitness = {
/** The engine's currently available uncommitted funding, or null if a
* pending continuation means no reliable current quote is available. */
available: number | null;
change: 'voluntary' | 'external';
/** Each ID must be proven individually impossible by the engine. A mere
* cash shortage does not prove loss of future income or the other debts. */
impossibleDealIds?: readonly string[];
};
/** An actor may not voluntarily make their accepted, funded payments
* impossible. External uncertainty leaves every agreement intact; only
* independently proven impossible terms are voided. No funds move here. */
export function reconcilePublicDealPayments(
deals: readonly PublicDeal[], players: readonly PublicDealSeat[], payer: string, turn: number,
witness: PublicDealPaymentWitness,
): PublicDeal[] {
validatePublicDeals(deals, players);
if (!witness || (witness.change !== 'voluntary' && witness.change !== 'external') ||
(witness.available !== null && !nonnegative(witness.available)) ||
(witness.impossibleDealIds !== undefined && !Array.isArray(witness.impossibleDealIds)))
throw new PublicDealObligationError('Retain the actual funded witness and cause of the change.');
const pending = pendingPublicDealPayments(deals, payer, turn);
const ids = witness.impossibleDealIds ?? [];
if (new Set(ids).size !== ids.length || ids.some(id => !pending.some(row => row.deal === id)))
throw new PublicDealObligationError('Void only individually proven pending terms of this payer and turn.');
if (witness.change === 'voluntary' && (ids.length > 0 ||
witness.available !== null && witness.available < publicDealPaymentTotal(deals, payer, turn)))
throw new PublicDealObligationError('Keep the funding needed for your accepted public deal payments.');
if (witness.change !== 'external' || !ids.length) return [...deals];
return deals.map(deal => ids.includes(deal.id) ? voidPublicDealPayment(deal, players) : deal);
}
+66 -15
View File
@@ -1,11 +1,18 @@
/** Public agreements record written terms without parsing or enforcing them.
* Older two-seat records omit participants and acceptVotes entirely. */
/** Written terms remain player-managed. Typed payment terms use the native
* bribe rules; older two-seat records omit participants and acceptVotes. */
export type PublicDealPayment = {
kind: 'immediateBribe';
payer: string;
recipient: string;
amount: number;
} | {
kind: 'deferredBribe';
payer: string;
recipient: string;
amount: number;
dueTurn: number;
};
export type PublicDealPaymentStatus = 'pending' | 'paid' | 'void' | 'released';
export type PublicDeal = {
id: string;
from: string;
@@ -17,8 +24,10 @@ export type PublicDeal = {
/** Present together only on the multi-party record format. */
participants?: string[];
acceptVotes?: string[];
/** Exact gross bribe due when the last participant accepts. */
/** Exact gross bribe; immediate payment settles on final acceptance. */
payment?: PublicDealPayment;
/** Present only for accepted or released deferred payments. */
paymentStatus?: PublicDealPaymentStatus;
};
export type PublicDealSeat = { id: string; bot?: unknown; ally?: string | null };
@@ -73,13 +82,22 @@ function orderedSubset(votes: readonly string[], parties: readonly string[]): bo
}
/** Parse one public gross payment term; no funds move until native settlement. */
export function parsePublicDealPayment(value: unknown, parties: readonly string[]): PublicDealPayment {
requireDeal(object(value) && Object.keys(value).sort().join(',') === 'amount,kind,payer,recipient' &&
value.kind === 'immediateBribe' && id(value.payer) && id(value.recipient) && value.payer !== value.recipient &&
export function parsePublicDealPayment(value: unknown, parties: readonly string[], turn?: number): PublicDealPayment {
requireDeal(object(value) && id(value.payer) && id(value.recipient) && value.payer !== value.recipient &&
parties.includes(value.payer) && parties.includes(value.recipient) &&
typeof value.amount === 'number' && Number.isSafeInteger(value.amount) && value.amount > 0,
'Choose a positive whole immediate bribe between two deal participants.');
return { kind: 'immediateBribe', payer: value.payer, recipient: value.recipient, amount: value.amount };
'Choose a positive whole bribe between two deal participants.');
if (value.kind === 'immediateBribe') {
requireDeal(Object.keys(value).sort().join(',') === 'amount,kind,payer,recipient',
'Choose an exact immediate bribe term.');
return { kind: 'immediateBribe', payer: value.payer, recipient: value.recipient, amount: value.amount };
}
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.');
return { kind: 'deferredBribe', payer: value.payer, recipient: value.recipient,
amount: value.amount, dueTurn: turn as number };
}
/** Validate persisted ledger shape and lifecycle without applying current
@@ -89,7 +107,7 @@ export function validatePublicDeals(value: unknown, players: readonly PublicDeal
requireDeal(Array.isArray(value), 'The public deal ledger is malformed.');
const ids = new Set<string>();
for (const deal of value) {
const keys = object(deal) ? Object.keys(deal).filter(key => key !== 'payment').sort().join(',') : '';
const keys = object(deal) ? Object.keys(deal).filter(key => key !== 'payment' && key !== 'paymentStatus').sort().join(',') : '';
const multi = keys === 'acceptVotes,from,id,participants,releaseVotes,status,terms,to,turn';
requireDeal(object(deal) && (multi || keys === 'from,id,releaseVotes,status,terms,to,turn') &&
id(deal.id) && id(deal.from) && id(deal.to) && deal.from !== deal.to &&
@@ -118,7 +136,15 @@ export function validatePublicDeals(value: unknown, players: readonly PublicDeal
parties = [deal.from, deal.to];
participants(players, parties);
}
if (Object.prototype.hasOwnProperty.call(deal, 'payment')) parsePublicDealPayment(deal.payment, parties);
const payment = Object.prototype.hasOwnProperty.call(deal, 'payment')
? parsePublicDealPayment(deal.payment, parties, deal.turn as number) : undefined;
if (payment?.kind === 'deferredBribe') {
const status = deal.paymentStatus;
requireDeal(deal.status === 'active' ? status === 'pending' || status === 'paid' || status === 'void' :
deal.status === 'released' ? status === 'released' || status === 'paid' :
!Object.hasOwn(deal, 'paymentStatus'), 'The deferred payment status does not match the deal lifecycle.');
} else requireDeal(!Object.hasOwn(deal, 'paymentStatus'),
'Only an accepted deferred payment has a payment status.');
const votes = deal.releaseVotes as string[];
requireDeal(new Set(votes).size === votes.length && votes.every(vote => parties.includes(vote)) &&
(!multi || orderedSubset(votes, parties)) &&
@@ -143,7 +169,7 @@ export function createPublicDeal(
payment?: PublicDealPayment,
): PublicDeal {
const text = validOffer(players, actor, [target], terms, turn, event);
const paymentTerm = arguments.length >= 7 ? { payment: parsePublicDealPayment(payment, [actor, target]) } : {};
const paymentTerm = arguments.length >= 7 ? { payment: parsePublicDealPayment(payment, [actor, target], turn) } : {};
return { id: event, from: actor, to: target, terms: text, turn, status: 'offered', releaseVotes: [],
...paymentTerm };
}
@@ -155,7 +181,7 @@ export function createMultipartyPublicDeal(
requireDeal(Array.isArray(targets) && targets.length >= 2 && targets.length <= 5,
'Choose two through five other participants.');
const text = validOffer(players, actor, targets, terms, turn, event);
const paymentTerm = arguments.length >= 7 ? { payment: parsePublicDealPayment(payment, [actor, ...targets]) } : {};
const paymentTerm = arguments.length >= 7 ? { payment: parsePublicDealPayment(payment, [actor, ...targets], turn) } : {};
return { id: event, from: actor, to: targets[0], terms: text, turn, status: 'offered',
releaseVotes: [], participants: [actor, ...targets], acceptVotes: [actor],
...paymentTerm };
@@ -168,11 +194,14 @@ export function respondPublicDeal(deal: PublicDeal, players: readonly PublicDeal
'Only an invited recipient may accept or decline an outstanding deal.');
if (!accept) return { ...deal, status: 'declined', releaseVotes: [] };
notAllied(participants(players, parties));
if (!deal.participants) return { ...deal, status: 'active', releaseVotes: [] };
if (!deal.participants) return { ...deal, status: 'active', releaseVotes: [],
...(deal.payment?.kind === 'deferredBribe' ? { paymentStatus: 'pending' as const } : {}) };
const alreadyAccepted = publicDealAcceptVotes(deal);
requireDeal(!alreadyAccepted.includes(actor), 'This participant already accepted the offer.');
const accepts = parties.filter(seat => seat === actor || alreadyAccepted.includes(seat));
return { ...deal, acceptVotes: accepts, status: accepts.length === parties.length ? 'active' : 'offered', releaseVotes: [] };
const active = accepts.length === parties.length;
return { ...deal, acceptVotes: accepts, status: active ? 'active' : 'offered', releaseVotes: [],
...(active && deal.payment?.kind === 'deferredBribe' ? { paymentStatus: 'pending' as const } : {}) };
}
export function withdrawPublicDeal(deal: PublicDeal, players: readonly PublicDealSeat[], actor: string): PublicDeal {
@@ -191,5 +220,27 @@ export function releasePublicDeal(deal: PublicDeal, players: readonly PublicDeal
let votes = deal.releaseVotes.filter(vote => vote !== actor);
if (agree) votes.push(actor);
if (deal.participants) votes = parties.filter(seat => votes.includes(seat));
return { ...deal, status: votes.length === parties.length ? 'released' : 'active', releaseVotes: votes };
const released = votes.length === parties.length;
return { ...deal, status: released ? 'released' : 'active', releaseVotes: votes,
...(released && deal.payment?.kind === 'deferredBribe' && deal.paymentStatus !== 'paid'
? { paymentStatus: 'released' as const } : {}) };
}
/** The engine must first authorize the native payer and exact bribe action.
* Apply this transition on its detached action state, so native failure rolls
* both the payment and the status change back together. */
export function markPublicDealPaid(deal: PublicDeal, players: readonly PublicDealSeat[], payer: string): PublicDeal {
validatePublicDeals([deal], players);
requireDeal(deal.status === 'active' && deal.payment?.kind === 'deferredBribe' &&
deal.paymentStatus === 'pending' && deal.payment.payer === payer,
'Only the payer may settle a pending deferred deal payment.');
return { ...deal, paymentStatus: 'paid' };
}
/** The caller must establish actual external impossibility before voiding. */
export function voidPublicDealPayment(deal: PublicDeal, players: readonly PublicDealSeat[]): PublicDeal {
validatePublicDeals([deal], players);
requireDeal(deal.status === 'active' && deal.payment?.kind === 'deferredBribe' && deal.paymentStatus === 'pending',
'Only a pending deferred deal payment can become impossible.');
return { ...deal, paymentStatus: 'void' };
}
+4 -3
View File
@@ -301,16 +301,17 @@ 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.',
'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. An optional immediate payment executes through the native bribe action upon final acceptance; prose compliance remains player managed. Bribe escrow and Bureaucrat continuation remain native. Complete negotiation and all module combinations remain open.', evidence: ['game/public-deals.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 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: '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 native payments, pending decisions and authenticated visibility have focused coverage, including multiparty 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/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-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: 'Public deal consent, release, immediate and deferred native payments, authenticated visibility and legal bot continuation have focused coverage, including multiparty 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/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'] },
],
},
{
@@ -2118,7 +2119,7 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'Ixians begin with three cyborgs and three suboids inside. After the first storm moves, select its pointing sector in any non-stronghold territory, including the Polar Sink. Placement collects no spice.',
'Before later storm dials or card reveals, occupied Ixians may keep the current position or declare a route of up to three territories. Use the route selector to name each sector. The destination must be a non-stronghold territory. The route cannot begin, end or pass through the current storm sector. Occupants stay inside throughout relocation.',
'Choose the spice collected in each visited sector, including the starting sector. This preview provisionally permits any whole amount up to the available pile and two per Ixian force inside, separately in each sector. A public Karama response resolves before relocation or collection. The printed rule does not detail partial allocations or all passenger interactions.',
'Choose the spice collected in each visited sector, including the starting sector. This preview provisionally permits any whole amount up to the available pile and two per eligible force inside, separately in each sector. Foreign fighters count, a concealed No-Field counts as one, and advisors do not count. Older moves without selected amounts retain their Ixian-only automatic limit. A public Karama response resolves before relocation or collection. The printed rule does not detail partial allocations or all passenger interactions.',
'Forces inside are separate from forces outside. Enter and leave through the pointing sector, counting the stronghold as one more territory of movement. A storm in that pointing sector blocks ordinary entry or exit. Direct shipping inside is restricted to Ixians; advanced Bene Gesserit may accompany their shipment.',
'Worms and storms do not kill the occupants. Battles wait while the pointing sector is in storm; otherwise normal battles and the two-faction capacity apply inside. The sole controller counts it toward victory, even when that controller is not Ixian. Capturing it does not grant another faction permission to move it.',
'In advanced play, Ixians may spend special Karama once during their own Shipment and Movement turn to relocate up to two territories while their forces occupy it. This preserves their ordinary shipment and troop movement. Setup and bidding technology now have decision controls; expansion starts and remaining interaction audits are unfinished.',