Prototype public deal agreements
This commit is contained in:
1 parent
c3f37a574d
commit
86c51b4aa7
15 files changed
+799
-7
No files matched your search
@@ -0,0 +1,107 @@
|
||||
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 type { Action, GameView } from '../game/engine';
|
||||
|
||||
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
const offer = { id: 'deal-1', from: 'a', to: 'b', terms: 'Pay 3 spice later', turn: 2, status: 'offered', releaseVotes: [] };
|
||||
const base = {
|
||||
status: 'playing', me: 'a', roomControl: null,
|
||||
players: [
|
||||
{ id: 'a', name: 'Alice', faction: 'atreides', ally: 'c' },
|
||||
{ id: 'b', name: 'Basil', faction: 'harkonnen' },
|
||||
{ id: 'c', name: 'Chani', faction: 'fremen', ally: 'a' },
|
||||
{ id: 'd', name: 'Duncan', faction: 'guild', bot: 'Medium' },
|
||||
{ id: 'e', name: 'Emperor', faction: 'emperor', ally: 'b' },
|
||||
],
|
||||
deals: [offer],
|
||||
} as unknown as GameView;
|
||||
const record = (calls: Action[]) => (action: Action) => { calls.push(action); };
|
||||
function actions(game: GameView, calls: Action[], disabled = false) {
|
||||
return elements(PublicDealList({ game, act: record(calls), disabled }))
|
||||
.filter(element => typeof element.props.onClick === 'function');
|
||||
}
|
||||
function click(nodes: Element[], label: string) {
|
||||
const node = nodes.find(element => element.props.children === label);
|
||||
assert.ok(node, `Missing ${label}`);
|
||||
(node.props.onClick as () => void)();
|
||||
}
|
||||
function render(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(PublicDeals, { game, act() {}, busy }));
|
||||
}
|
||||
|
||||
void test('deal form exposes human non-allied recipients and clearly describes limits', () => {
|
||||
const html = render(base);
|
||||
assert.match(html, /Public deals/);
|
||||
assert.match(html, /Basil · Harkonnen/);
|
||||
assert.match(html, /Emperor · Emperor/);
|
||||
assert.doesNotMatch(html, /Chani · Fremen/);
|
||||
assert.doesNotMatch(html, /Duncan · Spacing Guild/);
|
||||
assert.match(html, /cannot automatically enforce free-text promises/);
|
||||
assert.match(html, /does not automatically transfer spice/);
|
||||
assert.match(html, /Do not trade cards, forces, leaders or faction powers/);
|
||||
assert.match(html, /maxlength="500"/i);
|
||||
assert.match(html, /Alice \(Atreides\) → Basil \(Harkonnen\)/);
|
||||
assert.match(html, /Offer public deal/);
|
||||
assert.equal(render({ ...base, status: 'lobby' }), '');
|
||||
assert.doesNotMatch(render({ ...base, status: 'finished' }), /Offer public deal/);
|
||||
assert.doesNotMatch(render({ ...base, me: 'd' }), /Offer public deal/);
|
||||
assert.ok((render(base, true).match(/disabled=""/g) ?? []).length >= 3);
|
||||
});
|
||||
|
||||
void test('offer actions are available only to the corresponding parties', () => {
|
||||
const calls: Action[] = [];
|
||||
click(actions(base, calls), 'Withdraw offer');
|
||||
const recipient = { ...base, me: 'b' };
|
||||
assert.match(render(recipient), /your response requested/);
|
||||
const choices = actions(recipient, calls);
|
||||
click(choices, 'Accept deal');
|
||||
click(choices, 'Decline deal');
|
||||
assert.deepEqual(calls, [
|
||||
{ type: 'dealWithdraw', deal: 'deal-1' },
|
||||
{ type: 'dealRespond', deal: 'deal-1', accept: true },
|
||||
{ type: 'dealRespond', deal: 'deal-1', accept: false },
|
||||
]);
|
||||
assert.equal(actions({ ...base, me: 'e' }, calls).length, 0);
|
||||
assert.ok(actions(recipient, calls, true).every(node => node.props.disabled));
|
||||
});
|
||||
|
||||
void test('either party can request, agree to, or withdraw mutual release', () => {
|
||||
const calls: Action[] = [];
|
||||
const active = { ...offer, status: 'active' as const, releaseVotes: [] };
|
||||
const current = { ...base, deals: [active] } as unknown as GameView;
|
||||
click(actions(current, calls), 'Request release of deal');
|
||||
const requested = { ...current, deals: [{ ...active, releaseVotes: ['a'] }] } as unknown as GameView;
|
||||
assert.match(render({ ...requested, me: 'b' }), /your response requested/);
|
||||
click(actions(requested, calls), 'Withdraw release request');
|
||||
click(actions({ ...requested, me: 'b' }, calls), 'Agree to release deal');
|
||||
assert.equal(actions({ ...requested, me: 'c' }, calls).length, 0);
|
||||
assert.deepEqual(calls, [
|
||||
{ type: 'dealRelease', deal: 'deal-1', agree: true },
|
||||
{ type: 'dealRelease', deal: 'deal-1', agree: false },
|
||||
{ type: 'dealRelease', deal: 'deal-1', agree: true },
|
||||
]);
|
||||
const html = render(requested);
|
||||
assert.match(html, /Release requested by Alice/);
|
||||
assert.match(html, /Both parties must agree/);
|
||||
});
|
||||
|
||||
void test('all statuses and public text remain readable without treating text as markup', () => {
|
||||
const game = { ...base, deals: [
|
||||
{ ...offer, terms: '<b>example</b>', status: 'active' },
|
||||
{ ...offer, id: 'deal-2', status: 'released', releaseVotes: ['a', 'b'] },
|
||||
{ ...offer, id: 'deal-3', status: 'declined' },
|
||||
{ ...offer, id: 'deal-4', status: 'withdrawn' },
|
||||
] } as unknown as GameView;
|
||||
const html = render(game);
|
||||
assert.match(html, /<b>example<\/b>/);
|
||||
for (const status of ['active', 'released', 'declined', 'withdrawn']) assert.match(html, new RegExp(status));
|
||||
assert.equal(actions({ ...game, status: 'finished' }, []).length, 0);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { knowledgeGame } from './truthtrance-knowledge-fixture';
|
||||
|
||||
/** Complete a genuine Basic setup with distinct nonallied human factions. */
|
||||
function playing(withBot = false): Game {
|
||||
let game = createGame('DEALS', newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
|
||||
if (withBot) game = applyAction(game, 'a', { type: 'addBot', faction: 'guild', difficulty: 'Easy' });
|
||||
for (const p of game.players.filter(player => !player.bot)) game = applyAction(game, p.id, { type: 'ready' });
|
||||
game = applyAction(game, 'a', { type: 'start' });
|
||||
for (const p of game.players) if (p.traitorChoices.length)
|
||||
game = applyAction(game, p.id, { type: 'traitor', leader: p.traitorChoices[0] });
|
||||
assert.equal(game.status, 'playing');
|
||||
return game;
|
||||
}
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const original = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action), `${action.type} must reject`);
|
||||
assert.deepEqual(game, original, `${action.type} must not mutate rejected input`);
|
||||
}
|
||||
|
||||
function offer(game: Game, from = 'a', to = 'h') {
|
||||
const terms = 'I will not attack your forces this turn.';
|
||||
const next = applyAction(game, from, { type: 'dealOffer', target: to, terms });
|
||||
const deal = next.deals?.at(-1);
|
||||
assert.ok(deal);
|
||||
assert.equal(deal.from, from);
|
||||
assert.equal(deal.to, to);
|
||||
assert.equal(deal.terms, terms);
|
||||
assert.equal(deal.turn, game.turn);
|
||||
assert.equal(deal.status, 'offered');
|
||||
assert.deepEqual(deal.releaseVotes, []);
|
||||
return { game: next, deal };
|
||||
}
|
||||
|
||||
function preservedRules(game: Game) {
|
||||
const { players, phase, turn, active, response, decision, phaseOpening, auction, deck, discard } = game;
|
||||
return structuredClone({ players, phase, turn, active, response, decision, phaseOpening, auction, deck, discard });
|
||||
}
|
||||
|
||||
void test('public offer, consent and mutual release preserve the native game', () => {
|
||||
const original = playing();
|
||||
const baseline = preservedRules(original);
|
||||
const offered = offer(original);
|
||||
let game = offered.game;
|
||||
const deal = offered.deal;
|
||||
assert.ok(deal.id);
|
||||
for (const player of game.players)
|
||||
assert.deepEqual(viewGame(game, player.id).deals.find(item => item.id === deal.id), deal);
|
||||
assert.deepEqual(preservedRules(game), baseline);
|
||||
|
||||
reject(game, 'a', { type: 'dealRespond', deal: deal.id, accept: true });
|
||||
reject(game, 'e', { type: 'dealRespond', deal: deal.id, accept: true });
|
||||
game = applyAction(game, 'h', { type: 'dealRespond', deal: deal.id, accept: true });
|
||||
assert.equal(game.deals!.find(item => item.id === deal.id)!.status, 'active');
|
||||
assert.deepEqual(preservedRules(game), baseline);
|
||||
reject(game, 'a', { type: 'dealWithdraw', deal: deal.id });
|
||||
reject(game, 'e', { type: 'dealRelease', deal: deal.id, agree: true });
|
||||
|
||||
game = applyAction(game, 'a', { type: 'dealRelease', deal: deal.id, agree: true });
|
||||
let active = game.deals!.find(item => item.id === deal.id)!;
|
||||
assert.equal(active.status, 'active');
|
||||
assert.deepEqual(active.releaseVotes, ['a']);
|
||||
game = applyAction(game, 'a', { type: 'dealRelease', deal: deal.id, agree: false });
|
||||
active = game.deals!.find(item => item.id === deal.id)!;
|
||||
assert.equal(active.status, 'active');
|
||||
assert.deepEqual(active.releaseVotes, []);
|
||||
game = applyAction(game, 'h', { type: 'dealRelease', deal: deal.id, agree: true });
|
||||
assert.equal(game.deals!.find(item => item.id === deal.id)!.status, 'active');
|
||||
game = applyAction(game, 'a', { type: 'dealRelease', deal: deal.id, agree: true });
|
||||
assert.equal(game.deals!.find(item => item.id === deal.id)!.status, 'released');
|
||||
assert.deepEqual(preservedRules(game), baseline);
|
||||
reject(game, 'h', { type: 'dealRelease', deal: deal.id, agree: true });
|
||||
});
|
||||
|
||||
void test('declined and withdrawn offers remain public without changing game pieces', () => {
|
||||
const original = playing();
|
||||
const baseline = preservedRules(original);
|
||||
let { game, deal } = offer(original);
|
||||
reject(game, 'h', { type: 'dealWithdraw', deal: deal.id });
|
||||
game = applyAction(game, 'h', { type: 'dealRespond', deal: deal.id, accept: false });
|
||||
assert.equal(game.deals!.find(item => item.id === deal.id)!.status, 'declined');
|
||||
reject(game, 'h', { type: 'dealRespond', deal: deal.id, accept: true });
|
||||
({ game, deal } = offer(game));
|
||||
game = applyAction(game, 'a', { type: 'dealWithdraw', deal: deal.id });
|
||||
assert.equal(game.deals!.find(item => item.id === deal.id)!.status, 'withdrawn');
|
||||
reject(game, 'h', { type: 'dealRespond', deal: deal.id, accept: true });
|
||||
assert.deepEqual(preservedRules(game), baseline);
|
||||
});
|
||||
|
||||
void test('deals reject self, ally, bot, unknown and malformed parties or packets', () => {
|
||||
const game = playing(true);
|
||||
const bot = game.players.find(player => player.bot)!;
|
||||
for (const [actor, action] of [
|
||||
['a', { type: 'dealOffer', target: 'a', terms: 'Self' }],
|
||||
['a', { type: 'dealOffer', target: bot.id, terms: 'Bot' }],
|
||||
[bot.id, { type: 'dealOffer', target: 'a', terms: 'Bot' }],
|
||||
['missing', { type: 'dealOffer', target: 'a', terms: 'Forged' }],
|
||||
['a', { type: 'dealOffer', target: 'missing', terms: 'Forged' }],
|
||||
['a', { type: 'dealOffer', target: 'h', terms: '' }],
|
||||
['a', { type: 'dealOffer', target: 'h', terms: ' ' }],
|
||||
['a', { type: 'dealOffer', target: 'h', terms: null }],
|
||||
['a', { type: 'dealOffer', target: 'h', terms: 'Terms', from: 'e' }],
|
||||
['h', { type: 'dealRespond', deal: 'stale', accept: true }],
|
||||
] as Array<[string, Action]>) reject(game, actor, action);
|
||||
|
||||
const allied = structuredClone(game);
|
||||
allied.players.find(player => player.id === 'a')!.ally = 'h';
|
||||
allied.players.find(player => player.id === 'h')!.ally = 'a';
|
||||
reject(allied, 'a', { type: 'dealOffer', target: 'h', terms: 'Agreement' });
|
||||
const separatelyAllied = structuredClone(game);
|
||||
separatelyAllied.players.find(player => player.id === 'a')!.ally = 'e';
|
||||
separatelyAllied.players.find(player => player.id === 'e')!.ally = 'a';
|
||||
const allowed = offer(separatelyAllied, 'a', 'h');
|
||||
assert.equal(allowed.deal.status, 'offered');
|
||||
const proposed = offer(game);
|
||||
const formed = structuredClone(proposed.game);
|
||||
formed.players.find(player => player.id === 'a')!.ally = 'h';
|
||||
formed.players.find(player => player.id === 'h')!.ally = 'a';
|
||||
reject(formed, 'h', { type: 'dealRespond', deal: proposed.deal.id, accept: true });
|
||||
reject(proposed.game, 'h', { type: 'dealRespond', deal: proposed.deal.id, accept: 'yes' });
|
||||
reject(proposed.game, 'h', { type: 'dealRespond', deal: proposed.deal.id, accept: true, from: 'e' });
|
||||
reject(proposed.game, 'a', { type: 'dealWithdraw', deal: proposed.deal.id, extra: true });
|
||||
});
|
||||
|
||||
void test('public deal negotiation leaves a native Truthtrance priority response resumable', () => {
|
||||
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 original = structuredClone(paused.truthtrance);
|
||||
const before = preservedRules(paused);
|
||||
const proposal = offer(paused, 'b', 'f');
|
||||
assert.deepEqual(proposal.game.truthtrance, original);
|
||||
assert.deepEqual(preservedRules(proposal.game), before);
|
||||
const declined = applyAction(proposal.game, 'f', { type: 'dealRespond', deal: proposal.deal.id, accept: false });
|
||||
assert.deepEqual(declined.truthtrance, original);
|
||||
const passer = declined.players.find(player => !declined.truthtrance!.passed.includes(player.id));
|
||||
assert.ok(passer);
|
||||
const resumed = applyAction(declined, passer.id, { type: 'truthPass' });
|
||||
assert.ok(resumed.truthtrance);
|
||||
assert.ok(resumed.truthtrance.passed.includes(passer.id));
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
// @dune-suite integration
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { GameView } from '../game/engine';
|
||||
|
||||
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
|
||||
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('public deals use authenticated seats, persist for observers, and preserve normal gameplay', async () => {
|
||||
const host = await request('/api/rooms', undefined, {
|
||||
name: 'Deal host', faction: 'atreides', advanced: false, expansions: [],
|
||||
});
|
||||
assert.equal(host.status, 201); assert.ok(host.cookie);
|
||||
const path = `/api/rooms/${host.data.code}`;
|
||||
const guest = await request(path, undefined, { type: 'join', name: 'Deal recipient', faction: 'harkonnen' });
|
||||
assert.equal(guest.status, 200); assert.ok(guest.cookie);
|
||||
const observer = await request(path, undefined, { type: 'join', name: 'Deal observer', faction: 'emperor' });
|
||||
assert.equal(observer.status, 200); assert.ok(observer.cookie);
|
||||
const seats = [
|
||||
{ id: host.data.me, cookie: host.cookie },
|
||||
{ id: guest.data.me, cookie: guest.cookie },
|
||||
{ id: observer.data.me, cookie: observer.cookie },
|
||||
];
|
||||
let view = observer.data;
|
||||
const act = async (cookie: string, action: object) => {
|
||||
const next = await request(path, cookie, { version: view.version, action });
|
||||
assert.equal(next.status, 200, JSON.stringify(next.data));
|
||||
view = next.data;
|
||||
};
|
||||
for (const seat of seats) await act(seat.cookie, { type: 'ready' });
|
||||
await act(host.cookie, { type: 'start' });
|
||||
for (const seat of seats) {
|
||||
const own = await request(path, seat.cookie);
|
||||
assert.equal(own.status, 200);
|
||||
view = own.data;
|
||||
const choices = own.data.players.find(player => player.id === seat.id)?.traitorChoices;
|
||||
if (choices?.length) await act(seat.cookie, { 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 })),
|
||||
};
|
||||
|
||||
await act(host.cookie, { type: 'dealOffer', target: guest.data.me, terms: 'I will not attack your forces this turn.' });
|
||||
const offer = view.deals.at(-1);
|
||||
assert.ok(offer);
|
||||
assert.equal(offer.status, 'offered');
|
||||
assert.equal(offer.from, host.data.me);
|
||||
assert.equal(offer.to, guest.data.me);
|
||||
for (const seat of seats) {
|
||||
const read = await request(path, seat.cookie);
|
||||
assert.equal(read.status, 200);
|
||||
assert.deepEqual(read.data.deals.find(deal => deal.id === offer.id), offer);
|
||||
}
|
||||
|
||||
// The selected seat header is not a credential, and an authenticated
|
||||
// bystander cannot answer on the recipient's behalf.
|
||||
const accept = { version: view.version, action: { type: 'dealRespond', deal: offer.id, accept: true } };
|
||||
assert.equal((await request(path, host.cookie, accept, guest.data.me)).status, 409);
|
||||
assert.equal((await request(path, observer.cookie, accept)).status, 409);
|
||||
const unchanged = await request(path, guest.cookie);
|
||||
assert.equal(unchanged.status, 200);
|
||||
assert.equal(unchanged.data.version, view.version);
|
||||
assert.equal(unchanged.data.deals.find(deal => deal.id === offer.id)?.status, 'offered');
|
||||
await act(guest.cookie, accept.action);
|
||||
assert.equal(view.deals.find(deal => deal.id === offer.id)?.status, 'active');
|
||||
await act(host.cookie, { type: 'dealRelease', deal: offer.id, agree: true });
|
||||
assert.equal(view.deals.find(deal => deal.id === offer.id)?.status, 'active');
|
||||
await act(guest.cookie, { type: 'dealRelease', deal: offer.id, agree: true });
|
||||
assert.equal(view.deals.find(deal => deal.id === offer.id)?.status, 'released');
|
||||
const restored = await request(path, observer.cookie);
|
||||
assert.equal(restored.status, 200);
|
||||
assert.equal(restored.data.deals.find(deal => deal.id === offer.id)?.status, 'released');
|
||||
assert.deepEqual({
|
||||
phase: restored.data.phase, turn: restored.data.turn, active: restored.data.active, storm: restored.data.storm,
|
||||
stormDialers: restored.data.stormDialers, stormSubmitted: restored.data.stormSubmitted,
|
||||
players: restored.data.players.map(({ id, faction, ready }) => ({ id, faction, ready })),
|
||||
}, baseline);
|
||||
|
||||
// A native storm dial still succeeds after the agreement lifecycle.
|
||||
const dialer = seats.find(seat => restored.data.stormDialers.includes(seat.id));
|
||||
assert.ok(dialer);
|
||||
view = restored.data;
|
||||
await act(dialer.cookie, { type: 'stormDial', amount: 0 });
|
||||
assert.ok(view.stormSubmitted.includes(dialer.id) || view.phase !== 0);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
PublicDealError, createPublicDeal, releasePublicDeal, respondPublicDeal,
|
||||
validatePublicDeals, withdrawPublicDeal,
|
||||
} from '../game/public-deals';
|
||||
|
||||
const players = [
|
||||
{ id: 'a', ally: null }, { id: 'b', ally: null }, { id: 'c', ally: null },
|
||||
{ id: 'bot', bot: 'Medium', ally: null },
|
||||
];
|
||||
const offer = () => createPublicDeal(players, 'a', 'b', ' Trade two spice later. ', 2, 'event-1');
|
||||
|
||||
void test('an explicit offer and recipient response preserve input and do not form an alliance', () => {
|
||||
const before = structuredClone(players);
|
||||
const pending = offer();
|
||||
assert.deepEqual(pending, { id: 'event-1', from: 'a', to: 'b', terms: 'Trade two spice later.', turn: 2, status: 'offered', releaseVotes: [] });
|
||||
const original = structuredClone(pending);
|
||||
assert.throws(() => respondPublicDeal(pending, players, 'a', true), PublicDealError);
|
||||
assert.throws(() => respondPublicDeal(pending, players, 'c', false), PublicDealError);
|
||||
const active = respondPublicDeal(pending, players, 'b', true);
|
||||
assert.equal(active.status, 'active');
|
||||
assert.deepEqual(pending, original);
|
||||
assert.deepEqual(players, before);
|
||||
validatePublicDeals([pending, { ...active, id: 'event-2' }], players);
|
||||
assert.throws(() => respondPublicDeal(active, players, 'b', true), PublicDealError);
|
||||
});
|
||||
|
||||
void test('decline and withdrawal require their respective participant and are terminal', () => {
|
||||
const pending = offer();
|
||||
assert.throws(() => withdrawPublicDeal(pending, players, 'b'), PublicDealError);
|
||||
const declined = respondPublicDeal(pending, players, 'b', false);
|
||||
const withdrawn = withdrawPublicDeal(pending, players, 'a');
|
||||
assert.equal(declined.status, 'declined');
|
||||
assert.equal(withdrawn.status, 'withdrawn');
|
||||
for (const deal of [declined, withdrawn]) {
|
||||
validatePublicDeals([deal], players);
|
||||
assert.throws(() => releasePublicDeal(deal, players, 'a', true), PublicDealError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('only both participants can release an accepted deal and a prior vote may be withdrawn', () => {
|
||||
const initial = respondPublicDeal(offer(), players, 'b', true);
|
||||
const aVotes = releasePublicDeal(initial, players, 'a', true);
|
||||
assert.deepEqual(aVotes.releaseVotes, ['a']);
|
||||
assert.deepEqual(initial.releaseVotes, []);
|
||||
assert.throws(() => releasePublicDeal(aVotes, players, 'c', true), PublicDealError);
|
||||
const aWithdraws = releasePublicDeal(aVotes, players, 'a', false);
|
||||
assert.deepEqual(aWithdraws.releaseVotes, []);
|
||||
const bVotes = releasePublicDeal(aWithdraws, players, 'b', true);
|
||||
assert.deepEqual(bVotes.releaseVotes, ['b']);
|
||||
const released = releasePublicDeal(bVotes, players, 'a', true);
|
||||
assert.equal(released.status, 'released');
|
||||
assert.deepEqual(new Set(released.releaseVotes), new Set(['a', 'b']));
|
||||
validatePublicDeals([released], players);
|
||||
assert.throws(() => releasePublicDeal(released, players, 'a', false), PublicDealError);
|
||||
});
|
||||
|
||||
void test('alliance of the two parties prevents new and accepted deals, but not decline or release', () => {
|
||||
const pending = offer();
|
||||
const between = players.map(player => player.id === 'a' ? { ...player, ally: 'b' } : player);
|
||||
assert.throws(() => createPublicDeal(between, 'a', 'b', 'terms', 2, 'x'), PublicDealError);
|
||||
assert.throws(() => respondPublicDeal(pending, between, 'b', true), PublicDealError);
|
||||
assert.equal(respondPublicDeal(pending, between, 'b', false).status, 'declined');
|
||||
const active = respondPublicDeal(pending, players, 'b', true);
|
||||
const first = releasePublicDeal(active, between, 'a', true);
|
||||
assert.equal(releasePublicDeal(first, between, 'b', true).status, 'released');
|
||||
const otherAlliance = players.map(player => player.id === 'a' ? { ...player, ally: 'c' } : player);
|
||||
assert.equal(createPublicDeal(otherAlliance, 'a', 'b', 'terms', 2, 'y').status, 'offered');
|
||||
});
|
||||
|
||||
void test('strict ledger and creation validation reject malformed or forged records', () => {
|
||||
validatePublicDeals([], [players[0]]);
|
||||
for (const [actor, target, terms, turn, event] of [
|
||||
['a', 'a', 'x', 1, 'e'], ['a', 'bot', 'x', 1, 'e'], ['bot', 'a', 'x', 1, 'e'],
|
||||
['a', 'missing', 'x', 1, 'e'], ['a', 'b', ' ', 1, 'e'], ['a', 'b', 'x'.repeat(501), 1, 'e'],
|
||||
['a', 'b', 'x', 0, 'e'], ['a', 'b', 'x', 1, ' e '],
|
||||
] as Array<[string, string, string, number, string]>)
|
||||
assert.throws(() => createPublicDeal(players, actor, target, terms, turn, event), PublicDealError);
|
||||
const pending = offer();
|
||||
for (const ledger of [
|
||||
[pending, pending], [{ ...pending, unexpected: true }], [{ ...pending, releaseVotes: ['c'] }],
|
||||
[{ ...pending, status: 'active', releaseVotes: ['a', 'a'] }],
|
||||
[{ ...pending, status: 'released', releaseVotes: ['a'] }],
|
||||
[{ ...pending, status: 'offered', releaseVotes: ['a'] }],
|
||||
]) assert.throws(() => validatePublicDeals(ledger, players), PublicDealError);
|
||||
});
|
||||
Reference in new issue
Block a user