Support multiparty public deals

This commit is contained in:
Quintine committed 2026-10-28 00:48:40 +11:00
1 parent 86c51b4aa7
commit e133a5fe2b
14 files changed
+676 -98

No files matched your search

+62 -3
View File
@@ -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));
});
+114
View File
@@ -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
View File
@@ -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);