Support immediate payments in public deals
This commit is contained in:
1 parent
e133a5fe2b
commit
6da6ff7f83
14 files changed
+545
-32
No files matched your search
@@ -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'],
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user