Files
Dune/game/public-deals.ts
T
Quintine 6c1530382d
Verify and publish checkpoint container / container (push) Successful in 10m49s
Support immediate payments in group travel deals
2026-10-29 17:41:58 +11:00

327 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { parseTravelActionClaim, TravelPromiseError } from './travel-expression-promises';
/** 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';
/** One invited recipient commits to one native action (or its absence).
* Old two-party records identify that recipient through `to`. */
export type PublicDealTravel = {
kind: 'ship' | 'move';
territory: string;
minimum: number;
answer: boolean;
/** Present only in a multi-party term; the selected recipient accepts last. */
actor?: string;
/** Absent on existing terms: their original offer turn is the due turn. */
dueTurn?: number;
};
export type PublicDeal = {
id: string;
from: string;
to: string;
terms: string;
turn: number;
status: 'offered' | 'active' | 'declined' | 'withdrawn' | 'released';
releaseVotes: string[];
/** Present together only on the multi-party record format. */
participants?: string[];
acceptVotes?: string[];
/** Exact gross bribe; immediate payment settles on final acceptance. */
payment?: PublicDealPayment;
/** Present only for accepted or released deferred payments. */
paymentStatus?: PublicDealPaymentStatus;
travel?: PublicDealTravel;
};
export type PublicDealSeat = { id: string; bot?: unknown; ally?: string | null; allies?: readonly string[] };
export class PublicDealError extends Error {
constructor(message: string) {
super(message);
this.name = 'PublicDealError';
}
}
const id = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value === value.trim();
const object = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
function requireDeal(condition: unknown, message: string): asserts condition {
if (!condition) throw new PublicDealError(message);
}
function roster(players: readonly PublicDealSeat[]): void {
requireDeal(Array.isArray(players) && players.length >= 1 && players.length <= 6 &&
players.every(player => object(player) && id(player.id)) &&
new Set(players.map(player => player.id)).size === players.length,
'A deal requires distinct seated players.');
}
function participants(players: readonly PublicDealSeat[], ids: readonly string[]): PublicDealSeat[] {
const seats = ids.map(seat => players.find(player => player.id === seat));
requireDeal(ids.length >= 2 && ids.length <= 6 && ids.every(id) &&
new Set(ids).size === ids.length && seats.every(seat => seat && !seat.bot),
'A deal requires distinct human seats.');
return seats as PublicDealSeat[];
}
function notAllied(seats: readonly PublicDealSeat[]): void {
requireDeal(seats.every((seat, index) => seats.slice(index + 1).every(other =>
seat.ally !== other.id && other.ally !== seat.id &&
!seat.allies?.includes(other.id) && !other.allies?.includes(seat.id))),
'A deal is available only between seats that are not allied with each other.');
}
/** These accessors do not rewrite legacy two-party saves. Call after validation. */
export function publicDealParticipants(deal: PublicDeal): string[] {
return deal.participants ? [...deal.participants] : [deal.from, deal.to];
}
export function publicDealAcceptVotes(deal: PublicDeal): string[] {
if (deal.acceptVotes) return [...deal.acceptVotes];
return deal.status === 'active' || deal.status === 'released'
? [deal.from, deal.to] : [deal.from];
}
/** Call after ledger validation; legacy two-party terms need no new field. */
export function publicDealTravelActor(deal: PublicDeal): string {
return deal.travel?.actor ?? deal.to;
}
/** Call after ledger validation; older travel records are due on their offer turn. */
export function publicDealTravelDueTurn(deal: PublicDeal): number {
return deal.travel?.dueTurn ?? deal.turn;
}
function orderedSubset(votes: readonly string[], parties: readonly string[]): boolean {
return new Set(votes).size === votes.length &&
votes.every(vote => parties.includes(vote)) &&
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[], 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 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 &&
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: value.dueTurn };
}
/** Parse an exact travel claim; the multi-party actor is validated against the deal. */
export function parsePublicDealTravel(value: unknown): PublicDealTravel {
const keys = object(value) ? Object.keys(value).sort().join(',') : '';
requireDeal(object(value) && (keys === 'answer,kind,minimum,territory' ||
keys === 'answer,dueTurn,kind,minimum,territory' ||
keys === 'actor,answer,kind,minimum,territory' ||
keys === 'actor,answer,dueTurn,kind,minimum,territory') &&
(!Object.hasOwn(value, 'actor') || id(value.actor)) &&
(!Object.hasOwn(value, 'dueTurn') || typeof value.dueTurn === 'number' &&
Number.isSafeInteger(value.dueTurn) && value.dueTurn > 0) &&
typeof value.answer === 'boolean', 'Choose an exact shipment or movement term and require or avoid it.');
try {
const claim = parseTravelActionClaim({ kind: value.kind, territory: value.territory, minimum: value.minimum });
return { kind: claim.kind, territory: claim.territory, minimum: claim.minimum, answer: value.answer,
...(Object.hasOwn(value, 'actor') ? {actor: value.actor as string} : {}),
...(Object.hasOwn(value, 'dueTurn') ? {dueTurn: value.dueTurn as number} : {}) };
} catch (error) {
if (error instanceof TravelPromiseError) throw new PublicDealError(error.message);
throw error;
}
}
/** 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[] {
roster(players);
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' && key !== 'paymentStatus' && key !== 'travel').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 &&
typeof deal.terms === 'string' && deal.terms.length > 0 && deal.terms.length <= 500 && deal.terms === deal.terms.trim() &&
Number.isSafeInteger(deal.turn) && (deal.turn as number) > 0 &&
typeof deal.status === 'string' && ['offered', 'active', 'declined', 'withdrawn', 'released'].includes(deal.status) &&
Array.isArray(deal.releaseVotes) && deal.releaseVotes.every(id),
'A public deal is malformed.');
requireDeal(!ids.has(deal.id), 'Public deal IDs must be unique.');
ids.add(deal.id);
let parties: string[];
if (multi) {
requireDeal(Array.isArray(deal.participants) && deal.participants.length >= 3 &&
deal.participants.length <= 6 && deal.participants.every(id) &&
deal.participants[0] === deal.from && deal.participants[1] === deal.to &&
Array.isArray(deal.acceptVotes) && deal.acceptVotes.every(id), 'The public deal participants are malformed.');
parties = deal.participants;
participants(players, parties);
const accepts = deal.acceptVotes as string[];
requireDeal(accepts[0] === deal.from && orderedSubset(accepts, parties) &&
(deal.status === 'active' || deal.status === 'released'
? accepts.length === parties.length : accepts.length < parties.length),
'The public deal has invalid acceptance votes.');
} else {
parties = [deal.from, deal.to];
participants(players, parties);
}
const payment = Object.prototype.hasOwnProperty.call(deal, 'payment')
? parsePublicDealPayment(deal.payment, parties, deal.turn as number) : undefined;
if (Object.hasOwn(deal, 'travel')) {
const travel = parsePublicDealTravel(deal.travel);
requireDeal(travel.dueTurn === undefined || travel.dueTurn >= (deal.turn as number),
'Choose a travel turn no earlier than the offer turn.');
requireDeal(!Object.hasOwn(deal, 'paymentStatus') && (multi
? !!travel.actor && travel.actor !== deal.from && parties.includes(travel.actor) && (!payment || payment.kind === 'immediateBribe')
: !Object.hasOwn(travel, 'actor') && (!payment || payment.kind === 'immediateBribe')),
'Choose an invited traveler and an optional immediate bribe between participants.');
// The actor's final acceptance is checked privately. An unfinished offer
// cannot already contain that vote, even after a decline or withdrawal.
if (multi && deal.status !== 'active' && deal.status !== 'released')
requireDeal(!(deal.acceptVotes as string[]).includes(travel.actor!),
'The committed traveler accepts after all other invited participants.');
}
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)) &&
(deal.status === 'active' ? votes.length < parties.length :
deal.status === 'released' ? votes.length === parties.length : votes.length === 0),
'The public deal has invalid release votes.');
}
}
function validOffer(players: readonly PublicDealSeat[], actor: string, targets: readonly string[], terms: string, turn: number, event: string): string {
roster(players);
requireDeal(id(actor) && id(event) && Number.isSafeInteger(turn) && turn > 0,
'Choose a current public deal and seated participants.');
requireDeal(typeof terms === 'string' && terms.trim().length > 0 && terms.trim().length <= 500,
'Write deal terms between 1 and 500 characters.');
notAllied(participants(players, [actor, ...targets]));
return terms.trim();
}
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);
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 };
}
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], turn) } : {};
return { id: event, from: actor, to: targets[0], terms: text, turn, status: 'offered',
releaseVotes: [], participants: [actor, ...targets], acceptVotes: [actor],
...paymentTerm };
}
/** Attach a travel term while constructing a fresh offer. */
export function setPublicDealTravel(deal: PublicDeal, value: unknown, players: readonly PublicDealSeat[]): PublicDeal {
validatePublicDeals([deal], players);
requireDeal(deal.status === 'offered' && !Object.hasOwn(deal, 'travel') &&
!Object.hasOwn(deal, 'paymentStatus') && (deal.participants
? !!deal.acceptVotes && deal.acceptVotes.length === 1 && deal.acceptVotes[0] === deal.from &&
(!deal.payment || deal.payment.kind === 'immediateBribe')
: !Object.hasOwn(deal, 'acceptVotes') && (!deal.payment ||
deal.payment.kind === 'immediateBribe')),
'Only a fresh offer may add a travel term with a supported payment.');
notAllied(participants(players, publicDealParticipants(deal)));
const result = { ...deal, travel: parsePublicDealTravel(value) };
validatePublicDeals([result], players);
return result;
}
export function respondPublicDeal(deal: PublicDeal, players: readonly PublicDealSeat[], actor: string, accept: boolean): PublicDeal {
validatePublicDeals([deal], players);
const parties = publicDealParticipants(deal);
requireDeal(deal.status === 'offered' && actor !== deal.from && parties.includes(actor) && typeof accept === 'boolean',
'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: [],
...(deal.payment?.kind === 'deferredBribe' ? { paymentStatus: 'pending' as const } : {}) };
const alreadyAccepted = publicDealAcceptVotes(deal);
requireDeal(!alreadyAccepted.includes(actor), 'This participant already accepted the offer.');
if (deal.travel && actor === publicDealTravelActor(deal))
requireDeal(parties.every(seat => seat === actor || alreadyAccepted.includes(seat)),
'The committed traveler accepts after all other invited participants.');
const accepts = parties.filter(seat => seat === actor || alreadyAccepted.includes(seat));
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 {
validatePublicDeals([deal], players);
requireDeal(deal.status === 'offered' && actor === deal.from,
'Only the proposer may withdraw an outstanding deal.');
return { ...deal, status: 'withdrawn', releaseVotes: [] };
}
/** All participants must agree to release; either may withdraw a pending vote. */
export function releasePublicDeal(deal: PublicDeal, players: readonly PublicDealSeat[], actor: string, agree: boolean): PublicDeal {
validatePublicDeals([deal], players);
const parties = publicDealParticipants(deal);
requireDeal(deal.status === 'active' && parties.includes(actor) && typeof agree === 'boolean',
'Only a participant may change a release vote on an active deal.');
let votes = deal.releaseVotes.filter(vote => vote !== actor);
if (agree) votes.push(actor);
if (deal.participants) votes = parties.filter(seat => votes.includes(seat));
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' };
}