Support group travel agreements
Verify and publish checkpoint container / container (push) Successful in 11m13s
Verify and publish checkpoint container / container (push) Successful in 11m13s
This commit is contained in:
1 parent
82d857b667
commit
e1e02fc1a1
10 files changed
+432
-56
No files matched your search
@@ -0,0 +1,187 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
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', second = 'fremen';
|
||||
const shipment: Action = {type:'ship', territory:'polar_sink', sector:0, amount:1, elite:0, allyPayment:0};
|
||||
const movement: Action = {type:'move', from:'polar_sink:0', territory:'imperial_basin', sector:9, amount:1};
|
||||
type Term = {actor:string; kind:'ship'|'move'; territory:string; minimum:number; answer:boolean};
|
||||
|
||||
function reach(game: Game, until: (game: Game) => boolean): Game {
|
||||
for (let count = 0; count < 1200; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The native game ended before the travel opportunity.');
|
||||
const step = next(game); assert.ok(step, `Supply native continuation at ${game.turn}/${game.phase}`);
|
||||
game = act(game, step.actor, step.action);
|
||||
}
|
||||
throw Error('The native travel opportunity was not reached.');
|
||||
}
|
||||
function settle(game: Game): Game {
|
||||
return reach(game, state => clean(state) && !state.pendingShipment);
|
||||
}
|
||||
|
||||
/** Four actual human Basic seats. Reorder only original unopened starting
|
||||
* cards and Spice cards, and use native setup and phase actions to reach the
|
||||
* Emperor's unused first-turn shipment. No resource, force, promise, vote or
|
||||
* pending decision is installed by the test. */
|
||||
function nativeSource(): Game {
|
||||
let game = createGame('GROUPTRAVEL', newPlayer(proposer, 'Atreides', 'atreides'), false, []);
|
||||
for (const [id, faction] of [[traveler,'emperor'], [first,'harkonnen'], [second,'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'});
|
||||
assert.equal(game.status, 'setup');
|
||||
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');
|
||||
assert.equal(game.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);
|
||||
assert.ok(!game.travelPromises?.length);
|
||||
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, 'Rejected deal and travel actions preserve the table.');
|
||||
}
|
||||
function offer(game: Game, targets: string[], travel: Term) {
|
||||
const nextGame = act(game, proposer, {type:'dealOffer', targets, terms:'A shared current-turn journey.', travel});
|
||||
const deal = nextGame.deals?.at(-1); assert.ok(deal);
|
||||
return {game:nextGame, id:deal.id};
|
||||
}
|
||||
function respond(game: Game, actor: string, id: string, accept = true) {
|
||||
return act(game, actor, {type:'dealRespond', deal:id, accept});
|
||||
}
|
||||
function status(game: Game, actor: string, id: string) {
|
||||
return viewGame(game, actor).dealTravelStatus[id];
|
||||
}
|
||||
function receipt(game: Game, id: string) {
|
||||
const row = game.travelPromises?.find(promise => promise.sourceDeal === id); assert.ok(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
void test('a named traveler accepts last and performs the real shipment for a three-party deal', () => {
|
||||
const initial = fixture(), originalCards = inventory(initial);
|
||||
const offered = offer(initial, [first, traveler],
|
||||
{actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true});
|
||||
let game = offered.game;
|
||||
assert.equal(game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
|
||||
reject(game, traveler, {type:'dealRespond', deal:offered.id, accept:true});
|
||||
game = respond(game, first, offered.id);
|
||||
assert.equal(game.deals!.at(-1)!.status, 'offered');
|
||||
assert.equal(game.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
|
||||
game = respond(game, traveler, offered.id);
|
||||
assert.equal(game.deals!.at(-1)!.status, 'active');
|
||||
assert.equal(receipt(game, offered.id).player, traveler);
|
||||
assert.equal(receipt(game, offered.id).asker, proposer);
|
||||
for (const viewer of [proposer, first, traveler, second]) assert.equal(status(game, viewer, offered.id), 'pending');
|
||||
reject(game, traveler, {type:'endMovement'});
|
||||
const completed = settle(act(game, traveler, shipment));
|
||||
assert.equal(player(completed, traveler).forces['polar_sink:0'], 1);
|
||||
assert.equal(player(completed, traveler).reserves, player(initial, traveler).reserves - 1);
|
||||
for (const viewer of [proposer, first, traveler]) assert.equal(status(completed, viewer, offered.id), 'fulfilled');
|
||||
assert.equal(completed.travelPromises!.filter(row => row.sourceDeal === offered.id).length, 1);
|
||||
assert.deepEqual(inventory(completed), originalCards);
|
||||
});
|
||||
|
||||
void test('four-party consent requires all other recipients first and release requires every participant', () => {
|
||||
const offered = offer(fixture(), [traveler, first, second],
|
||||
{actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true});
|
||||
let game = offered.game;
|
||||
game = respond(game, second, offered.id);
|
||||
reject(game, traveler, {type:'dealRespond', deal:offered.id, accept:true});
|
||||
game = respond(game, first, offered.id);
|
||||
game = respond(game, traveler, offered.id);
|
||||
assert.equal(game.deals!.at(-1)!.status, 'active');
|
||||
for (const actor of [proposer, second, first]) {
|
||||
game = act(game, actor, {type:'dealRelease', deal:offered.id, agree:true});
|
||||
assert.equal(status(game, traveler, offered.id), 'pending');
|
||||
}
|
||||
reject(game, traveler, {type:'endMovement'});
|
||||
game = act(game, traveler, {type:'dealRelease', deal:offered.id, agree:true});
|
||||
assert.equal(status(game, traveler, offered.id), 'released');
|
||||
assert.equal(receipt(game, offered.id).releasedByDeal, true);
|
||||
game = settle(act(game, traveler, {type:'endMovement'}));
|
||||
assert.equal(status(game, first, offered.id), 'released');
|
||||
});
|
||||
|
||||
for (const answer of [true, false]) void test(`group movement ${answer ? 'requires' : 'forbids'} one real matching action`, () => {
|
||||
const moved = settle(act(fixture(), traveler, shipment));
|
||||
const offered = offer(moved, [first, traveler],
|
||||
{actor:traveler, kind:'move', territory:'imperial_basin', minimum:1, answer});
|
||||
let game = respond(offered.game, first, offered.id);
|
||||
game = respond(game, traveler, offered.id);
|
||||
if (answer) {
|
||||
reject(game, traveler, {type:'endMovement'});
|
||||
game = settle(act(game, traveler, movement));
|
||||
assert.equal(player(game, traveler).forces['imperial_basin:9'], 1);
|
||||
} else {
|
||||
reject(game, traveler, movement);
|
||||
game = settle(act(game, traveler, {type:'endMovement'}));
|
||||
assert.equal(player(game, traveler).forces['imperial_basin:9'] ?? 0, 0);
|
||||
}
|
||||
assert.equal(status(game, first, offered.id), 'fulfilled');
|
||||
});
|
||||
|
||||
void test('the traveler may decline early; malformed or uninvited actors and payment combinations reject', () => {
|
||||
const initial = fixture(), term:Term = {actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true};
|
||||
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}});
|
||||
const offered = offer(initial, [first, traveler], term);
|
||||
const declined = respond(offered.game, traveler, offered.id, false);
|
||||
assert.equal(declined.deals!.at(-1)!.status, 'declined');
|
||||
assert.equal(declined.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
|
||||
});
|
||||
|
||||
void test('an unavailable journey rejects only the final traveler consent and leaves the group offer open', () => {
|
||||
const alreadyShipped = settle(act(fixture(), traveler, shipment));
|
||||
const offered = offer(alreadyShipped, [first, traveler],
|
||||
{actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true});
|
||||
const waiting = respond(offered.game, first, offered.id);
|
||||
assert.equal(waiting.deals!.at(-1)!.status, 'offered');
|
||||
assert.equal(waiting.travelPromises?.some(row => row.sourceDeal === offered.id) ?? false, false);
|
||||
reject(waiting, traveler, {type:'dealRespond', deal:offered.id, accept:true});
|
||||
assert.equal(viewGame(waiting, first).deals.at(-1)!.status, 'offered');
|
||||
});
|
||||
|
||||
void test('the legacy two-person packet still binds the recipient without a named actor', () => {
|
||||
const game = fixture();
|
||||
let nextGame = act(game, proposer, {type:'dealOffer', target:traveler, terms:'Two-player travel.',
|
||||
travel:{kind:'ship', territory:'polar_sink', minimum:1, answer:true}});
|
||||
const deal = nextGame.deals!.at(-1)!;
|
||||
nextGame = respond(nextGame, traveler, deal.id);
|
||||
assert.equal(receipt(nextGame, deal.id).player, traveler);
|
||||
assert.equal(status(nextGame, first, deal.id), 'pending');
|
||||
});
|
||||
|
||||
void test('all minimal autopilot profiles can complete the named group travel obligation', () => {
|
||||
const offered = offer(fixture(), [first, traveler],
|
||||
{actor:traveler, kind:'ship', territory:'polar_sink', minimum:1, answer:true});
|
||||
const accepted = respond(respond(offered.game, first, offered.id), traveler, offered.id);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = act(accepted, traveler, {type:'setAutopilot', difficulty});
|
||||
for (let step = 0; step < 8 && status(game, traveler, offered.id) === 'pending'; step++) {
|
||||
const actions = botActions(viewGame(game, traveler));
|
||||
assert.ok(actions.length, `${difficulty} needs a legal native continuation`);
|
||||
game = settle(act(game, traveler, actions[0]));
|
||||
}
|
||||
assert.equal(status(game, traveler, offered.id), 'fulfilled', difficulty);
|
||||
}
|
||||
});
|
||||
@@ -30,13 +30,19 @@ const base = {status:'playing', turn:2, me:'b', roomControl:null, coalitionState
|
||||
const markup = (game:GameView, disabled = false) =>
|
||||
renderToStaticMarkup(createElement(PublicDealList, {game, act() {}, disabled}));
|
||||
|
||||
void test('the offer packet retains the original recipient action and validates one exact physical term', () => {
|
||||
void test('the offer packet retains legacy two-party terms and validates an explicitly named group traveler', () => {
|
||||
assert.deepEqual(publicDealOfferAction(['b'], ' Terms ', undefined, 'a', 2, travel),
|
||||
{type:'dealOffer', target:'b', terms:'Terms', travel});
|
||||
const avoid = {...travel, kind:'move' as const, answer:false, minimum:20};
|
||||
assert.deepEqual(publicDealOfferAction(['b'], 'Avoid', undefined, 'a', 2, avoid),
|
||||
{type:'dealOffer', target:'b', terms:'Avoid', travel:avoid});
|
||||
assert.equal(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, travel), null);
|
||||
assert.deepEqual(publicDealOfferAction(['b','c'], 'Terms', undefined, 'a', 2, {...travel, actor:'c'}),
|
||||
{type:'dealOffer', targets:['b','c'], terms:'Terms', travel:{...travel, actor:'c'}});
|
||||
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'], 'Terms', payment, 'a', 2, travel),
|
||||
{type:'dealOffer', target:'b', terms:'Terms', payment, travel});
|
||||
assert.equal(publicDealOfferAction(['b'], 'Terms', {...payment,payer:'b',recipient:'a'}, 'a', 2, travel), null);
|
||||
@@ -75,7 +81,7 @@ void test('the combined offer explains payment timing and the recipient can acce
|
||||
});
|
||||
|
||||
/** Stateful VM exercises the actual authoring callbacks without claiming browser hydration. */
|
||||
function combinedForm() {
|
||||
function combinedForm(gameOverride?: GameView) {
|
||||
const states:unknown[] = [], sent:Action[] = [];
|
||||
let cursor = 0;
|
||||
const exported:{PublicDeals?:typeof PublicDeals} = {};
|
||||
@@ -96,7 +102,7 @@ function combinedForm() {
|
||||
throw new Error(`Unexpected import ${name}`);
|
||||
}});
|
||||
assert.ok(exported.PublicDeals);
|
||||
const game = {...base, me:'a', deals:[]} as GameView;
|
||||
const game = gameOverride ?? {...base, me:'a', deals:[]} as GameView;
|
||||
const render = () => {cursor = 0; return elements(exported.PublicDeals!({game,busy:false,act(action) {sent.push(action);}}));};
|
||||
const check = (index:number) => {
|
||||
const node = render().filter(element => element.type === 'input' && element.props.type === 'checkbox')[index];
|
||||
@@ -156,6 +162,62 @@ void test('the public deal shows the exact recipient, travel intent, turn and au
|
||||
assert.match(markup({...base, dealTravelStatus:{deal:status}} as unknown as GameView), new RegExp(`Travel status: ${status}`));
|
||||
});
|
||||
|
||||
void test('group offers name the traveler, require their last vote and still permit declining early', () => {
|
||||
const players = [...base.players, {id:'c',name:'Cora',faction:'emperor',ally:null},
|
||||
{id:'d',name:'Dara',faction:'guild',ally:null}];
|
||||
const group = {...deal, participants:['a','b','c','d'], acceptVotes:['a','b'], status:'offered',
|
||||
travel:{...travel,actor:'c'}};
|
||||
const game = {...base, players, deals:[group], me:'c', dealTravelStatus:{}} as unknown as GameView;
|
||||
assert.match(markup(game), /Cora \(Emperor\) would make a reserve shipment/);
|
||||
assert.match(markup(game), /Cora \(Emperor\) must accept last/);
|
||||
const actions:Action[] = [];
|
||||
const early = elements(PublicDealList({game,disabled:false,act(action) {actions.push(action);}}));
|
||||
assert.equal(early.find(node => node.props.children === 'Accept deal')?.props.disabled,true);
|
||||
const decline = early.find(node => node.props.children === 'Decline deal');
|
||||
assert.ok(decline); assert.equal(decline.props.disabled,false);
|
||||
(decline.props.onClick as () => void)();
|
||||
assert.deepEqual(actions,[{type:'dealRespond',deal:'deal',accept:false}]);
|
||||
assert.deepEqual(publicDealRequests(game), [['offer','deal','waiting']], 'The traveler can see and decline the offer before other votes are in.');
|
||||
const ordinary = {...game,me:'d'};
|
||||
assert.deepEqual(publicDealRequests(ordinary), [['offer','deal']]);
|
||||
assert.equal(elements(PublicDealList({game:ordinary,disabled:false,act() {}}))
|
||||
.find(node => node.props.children === 'Accept deal')?.props.disabled,false);
|
||||
const final = {...game,deals:[{...group,acceptVotes:['a','b','d']}]} as unknown as GameView;
|
||||
assert.deepEqual(publicDealRequests(final), [['offer','deal','ready']], 'A changed request reopens the panel for the final vote.');
|
||||
const finalActions:Action[] = [];
|
||||
const accept = elements(PublicDealList({game:final,disabled:false,act(action) {finalActions.push(action);}}))
|
||||
.find(node => node.props.children === 'Accept deal');
|
||||
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 active = {...game, deals:[{...group,status:'active',acceptVotes:['a','b','c','d']}],
|
||||
dealTravelStatus:{deal:'pending'}} as unknown as GameView;
|
||||
assert.deepEqual(publicDealRequests(active), [['travel','deal']]);
|
||||
assert.deepEqual(publicDealRequests({...active,me:'b'}), []);
|
||||
});
|
||||
|
||||
void test('the group offer form selects an invited traveler and revalidates participant changes', () => {
|
||||
const game = {...base, me:'a', deals:[], players:[...base.players,
|
||||
{id:'c',name:'Cora',faction:'emperor',ally:null}, {id:'d',name:'Dara',faction:'guild',ally:null}]} as unknown as GameView;
|
||||
const ui = combinedForm(game);
|
||||
ui.check(0); ui.check(1); ui.check(4); // Basil, Cora, then travel.
|
||||
const button = () => ui.render().find(node => node.type === 'button' && node.props.type === 'submit');
|
||||
ui.select(0,'c'); ui.select(3,'carthag');
|
||||
const terms = ui.render().find(node => node.type === 'textarea'); assert.ok(terms);
|
||||
(terms.props.onChange as (event:{target:{value:string}}) => void)({target:{value:'Group travel'}});
|
||||
assert.equal(button()?.props.disabled,false);
|
||||
const form = ui.render().find(node => node.type === 'form'); assert.ok(form);
|
||||
(form.props.onSubmit as (event:{preventDefault():void}) => void)({preventDefault() {}});
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent[0])), {type:'dealOffer',targets:['b','c'],terms:'Group travel',
|
||||
travel:{kind:'ship',territory:'carthag',minimum:1,answer:true,actor:'c'}});
|
||||
const cora = ui.render().filter(node => node.type === 'input' && node.props.type === 'checkbox')[1];
|
||||
(cora.props.onChange as (event:{target:{checked:boolean}}) => void)({target:{checked:false}});
|
||||
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.');
|
||||
});
|
||||
|
||||
void test('only the obligated recipient gets a persistent pending reminder', () => {
|
||||
assert.deepEqual(publicDealRequests(base), [['travel','deal']]);
|
||||
assert.match(renderToStaticMarkup(createElement(PublicDeals,{game:base,act() {},busy:false})), /Public deals · travel term pending/);
|
||||
@@ -211,9 +273,9 @@ void test('the recipient guide distinguishes a void deal from unanimous release'
|
||||
}
|
||||
});
|
||||
|
||||
void test('the real offer form explains single-recipient eligibility before a travel term is selected', () => {
|
||||
void test('the real offer form asks for recipients before a travel term is selected', () => {
|
||||
const html = renderToStaticMarkup(createElement(PublicDeals,{game:base,act() {},busy:false}));
|
||||
assert.match(html, /Include a current-turn travel term/);
|
||||
assert.match(html, /Select exactly one human recipient to add a travel term/);
|
||||
assert.match(html, /Select at least one human recipient to add a travel term/);
|
||||
assert.match(html, /cannot automatically enforce free-text promises/);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createMultipartyPublicDeal, createPublicDeal, parsePublicDealTravel, PublicDealError,
|
||||
publicDealTravelActor,
|
||||
releasePublicDeal, respondPublicDeal, setPublicDealTravel, validatePublicDeals, withdrawPublicDeal,
|
||||
type PublicDeal, type PublicDealSeat,
|
||||
} from '../game/public-deals';
|
||||
@@ -22,8 +23,10 @@ void test('travel terms accept only exact bounded native actions and an explicit
|
||||
{ ...term, kind: 'fly' }, { ...term, territory: 'not_a_territory' },
|
||||
...[0, -1, 21, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1, '1'].map(minimum => ({ ...term, minimum })),
|
||||
{ ...term, answer: 'yes' }, { ...term, answer: undefined },
|
||||
{ ...term, sector: 0 }, { ...term, turn: 4 }, { ...term, actor: 'a' },
|
||||
{ ...term, sector: 0 }, { ...term, turn: 4 }, { ...term, actor: '' }, { ...term, actor: undefined },
|
||||
{ ...term, actor: ' c ' },
|
||||
]) assert.throws(() => parsePublicDealTravel(invalid), PublicDealError);
|
||||
assert.deepEqual(parsePublicDealTravel({...term, actor:'c'}), {...term, actor:'c'});
|
||||
});
|
||||
|
||||
void test('a recipient accepts and both release an unchanged current-turn travel term', () => {
|
||||
@@ -34,6 +37,7 @@ void test('a recipient accepts and both release an unchanged current-turn travel
|
||||
assert.equal(source.travel, undefined);
|
||||
assert.deepEqual(offered.travel, term);
|
||||
assert.equal(offered.turn, 3);
|
||||
assert.equal(publicDealTravelActor(offered), 'b');
|
||||
assert.throws(() => respondPublicDeal(offered, seats, 'a', true), PublicDealError);
|
||||
assert.throws(() => respondPublicDeal(offered, seats, 'c', true), PublicDealError);
|
||||
const active = respondPublicDeal(offered, seats, 'b', true);
|
||||
@@ -57,7 +61,7 @@ void test('a recipient accepts and both release an unchanged current-turn travel
|
||||
assert.deepEqual(withdrawPublicDeal(offered, seats, 'a').travel, term);
|
||||
});
|
||||
|
||||
void test('travel is incompatible with multi-party, replacement and malformed persisted terms', () => {
|
||||
void test('travel requires an actor for multiparty offers and cannot replace an existing term', () => {
|
||||
const multi = createMultipartyPublicDeal(seats, 'a', ['b', 'c'], 'Terms', 3, 'multi');
|
||||
const offered = setPublicDealTravel(fresh(), term, seats);
|
||||
const active = respondPublicDeal(offered, seats, 'b', true);
|
||||
@@ -65,11 +69,62 @@ void test('travel is incompatible with multi-party, replacement and malformed pe
|
||||
assert.throws(() => setPublicDealTravel(deal, term, seats), PublicDealError);
|
||||
for (const invalid of [
|
||||
{ ...multi, travel: term },
|
||||
{ ...fresh(), travel: {...term, actor:'b'} },
|
||||
{ ...fresh(), travel: undefined }, { ...fresh(), travel: { ...term, sector: 0 } },
|
||||
{ ...offered, paymentStatus: 'pending' }, { ...offered, travelStatus: 'pending' },
|
||||
]) assert.throws(() => validatePublicDeals([invalid], seats), PublicDealError);
|
||||
});
|
||||
|
||||
void test('multiparty travel requires the committed recipient to accept last and all parties to release', () => {
|
||||
const people: PublicDealSeat[] = ['a','b','c','d','e','f'].map(id => ({id}));
|
||||
for (const actor of ['b','c','d','e','f']) {
|
||||
const multi = createMultipartyPublicDeal(people, 'a', ['b','c','d','e','f'], 'Travel together.', 3, `multi-${actor}`);
|
||||
const offered = setPublicDealTravel(multi, {...term,actor}, people);
|
||||
assert.deepEqual(offered.travel, {...term,actor});
|
||||
assert.equal(publicDealTravelActor(offered), actor);
|
||||
assert.equal(multi.travel, undefined);
|
||||
assert.throws(() => respondPublicDeal(offered, people, actor, true), PublicDealError);
|
||||
assert.equal(respondPublicDeal(offered, people, actor, false).status, 'declined');
|
||||
assert.equal(withdrawPublicDeal(offered, people, 'a').status, 'withdrawn');
|
||||
let current = offered;
|
||||
// Deliberately vote in reverse roster order; serialization stays canonical.
|
||||
for (const voter of ['f','e','d','c','b'].filter(id => id !== actor)) {
|
||||
current = respondPublicDeal(current, people, voter, true);
|
||||
assert.equal(current.status, 'offered');
|
||||
if (current.acceptVotes!.length < people.length - 1)
|
||||
assert.throws(() => respondPublicDeal(current, people, actor, true), PublicDealError);
|
||||
}
|
||||
const last = respondPublicDeal(current, people, actor, true);
|
||||
assert.equal(last.status, 'active');
|
||||
assert.deepEqual(last.acceptVotes, ['a','b','c','d','e','f']);
|
||||
let released = last;
|
||||
for (const voter of ['f','e','d','c','b','a']) released = releasePublicDeal(released, people, voter, true);
|
||||
assert.equal(released.status, 'released');
|
||||
for (const snapshot of [offered,current,last,released]) {
|
||||
const copy = JSON.parse(JSON.stringify(snapshot)) as PublicDeal;
|
||||
validatePublicDeals([copy], people);
|
||||
assert.deepEqual(copy.travel, {...term,actor});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('multiparty travel rejects forged actors, early votes, payments and nonfresh offers', () => {
|
||||
const multi = createMultipartyPublicDeal(seats, 'a', ['b','c'], 'Terms', 3, 'multi');
|
||||
for (const actor of ['a','d','',undefined]) {
|
||||
const invalid = {...term, actor};
|
||||
assert.throws(() => setPublicDealTravel(multi, invalid, seats), PublicDealError);
|
||||
assert.throws(() => validatePublicDeals([{...multi, travel:invalid}], seats), PublicDealError);
|
||||
}
|
||||
const offered = setPublicDealTravel(multi, {...term,actor:'b'}, seats);
|
||||
for (const status of ['offered','withdrawn','declined'] as const) {
|
||||
assert.throws(() => validatePublicDeals([{...offered,status,acceptVotes:['a','b']}], seats), PublicDealError);
|
||||
}
|
||||
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');
|
||||
});
|
||||
|
||||
void test('a proposer-to-recipient immediate payment can accompany travel through the complete deal lifecycle', () => {
|
||||
const payment = { kind: 'immediateBribe', payer: 'a', recipient: 'b', amount: 3 } as const;
|
||||
const original = createPublicDeal(seats, 'a', 'b', 'Payment for travel.', 3, 'paid-travel', payment);
|
||||
|
||||
Reference in new issue
Block a user