Support multiparty public deals
This commit is contained in:
1 parent
86c51b4aa7
commit
e133a5fe2b
14 files changed
+676
-98
No files matched your search
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { PublicDealList, PublicDeals } from '../components/public-deals';
|
||||
import { PublicDealList, PublicDeals, publicDealOfferAction } from '../components/public-deals';
|
||||
import type { Action, GameView } from '../game/engine';
|
||||
|
||||
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
|
||||
@@ -42,7 +42,7 @@ void test('deal form exposes human non-allied recipients and clearly describes l
|
||||
assert.match(html, /Public deals/);
|
||||
assert.match(html, /Basil · Harkonnen/);
|
||||
assert.match(html, /Emperor · Emperor/);
|
||||
assert.doesNotMatch(html, /Chani · Fremen/);
|
||||
assert.match(html, /Chani · Fremen \(allied with a selected party\)/);
|
||||
assert.doesNotMatch(html, /Duncan · Spacing Guild/);
|
||||
assert.match(html, /cannot automatically enforce free-text promises/);
|
||||
assert.match(html, /does not automatically transfer spice/);
|
||||
@@ -90,7 +90,66 @@ void test('either party can request, agree to, or withdraw mutual release', () =
|
||||
]);
|
||||
const html = render(requested);
|
||||
assert.match(html, /Release requested by Alice/);
|
||||
assert.match(html, /Both parties must agree/);
|
||||
assert.match(html, /All parties must agree/);
|
||||
});
|
||||
|
||||
void test('the form keeps the legacy packet for one seat and sends all selected seats for multiparty offers', () => {
|
||||
assert.deepEqual(publicDealOfferAction(['b'], ' Terms '), { type: 'dealOffer', target: 'b', terms: 'Terms' });
|
||||
assert.deepEqual(publicDealOfferAction(['b', 'e'], 'Terms'), { type: 'dealOffer', targets: ['b', 'e'], terms: 'Terms' });
|
||||
assert.equal(publicDealOfferAction([], 'Terms'), null);
|
||||
assert.equal(publicDealOfferAction(['b', 'b'], 'Terms'), null);
|
||||
assert.equal(publicDealOfferAction(['b'], ' '), null);
|
||||
assert.equal(publicDealOfferAction(['b'], 'x'.repeat(501)), null);
|
||||
});
|
||||
|
||||
void test('multiparty offers expose all pending consent and let an accepted recipient decline', () => {
|
||||
const multi = {
|
||||
...offer, participants: ['a', 'b', 'e'], acceptVotes: ['a', 'b'],
|
||||
};
|
||||
const game = { ...base, players: base.players.map(player =>
|
||||
player.id === 'b' || player.id === 'e' ? { ...player, ally: null } : player), deals: [multi] } as unknown as GameView;
|
||||
const html = render(game);
|
||||
assert.match(html, /Alice \(Atreides\) → Basil \(Harkonnen\), Emperor \(Emperor\)/);
|
||||
assert.match(html, /Accepted by Alice \(Atreides\), Basil \(Harkonnen\)/);
|
||||
assert.match(html, /Waiting for Emperor \(Emperor\)/);
|
||||
const calls: Action[] = [];
|
||||
const accepted = actions({ ...game, me: 'b' }, calls);
|
||||
assert.equal(accepted.length, 1);
|
||||
click(accepted, 'Decline deal');
|
||||
assert.doesNotMatch(render({ ...game, me: 'b' }), /your response requested/);
|
||||
const waiting = { ...game, me: 'e' };
|
||||
assert.match(render(waiting), /your response requested/);
|
||||
click(actions(waiting, calls), 'Accept deal');
|
||||
assert.deepEqual(calls, [
|
||||
{ type: 'dealRespond', deal: 'deal-1', accept: false },
|
||||
{ type: 'dealRespond', deal: 'deal-1', accept: true },
|
||||
]);
|
||||
assert.equal(actions({ ...game, me: 'c' }, calls).length, 0);
|
||||
});
|
||||
|
||||
void test('an offer remains declinable when new alliances make acceptance unavailable', () => {
|
||||
const multi = { ...offer, participants: ['a', 'b', 'e'], acceptVotes: ['a'] };
|
||||
const game = { ...base, me: 'e', deals: [multi] } as unknown as GameView;
|
||||
const choices = actions(game, []);
|
||||
assert.equal(choices.find(node => node.props.children === 'Accept deal')?.props.disabled, true);
|
||||
assert.equal(choices.find(node => node.props.children === 'Decline deal')?.props.disabled, false);
|
||||
assert.match(render(game), /Acceptance is unavailable while any participants are allied/);
|
||||
});
|
||||
|
||||
void test('all multiparty signatories participate in mutual release', () => {
|
||||
const multi = {
|
||||
...offer, participants: ['a', 'b', 'e'], acceptVotes: ['a', 'b', 'e'],
|
||||
status: 'active', releaseVotes: ['a', 'b'],
|
||||
};
|
||||
const game = { ...base, deals: [multi] } as unknown as GameView;
|
||||
const calls: Action[] = [];
|
||||
assert.match(render({ ...game, me: 'e' }), /your response requested/);
|
||||
click(actions({ ...game, me: 'e' }, calls), 'Agree to release deal');
|
||||
click(actions({ ...game, me: 'b' }, calls), 'Withdraw release request');
|
||||
assert.deepEqual(calls, [
|
||||
{ type: 'dealRelease', deal: 'deal-1', agree: true },
|
||||
{ type: 'dealRelease', deal: 'deal-1', agree: false },
|
||||
]);
|
||||
});
|
||||
|
||||
void test('all statuses and public text remain readable without treating text as markup', () => {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction, createGame, joinControlledFaction, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { knowledgeGame } from './truthtrance-knowledge-fixture';
|
||||
|
||||
/** Genuine two-controller/four-faction Basic setup. Each faction keeps its own seat. */
|
||||
function playing(): Game {
|
||||
let game = createGame('DEALMANY', newPlayer('a', 'First', 'atreides'));
|
||||
game = applyAction(game, 'a', { type: 'twoPlayerMode', enabled: true });
|
||||
joinControlledFaction(game, newPlayer('b', 'First', 'emperor'), 'a');
|
||||
joinGame(game, newPlayer('c', 'Second', 'guild'));
|
||||
joinControlledFaction(game, newPlayer('d', 'Second', 'harkonnen'), 'c');
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game = applyAction(game, 'a', { type: 'start' });
|
||||
for (const player of game.players) if (player.traitorChoices.length)
|
||||
game = applyAction(game, player.id, { type: 'traitor', leader: player.traitorChoices[0] });
|
||||
assert.equal(game.status, 'playing');
|
||||
return game;
|
||||
}
|
||||
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action), `${action.type} must reject`);
|
||||
assert.deepEqual(game, before, `${action.type} must not mutate a rejected input`);
|
||||
}
|
||||
|
||||
function proposal(game: Game, targets = ['b', 'c']) {
|
||||
const next = applyAction(game, 'a', { type: 'dealOffer', targets, terms: 'All three will share information.' });
|
||||
const deal = next.deals?.at(-1);
|
||||
assert.ok(deal);
|
||||
return { game: next, deal };
|
||||
}
|
||||
|
||||
function preserved(game: Game) {
|
||||
const { players, playerPositions, twoPlayerControllers, phase, turn, active, response, decision,
|
||||
phaseOpening, auction, deck, discard } = game;
|
||||
return structuredClone({ players, playerPositions, twoPlayerControllers, phase, turn, active, response,
|
||||
decision, phaseOpening, auction, deck, discard });
|
||||
}
|
||||
|
||||
void test('every named faction accepts and releases a three-party deal independently', () => {
|
||||
const initial = playing();
|
||||
const baseline = preserved(initial);
|
||||
const offered = proposal(initial);
|
||||
let game = offered.game;
|
||||
const id = offered.deal.id;
|
||||
assert.deepEqual(offered.deal.participants, ['a', 'b', 'c']);
|
||||
assert.deepEqual(offered.deal.acceptVotes, ['a']);
|
||||
assert.equal(offered.deal.status, 'offered');
|
||||
for (const player of game.players)
|
||||
assert.deepEqual(viewGame(game, player.id).deals.find(deal => deal.id === id), offered.deal);
|
||||
assert.deepEqual(viewGame(game, 'b').controlledFactionSeats.map(seat => seat.id), ['a', 'b']);
|
||||
reject(game, 'd', { type: 'dealRespond', deal: id, accept: true });
|
||||
reject(game, 'a', { type: 'dealRespond', deal: id, accept: true });
|
||||
game = applyAction(game, 'c', { type: 'dealRespond', deal: id, accept: true });
|
||||
assert.equal(game.deals!.at(-1)!.status, 'offered');
|
||||
assert.deepEqual([...game.deals!.at(-1)!.acceptVotes!].sort(), ['a', 'c']);
|
||||
reject(game, 'c', { type: 'dealRespond', deal: id, accept: true });
|
||||
game = applyAction(game, 'b', { type: 'dealRespond', deal: id, accept: true });
|
||||
assert.equal(game.deals!.at(-1)!.status, 'active');
|
||||
assert.deepEqual([...game.deals!.at(-1)!.acceptVotes!].sort(), ['a', 'b', 'c']);
|
||||
assert.deepEqual(preserved(game), baseline);
|
||||
|
||||
reject(game, 'd', { type: 'dealRelease', deal: id, agree: true });
|
||||
game = applyAction(game, 'a', { type: 'dealRelease', deal: id, agree: true });
|
||||
game = applyAction(game, 'b', { type: 'dealRelease', deal: id, agree: true });
|
||||
assert.equal(game.deals!.at(-1)!.status, 'active');
|
||||
game = applyAction(game, 'b', { type: 'dealRelease', deal: id, agree: false });
|
||||
assert.deepEqual(game.deals!.at(-1)!.releaseVotes, ['a']);
|
||||
game = applyAction(game, 'c', { type: 'dealRelease', deal: id, agree: true });
|
||||
assert.equal(game.deals!.at(-1)!.status, 'active');
|
||||
game = applyAction(game, 'b', { type: 'dealRelease', deal: id, agree: true });
|
||||
assert.equal(game.deals!.at(-1)!.status, 'released');
|
||||
assert.deepEqual([...game.deals!.at(-1)!.releaseVotes].sort(), ['a', 'b', 'c']);
|
||||
assert.deepEqual(preserved(game), baseline);
|
||||
reject(game, 'a', { type: 'dealRelease', deal: id, agree: true });
|
||||
|
||||
// The agreement never consumes or replays the original native Storm choice.
|
||||
const dialer = game.stormDialers[0];
|
||||
assert.ok(dialer);
|
||||
game = applyAction(game, dialer, { type: 'stormDial', amount: 0 });
|
||||
assert.ok(game.phase !== initial.phase || game.stormDials[dialer] === 0);
|
||||
});
|
||||
|
||||
void test('a recipient may decline while other votes are pending and only the proposer may withdraw', () => {
|
||||
let { game, deal } = proposal(playing());
|
||||
const id = deal.id;
|
||||
game = applyAction(game, 'b', { type: 'dealRespond', deal: id, accept: true });
|
||||
game = applyAction(game, 'c', { type: 'dealRespond', deal: id, accept: false });
|
||||
assert.equal(game.deals!.at(-1)!.status, 'declined');
|
||||
reject(game, 'b', { type: 'dealRespond', deal: id, accept: true });
|
||||
({ game, deal } = proposal(game));
|
||||
reject(game, 'c', { type: 'dealWithdraw', deal: deal.id });
|
||||
game = applyAction(game, 'a', { type: 'dealWithdraw', deal: deal.id });
|
||||
assert.equal(game.deals!.at(-1)!.status, 'withdrawn');
|
||||
});
|
||||
|
||||
void test('every participant pair must be nonallied at offer and acceptance, while existing deals remain releasable', () => {
|
||||
const initial = playing();
|
||||
const pairAllied = structuredClone(initial);
|
||||
pairAllied.players.find(player => player.id === 'b')!.ally = 'c';
|
||||
pairAllied.players.find(player => player.id === 'c')!.ally = 'b';
|
||||
reject(pairAllied, 'a', { type: 'dealOffer', targets: ['b', 'c'], terms: 'Terms' });
|
||||
const offered = proposal(initial);
|
||||
const changed = structuredClone(offered.game);
|
||||
changed.players.find(player => player.id === 'b')!.ally = 'c';
|
||||
changed.players.find(player => player.id === 'c')!.ally = 'b';
|
||||
reject(changed, 'b', { type: 'dealRespond', deal: offered.deal.id, accept: true });
|
||||
const declined = applyAction(changed, 'b', { type: 'dealRespond', deal: offered.deal.id, accept: false });
|
||||
assert.equal(declined.deals!.at(-1)!.status, 'declined');
|
||||
|
||||
let game = applyAction(offered.game, 'b', { type: 'dealRespond', deal: offered.deal.id, accept: true });
|
||||
game = applyAction(game, 'c', { type: 'dealRespond', deal: offered.deal.id, accept: true });
|
||||
game.players.find(player => player.id === 'b')!.ally = 'c';
|
||||
game.players.find(player => player.id === 'c')!.ally = 'b';
|
||||
for (const id of ['a', 'b', 'c']) game = applyAction(game, id, { type: 'dealRelease', deal: offered.deal.id, agree: true });
|
||||
assert.equal(game.deals!.at(-1)!.status, 'released');
|
||||
});
|
||||
|
||||
void test('multiparty packets reject empty, duplicate, forged and incompatible targets', () => {
|
||||
const game = playing();
|
||||
for (const action of [
|
||||
{ type: 'dealOffer', targets: [], terms: 'Terms' },
|
||||
{ type: 'dealOffer', targets: ['a', 'b'], terms: 'Terms' },
|
||||
{ type: 'dealOffer', targets: ['b', 'b'], terms: 'Terms' },
|
||||
{ type: 'dealOffer', targets: ['b', 'unknown'], terms: 'Terms' },
|
||||
{ type: 'dealOffer', targets: 'b', terms: 'Terms' },
|
||||
{ type: 'dealOffer', target: 'b', targets: ['b', 'c'], terms: 'Terms' },
|
||||
{ type: 'dealOffer', targets: ['b', 'c'], terms: 'Terms', from: 'd' },
|
||||
] as Action[]) reject(game, 'a', action);
|
||||
});
|
||||
|
||||
void test('multiparty negotiation preserves and resumes a pending Truthtrance priority response', () => {
|
||||
const initial = knowledgeGame();
|
||||
const card = initial.players[0].hand.find(item => item.effect === 'truthtrance');
|
||||
assert.ok(card);
|
||||
const paused = applyAction(initial, 'a', { type: 'card', card: card.id });
|
||||
assert.equal(paused.truthtrance?.stage, 'priority');
|
||||
const before = structuredClone(paused.truthtrance);
|
||||
const offered = applyAction(paused, 'b', { type: 'dealOffer', targets: ['f', 'o'], terms: 'We will disclose our intentions.' });
|
||||
const deal = offered.deals!.at(-1)!;
|
||||
const acceptedOne = applyAction(offered, 'f', { type: 'dealRespond', deal: deal.id, accept: true });
|
||||
const acceptedAll = applyAction(acceptedOne, 'o', { type: 'dealRespond', deal: deal.id, accept: true });
|
||||
assert.equal(acceptedAll.deals!.at(-1)!.status, 'active');
|
||||
assert.deepEqual(acceptedAll.truthtrance, before);
|
||||
const passer = acceptedAll.players.find(player => !acceptedAll.truthtrance!.passed.includes(player.id));
|
||||
assert.ok(passer);
|
||||
const resumed = applyAction(acceptedAll, passer.id, { type: 'truthPass' });
|
||||
assert.ok(resumed.truthtrance?.passed.includes(passer.id));
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// @dune-suite integration
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import type { GameView } from '../game/engine';
|
||||
|
||||
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
|
||||
const entry = () => ({ operationId: randomUUID(), sessionToken: randomBytes(32).toString('hex') });
|
||||
async function request(path: string, cookie?: string, body?: unknown, selected?: string) {
|
||||
const response = await fetch(base + path, {
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
headers: {
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(cookie ? { cookie } : {}),
|
||||
...(selected ? { 'X-Dune-Seat': selected } : {}),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
return { status: response.status, cookie: response.headers.get('set-cookie')?.split(';')[0],
|
||||
data: await response.json() as GameView & { error?: string } };
|
||||
}
|
||||
|
||||
void test('three-party deals require each authenticated faction seat and leave native play intact', async () => {
|
||||
const host = await request('/api/rooms', undefined, {
|
||||
name: 'Many-deal first', faction: 'atreides', advanced: false, expansions: [], entry: entry(),
|
||||
});
|
||||
assert.equal(host.status, 201, JSON.stringify(host.data));
|
||||
assert.ok(host.cookie);
|
||||
const path = `/api/rooms/${host.data.code}`;
|
||||
const enabled = await request(path, host.cookie, { version: host.data.version, action: { type: 'twoPlayerMode', enabled: true } }, host.data.me);
|
||||
assert.equal(enabled.status, 200, JSON.stringify(enabled.data));
|
||||
const hostSecond = await request(path, host.cookie, { type: 'addControlledFaction', version: enabled.data.version,
|
||||
name: 'Many-deal first', faction: 'emperor', entry: entry() }, host.data.me);
|
||||
assert.equal(hostSecond.status, 200, JSON.stringify(hostSecond.data));
|
||||
assert.ok(hostSecond.cookie);
|
||||
const hostCookies = `${host.cookie}; ${hostSecond.cookie}`;
|
||||
const guest = await request(path, undefined, { type: 'join', name: 'Many-deal second', faction: 'guild', entry: entry() });
|
||||
assert.equal(guest.status, 200, JSON.stringify(guest.data));
|
||||
assert.ok(guest.cookie);
|
||||
const guestSecond = await request(path, guest.cookie, { type: 'addControlledFaction', version: guest.data.version,
|
||||
name: 'Many-deal second', faction: 'harkonnen', entry: entry() }, guest.data.me);
|
||||
assert.equal(guestSecond.status, 200, JSON.stringify(guestSecond.data));
|
||||
assert.ok(guestSecond.cookie);
|
||||
const guestCookies = `${guest.cookie}; ${guestSecond.cookie}`;
|
||||
const seats = [
|
||||
{ id: host.data.me, cookie: hostCookies }, { id: hostSecond.data.me, cookie: hostCookies },
|
||||
{ id: guest.data.me, cookie: guestCookies }, { id: guestSecond.data.me, cookie: guestCookies },
|
||||
];
|
||||
let view = guestSecond.data;
|
||||
const act = async (seat: typeof seats[number], action: object) => {
|
||||
const next = await request(path, seat.cookie, { version: view.version, action }, seat.id);
|
||||
assert.equal(next.status, 200, JSON.stringify(next.data));
|
||||
view = next.data;
|
||||
};
|
||||
for (const seat of seats) await act(seat, { type: 'ready' });
|
||||
await act(seats[0], { type: 'start' });
|
||||
for (const seat of seats) {
|
||||
const own = await request(path, seat.cookie, undefined, seat.id);
|
||||
assert.equal(own.status, 200, JSON.stringify(own.data));
|
||||
view = own.data;
|
||||
const choices = own.data.players.find(player => player.id === seat.id)?.traitorChoices;
|
||||
if (choices?.length) await act(seat, { type: 'traitor', leader: choices[0] });
|
||||
}
|
||||
assert.equal(view.status, 'playing');
|
||||
assert.equal(view.phase, 0);
|
||||
const baseline = { phase: view.phase, turn: view.turn, active: view.active, storm: view.storm,
|
||||
stormDialers: view.stormDialers, stormSubmitted: view.stormSubmitted,
|
||||
players: view.players.map(({ id, faction, ready }) => ({ id, faction, ready })),
|
||||
controllers: view.twoPlayerControllers };
|
||||
|
||||
await act(seats[0], { type: 'dealOffer', targets: [seats[1].id, seats[2].id], terms: 'The three factions will share information.' });
|
||||
const offer = view.deals.at(-1);
|
||||
assert.ok(offer);
|
||||
assert.deepEqual(offer.participants, [seats[0].id, seats[1].id, seats[2].id]);
|
||||
assert.deepEqual(offer.acceptVotes, [seats[0].id]);
|
||||
assert.equal(offer.status, 'offered');
|
||||
for (const seat of seats) {
|
||||
const read = await request(path, seat.cookie, undefined, seat.id);
|
||||
assert.equal(read.status, 200, JSON.stringify(read.data));
|
||||
assert.deepEqual(read.data.deals.find(deal => deal.id === offer.id), offer);
|
||||
}
|
||||
|
||||
const yes = { version: view.version, action: { type: 'dealRespond', deal: offer.id, accept: true } };
|
||||
// Another controller's seat selector is not a credential. The fourth seat
|
||||
// is authenticated but is not one of this agreement's participants.
|
||||
assert.equal((await request(path, hostCookies, yes, seats[2].id)).status, 409);
|
||||
assert.equal((await request(path, guestCookies, yes, seats[3].id)).status, 409);
|
||||
// The same controller's second faction still needs its own seat credential.
|
||||
assert.equal((await request(path, host.cookie, yes, seats[1].id)).status, 409);
|
||||
await act(seats[1], yes.action);
|
||||
assert.equal(view.deals.at(-1)?.status, 'offered');
|
||||
assert.deepEqual([...view.deals.at(-1)!.acceptVotes!].sort(), [seats[0].id, seats[1].id].sort());
|
||||
await act(seats[2], yes.action);
|
||||
assert.equal(view.deals.at(-1)?.status, 'active');
|
||||
|
||||
await act(seats[2], { type: 'dealRelease', deal: offer.id, agree: true });
|
||||
await act(seats[1], { type: 'dealRelease', deal: offer.id, agree: true });
|
||||
assert.equal(view.deals.at(-1)?.status, 'active');
|
||||
await act(seats[0], { type: 'dealRelease', deal: offer.id, agree: true });
|
||||
assert.equal(view.deals.at(-1)?.status, 'released');
|
||||
const observer = await request(path, seats[3].cookie, undefined, seats[3].id);
|
||||
assert.equal(observer.status, 200, JSON.stringify(observer.data));
|
||||
assert.equal(observer.data.deals.find(deal => deal.id === offer.id)?.status, 'released');
|
||||
assert.deepEqual({ phase: observer.data.phase, turn: observer.data.turn, active: observer.data.active,
|
||||
storm: observer.data.storm, stormDialers: observer.data.stormDialers, stormSubmitted: observer.data.stormSubmitted,
|
||||
players: observer.data.players.map(({ id, faction, ready }) => ({ id, faction, ready })),
|
||||
controllers: observer.data.twoPlayerControllers }, baseline);
|
||||
const dialer = seats.find(seat => observer.data.stormDialers.includes(seat.id));
|
||||
assert.ok(dialer);
|
||||
view = observer.data;
|
||||
await act(dialer, { type: 'stormDial', amount: 0 });
|
||||
assert.ok(view.stormSubmitted.includes(dialer.id) || view.phase !== 0);
|
||||
});
|
||||
+109
-1
@@ -1,7 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
PublicDealError, createPublicDeal, releasePublicDeal, respondPublicDeal,
|
||||
PublicDealError, createPublicDeal, createMultipartyPublicDeal, publicDealAcceptVotes,
|
||||
publicDealParticipants, releasePublicDeal, respondPublicDeal,
|
||||
validatePublicDeals, withdrawPublicDeal,
|
||||
} from '../game/public-deals';
|
||||
|
||||
@@ -26,6 +27,113 @@ void test('an explicit offer and recipient response preserve input and do not fo
|
||||
assert.throws(() => respondPublicDeal(active, players, 'b', true), PublicDealError);
|
||||
});
|
||||
|
||||
void test('legacy two-party JSON keeps its exact representation and implicit votes', () => {
|
||||
const pending = JSON.parse(JSON.stringify(offer()));
|
||||
const before = JSON.stringify(pending);
|
||||
validatePublicDeals([pending], players);
|
||||
assert.deepEqual(publicDealParticipants(pending), ['a', 'b']);
|
||||
assert.deepEqual(publicDealAcceptVotes(pending), ['a']);
|
||||
assert.equal(JSON.stringify(pending), before);
|
||||
const active = respondPublicDeal(pending, players, 'b', true);
|
||||
assert.deepEqual(publicDealAcceptVotes(active), ['a', 'b']);
|
||||
assert.equal('participants' in active, false);
|
||||
assert.equal('acceptVotes' in active, false);
|
||||
const released = releasePublicDeal(releasePublicDeal(active, players, 'b', true), players, 'a', true);
|
||||
assert.deepEqual(released.releaseVotes, ['b', 'a']);
|
||||
assert.equal('participants' in released, false);
|
||||
validatePublicDeals(JSON.parse(JSON.stringify([pending, { ...active, id: 'legacy-active' },
|
||||
{ ...released, id: 'legacy-released' }])), players);
|
||||
});
|
||||
|
||||
void test('multiparty deals activate only after every invited human accepts and release unanimously', () => {
|
||||
const seats = [...players, { id: 'd', ally: null }, { id: 'e', ally: null }];
|
||||
const initial = createMultipartyPublicDeal(seats, 'a', ['b', 'c', 'd'], ' No raids this turn. ', 3, 'multi-1');
|
||||
assert.deepEqual(initial, { id: 'multi-1', from: 'a', to: 'b', terms: 'No raids this turn.', turn: 3,
|
||||
status: 'offered', releaseVotes: [], participants: ['a', 'b', 'c', 'd'], acceptVotes: ['a'] });
|
||||
const first = respondPublicDeal(initial, seats, 'd', true);
|
||||
assert.equal(first.status, 'offered');
|
||||
assert.deepEqual(first.acceptVotes, ['a', 'd']);
|
||||
assert.deepEqual(initial.acceptVotes, ['a']);
|
||||
assert.throws(() => respondPublicDeal(first, seats, 'd', true), PublicDealError);
|
||||
assert.throws(() => respondPublicDeal(first, seats, 'a', true), PublicDealError);
|
||||
assert.throws(() => respondPublicDeal(first, seats, 'e', true), PublicDealError);
|
||||
const next = respondPublicDeal(first, seats, 'b', true);
|
||||
assert.deepEqual(next.acceptVotes, ['a', 'b', 'd']);
|
||||
const active = respondPublicDeal(next, seats, 'c', true);
|
||||
assert.equal(active.status, 'active');
|
||||
assert.deepEqual(publicDealAcceptVotes(active), ['a', 'b', 'c', 'd']);
|
||||
let current = releasePublicDeal(active, seats, 'd', true);
|
||||
current = releasePublicDeal(current, seats, 'a', true);
|
||||
assert.deepEqual(current.releaseVotes, ['a', 'd']);
|
||||
current = releasePublicDeal(current, seats, 'd', false);
|
||||
assert.deepEqual(current.releaseVotes, ['a']);
|
||||
for (const actor of ['c', 'b']) current = releasePublicDeal(current, seats, actor, true);
|
||||
assert.equal(current.status, 'active');
|
||||
current = releasePublicDeal(current, seats, 'd', true);
|
||||
assert.equal(current.status, 'released');
|
||||
assert.deepEqual(current.releaseVotes, ['a', 'b', 'c', 'd']);
|
||||
validatePublicDeals([initial, { ...active, id: 'multi-active' }, { ...current, id: 'multi-released' }], seats);
|
||||
});
|
||||
|
||||
void test('a recipient can decline after accepting and a proposer can withdraw a partial offer', () => {
|
||||
const initial = createMultipartyPublicDeal(players, 'a', ['b', 'c'], 'terms', 2, 'decline');
|
||||
const accepted = respondPublicDeal(initial, players, 'b', true);
|
||||
const declined = respondPublicDeal(accepted, players, 'b', false);
|
||||
const withdrawn = withdrawPublicDeal(accepted, players, 'a');
|
||||
assert.equal(declined.status, 'declined');
|
||||
assert.equal(withdrawn.status, 'withdrawn');
|
||||
assert.deepEqual(declined.acceptVotes, ['a', 'b']);
|
||||
assert.deepEqual(withdrawn.acceptVotes, ['a', 'b']);
|
||||
assert.throws(() => respondPublicDeal(declined, players, 'c', true), PublicDealError);
|
||||
validatePublicDeals([{ ...declined, id: 'declined' }, withdrawn], players);
|
||||
});
|
||||
|
||||
void test('a six-seat deal requires all five responses and all six release votes', () => {
|
||||
const seats = ['a', 'b', 'c', 'd', 'e', 'f'].map(id => ({ id, ally: null }));
|
||||
let deal = createMultipartyPublicDeal(seats, 'a', ['b', 'c', 'd', 'e', 'f'], 'Terms', 1, 'six');
|
||||
for (const actor of ['b', 'c', 'd', 'e']) deal = respondPublicDeal(deal, seats, actor, true);
|
||||
assert.equal(deal.status, 'offered');
|
||||
deal = respondPublicDeal(deal, seats, 'f', true);
|
||||
assert.equal(deal.status, 'active');
|
||||
for (const actor of ['a', 'b', 'c', 'd', 'e']) deal = releasePublicDeal(deal, seats, actor, true);
|
||||
assert.equal(deal.status, 'active');
|
||||
deal = releasePublicDeal(deal, seats, 'f', true);
|
||||
assert.equal(deal.status, 'released');
|
||||
validatePublicDeals([deal], seats);
|
||||
});
|
||||
|
||||
void test('a new alliance between any invitees blocks acceptance but not decline or mutual release', () => {
|
||||
const initial = createMultipartyPublicDeal(players, 'a', ['b', 'c'], 'terms', 2, 'alliance');
|
||||
const allied = players.map(player => player.id === 'b' ? { ...player, ally: 'c' } : player);
|
||||
assert.throws(() => createMultipartyPublicDeal(allied, 'a', ['b', 'c'], 'terms', 2, 'bad'), PublicDealError);
|
||||
assert.throws(() => respondPublicDeal(initial, allied, 'b', true), PublicDealError);
|
||||
assert.equal(respondPublicDeal(initial, allied, 'b', false).status, 'declined');
|
||||
let active = respondPublicDeal(respondPublicDeal(initial, players, 'b', true), players, 'c', true);
|
||||
for (const actor of ['b', 'a', 'c']) active = releasePublicDeal(active, allied, actor, true);
|
||||
assert.equal(active.status, 'released');
|
||||
});
|
||||
|
||||
void test('multiparty shape rejects duplicate, noncanonical and impossible lifecycle votes', () => {
|
||||
const initial = createMultipartyPublicDeal(players, 'a', ['b', 'c'], 'terms', 2, 'shape');
|
||||
for (const targets of [['b'], ['b', 'b'], ['a', 'b'], ['b', 'bot'], ['b', 'missing'],
|
||||
['a', 'b', 'c', 'bot', 'missing', 'extra']])
|
||||
assert.throws(() => createMultipartyPublicDeal(players, 'a', targets, 'terms', 2, 'invalid'), PublicDealError);
|
||||
for (const invalid of [
|
||||
{ ...initial, participants: ['a', 'c', 'b'] },
|
||||
{ ...initial, participants: ['a', 'b', 'b'] },
|
||||
{ ...initial, acceptVotes: ['b'] },
|
||||
{ ...initial, acceptVotes: ['a', 'c', 'b'] },
|
||||
{ ...initial, acceptVotes: ['a', 'b', 'b'] },
|
||||
{ ...initial, acceptVotes: ['a', 'b', 'c'] },
|
||||
{ ...initial, status: 'active', acceptVotes: ['a', 'b'] },
|
||||
{ ...initial, status: 'active', acceptVotes: ['a', 'b', 'c'], releaseVotes: ['c', 'b'] },
|
||||
{ ...initial, status: 'released', acceptVotes: ['a', 'b', 'c'], releaseVotes: ['a', 'b'] },
|
||||
{ ...initial, releaseVotes: ['a'] },
|
||||
{ ...initial, acceptVotes: undefined },
|
||||
{ ...initial, extra: true },
|
||||
]) assert.throws(() => validatePublicDeals([invalid], players), PublicDealError);
|
||||
});
|
||||
|
||||
void test('decline and withdrawal require their respective participant and are terminal', () => {
|
||||
const pending = offer();
|
||||
assert.throws(() => withdrawPublicDeal(pending, players, 'b'), PublicDealError);
|
||||
|
||||
Reference in new issue
Block a user