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

+3 -2
View File
@@ -30,8 +30,9 @@ accept or decline an offer, and agree to release it. Every party must accept,
and every pair of parties must be nonallied when the offer is made or accepted.
Terms are visible to the
table and rely on players to honor them; the game does not interpret promises
or determine impossibility. Use **Bribes** separately for an actual spice
payment. AI negotiation and automatic enforcement remain unfinished. See
or determine impossibility. An optional immediate spice payment is executed
using the normal bribe rules when the final participant accepts. AI negotiation
and enforcement of future promises remain unfinished. See
[public deals](docs/PUBLIC_DEALS.md).
For the provisional two-person setup, the host enables **Two players, two
+55 -6
View File
@@ -7,14 +7,19 @@ import type { Action, GameView } from '@/game/engine';
import { Button } from '@/components/ui/button';
type Deal = GameView['deals'][number];
type ImmediateBribe = { kind: 'immediateBribe'; payer: string; recipient: string; amount: number };
/** Keep the legacy single-recipient wire packet when only one seat is chosen. */
export function publicDealOfferAction(targets: readonly string[], terms: string): Action | null {
export function publicDealOfferAction(targets: readonly string[], terms: string, payment?: ImmediateBribe, proposer?: string): Action | null {
const text = terms.trim();
if (!targets.length || targets.length > 5 || new Set(targets).size !== targets.length || !text || text.length > 500) return null;
if (payment && (!proposer || payment.kind !== 'immediateBribe' || payment.payer === payment.recipient ||
![proposer, ...targets].includes(payment.payer) || ![proposer, ...targets].includes(payment.recipient) ||
!Number.isSafeInteger(payment.amount) || payment.amount < 1)) return null;
const offeredPayment = payment ? { payment } : {};
return targets.length === 1
? { type: 'dealOffer', target: targets[0], terms: text }
: { type: 'dealOffer', targets: [...targets], terms: text };
? { type: 'dealOffer', target: targets[0], terms: text, ...offeredPayment }
: { type: 'dealOffer', targets: [...targets], terms: text, ...offeredPayment };
}
/** The whole table can read an offer and its history; only its parties act. */
@@ -40,18 +45,23 @@ export function PublicDealList({ game, act, disabled }: {
const seats = participants.map(id => game.players.find(player => player.id === id));
const eligible = seats.every((player, index) => player && seats.slice(index + 1).every(other =>
other && player.ally !== other.id && other.ally !== player.id));
const payment = (deal as Deal & { payment?: ImmediateBribe }).payment;
const finalAcceptance = recipient && !accepted.includes(game.me) && accepted.length === participants.length - 1;
const paymentBlocked = payment && finalAcceptance ? game.bribeOptions?.blocked : null;
return <li key={deal.id}>
<p><strong>{name(deal.from)} → {participants.filter(id => id !== deal.from).map(name).join(', ')}</strong> · Turn {deal.turn} · {deal.status}</p>
<p>{deal.terms}</p>
{payment && <p><strong>Immediate payment term:</strong> {name(payment.payer)} pays {payment.amount} spice to {name(payment.recipient)} on final acceptance. The payer loses the gross amount immediately; the recipient collects their share at the next Mentat opening. A Bureaucrat may divert two spice to the bank before that share is allocated. Releasing the deal later does not refund the payment.</p>}
{deal.status === 'offered' && <p>
Accepted by {accepted.map(name).join(', ')}. Waiting for {participants.filter(id => !accepted.includes(id)).map(name).join(', ')}.
</p>}
{deal.status === 'offered' && !eligible && <p>Acceptance is unavailable while any participants are allied; the offer may still be declined or withdrawn.</p>}
{deal.status === 'offered' && paymentBlocked && <p>Final acceptance must wait: {paymentBlocked} The offer may still be declined or withdrawn.</p>}
{deal.status === 'active' && deal.releaseVotes.length > 0 && <p>
Release requested by {deal.releaseVotes.map(name).join(', ')}. All parties must agree to release the deal.
</p>}
{canAct && deal.status === 'offered' && recipient && <div className="flex flex-wrap gap-2">
{!accepted.includes(game.me) && <Button type="button" disabled={disabled || !eligible} onClick={() => act({ type: 'dealRespond', deal: deal.id, accept: true })}>Accept deal</Button>}
{!accepted.includes(game.me) && <Button type="button" disabled={disabled || !eligible || !!paymentBlocked} onClick={() => act({ type: 'dealRespond', deal: deal.id, accept: true })}>Accept deal</Button>}
<Button type="button" variant="outline" disabled={disabled} onClick={() => act({ type: 'dealRespond', deal: deal.id, accept: false })}>Decline deal</Button>
</div>}
{canAct && deal.status === 'offered' && deal.from === game.me && <Button type="button" variant="outline" disabled={disabled}
@@ -72,6 +82,10 @@ export function PublicDeals({ game, act, busy }: {
}) {
const [selected, setSelected] = useState<string[]>([]);
const [terms, setTerms] = useState('');
const [includePayment, setIncludePayment] = useState(false);
const [payer, setPayer] = useState('');
const [paymentRecipient, setPaymentRecipient] = useState('');
const [paymentAmount, setPaymentAmount] = useState('1');
const requests = game.status === 'playing' ? game.deals.flatMap(deal => {
const participant = publicDealParticipants(deal).includes(game.me);
if (deal.status === 'offered' && participant && deal.from !== game.me && !publicDealAcceptVotes(deal).includes(game.me))
@@ -93,13 +107,20 @@ export function PublicDeals({ game, act, busy }: {
const legalSelection = !!me && selected.length > 0 && selected.length === selectedPlayers.length &&
selectedPlayers.every((player, index) => !allied(me, player) &&
selectedPlayers.slice(index + 1).every(other => !allied(player, other)));
const offer = legalSelection ? publicDealOfferAction(selected, terms) : null;
const parties = me ? [me, ...selectedPlayers] : [];
const amount = Number(paymentAmount);
const payment: ImmediateBribe | undefined = includePayment
? { kind: 'immediateBribe', payer, recipient: paymentRecipient, amount } : undefined;
const paymentValid = !includePayment || !!me && payer !== paymentRecipient &&
parties.some(player => player.id === payer) && parties.some(player => player.id === paymentRecipient) &&
Number.isSafeInteger(amount) && amount > 0;
const offer = legalSelection && paymentValid ? publicDealOfferAction(selected, terms, payment, me?.id) : null;
const disabled = busy || !!game.roomControl?.paused || !!game.roomControl?.closed || !!me?.autopilot;
const mayOffer = game.status === 'playing' && me && !me.bot;
return <details ref={panel} className="notice public-deals-panel">
<summary>Public deals{needsResponse ? ' · your response requested' : game.deals?.some(deal => deal.status === 'offered' || deal.status === 'active') ? ' · proposals or active deals' : ''}</summary>
<p>Deals are public. Every recipient must accept an offer. Players must honor valid terms; an impossible part is void, and all parties can agree to release a deal. The game records the terms but cannot automatically enforce free-text promises or determine when they become impossible.</p>
<p>Do not trade cards, forces, leaders or faction powers, or make terms that contradict the rules or faction powers. A deal does not automatically transfer spice; use the Bribes control to make an eligible payment. <a href="/rules?topic=bribes#bribes">Bribe rules</a></p>
<p>Do not trade cards, forces, leaders or faction powers, or make terms that contradict the rules or faction powers. An optional immediate payment uses the ordinary bribe rules; free-text terms do not automatically transfer spice. <a href="/rules?topic=bribes#bribes">Bribe rules</a></p>
{mayOffer && <form onSubmit={event => {
event.preventDefault();
if (!disabled && offer) act(offer);
@@ -120,6 +141,34 @@ export function PublicDeals({ game, act, busy }: {
</label>;
})}
</fieldset>
<label className="checkbox-row">
<input type="checkbox" checked={includePayment} disabled={disabled}
onChange={event => setIncludePayment(event.target.checked)} />
Include an immediate spice payment
</label>
{includePayment && <fieldset>
<legend>Payment on final acceptance</legend>
<label>Paying participant
<select value={parties.some(player => player.id === payer) ? payer : ''} disabled={disabled}
onChange={event => setPayer(event.target.value)}>
<option value="">Choose a payer</option>
{parties.map(player => <option key={player.id} value={player.id}>{player.name} · {faction(player.faction).name}</option>)}
</select>
</label>
<label>Receiving participant
<select value={parties.some(player => player.id === paymentRecipient) ? paymentRecipient : ''} disabled={disabled}
onChange={event => setPaymentRecipient(event.target.value)}>
<option value="">Choose a recipient</option>
{parties.filter(player => player.id !== payer).map(player => <option key={player.id} value={player.id}>{player.name} · {faction(player.faction).name}</option>)}
</select>
</label>
<label>Gross spice amount
<input type="number" inputMode="numeric" min={1} step={1} value={paymentAmount} disabled={disabled}
onChange={event => setPaymentAmount(event.target.value)} />
</label>
<p className="fine">No spice is reserved before final acceptance. The payer loses the full amount immediately; the recipient collects their share at the next Mentat opening. A Bureaucrat may divert two spice to the bank before the share is allocated. Native payment timing and available funds are checked at final acceptance.</p>
{legalSelection && !paymentValid && <p>Choose two different participants and a positive whole spice amount.</p>}
</fieldset>}
<label>Public deal terms
<textarea rows={3} maxLength={500} value={terms} onChange={event => setTerms(event.target.value)} disabled={disabled}
placeholder="Describe the agreement for the table" />
+3 -2
View File
@@ -24,8 +24,9 @@ Chat messages and verbal promises do not themselves transfer spice. Emperor
gifts to allies remain a separate power.
The separate [public deal record](PUBLIC_DEALS.md) can capture a two-to-six-human
offer, unanimous acceptance and mutual release. Its written terms do not automatically
transfer spice or enforce future promises; use this native action to pay.
offer, unanimous acceptance and mutual release. Its optional immediate payment
uses this native action on final acceptance; written terms do not automatically
transfer spice or enforce future promises.
The server derives the owner's limits from existing uncommitted spice, excluding
sealed bids, paid support, Banker spending and other reserved costs. Existing
+14 -3
View File
@@ -16,6 +16,17 @@ open mode gates.
## Current checkpoint and work
**Immediate deal payments — 28 October. Prototyped / Partial.** A public deal can
include a gross spice payment between signatories. Final unanimous acceptance
invokes the native bribe action atomically, including its timing, funds, escrow
and Bureaucrat behavior; failure leaves the offer unaccepted. Earlier records
retain their shape. Nine related unit files pass 71 cases, including a native
Bureaucrat continuation; three isolated HTTP cases cover authenticated consent,
payment, replay and continued play. Future promises, arbitrary prose enforcement,
AI negotiation and deployed acceptance remain open. An isolated browser room
also exercised a two-spice offer, acceptance, payer debit and deferred receipt.
[Scope](PUBLIC_DEALS.md).
**Multiparty public deals — 28 October. Prototyped / Partial.** Two to six human
seats can record public terms; every recipient must accept, any may decline and
all parties must agree to release. Existing two-party records and actions retain
@@ -29,9 +40,9 @@ control is covered by a focused component test. [Scope](PUBLIC_DEALS.md).
**Public deal records — 28 October. Prototyped / Partial.** Two nonallied human
seats can record an offer, acceptance or decline, withdrawal and mutual release,
including while a native decision is pending. Public terms rely on players to
honor them; the record does not enforce prose, determine impossibility or pay
spice. Seven related unit files pass 43 cases; an isolated authenticated HTTP
including while a native decision is pending. Public prose relies on players to
honor it; the original text-only record does not enforce terms, determine
impossibility or pay spice. Seven related unit files pass 43 cases; an isolated authenticated HTTP
case covers public visibility, consent, release and resumed play. AI negotiation,
automatic enforcement and deployed acceptance remain open; the multiparty
follow-up above expands this original two-seat checkpoint.
+12
View File
@@ -1,5 +1,17 @@
# Dune implementation status
## 28 October 2026 — Immediate public-deal payments
Two to six human signatories can include an immediate gross spice bribe in a
public agreement. The final acceptance executes the already-consenting payer's
native bribe in the same action; failure preserves the outstanding offer. Its
usual timing, spending limits, deferred receipt and Bureaucrat continuation
apply. Nine related unit files pass 71 cases; three isolated authenticated HTTP
cases cover consent, payment and replay. Future obligations, arbitrary prose,
AI negotiation and deployed acceptance remain open. An isolated browser room
also confirmed a two-spice debit and deferred recipient receipt.
[Scope](PUBLIC_DEALS.md).
## 28 October 2026 — Multiparty public deals
Two to six human seats can agree to public terms. Every recipient must accept,
+19 -2
View File
@@ -22,10 +22,27 @@ released if participants subsequently become allies. Records remain active
until unanimously released, even after the players consider terms fulfilled.
Their text is public to the whole table.
The record itself moves no spice and creates no automatically enforced game
An optional structured immediate bribe names a payer, recipient and gross spice
amount, all visible before anyone consents. Both must be distinct signatories.
On the final acceptance the engine invokes the ordinary bribe action as the
already-consenting payer, atomically with that acceptance. The payer loses the
gross amount immediately; a Bureaucrat may divert two spice before the
recipient's remaining share reaches deferred Mentat supply. No funds are
reserved during an offer. If timing, available funds, another binding rule or
the payer's AI control makes payment unavailable, final acceptance fails without
recording that vote or debiting spice. A pending native Bureaucrat decision
continues normally; later release of the deal does not reverse a paid bribe.
Nine related unit files pass 71 cases, including a native Bureaucrat diversion
and unavailable-payment rollback. Three isolated HTTP cases include an earlier
consenting payer, final approval by another participant and replay rejection.
An isolated browser room also exercised a two-spice offer and acceptance,
showing the payer's debit and recipient's deferred balance. Its roster did not
include a Bureaucrat.
Free-text terms alone move no spice and create no automatically enforced game
action. Players remain responsible for honoring valid terms, and an impossible
part is void under the rules. Existing [bribe controls](BRIBE_CONTROLS.md)
make actual eligible payments into the recipient's deferred Mentat supply.
also make actual eligible payments into the recipient's deferred Mentat supply.
Automatic interpretation, enforcement and impossibility handling,
AI negotiation and complete multiplayer/deployed acceptance remain
open. The text interface is an online convention, not a publisher ruling.
+4 -3
View File
@@ -6,9 +6,10 @@ The publisher's 2019 rulebook permits public nonallied deals, requires their
legal terms to be honored, voids impossible parts and permits mutual release.
Written records allow two to six human seats to consent and mutually release;
pairwise nonalliance at offer and acceptance is a conservative provisional
interpretation, not an explicit publisher ruling on multiparty offers. Prose
and impossibility are player-managed, and spice still requires the native bribe
action. AI negotiation and automatic enforcement remain open. [Scope](PUBLIC_DEALS.md).
interpretation, not an explicit publisher ruling on multiparty offers. A typed
immediate spice payment executes the native bribe action only after all parties
consent. Prose and impossibility remain player-managed; AI negotiation and
enforcement of future promises remain open. [Scope](PUBLIC_DEALS.md).
## Truthtrance and Basic Ixian auction knowledge — 28 October 2026
+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'] },
],
},
{
+41 -1
View File
@@ -45,7 +45,8 @@ void test('deal form exposes human non-allied recipients and clearly describes l
assert.match(html, /Chani · Fremen \(allied with a selected party\)/);
assert.doesNotMatch(html, /Duncan · Spacing Guild/);
assert.match(html, /cannot automatically enforce free-text promises/);
assert.match(html, /does not automatically transfer spice/);
assert.match(html, /free-text terms do not automatically transfer spice/);
assert.match(html, /Include an immediate spice payment/);
assert.match(html, /Do not trade cards, forces, leaders or faction powers/);
assert.match(html, /maxlength="500"/i);
assert.match(html, /Alice \(Atreides\) → Basil \(Harkonnen\)/);
@@ -102,6 +103,45 @@ void test('the form keeps the legacy packet for one seat and sends all selected
assert.equal(publicDealOfferAction(['b'], 'x'.repeat(501)), null);
});
void test('an optional gross bribe is bound to two distinct participants', () => {
const payment = { kind: 'immediateBribe' as const, payer: 'a', recipient: 'b', amount: 3 };
assert.deepEqual(publicDealOfferAction(['b'], ' Terms ', payment, 'a'),
{ type: 'dealOffer', target: 'b', terms: 'Terms', payment });
const thirdPartyPayment = { ...payment, payer: 'b', recipient: 'e' };
assert.deepEqual(publicDealOfferAction(['b', 'e'], 'Terms', thirdPartyPayment, 'a'),
{ type: 'dealOffer', targets: ['b', 'e'], terms: 'Terms', payment: thirdPartyPayment });
for (const invalid of [
{ ...payment, payer: 'outsider' }, { ...payment, recipient: 'outsider' },
{ ...payment, payer: 'b' }, { ...payment, amount: 0 }, { ...payment, amount: 1.5 },
{ ...payment, amount: Number.MAX_SAFE_INTEGER + 1 },
]) assert.equal(publicDealOfferAction(['b'], 'Terms', invalid, 'a'), null);
assert.equal(publicDealOfferAction(['b'], 'Terms', payment), null);
});
void test('all players can review the gross payment and only final acceptance is held at a blocked payment window', () => {
const payment = { kind: 'immediateBribe' as const, payer: 'b', recipient: 'a', amount: 3 };
const multi = { ...offer, participants: ['a', 'b', 'e'], acceptVotes: ['a'], payment };
const game = { ...base, players: base.players.map(player =>
player.id === 'b' || player.id === 'e' ? { ...player, ally: null } : player),
bribeOptions: { blocked: 'Finish the current decision before paying a bribe.', available: 987, incoming: 0, targets: [] },
deals: [multi] } as unknown as GameView;
const html = render({ ...game, me: 'b' });
assert.match(html, /Immediate payment term/);
assert.match(html, /Basil \(Harkonnen\) pays 3 spice to Alice \(Atreides\)/);
assert.match(html, /payer loses the gross amount immediately/);
assert.match(html, /Bureaucrat may divert two spice/);
assert.doesNotMatch(html, /987/);
const first = actions({ ...game, me: 'b' }, []);
assert.equal(first.find(node => node.props.children === 'Accept deal')?.props.disabled, false);
const lastGame = { ...game, me: 'e', deals: [{ ...multi, acceptVotes: ['a', 'b'] }] } as unknown as GameView;
const last = actions(lastGame, []);
assert.equal(last.find(node => node.props.children === 'Accept deal')?.props.disabled, true);
assert.equal(last.find(node => node.props.children === 'Decline deal')?.props.disabled, false);
assert.match(render(lastGame), /Final acceptance must wait: Finish the current decision before paying a bribe/);
const unblocked = { ...lastGame, bribeOptions: { ...lastGame.bribeOptions, blocked: null } } as GameView;
assert.equal(actions(unblocked, []).find(node => node.props.children === 'Accept deal')?.props.disabled, false);
});
void test('multiparty offers expose all pending consent and let an accepted recipient decline', () => {
const multi = {
...offer, participants: ['a', 'b', 'e'], acceptVotes: ['a', 'b'],
+149
View File
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { bureaucratPaymentGame, bureaucratPlayer } from './bureaucrat-payment-fixture';
import { knowledgeGame } from './truthtrance-knowledge-fixture';
/** Normal Basic setup; no purse or phase is fabricated. */
function playing(): Game {
let game = createGame('PAYDEAL', newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('h', 'Harkonnen', 'harkonnen'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = applyAction(game, 'a', { type: 'start' });
for (const player of game.players) if (player.traitorChoices.length)
game = applyAction(game, player.id, { type: 'traitor', leader: player.traitorChoices[0] });
assert.equal(game.status, 'playing');
return game;
}
const payment = (payer = 'a', recipient = 'h', amount = 3) =>
({ kind: 'immediateBribe', payer, recipient, amount } as const);
const player = (game: Game, id: string) => game.players.find(seat => seat.id === id)!;
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'A rejected deal action must not change its source game.');
}
function propose(game: Game, actor: string, target: string, clause: unknown) {
const next = applyAction(game, actor, { type: 'dealOffer', target, terms: 'Information for spice.', payment: clause });
const deal = next.deals?.at(-1);
assert.ok(deal);
assert.equal(deal.status, 'offered');
return { game: next, deal };
}
void test('two-party consent pays the pledged gross bribe exactly once into native Mentat escrow', () => {
const initial = playing();
const beforePayer = player(initial, 'a').spice;
const beforeRecipient = player(initial, 'h').bribes;
const offered = propose(initial, 'a', 'h', payment());
assert.equal(player(offered.game, 'a').spice, beforePayer);
assert.equal(player(offered.game, 'h').bribes, beforeRecipient);
assert.deepEqual((offered.deal as unknown as { payment: unknown }).payment, payment());
assert.deepEqual(viewGame(offered.game, 'e').deals.at(-1), offered.deal);
const accepted = applyAction(offered.game, 'h', { type: 'dealRespond', deal: offered.deal.id, accept: true });
assert.equal(accepted.deals!.at(-1)!.status, 'active');
assert.equal(player(accepted, 'a').spice, beforePayer - 3);
assert.equal(player(accepted, 'h').bribes, beforeRecipient + 3);
assert.equal(player(accepted, 'h').spice, player(initial, 'h').spice);
assert.match(accepted.log.map(entry => entry.text).join('\n'), /paid a 3-spice bribe/i);
reject(accepted, 'h', { type: 'dealRespond', deal: offered.deal.id, accept: true });
assert.equal(player(accepted, 'a').spice, beforePayer - 3);
assert.equal(player(accepted, 'h').bribes, beforeRecipient + 3);
});
void test('a multiparty payment waits for every recipient and may have a different payer from the last voter', () => {
const initial = playing();
const beforePayer = player(initial, 'h').spice;
const beforeRecipient = player(initial, 'e').bribes;
let game = applyAction(initial, 'a', {
type: 'dealOffer', targets: ['h', 'e'], terms: 'Pledge when everyone agrees.', payment: payment('h', 'e', 2),
});
const deal = game.deals!.at(-1)!;
game = applyAction(game, 'h', { type: 'dealRespond', deal: deal.id, accept: true });
assert.equal(game.deals!.at(-1)!.status, 'offered');
assert.equal(player(game, 'h').spice, beforePayer);
assert.equal(player(game, 'e').bribes, beforeRecipient);
game = applyAction(game, 'e', { type: 'dealRespond', deal: deal.id, accept: true });
assert.equal(game.deals!.at(-1)!.status, 'active');
assert.equal(player(game, 'h').spice, beforePayer - 2);
assert.equal(player(game, 'e').bribes, beforeRecipient + 2);
reject(game, 'e', { type: 'dealRespond', deal: deal.id, accept: true });
});
void test('declining or withdrawing a payment offer does not pay', () => {
const initial = playing();
const baseline = initial.players.map(seat => ({ id: seat.id, spice: seat.spice, bribes: seat.bribes }));
let offered = propose(initial, 'a', 'h', payment());
let game = applyAction(offered.game, 'h', { type: 'dealRespond', deal: offered.deal.id, accept: false });
assert.equal(game.deals!.at(-1)!.status, 'declined');
assert.deepEqual(game.players.map(seat => ({ id: seat.id, spice: seat.spice, bribes: seat.bribes })), baseline);
offered = propose(game, 'a', 'h', payment());
game = applyAction(offered.game, 'a', { type: 'dealWithdraw', deal: offered.deal.id });
assert.equal(game.deals!.at(-1)!.status, 'withdrawn');
assert.deepEqual(game.players.map(seat => ({ id: seat.id, spice: seat.spice, bribes: seat.bribes })), baseline);
reject(game, 'h', { type: 'dealRespond', deal: offered.deal.id, accept: true });
});
void test('forged, invalid and nonparticipant payments are rejected without a deal or debit', () => {
const game = playing();
for (const clause of [
{ ...payment(), payer: 'e' }, { ...payment(), recipient: 'e' }, { ...payment(), recipient: 'a' },
{ ...payment(), payer: 'missing' }, { ...payment(), amount: 0 }, { ...payment(), amount: -1 },
{ ...payment(), amount: 1.5 }, { ...payment(), amount: '3' }, { ...payment(), amount: Number.MAX_SAFE_INTEGER + 1 },
{ ...payment(), kind: 'spiceGift' }, { ...payment(), extra: true }, null,
]) reject(game, 'a', { type: 'dealOffer', target: 'h', terms: 'Terms.', payment: clause });
reject(game, 'a', { type: 'dealOffer', targets: ['h', 'e'], terms: 'Terms.', payment: payment('h', 'h', 2) });
});
void test('a once-affordable payment that becomes unaffordable leaves the final consent and purse unchanged', () => {
const initial = playing();
const offered = propose(initial, 'a', 'h', payment('a', 'h', 6));
const spent = applyAction(offered.game, 'a', { type: 'bribe', target: 'e', amount: 8 });
assert.equal(player(spent, 'a').spice, 2);
reject(spent, 'h', { type: 'dealRespond', deal: offered.deal.id, accept: true });
assert.equal(spent.deals!.at(-1)!.status, 'offered');
assert.equal(player(spent, 'h').bribes, 0);
assert.throws(() => applyAction(spent, 'h', { type: 'dealRespond', deal: offered.deal.id, accept: true }),
/The agreed payment could not be completed/);
});
void test('an earlier consenting payer must retake control before another seat can settle the payment', () => {
const offered = propose(playing(), 'a', 'h', payment());
const delegated = applyAction(offered.game, 'a', { type: 'setAutopilot', difficulty: 'Easy' });
reject(delegated, 'h', { type: 'dealRespond', deal: offered.deal.id, accept: true });
assert.equal(delegated.deals!.at(-1)!.status, 'offered');
const resumed = applyAction(delegated, 'a', { type: 'setAutopilot', difficulty: null });
const done = applyAction(resumed, 'h', { type: 'dealRespond', deal: offered.deal.id, accept: true });
assert.equal(done.deals!.at(-1)!.status, 'active');
assert.equal(player(done, 'a').spice, player(resumed, 'a').spice - 3);
});
void test('a payment offered before a real Truthtrance priority cannot settle through the blocked timing window', () => {
let game = knowledgeGame();
const offered = propose(game, 'a', 'b', payment('a', 'b', 1));
const card = player(offered.game, 'a').hand.find(item => item.effect === 'truthtrance');
assert.ok(card);
game = applyAction(offered.game, 'a', { type: 'card', card: card.id });
assert.equal(game.truthtrance?.stage, 'priority');
reject(game, 'b', { type: 'dealRespond', deal: offered.deal.id, accept: true });
assert.equal(game.deals!.at(-1)!.status, 'offered');
assert.equal(game.truthtrance?.stage, 'priority');
});
void test('native Bureaucrat diversion suspends the already-paid deal and settles the original bribe once', () => {
const initial = bureaucratPaymentGame();
const offered = propose(initial, 'p', 'e', payment('p', 'e', 5));
let game = applyAction(offered.game, 'e', { type: 'dealRespond', deal: offered.deal.id, accept: true });
assert.equal(game.deals!.at(-1)!.status, 'active');
assert.equal(bureaucratPlayer(game, 'p').spice, 25);
assert.equal(bureaucratPlayer(game, 'e').bribes, 0);
assert.equal(game.decision?.kind, 'bureaucratPayment');
game = applyAction(game, 'b', { type: 'decision', event: game.bureaucratPaymentEvent, redirect: true });
assert.equal(bureaucratPlayer(game, 'p').spice, 25);
assert.equal(bureaucratPlayer(game, 'e').bribes, 3);
assert.equal(viewGame(game, 'b').bureaucrat.usedThisPhase, true);
reject(game, 'e', { type: 'dealRespond', deal: offered.deal.id, accept: true });
});
+129
View File
@@ -0,0 +1,129 @@
// @dune-suite integration
import assert from 'node:assert/strict';
import test from 'node:test';
import type { GameView } from '../game/engine';
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
async function request(path: string, cookie?: string, body?: unknown, selected?: string) {
const response = await fetch(base + path, {
method: body === undefined ? 'GET' : 'POST',
signal: AbortSignal.timeout(15_000),
headers: {
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(cookie ? { cookie } : {}),
...(selected ? { 'X-Dune-Seat': selected } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return {
status: response.status,
cookie: response.headers.get('set-cookie')?.split(';')[0],
data: await response.json() as GameView & { error?: string },
};
}
void test('three authenticated human seats settle a public deal payment only on final consent', async () => {
const host = await request('/api/rooms', undefined, {
name: 'Deal payment recipient', faction: 'atreides', advanced: false, expansions: [],
});
assert.equal(host.status, 201, JSON.stringify(host.data));
assert.ok(host.cookie);
const path = `/api/rooms/${host.data.code}`;
const payer = await request(path, undefined, { type: 'join', name: 'Deal payer', faction: 'harkonnen' });
assert.equal(payer.status, 200, JSON.stringify(payer.data));
assert.ok(payer.cookie);
const finalVoter = await request(path, undefined, { type: 'join', name: 'Deal final voter', faction: 'emperor' });
assert.equal(finalVoter.status, 200, JSON.stringify(finalVoter.data));
assert.ok(finalVoter.cookie);
const seats = [
{ id: host.data.me, cookie: host.cookie },
{ id: payer.data.me, cookie: payer.cookie },
{ id: finalVoter.data.me, cookie: finalVoter.cookie },
];
let view = finalVoter.data;
const act = async (cookie: string, action: object) => {
const next = await request(path, cookie, { version: view.version, action });
assert.equal(next.status, 200, JSON.stringify(next.data));
view = next.data;
return next.data;
};
for (const seat of seats) await act(seat.cookie, { type: 'ready' });
await act(host.cookie, { type: 'start' });
for (const seat of seats) {
const own = await request(path, seat.cookie);
assert.equal(own.status, 200, JSON.stringify(own.data));
view = own.data;
const choices = own.data.players.find(player => player.id === seat.id)?.traitorChoices;
if (choices?.length) await act(seat.cookie, { type: 'traitor', leader: choices[0] });
}
assert.equal(view.status, 'playing');
assert.equal(view.phase, 0);
const payerBefore = await request(path, payer.cookie);
const recipientBefore = await request(path, host.cookie);
assert.equal(payerBefore.status, 200);
assert.equal(recipientBefore.status, 200);
const payerSpice = payerBefore.data.players.find(player => player.id === payer.data.me)!.spice;
const recipientSpice = recipientBefore.data.players.find(player => player.id === host.data.me)!.spice;
const recipientIncoming = recipientBefore.data.bribeOptions.incoming;
assert.equal(typeof payerSpice, 'number');
assert.ok(payerSpice! >= 3);
const payment = { kind: 'immediateBribe', payer: payer.data.me, recipient: host.data.me, amount: 3 };
await act(host.cookie, {
type: 'dealOffer', targets: [payer.data.me, finalVoter.data.me],
terms: 'Information for spice.', payment,
});
const deal = view.deals.at(-1);
assert.ok(deal);
assert.equal(deal.status, 'offered');
assert.deepEqual((deal as unknown as { payment?: unknown }).payment, payment);
for (const seat of seats) {
const read = await request(path, seat.cookie);
assert.equal(read.status, 200, JSON.stringify(read.data));
assert.deepEqual(read.data.deals.find(item => item.id === deal.id), deal);
}
assert.equal((await request(path, payer.cookie)).data.players.find(player => player.id === payer.data.me)!.spice, payerSpice);
assert.equal((await request(path, host.cookie)).data.bribeOptions.incoming, recipientIncoming);
const payerAccept = { type: 'dealRespond', deal: deal.id, accept: true };
const forged = await request(path, host.cookie, { version: view.version, action: payerAccept }, payer.data.me);
assert.equal(forged.status, 409, JSON.stringify(forged.data));
const beforeFirstVote = view.version;
await act(payer.cookie, payerAccept);
assert.equal(view.deals.find(item => item.id === deal.id)?.status, 'offered');
assert.equal((await request(path, payer.cookie)).data.players.find(player => player.id === payer.data.me)!.spice, payerSpice);
assert.equal((await request(path, host.cookie)).data.bribeOptions.incoming, recipientIncoming);
const finalAccept = { type: 'dealRespond', deal: deal.id, accept: true };
const stale = await request(path, finalVoter.cookie, { version: beforeFirstVote, action: finalAccept });
assert.equal(stale.status, 409, JSON.stringify(stale.data));
assert.equal((await request(path, payer.cookie)).data.players.find(player => player.id === payer.data.me)!.spice, payerSpice);
const preFinalVersion = view.version;
await act(finalVoter.cookie, finalAccept);
assert.equal(view.deals.find(item => item.id === deal.id)?.status, 'active');
assert.match(view.log.map(item => item.text).join('\n'), /paid a 3-spice bribe/i);
const afterPayer = await request(path, payer.cookie);
const afterRecipient = await request(path, host.cookie);
assert.equal(afterPayer.status, 200);
assert.equal(afterRecipient.status, 200);
assert.equal(afterPayer.data.players.find(player => player.id === payer.data.me)!.spice, payerSpice! - 3);
assert.equal(afterRecipient.data.players.find(player => player.id === host.data.me)!.spice, recipientSpice);
assert.equal(afterRecipient.data.bribeOptions.incoming, recipientIncoming + 3);
for (const seat of seats) {
const read = await request(path, seat.cookie);
assert.equal(read.status, 200, JSON.stringify(read.data));
assert.equal(read.data.deals.find(item => item.id === deal.id)?.status, 'active');
assert.deepEqual((read.data.deals.find(item => item.id === deal.id) as unknown as { payment?: unknown }).payment, payment);
}
const staleReplay = await request(path, finalVoter.cookie, { version: preFinalVersion, action: finalAccept });
assert.equal(staleReplay.status, 409, JSON.stringify(staleReplay.data));
const replay = await request(path, finalVoter.cookie, { version: view.version, action: finalAccept });
assert.equal(replay.status, 409, JSON.stringify(replay.data));
const finalPayer = await request(path, payer.cookie);
const finalRecipient = await request(path, host.cookie);
assert.equal(finalPayer.data.players.find(player => player.id === payer.data.me)!.spice, payerSpice! - 3);
assert.equal(finalRecipient.data.bribeOptions.incoming, recipientIncoming + 3);
assert.equal(finalPayer.data.version, view.version);
});
+55 -1
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
PublicDealError, createPublicDeal, createMultipartyPublicDeal, publicDealAcceptVotes,
publicDealParticipants, releasePublicDeal, respondPublicDeal,
publicDealParticipants, parsePublicDealPayment, releasePublicDeal, respondPublicDeal,
validatePublicDeals, withdrawPublicDeal,
} from '../game/public-deals';
@@ -45,6 +45,60 @@ void test('legacy two-party JSON keeps its exact representation and implicit vot
{ ...released, id: 'legacy-released' }])), players);
});
void test('an immediate gross bribe is an explicit, immutable term between signatories', () => {
const payment = { kind: 'immediateBribe' as const, payer: 'b', recipient: 'a', amount: 5 };
const pending = createPublicDeal(players, 'a', 'b', 'Terms', 2, 'payment', payment);
const before = structuredClone(pending);
assert.deepEqual(pending.payment, payment);
payment.amount = 9;
assert.deepEqual(pending, before, 'the signed record does not alias the caller’s term');
const active = respondPublicDeal(pending, players, 'b', true);
assert.deepEqual(active.payment, before.payment);
const firstRelease = releasePublicDeal(active, players, 'a', true);
const released = releasePublicDeal(firstRelease, players, 'b', true);
assert.deepEqual(released.payment, before.payment);
validatePublicDeals(JSON.parse(JSON.stringify([pending, { ...active, id: 'active-payment' },
{ ...released, id: 'released-payment' }])), players);
assert.equal('payment' in offer(), false, 'legacy records keep their exact shape');
});
void test('multiparty payment terms remain bound through partial consent, decline and release', () => {
const payment = { kind: 'immediateBribe' as const, payer: 'c', recipient: 'b', amount: 3 };
const pending = createMultipartyPublicDeal(players, 'a', ['b', 'c'], 'Terms', 3, 'multi-payment', payment);
const partial = respondPublicDeal(pending, players, 'b', true);
assert.deepEqual(partial.payment, payment);
const declined = respondPublicDeal(partial, players, 'c', false);
assert.deepEqual(declined.payment, payment);
const withdrawn = withdrawPublicDeal(partial, players, 'a');
assert.deepEqual(withdrawn.payment, payment);
let active = respondPublicDeal(partial, players, 'c', true);
for (const actor of ['a', 'b', 'c']) active = releasePublicDeal(active, players, actor, true);
assert.equal(active.status, 'released');
assert.deepEqual(active.payment, payment);
validatePublicDeals([pending, { ...declined, id: 'declined-payment' },
{ ...withdrawn, id: 'withdrawn-payment' }, { ...active, id: 'active-payment' }], players);
});
void test('payment terms reject malformed amounts, fields and nonsignatory parties', () => {
const valid = { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 5 };
for (const term of [
null, undefined, {}, [], { ...valid, kind: 'later' }, { ...valid, amount: 0 },
{ ...valid, amount: -1 }, { ...valid, amount: 1.5 }, { ...valid, amount: Number.MAX_SAFE_INTEGER + 1 },
{ ...valid, amount: '5' }, { ...valid, payer: 'b' }, { ...valid, payer: 'c' },
{ ...valid, recipient: 'bot' }, { ...valid, payer: ' a' }, { ...valid, extra: true },
]) {
assert.throws(() => parsePublicDealPayment(term, ['a', 'b']), PublicDealError);
assert.throws(() => validatePublicDeals([{ ...offer(), payment: term }], players), PublicDealError);
}
assert.deepEqual(parsePublicDealPayment(valid, ['a', 'b']), valid);
assert.throws(() => createPublicDeal(players, 'a', 'b', 'Terms', 1, 'bad', undefined), PublicDealError);
assert.throws(() => createMultipartyPublicDeal(players, 'a', ['b', 'c'], 'Terms', 1, 'bad', undefined), PublicDealError);
assert.throws(() => createPublicDeal(players, 'a', 'b', 'Terms', 1, 'bad',
{ kind: 'immediateBribe', payer: 'a', recipient: 'c', amount: 2 }), PublicDealError);
assert.throws(() => createMultipartyPublicDeal(players, 'a', ['b', 'c'], 'Terms', 1, 'bad',
{ kind: 'immediateBribe', payer: 'a', recipient: 'bot', amount: 2 }), PublicDealError);
});
void test('multiparty deals activate only after every invited human accepts and release unanimously', () => {
const seats = [...players, { id: 'd', ally: null }, { id: 'e', ally: null }];
const initial = createMultipartyPublicDeal(seats, 'a', ['b', 'c', 'd'], ' No raids this turn. ', 3, 'multi-1');