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

@@ -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);
});