Support immediate payments in group travel deals
Verify and publish checkpoint container / container (push) Successful in 10m49s
Verify and publish checkpoint container / container (push) Successful in 10m49s
This commit is contained in:
1 parent
3987c5c16b
commit
6c1530382d
10 files changed
+207
-24
No files matched your search
@@ -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)}>
|
||||
|
||||
@@ -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
@@ -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
@@ -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' &&
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user