Support immediate payments in group travel deals
Verify and publish checkpoint container / container (push) Successful in 10m49s

This commit is contained in:
Quintine committed 2026-10-29 17:41:58 +11:00
1 parent 3987c5c16b
commit 6c1530382d
10 files changed
+207 -24

No files matched your search

+5 -5
View File
@@ -69,7 +69,7 @@ export function publicDealOfferAction(targets: readonly string[], terms: string,
const travelKeys = travel ? Object.keys(travel).filter(key => key !== 'dueTurn').sort().join(',') : '';
const travelDeadline = travel && Object.hasOwn(travel, 'dueTurn') ? travel.dueTurn : undefined;
if (travel && (!proposer || targets.includes(proposer) ||
(groupTravel ? !!payment || travelKeys !== 'actor,answer,kind,minimum,territory' ||
(groupTravel ? !!payment && payment.kind !== 'immediateBribe' || travelKeys !== 'actor,answer,kind,minimum,territory' ||
typeof travel.actor !== 'string' || !targets.includes(travel.actor) :
travelKeys !== 'answer,kind,minimum,territory' ||
!!payment && payment.kind !== 'immediateBribe') ||
@@ -121,7 +121,7 @@ export function PublicDealList({ game, act, disabled }: {
const finalAcceptance = recipient && !accepted.includes(game.me) && accepted.length === participants.length - 1;
const travelActorWaiting = !!travel && participants.length > 2 && game.me === travelActor &&
participants.some(participant => participant !== travelActor && !accepted.includes(participant));
const paymentBlocked = payment?.kind === 'immediateBribe' && finalAcceptance ? game.bribeOptions?.blocked : null;
const paymentBlocked = payment?.kind === 'immediateBribe' && finalAcceptance && payment.payer === game.me ? game.bribeOptions?.blocked : null;
const paymentExpired = payment?.kind === 'deferredBribe' && game.turn > payment.dueTurn;
const deferredOption = (game as DeferredPaymentProjection).deferredDealPaymentOptions?.[deal.id];
const mayPay = payment?.kind === 'deferredBribe' && deal.status === 'active' && paymentStatus === 'pending' &&
@@ -227,7 +227,7 @@ export function PublicDeals({ game, act, busy }: {
...(selected.length > 1 ? {actor:travelActor} : {}),
...(travelTurn > game.turn ? {dueTurn:travelTurn} : {})} : undefined;
const combinedPaymentValid = !includeTravel || !payment || payment.kind === 'immediateBribe' &&
selected.length === 1 && paymentPeopleAndAmountValid;
paymentPeopleAndAmountValid;
const offer = legalSelection && paymentValid && (!includeTravel || travelDueValid)
? publicDealOfferAction(selected, terms, payment, me?.id, game.turn, travel) : null;
const disabled = busy || !!game.roomControl?.paused || !!game.roomControl?.closed || !!me?.autopilot;
@@ -308,8 +308,8 @@ export function PublicDeals({ game, act, busy }: {
{includeTravel && <fieldset>
<legend>Travel term</legend>
<p className="fine">This term concerns one physical action during the selected turn. The sole recipient commits to travel in a two-party deal. In a group deal, choose one recipient to commit; they accept last after all other recipients. Written terms remain player-managed.</p>
<p className="fine">A two-party travel term may include an immediate payment in either direction. The payment recipient collects their share at the next Mentat opening and can use it after collection. Group travel terms cannot include a payment.</p>
{!combinedPaymentValid && <p role="alert">For two-party travel, choose an immediate payment between the two participants. Group travel cannot include payment.</p>}
<p className="fine">A travel term may include an immediate payment between two participants. The payment recipient collects their share at the next Mentat opening and can use it after collection. All invited parties accept before the traveler gives final consent.</p>
{!combinedPaymentValid && <p role="alert">For travel, choose an immediate payment between two participants.</p>}
{selected.length > 1 && <label>Committed traveler
<select value={selected.includes(travelActor) ? travelActor : ''} disabled={disabled}
onChange={event => setTravelActor(event.target.value)}>
+9
View File
@@ -17,6 +17,15 @@ open mode gates.
## Current checkpoint and work
**Group paid travel — 29 October. Prototyped / Partial.** A Basic group
travel offer can include one immediate bribe between participants. Every
party consents before the named traveler accepts last; payment and the travel
commitment settle atomically. Native tests cover the traveler and an earlier
accepting payer, no premature payment and rollback if funds are insufficient.
Seven related files pass 70 cases. Deferred or multiple payments, broader
interactions and deployed verification remain open.
[Contract](PUBLIC_DEALS.md#group-travel-with-an-immediate-payment--29-october-2026).
**Reverse paid travel — 29 October. Prototyped / Partial.** Two-party Basic
travel offers may pair the travel with an immediate bribe from the traveler
to the proposer. Final consent and payment are atomic, and current-turn travel
+27 -6
View File
@@ -1,5 +1,25 @@
# Public deal records
## Group travel with an immediate payment — 29 October 2026
**Prototyped / Partial.** A Basic classic group offer of three to six human
participants can combine its one named travel term with one immediate bribe
between two distinct participants. The proposer implicitly accepts the
immutable offer, each other invitee accepts explicitly, and the named traveler
accepts last. No payment is made on an earlier vote. The final acceptance
settles the native bribe and binds the travel together; if payment or the
current-turn travel completion fails, that acceptance and its payment roll
back. The payer may be the proposer, traveler or another participant who has
already accepted. The recipient collects their share under the native deferred
bribe rule, and mutual release does not refund a settled payment.
Native-action tests cover traveler and previously accepting payers, the absence
of early payment, and rejected final acceptance with unchanged funds. Seventy
focused cases pass across the related files. The actor-last consent order and
structured term are provisional online compositions, not publisher limits on
agreements. Deferred payments paired with travel, multiple payments, optional
modules, broader combinations and deployed verification remain open.
## Reverse immediate payment with travel — 29 October 2026
**Prototyped / Partial.** A two-party Basic classic offer can pair a travel term with an immediate bribe from the traveler to the
@@ -10,7 +30,8 @@ travel completion leaves the offer and balances unchanged. The payment uses
the native bribe and its deferred recipient collection; it does not invent an
additional travel resource or foreign consent. Current- and later-turn travel
retain their respective feasibility policies. Deferred payments paired with
travel and multi-party paid travel remain outside this prototype. Native action tests cover payment, completed travel and rollback when the
travel remain outside this prototype. The later group extension above supports
an immediate payment between two participants. Native action tests cover payment, completed travel and rollback when the
payment would make a current-turn shipment unaffordable. Broader interactions
and deployed verification remain open.
@@ -30,13 +51,13 @@ consent and the server privately checks their own ability to comply. Any invitee
can decline, and release still requires every party. This actor-last online
ordering is provisional and does not restrict the printed scope of agreements.
A two-party offer can also pair that term with an immediate ordinary bribe
between the proposer and traveler in either direction. Final acceptance binds the travel and
A two-party or group offer can also pair that term with one immediate ordinary
bribe between two participants. Final acceptance binds the travel and
settles the payment together; if either cannot proceed, neither takes effect.
The payer loses the spice immediately, and the recipient's share becomes
spendable at the next Mentat opening, so it cannot fund this turn's travel.
Later mutual release does not refund the settled bribe. Deferred payments in
the same offer and multi-party paid travel remain open.
the same offer and multiple-payment travel remain open.
Each receipt retains its public-deal identity, independent of Truthtrance
history and cards. Unanimous release frees only that agreement; completion,
@@ -48,7 +69,7 @@ be newly combined in either order. Existing simple or expression Truthtrance
bribe commitments block new travel consent; while travel is pending, a simple,
expression or mixed bribe question offers only I don't know until the two
planners can evaluate those obligations together. Optional modules, Advanced,
multi-party payments paired with travel, arbitrary prose and other action families remain
multiple payments paired with travel, arbitrary prose and other action families remain
unfinished. An unaccepted offer from an earlier turn cannot be accepted.
The publisher's [base rulebook, p. 12](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
@@ -64,7 +85,7 @@ turn. All parties may accept until that turn; a group traveler still accepts
last. Final consent records a scheduled native receipt without reserving
today's forces or spice or predicting future income. On the due turn the term
becomes pending and the existing physical shipment/movement controls and owner
guide apply. An immediate two-party bribe still pays at final consent and can
guide apply. An immediate bribe between participants still pays at final consent and can
be collected and spent before later travel. All parties may release a scheduled
term; a game ending before the due turn voids it.
+1 -1
View File
@@ -42974,7 +42974,7 @@ function applyActionWithInitiator(state: Game, id: string, action: Action, initi
const single = (keys === 'target,terms,type' || keys === 'payment,target,terms,type' ||
keys === 'target,terms,travel,type' || keys === 'payment,target,terms,travel,type') && typeof action.target === 'string';
const multiple = (keys === 'targets,terms,type' || keys === 'payment,targets,terms,type' ||
keys === 'targets,terms,travel,type') && Array.isArray(action.targets) &&
keys === 'targets,terms,travel,type' || keys === 'payment,targets,terms,travel,type') && Array.isArray(action.targets) &&
action.targets.every(target => typeof target === 'string');
const hasPayment = Object.hasOwn(action, 'payment');
requireRule((single || multiple) && typeof action.terms === 'string' &&
+4 -3
View File
@@ -190,9 +190,9 @@ export function validatePublicDeals(value: unknown, players: readonly PublicDeal
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
? !!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; payment is available only as a two-party immediate bribe.');
'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')
@@ -253,7 +253,8 @@ export function setPublicDealTravel(deal: PublicDeal, value: unknown, players: r
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.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.');
+1 -1
View File
@@ -304,7 +304,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A deal may include one immediate spice payment between two participants. The last acceptance invokes the normal bribe action: the payer loses the gross amount immediately, Bureaucrat may divert two, and the recipient collects the remaining share at the next Mentat opening. Nothing is reserved before acceptance, and an unavailable payment leaves the offer open.',
'A deal may instead specify one exact payment due this turn or a later turn. After everyone accepts, the payer can use that deal’s Pay button early or by the deadline. Future payments do not reserve current funds; obligations due now must still be respected. Separate deals require separate payments. A compatible Truthtrance bribe answer can be satisfied by the same payment. Mutual release cancels an unpaid term; a paid amount is not refunded.',
'In a supported Basic classic table, two to six people can agree that one invited recipient will make or avoid one reserve shipment or ground move of at least a selected number of physical forces to a named territory in the current or a later turn. In a group offer the other recipients accept first and the named traveler accepts last. A current-turn term is checked on acceptance; a later-turn term is scheduled without reserving present forces or spice. The actual action fulfills a required term; a forbidden action cannot be taken. Compatible travel terms can share one action. A bounded due-turn search protects witnessed completions; when the travel turn closes without a witness, the game provisionally voids that term. Unsupported combinations remain in development.',
'Two-party travel can include an immediate payment in either direction. Final consent and payment are atomic, and current-turn travel must remain feasible after the payer spends. Deferred and multi-party paid travel remain unfinished.',
'Two-party or group travel can include one immediate payment between two participants. All parties accept, including the payer, and the traveler accepts last; only then does the payment settle. Final consent and payment are atomic, and current-turn travel must remain feasible after the payer spends. Deferred and multiple-payment travel remain unfinished.',
'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.',
],
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { actTruthMovement as act, truthMovementClean as clean, truthMovementInventory as inventory,
truthMovementPlayer as player } from './fixture-truth-movement';
import { nextStrongholdFactionsNativeStep as next } from './fixture-stronghold-factions';
const proposer = 'atreides', traveler = 'emperor', first = 'harkonnen', outsider = 'fremen';
const shipment: Action = { type:'ship', territory:'polar_sink', sector:0, amount:1, elite:0, allyPayment:0 };
const travel = { actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true } as const;
function reach(game: Game, until: (game: Game) => boolean): Game {
for (let i = 0; i < 1200; i++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = next(game); assert.ok(step);
game = act(game, step.actor, step.action);
}
throw Error('The native shipment opportunity was not reached.');
}
function nativeSource(): Game {
let game = createGame('GROUPPAIDTRAVEL', newPlayer(proposer, 'Atreides', 'atreides'), false, []);
for (const [id, faction] of [[traveler, 'emperor'], [first, 'harkonnen'], [outsider, 'fremen']] as const)
joinGame(game, newPlayer(id, id, faction));
for (const seat of game.players) game = act(game, seat.id, { type:'ready' });
game = act(game, proposer, { type:'start' });
const cards = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 4);
assert.equal(cards.length, 4);
for (const [position, card] of cards.entries()) {
const from = game.deck.findIndex(row => row.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
}
game = reach(game, state => state.status === 'playing');
const land = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(land >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(land, 1)[0]);
game = reach(game, state => state.turn === 1 && state.phase === 5 && state.active === traveler && clean(state));
assert.equal(player(game, traveler).shipped, false);
assert.ok(player(game, traveler).spice >= 2);
return game;
}
let source: Game | undefined;
const fixture = () => structuredClone(source ??= nativeSource());
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before);
}
function offer(game: Game, payer: string, recipient: string, amount: number) {
const result = act(game, proposer, { type:'dealOffer', targets:[first, traveler], terms:'Travel and payment.',
payment:{ kind:'immediateBribe', payer, recipient, amount }, travel });
const deal = result.deals?.at(-1); assert.ok(deal);
return { game:result, id:deal.id };
}
const accept = (game: Game, actor: string, id: string) => act(game, actor, { type:'dealRespond', deal:id, accept:true });
for (const payer of [traveler, first]) void test(`group paid travel pays ${payer} only after the traveler gives final consent`, () => {
const initial = fixture(), cash = player(initial, payer).spice, escrow = player(initial, proposer).bribes;
assert.ok(cash >= 1);
const cards = inventory(initial), offered = offer(initial, payer, proposer, 1);
let game = offered.game;
reject(game, traveler, { type:'dealRespond', deal:offered.id, accept:true });
assert.equal(player(game, payer).spice, cash);
game = accept(game, first, offered.id);
assert.equal(game.deals!.find(deal => deal.id === offered.id)!.status, 'offered');
assert.equal(player(game, payer).spice, cash);
assert.equal(player(game, proposer).bribes, escrow);
assert.equal(game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
game = accept(game, traveler, offered.id);
assert.equal(game.deals!.find(deal => deal.id === offered.id)!.status, 'active');
assert.equal(player(game, payer).spice, cash - 1);
assert.equal(player(game, proposer).bribes, escrow + 1);
assert.equal(viewGame(game, traveler).dealTravelStatus[offered.id], 'pending');
reject(game, traveler, { type:'dealRespond', deal:offered.id, accept:true });
game = reach(act(game, traveler, shipment), state => clean(state) && !state.pendingShipment);
assert.equal(viewGame(game, traveler).dealTravelStatus[offered.id], 'fulfilled');
assert.deepEqual(inventory(game), cards);
});
void test('traveler payment that consumes shipment funds rolls back only final consent', () => {
const initial = fixture(), cash = player(initial, traveler).spice, escrow = player(initial, proposer).bribes;
const offered = offer(initial, traveler, proposer, cash);
const waiting = accept(offered.game, first, offered.id);
reject(waiting, traveler, { type:'dealRespond', deal:offered.id, accept:true });
assert.equal(waiting.deals!.find(deal => deal.id === offered.id)!.status, 'offered');
assert.equal(player(waiting, traveler).spice, cash);
assert.equal(player(waiting, proposer).bribes, escrow);
assert.equal(waiting.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
});
void test('a prior consenting payer with insufficient funds cannot make the traveler partly activate the group deal', () => {
const initial = fixture(), cash = player(initial, first).spice; assert.ok(cash >= 1);
const offered = offer(initial, first, proposer, cash);
let game = accept(offered.game, first, offered.id);
game = act(game, first, { type:'bribe', target:proposer, amount:1 });
const before = structuredClone(game);
reject(game, traveler, { type:'dealRespond', deal:offered.id, accept:true });
assert.deepEqual(game, before);
assert.equal(game.deals!.find(deal => deal.id === offered.id)!.status, 'offered');
assert.equal(game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
});
@@ -171,7 +171,7 @@ void test('the traveler may decline early; malformed or uninvited actors and pay
for (const bad of [proposer, second, 'unknown'])
reject(initial, proposer, {type:'dealOffer', targets:[first, traveler], terms:'Terms', travel:{...term, actor:bad}});
reject(initial, proposer, {type:'dealOffer', targets:[first, traveler], terms:'Terms', travel:term,
payment:{kind:'immediateBribe', payer:proposer, recipient:traveler, amount:1}});
payment:{kind:'deferredBribe', payer:proposer, recipient:traveler, amount:1, dueTurn:initial.turn}});
const offered = offer(initial, [first, traveler], term);
const declined = respond(offered.game, traveler, offered.id, false);
assert.equal(declined.deals!.at(-1)!.status, 'declined');
+19 -2
View File
@@ -50,7 +50,11 @@ void test('the offer packet retains legacy two-party terms and validates an expl
assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel, actor:'a'}), null);
assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel, actor:'d'}), null);
assert.equal(publicDealOfferAction(['b'], 'Terms', undefined, 'a', 2, {...travel, actor:'b'}), null);
assert.equal(publicDealOfferAction(['b','c'], 'Terms', payment, 'a', 2, {...travel, actor:'c'}), null);
assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms', payment, 'a', 2, {...travel, actor:'c'}),
{type:'dealOffer', targets:['b','c'], terms:'Terms', payment, travel:{...travel, actor:'c'}});
assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms', {...payment,payer:'b',recipient:'c'}, 'a', 2, {...travel, actor:'c'}),
{type:'dealOffer', targets:['b','c'], terms:'Terms', payment:{...payment,payer:'b',recipient:'c'}, travel:{...travel, actor:'c'}});
assert.equal(publicDealOfferAction(['b','c'], 'Terms', {kind:'deferredBribe',payer:'a',recipient:'b',amount:2,dueTurn:2}, 'a', 2, {...travel, actor:'c'}), null);
assert.deepEqual(publicDealOfferAction(['b'], 'Terms', payment, 'a', 2, travel),
{type:'dealOffer', target:'b', terms:'Terms', payment, travel});
const reverse = {...payment,payer:'b',recipient:'a'};
@@ -205,6 +209,11 @@ void test('group offers name the traveler, require their last vote and still per
assert.ok(accept); assert.equal(accept.props.disabled,false);
(accept.props.onClick as () => void)();
assert.deepEqual(finalActions.at(-1),{type:'dealRespond',deal:'deal',accept:true});
const paid = {...final, bribeOptions:{blocked:'Your own funds are committed'},
deals:[{...group,acceptVotes:['a','b','d'],payment:{kind:'immediateBribe',payer:'b',recipient:'d',amount:2}}]} as unknown as GameView;
assert.equal(elements(PublicDealList({game:paid,disabled:false,act() {}}))
.find(node => node.props.children === 'Accept deal')?.props.disabled,false,
'The traveler’s own bribe quote cannot reject another participant’s payment.');
const active = {...game, deals:[{...group,status:'active',acceptVotes:['a','b','c','d']}],
dealTravelStatus:{deal:'pending'}} as unknown as GameView;
assert.deepEqual(publicDealRequests(active), [['travel','deal']]);
@@ -230,7 +239,15 @@ void test('the group offer form selects an invited traveler and revalidates part
ui.check(2); // Add Dara after removing the chosen traveler.
assert.equal(button()?.props.disabled,true);
ui.select(0,'d'); assert.equal(button()?.props.disabled,false);
ui.check(3); assert.equal(button()?.props.disabled,true, 'Group travel cannot be combined with payment.');
ui.check(3); assert.equal(button()?.props.disabled,true, 'The payer and recipient must be selected.');
ui.select(1,'b'); ui.select(2,'d');
assert.equal(button()?.props.disabled,false);
const paidForm = ui.render().find(node => node.type === 'form'); assert.ok(paidForm);
(paidForm.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}});
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[1])), {type:'dealOffer',targets:['b','d'],terms:'Group travel',
payment:{kind:'immediateBribe',payer:'b',recipient:'d',amount:1},
travel:{kind:'ship',territory:'carthag',minimum:1,answer:true,actor:'d'}});
ui.select(0,'deferredBribe'); assert.equal(button()?.props.disabled,true, 'Deferred group payment remains unavailable.');
});
void test('the author chooses a future travel turn and can return to the legacy current-turn packet', () => {
+41 -5
View File
@@ -73,7 +73,10 @@ void test('future two-party and group terms retain due turns across consent and
}
const backwardsGroup = {...group, travel:{...term, actor:'c', dueTurn:2}};
assert.throws(() => validatePublicDeals([backwardsGroup], seats), PublicDealError);
assert.throws(() => validatePublicDeals([{...group, payment, travel:{...term, actor:'c', dueTurn:5}}], seats), PublicDealError);
const paidGroup = { ...group, payment, travel: { ...term, actor: 'c', dueTurn: 5 } };
validatePublicDeals([paidGroup], seats);
assert.equal(publicDealTravelDueTurn(paidGroup), 5);
assert.deepEqual(paidGroup.payment, payment);
});
void test('a recipient accepts and both release an unchanged current-turn travel term', () => {
@@ -155,7 +158,7 @@ void test('multiparty travel requires the committed recipient to accept last and
}
});
void test('multiparty travel rejects forged actors, early votes, payments and nonfresh offers', () => {
void test('multiparty travel rejects forged actors, early votes and nonfresh offers', () => {
const multi = createMultipartyPublicDeal(seats, 'a', ['b','c'], 'Terms', 3, 'multi');
for (const actor of ['a','d','',undefined]) {
const invalid = {...term, actor};
@@ -168,7 +171,6 @@ void test('multiparty travel rejects forged actors, early votes, payments and no
}
const acceptedOther = respondPublicDeal(offered, seats, 'c', true);
assert.throws(() => setPublicDealTravel(respondPublicDeal(multi, seats, 'c', true), {...term,actor:'b'}, seats), PublicDealError);
assert.throws(() => validatePublicDeals([{...offered,payment:{kind:'immediateBribe',payer:'a',recipient:'b',amount:1}}], seats), PublicDealError);
assert.equal(respondPublicDeal(acceptedOther, seats, 'b', true).status, 'active');
});
@@ -193,7 +195,7 @@ for (const [payer, recipient] of [['a', 'b'], ['b', 'a']] as const) void test(`a
assert.equal(released.status, 'released');
});
void test('paid travel rejects deferred, multiparty and forged persisted payment shapes', () => {
void test('paid travel rejects deferred and forged persisted payment shapes', () => {
const deferred = { kind: 'deferredBribe', payer: 'a', recipient: 'b', amount: 1, dueTurn: 3 } as const;
const paid = { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 1 } as const;
for (const payment of [deferred]) {
@@ -202,7 +204,6 @@ void test('paid travel rejects deferred, multiparty and forged persisted payment
assert.throws(() => validatePublicDeals([{ ...offer, travel: term }], seats), PublicDealError);
}
const multi = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Terms', 3, 'multi-paid', paid);
assert.throws(() => setPublicDealTravel(multi, term, seats), PublicDealError);
for (const invalid of [
{ ...multi, travel: term },
{ ...fresh(), payment: { ...paid, recipient: 'c' }, travel: term },
@@ -222,3 +223,38 @@ void test('legacy two-party and multi-party records validate without acquiring t
assert.equal(Object.hasOwn(legacy, 'travel'), false);
assert.equal(Object.hasOwn(multi, 'travel'), false);
});
for (const payer of ['a', 'b', 'c']) for (const recipient of ['a', 'b', 'c']) {
if (payer === recipient) continue;
void test(`group travel holds ${payer}-to-${recipient} payment until every party accepts`, () => {
const payment = { kind: 'immediateBribe', payer, recipient, amount: 3 } as const;
const group = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Travel and payment.', 3,
`group-${payer}-${recipient}`, payment);
const offered = setPublicDealTravel(group, { ...term, actor: 'b' }, seats);
assert.throws(() => respondPublicDeal(offered, seats, 'b', true), PublicDealError);
const partial = respondPublicDeal(offered, seats, 'c', true);
assert.equal(partial.status, 'offered');
const active = respondPublicDeal(partial, seats, 'b', true);
assert.equal(active.status, 'active');
assert.deepEqual(active.acceptVotes, ['a', 'b', 'c']);
assert.deepEqual(active.payment, payment);
assert.deepEqual(active.travel, { ...term, actor: 'b' });
for (const deal of [offered, partial, active, respondPublicDeal(offered, seats, 'c', false),
withdrawPublicDeal(offered, seats, 'a')]) validatePublicDeals([JSON.parse(JSON.stringify(deal))], seats);
const released = ['a', 'b', 'c'].reduce((deal, actor) => releasePublicDeal(deal, seats, actor, true), active);
assert.equal(released.status, 'released');
validatePublicDeals([released], seats);
});
}
void test('group travel still rejects deferred payments and forged party identities', () => {
const deferred = { kind: 'deferredBribe', payer: 'a', recipient: 'b', amount: 1, dueTurn: 3 } as const;
const group = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Terms', 3, 'group-deferred', deferred);
assert.throws(() => setPublicDealTravel(group, { ...term, actor: 'b' }, seats), PublicDealError);
assert.throws(() => validatePublicDeals([{ ...group, travel: { ...term, actor: 'b' } }], seats), PublicDealError);
const immediate = { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 1 } as const;
const paid = setPublicDealTravel(createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Terms', 3, 'group-paid', immediate),
{ ...term, actor: 'b' }, seats);
assert.throws(() => validatePublicDeals([{ ...paid, payment: { ...immediate, payer: 'd' } }], seats), PublicDealError);
assert.throws(() => validatePublicDeals([{ ...paid, acceptVotes: ['a', 'b'] }], seats), PublicDealError);
});