Support immediate payments in public deals

This commit is contained in:
Quintine committed 2026-10-28 00:54:20 +11:00
1 parent e133a5fe2b
commit 6da6ff7f83
14 files changed
+545 -32

No files matched your search

+29 -6
View File
@@ -5,7 +5,7 @@ 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 } from './public-deals';
releasePublicDeal, PublicDealError, type PublicDeal, type PublicDealPayment } from './public-deals';
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
import { createVictoryContinuation, validateVictoryContinuation, voteVictoryContinuation,
@@ -40093,13 +40093,22 @@ export function applyAction(state: Game, id: string, action: Action): Game {
const deals = g.deals ?? [];
if (action.type === 'dealOffer') {
const keys = Object.keys(action).sort().join(',');
const single = keys === 'target,terms,type' && typeof action.target === 'string';
const multiple = keys === 'targets,terms,type' && Array.isArray(action.targets) &&
const single = (keys === 'target,terms,type' || keys === 'payment,target,terms,type') && typeof action.target === 'string';
const multiple = (keys === 'targets,terms,type' || keys === 'payment,targets,terms,type') && Array.isArray(action.targets) &&
action.targets.every(target => typeof target === 'string');
requireRule((single || multiple) && typeof action.terms === 'string', 'Choose recipients and write the public terms.');
const hasPayment = Object.hasOwn(action, 'payment');
requireRule((single || multiple) && typeof action.terms === 'string' &&
(!hasPayment || !!action.payment && typeof action.payment === 'object' && !Array.isArray(action.payment)),
'Choose recipients and write the public terms.');
const payment = hasPayment ? action.payment as PublicDealPayment : undefined;
const event = crypto.randomUUID();
const offer = publicDealRule(() => single
? createPublicDeal(g.players, id, action.target as string, action.terms as string, g.turn, crypto.randomUUID())
: createMultipartyPublicDeal(g.players, id, action.targets as string[], action.terms as string, g.turn, crypto.randomUUID()));
? hasPayment
? createPublicDeal(g.players, id, action.target as string, action.terms as string, g.turn, event, payment!)
: createPublicDeal(g.players, id, action.target as string, action.terms as string, g.turn, event)
: hasPayment
? createMultipartyPublicDeal(g.players, id, action.targets as string[], action.terms as string, g.turn, event, payment!)
: createMultipartyPublicDeal(g.players, id, action.targets as string[], action.terms as string, g.turn, event));
requireRule(!deals.some(deal => deal.id === offer.id), 'This deal identifier is already in use.');
g.deals = [...deals, offer];
} else {
@@ -40119,6 +40128,20 @@ export function applyAction(state: Game, id: string, action: Action): Game {
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) {
// 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(!getPlayer(g, next.payment.payer).autopilot, 'The payer is not controlling this seat.');
return applyAction(g, next.payment.payer, {
type: 'bribe', target: next.payment.recipient, amount: next.payment.amount,
});
} catch (error) {
if (error instanceof RuleError)
throw new RuleError('The agreed payment could not be completed. Check the bribe timing and ask the payer to review it.');
throw error;
}
}
}
publicDealIntegrity(g);
return g;
+28 -3
View File
@@ -1,5 +1,11 @@
/** Public agreements record written terms without parsing or enforcing them.
* Older two-seat records omit participants and acceptVotes entirely. */
export type PublicDealPayment = {
kind: 'immediateBribe';
payer: string;
recipient: string;
amount: number;
};
export type PublicDeal = {
id: string;
from: string;
@@ -11,6 +17,8 @@ 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. */
payment?: PublicDealPayment;
};
export type PublicDealSeat = { id: string; bot?: unknown; ally?: string | null };
@@ -64,6 +72,16 @@ function orderedSubset(votes: readonly string[], parties: readonly string[]): bo
votes.every((vote, index) => index === 0 || parties.indexOf(votes[index - 1]) < parties.indexOf(vote));
}
/** 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 &&
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 };
}
/** Validate persisted ledger shape and lifecycle without applying current
* alliance restrictions retroactively to existing agreements. */
export function validatePublicDeals(value: unknown, players: readonly PublicDealSeat[]): asserts value is PublicDeal[] {
@@ -71,7 +89,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).sort().join(',') : '';
const keys = object(deal) ? Object.keys(deal).filter(key => key !== 'payment').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 &&
@@ -100,6 +118,7 @@ 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 votes = deal.releaseVotes as string[];
requireDeal(new Set(votes).size === votes.length && votes.every(vote => parties.includes(vote)) &&
(!multi || orderedSubset(votes, parties)) &&
@@ -121,19 +140,25 @@ function validOffer(players: readonly PublicDealSeat[], actor: string, targets:
export function createPublicDeal(
players: readonly PublicDealSeat[], actor: string, target: string, terms: string, turn: number, event: string,
payment?: PublicDealPayment,
): PublicDeal {
const text = validOffer(players, actor, [target], terms, turn, event);
return { id: event, from: actor, to: target, terms: text, turn, status: 'offered', releaseVotes: [] };
const paymentTerm = arguments.length >= 7 ? { payment: parsePublicDealPayment(payment, [actor, target]) } : {};
return { id: event, from: actor, to: target, terms: text, turn, status: 'offered', releaseVotes: [],
...paymentTerm };
}
export function createMultipartyPublicDeal(
players: readonly PublicDealSeat[], actor: string, targets: readonly string[], terms: string, turn: number, event: string,
payment?: PublicDealPayment,
): PublicDeal {
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]) } : {};
return { id: event, from: actor, to: targets[0], terms: text, turn, status: 'offered',
releaseVotes: [], participants: [actor, ...targets], acceptVotes: [actor] };
releaseVotes: [], participants: [actor, ...targets], acceptVotes: [actor],
...paymentTerm };
}
export function respondPublicDeal(deal: PublicDeal, players: readonly PublicDealSeat[], actor: string, accept: boolean): PublicDeal {
+4 -3
View File
@@ -300,16 +300,17 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'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.',
'Players must honor legal deal terms; an impossible part is void. The game does not interpret or enforce free-text promises or determine impossibility, and an agreement does not itself pay spice. Use Bribes for an actual payment. Deals must not transfer cards, leaders, forces or faction advantages or contradict the rules.',
'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.',
'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, while prose compliance remains player managed. Bribe payment and escrow include quiet Mentat payments deferred to next turn’s opening, with private server-derived limits and supported Bureaucrat continuation. 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. 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: '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, 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-controls.test.tsx', 'tests/public-deals-http.test.ts', 'tests/public-deals-multiparty-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 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'] },
],
},
{