Support consensual lobby faction swaps
This commit is contained in:
1 parent
4dc2df2513
commit
c6b4a165bd
15 files changed
+680
-14
No files matched your search
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { FactionSwap } from '../components/faction-swap';
|
||||
import type { Action, GameView } from '../game/engine';
|
||||
|
||||
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
|
||||
const 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 base = {
|
||||
status: 'lobby', me: 'a', factionDraft: null, factionSwap: null,
|
||||
players: [
|
||||
{ id: 'a', name: 'Alice', faction: 'atreides' },
|
||||
{ id: 'b', name: 'Basil', faction: 'harkonnen' },
|
||||
{ id: 'c', name: 'Chani', faction: 'fremen' },
|
||||
{ id: 'd', name: 'Computer', faction: 'guild', bot: 'Medium' },
|
||||
],
|
||||
} as unknown as GameView;
|
||||
const swap = { event: 'swap-1', from: 'a', to: 'b', fromFaction: 'atreides', toFaction: 'harkonnen' };
|
||||
const send = async (_action: Action) => {};
|
||||
const render = (game: GameView, busy = false) => renderToStaticMarkup(createElement(FactionSwap, { game, send, busy }));
|
||||
const controls = (game: GameView, sendAction = send, busy = false) =>
|
||||
elements(FactionSwap({ game, send: sendAction, busy })).filter((element) => typeof element.props.onClick === 'function');
|
||||
const click = (nodes: Element[], label: string) => {
|
||||
const button = nodes.find((node) => node.props.children === label);
|
||||
assert.ok(button, `Expected action ${label}`);
|
||||
(button.props.onClick as () => void)();
|
||||
};
|
||||
|
||||
void test('offers target other human seats and require the recipient to accept', () => {
|
||||
const calls: Action[] = [];
|
||||
const nodes = controls(base, async (action) => { calls.push(action); });
|
||||
assert.deepEqual(nodes.map((node) => node.props.children), [
|
||||
'Offer swap to Basil (Harkonnen)', 'Offer swap to Chani (Fremen)',
|
||||
]);
|
||||
assert.match(render(base), /other player must accept/);
|
||||
assert.match(render(base), /AI seats cannot receive swap offers/);
|
||||
assert.doesNotMatch(render({ ...base, status: 'playing' }), /Swap factions/);
|
||||
click(nodes, 'Offer swap to Basil (Harkonnen)');
|
||||
assert.deepEqual(calls, [{ type: 'factionSwapOffer', target: 'b' }]);
|
||||
});
|
||||
|
||||
void test('offers are unavailable during a draft or while busy', () => {
|
||||
const draft = { event: 'draft-1', order: [], picks: [] };
|
||||
const inDraft = { ...base, factionDraft: draft } as unknown as GameView;
|
||||
assert.match(render(inDraft), /Finish or cancel the faction draft/);
|
||||
for (const node of [...controls(inDraft), ...controls(base, send, true)]) assert.equal(node.props.disabled, true);
|
||||
const alone = { ...base, players: [base.players[0], base.players[3]] };
|
||||
assert.match(render(alone), /second human seat/);
|
||||
assert.equal(controls(alone).length, 0);
|
||||
});
|
||||
|
||||
void test('the pending offer is visible to everyone, with consent and cancellation limited to each party', () => {
|
||||
const calls: Action[] = [];
|
||||
const record = async (action: Action) => { calls.push(action); };
|
||||
const pending = { ...base, factionSwap: swap } as unknown as GameView;
|
||||
for (const me of ['a', 'b', 'c']) {
|
||||
const html = render({ ...pending, me });
|
||||
assert.match(html, /Alice offers to exchange Atreides with Basil's Harkonnen/);
|
||||
assert.match(html, /change only if Basil accepts/);
|
||||
}
|
||||
const sender = controls(pending, record);
|
||||
const recipient = controls({ ...pending, me: 'b' }, record);
|
||||
assert.deepEqual(sender.map((node) => node.props.children), ['Cancel swap offer']);
|
||||
assert.deepEqual(recipient.map((node) => node.props.children), ['Accept swap', 'Decline swap']);
|
||||
assert.equal(controls({ ...pending, me: 'c' }, record).length, 0);
|
||||
click(sender, 'Cancel swap offer');
|
||||
click(recipient, 'Accept swap');
|
||||
click(recipient, 'Decline swap');
|
||||
assert.deepEqual(calls, [
|
||||
{ type: 'factionSwapCancel', event: 'swap-1' },
|
||||
{ type: 'factionSwapRespond', event: 'swap-1', accept: true },
|
||||
{ type: 'factionSwapRespond', event: 'swap-1', accept: false },
|
||||
]);
|
||||
for (const node of controls({ ...pending, me: 'b' }, record, true)) assert.equal(node.props.disabled, true);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, joinControlledFaction, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
|
||||
function lobby(): Game {
|
||||
const game = createGame('SWAPTEST', newPlayer('host', 'Host', 'atreides'));
|
||||
joinGame(game, newPlayer('guest', 'Guest', 'harkonnen'));
|
||||
joinGame(game, newPlayer('third', 'Third', 'emperor'));
|
||||
return game;
|
||||
}
|
||||
|
||||
function readyAll(game: Game): Game {
|
||||
for (const player of game.players.filter(player => !player.bot)) {
|
||||
game = applyAction(game, player.id, { type: 'ready' });
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
function proposal(game: Game) {
|
||||
assert.ok(game.factionSwap);
|
||||
return game.factionSwap;
|
||||
}
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, `${action.type} must not mutate its input when rejected`);
|
||||
}
|
||||
|
||||
function identities(game: Game) {
|
||||
return game.players.map(({ id, name, faction, ready }) => ({ id, name, faction, ready }));
|
||||
}
|
||||
|
||||
void test('an accepted offer exchanges faction assets, preserves seats and circles, and can start', () => {
|
||||
let game = readyAll(lobby());
|
||||
const positions = structuredClone(game.playerPositions);
|
||||
const initial = identities(game);
|
||||
game = applyAction(game, 'guest', { type: 'factionSwapOffer', target: 'host' });
|
||||
const offer = proposal(game);
|
||||
assert.equal(typeof offer.event, 'string');
|
||||
assert.ok(offer.event.length > 0);
|
||||
assert.deepEqual({ ...offer, event: '<event>' }, {
|
||||
event: '<event>', from: 'guest', to: 'host', fromFaction: 'harkonnen', toFaction: 'atreides',
|
||||
});
|
||||
assert.deepEqual(identities(game), initial);
|
||||
assert.deepEqual(viewGame(game, 'third').factionSwap, offer);
|
||||
|
||||
game = applyAction(game, 'host', { type: 'factionSwapRespond', event: offer.event, accept: true });
|
||||
assert.equal(game.factionSwap, undefined);
|
||||
assert.equal(game.host, 'host');
|
||||
assert.deepEqual(game.playerPositions, positions);
|
||||
assert.deepEqual(game.players.map(({ id, name, faction, ready }) => ({ id, name, faction, ready })), [
|
||||
{ id: 'host', name: 'Host', faction: 'harkonnen', ready: false },
|
||||
{ id: 'guest', name: 'Guest', faction: 'atreides', ready: false },
|
||||
{ id: 'third', name: 'Third', faction: 'emperor', ready: false },
|
||||
]);
|
||||
assert.deepEqual(game.players[0].leaders, newPlayer('x', 'X', 'harkonnen').leaders);
|
||||
assert.deepEqual(game.players[1].leaders, newPlayer('x', 'X', 'atreides').leaders);
|
||||
game = readyAll(game);
|
||||
game = applyAction(game, 'host', { type: 'start' });
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.equal(game.players.find(player => player.id === 'host')!.faction, 'harkonnen');
|
||||
assert.equal(game.players.find(player => player.id === 'guest')!.faction, 'atreides');
|
||||
});
|
||||
|
||||
void test('declining and cancelling preserve factions and existing readiness', () => {
|
||||
let game = readyAll(lobby());
|
||||
const before = identities(game);
|
||||
game = applyAction(game, 'guest', { type: 'factionSwapOffer', target: 'host' });
|
||||
const first = proposal(game).event;
|
||||
game = applyAction(game, 'host', { type: 'factionSwapRespond', event: first, accept: false });
|
||||
assert.equal(game.factionSwap, undefined);
|
||||
assert.deepEqual(identities(game), before);
|
||||
game = applyAction(game, 'guest', { type: 'factionSwapOffer', target: 'host' });
|
||||
const second = proposal(game).event;
|
||||
assert.notEqual(first, second);
|
||||
rejected(game, 'host', { type: 'factionSwapRespond', event: first, accept: true });
|
||||
rejected(game, 'guest', { type: 'factionSwapCancel', event: first });
|
||||
game = applyAction(game, 'guest', { type: 'factionSwapCancel', event: second });
|
||||
assert.equal(game.factionSwap, undefined);
|
||||
assert.deepEqual(identities(game), before);
|
||||
rejected(game, 'host', { type: 'factionSwapRespond', event: second, accept: true });
|
||||
});
|
||||
|
||||
void test('swap actions reject forged parties, payloads, stale events and AI seats without mutation', () => {
|
||||
let game = lobby();
|
||||
game = applyAction(game, 'host', { type: 'addBot', faction: 'guild', difficulty: 'Easy' });
|
||||
const bot = game.players.find(player => player.bot)!;
|
||||
for (const [actor, action] of [
|
||||
['host', { type: 'factionSwapOffer', target: 'host' }],
|
||||
['host', { type: 'factionSwapOffer', target: 'missing' }],
|
||||
['host', { type: 'factionSwapOffer', target: bot.id }],
|
||||
[bot.id, { type: 'factionSwapOffer', target: 'host' }],
|
||||
['missing', { type: 'factionSwapOffer', target: 'host' }],
|
||||
['host', { type: 'factionSwapOffer', target: 'guest', from: 'third' }],
|
||||
['guest', { type: 'factionSwapRespond', event: 'missing', accept: true }],
|
||||
['host', { type: 'factionSwapCancel', event: 'missing' }],
|
||||
] as Array<[string, Action]>) rejected(game, actor, action);
|
||||
|
||||
game = applyAction(game, 'host', { type: 'factionSwapOffer', target: 'guest' });
|
||||
const event = proposal(game).event;
|
||||
for (const [actor, action] of [
|
||||
['host', { type: 'factionSwapRespond', event, accept: true }],
|
||||
['third', { type: 'factionSwapRespond', event, accept: true }],
|
||||
['guest', { type: 'factionSwapCancel', event }],
|
||||
['third', { type: 'factionSwapCancel', event }],
|
||||
['guest', { type: 'factionSwapRespond', event: 'forged', accept: true }],
|
||||
['guest', { type: 'factionSwapRespond', event, accept: 'true' }],
|
||||
['guest', { type: 'factionSwapRespond', event, accept: true, target: 'third' }],
|
||||
['host', { type: 'factionSwapCancel', event, target: 'third' }],
|
||||
['third', { type: 'factionSwapOffer', target: 'guest' }],
|
||||
] as Array<[string, Action]>) rejected(game, actor, action);
|
||||
});
|
||||
|
||||
void test('an outstanding offer freezes conflicting lobby actions and joining', () => {
|
||||
let game = readyAll(lobby());
|
||||
game = applyAction(game, 'host', { type: 'factionSwapOffer', target: 'guest' });
|
||||
for (const [actor, action] of [
|
||||
['guest', { type: 'faction', faction: 'guild' }],
|
||||
['third', { type: 'faction', faction: 'fremen' }],
|
||||
['host', { type: 'factionAllocation', mode: 'random' }],
|
||||
['host', { type: 'factionAllocation', mode: 'draft' }],
|
||||
['host', { type: 'addBot', faction: 'guild', difficulty: 'Easy' }],
|
||||
['host', { type: 'seatPosition', position: 6 }],
|
||||
['host', { type: 'start' }],
|
||||
] as Array<[string, Action]>) rejected(game, actor, action);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => joinGame(game, newPlayer('new', 'New', 'guild')));
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
|
||||
void test('Ixian elite reserves and faction leaders follow the faction on acceptance', () => {
|
||||
let game = createGame('SWAPIX', newPlayer('host', 'Host', 'atreides'), false, ['ix']);
|
||||
joinGame(game, newPlayer('ix', 'Ix', 'ixians'));
|
||||
game = applyAction(game, 'host', { type: 'factionSwapOffer', target: 'ix' });
|
||||
game = applyAction(game, 'ix', { type: 'factionSwapRespond', event: proposal(game).event, accept: true });
|
||||
assert.deepEqual(game.players[0].elites, { reserves: 7, tanks: 0, forces: {}, revived: 0 });
|
||||
assert.equal(game.players[1].elites, undefined);
|
||||
assert.deepEqual(game.players[0].leaders, newPlayer('x', 'X', 'ixians').leaders);
|
||||
assert.deepEqual(game.players[1].leaders, newPlayer('x', 'X', 'atreides').leaders);
|
||||
});
|
||||
|
||||
void test('two seats with one controller may exchange factions without changing their ownership', () => {
|
||||
let game = createGame('SWAPTWO', newPlayer('a', 'A', 'atreides'));
|
||||
game = applyAction(game, 'a', { type: 'twoPlayerMode', enabled: true });
|
||||
joinControlledFaction(game, newPlayer('b', 'A second', 'emperor'), 'a');
|
||||
joinGame(game, newPlayer('c', 'C', 'guild'));
|
||||
joinControlledFaction(game, newPlayer('d', 'C second', 'harkonnen'), 'c');
|
||||
const controllers = structuredClone(game.twoPlayerControllers);
|
||||
const positions = structuredClone(game.playerPositions);
|
||||
game = applyAction(game, 'a', { type: 'factionSwapOffer', target: 'b' });
|
||||
game = applyAction(game, 'b', { type: 'factionSwapRespond', event: proposal(game).event, accept: true });
|
||||
assert.deepEqual(game.twoPlayerControllers, controllers);
|
||||
assert.deepEqual(game.playerPositions, positions);
|
||||
assert.equal(game.players.find(player => player.id === 'a')!.faction, 'emperor');
|
||||
assert.equal(game.players.find(player => player.id === 'b')!.faction, 'atreides');
|
||||
assert.deepEqual(viewGame(game, 'b').controlledFactionSeats.map(seat => seat.id), ['a', 'b']);
|
||||
game = readyAll(game);
|
||||
game = applyAction(game, 'a', { type: 'start' });
|
||||
assert.equal(game.status, 'setup');
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
// @dune-suite integration
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
|
||||
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
|
||||
const receipt = () => ({ operationId: randomUUID(), sessionToken: randomBytes(32).toString('hex') });
|
||||
type Wire = {
|
||||
code: string;
|
||||
me: string;
|
||||
version: number;
|
||||
players: { id: string; faction: string; ready: boolean }[];
|
||||
factionSwap?: { event: string; from: string; to: string; fromFaction: string; toFaction: string } | null;
|
||||
controlledFactionSeats?: { id: string; faction: string }[];
|
||||
error?: string;
|
||||
};
|
||||
async function request(path: string, body?: unknown, cookie?: string, selected?: string) {
|
||||
const response = await fetch(base + path, {
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
headers: {
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(cookie ? { cookie } : {}),
|
||||
...(selected ? { 'X-Dune-Seat': selected } : {}),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
return { status: response.status, data: await response.json() as Wire,
|
||||
cookie: response.headers.get('set-cookie')?.split(';')[0] };
|
||||
}
|
||||
|
||||
void test('faction swaps require the recipient seat credential and preserve controlled seat ownership', async () => {
|
||||
const host = await request('/api/rooms', { name: 'Swap host', faction: 'atreides', advanced: false, expansions: [], entry: receipt() });
|
||||
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, { version: host.data.version, action: { type: 'twoPlayerMode', enabled: true } }, host.cookie, host.data.me);
|
||||
assert.equal(enabled.status, 200, JSON.stringify(enabled.data));
|
||||
const hostSecond = await request(path, { type: 'addControlledFaction', version: enabled.data.version, name: 'Host second', faction: 'harkonnen', entry: receipt() }, host.cookie, 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, { type: 'join', name: 'Other person', faction: 'emperor', entry: receipt() });
|
||||
assert.equal(guest.status, 200, JSON.stringify(guest.data));
|
||||
assert.ok(guest.cookie);
|
||||
const guestSecond = await request(path, { type: 'addControlledFaction', version: guest.data.version, name: 'Other second', faction: 'fremen', entry: receipt() }, guest.cookie, 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, cookies: hostCookies }, { id: hostSecond.data.me, cookies: hostCookies },
|
||||
{ id: guest.data.me, cookies: guestCookies }, { id: guestSecond.data.me, cookies: guestCookies },
|
||||
];
|
||||
let latest = guestSecond.data;
|
||||
for (const seat of seats) {
|
||||
const ready = await request(path, { version: latest.version, action: { type: 'ready' } }, seat.cookies, seat.id);
|
||||
assert.equal(ready.status, 200, JSON.stringify(ready.data));
|
||||
latest = ready.data;
|
||||
}
|
||||
assert.ok(latest.players.every(player => player.ready));
|
||||
const initial = latest.players.map(({ id, faction, ready }) => ({ id, faction, ready }));
|
||||
const offered = await request(path, { version: latest.version, action: { type: 'factionSwapOffer', target: guest.data.me } }, hostCookies, host.data.me);
|
||||
assert.equal(offered.status, 200, JSON.stringify(offered.data));
|
||||
assert.ok(offered.data.factionSwap);
|
||||
const offer = offered.data.factionSwap;
|
||||
assert.deepEqual({ ...offer, event: '<event>' }, {
|
||||
event: '<event>', from: host.data.me, to: guest.data.me, fromFaction: 'atreides', toFaction: 'emperor',
|
||||
});
|
||||
assert.deepEqual(offered.data.players.map(({ id, faction, ready }) => ({ id, faction, ready })), initial);
|
||||
|
||||
// A seat selector is not a credential; neither a forged selector nor another
|
||||
// authenticated seat is allowed to accept the recipient's offer.
|
||||
const response = { version: offered.data.version, action: { type: 'factionSwapRespond', event: offer.event, accept: true } };
|
||||
const forged = await request(path, response, hostCookies, guest.data.me);
|
||||
assert.equal(forged.status, 409, JSON.stringify(forged.data));
|
||||
const wrongSeat = await request(path, response, guestCookies, guestSecond.data.me);
|
||||
assert.equal(wrongSeat.status, 409, JSON.stringify(wrongSeat.data));
|
||||
const stillOffered = await request(path, undefined, guestCookies, guest.data.me);
|
||||
assert.equal(stillOffered.status, 200, JSON.stringify(stillOffered.data));
|
||||
assert.equal(stillOffered.data.version, offered.data.version);
|
||||
assert.deepEqual(stillOffered.data.factionSwap, offer);
|
||||
const accepted = await request(path, response, guestCookies, guest.data.me);
|
||||
assert.equal(accepted.status, 200, JSON.stringify(accepted.data));
|
||||
assert.ok(!accepted.data.factionSwap);
|
||||
assert.deepEqual(accepted.data.players.map(player => player.id), initial.map(player => player.id));
|
||||
assert.equal(accepted.data.players.find(player => player.id === host.data.me)?.faction, 'emperor');
|
||||
assert.equal(accepted.data.players.find(player => player.id === guest.data.me)?.faction, 'atreides');
|
||||
assert.equal(accepted.data.players.find(player => player.id === hostSecond.data.me)?.faction, 'harkonnen');
|
||||
assert.equal(accepted.data.players.find(player => player.id === guestSecond.data.me)?.faction, 'fremen');
|
||||
assert.ok(accepted.data.players.every(player => !player.ready));
|
||||
for (const seat of seats) {
|
||||
const view = await request(path, undefined, seat.cookies, seat.id);
|
||||
assert.equal(view.status, 200, JSON.stringify(view.data));
|
||||
assert.equal(view.data.me, seat.id);
|
||||
assert.ok(view.data.controlledFactionSeats?.some(own => own.id === seat.id));
|
||||
}
|
||||
|
||||
// A single controller still switches to the other independently authenticated
|
||||
// seat to respond; declining does not alter the assignments or readiness.
|
||||
latest = accepted.data;
|
||||
for (const seat of seats) {
|
||||
const ready = await request(path, { version: latest.version, action: { type: 'ready' } }, seat.cookies, seat.id);
|
||||
assert.equal(ready.status, 200, JSON.stringify(ready.data));
|
||||
latest = ready.data;
|
||||
}
|
||||
const beforeDecline = latest.players.map(({ id, faction, ready }) => ({ id, faction, ready }));
|
||||
const secondOffer = await request(path, { version: latest.version, action: { type: 'factionSwapOffer', target: hostSecond.data.me } }, hostCookies, host.data.me);
|
||||
assert.equal(secondOffer.status, 200, JSON.stringify(secondOffer.data));
|
||||
assert.ok(secondOffer.data.factionSwap);
|
||||
const decline = { version: secondOffer.data.version, action: { type: 'factionSwapRespond', event: secondOffer.data.factionSwap.event, accept: false } };
|
||||
const missingCredential = await request(path, decline, host.cookie, hostSecond.data.me);
|
||||
assert.equal(missingCredential.status, 409, JSON.stringify(missingCredential.data));
|
||||
const declined = await request(path, decline, hostCookies, hostSecond.data.me);
|
||||
assert.equal(declined.status, 200, JSON.stringify(declined.data));
|
||||
assert.ok(!declined.data.factionSwap);
|
||||
assert.deepEqual(declined.data.players.map(({ id, faction, ready }) => ({ id, faction, ready })), beforeDecline);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
answerFactionSwap, cancelFactionSwap, createFactionSwap, FactionSwapError,
|
||||
validateFactionSwap, type FactionSwapSeat,
|
||||
} from '../game/faction-swap';
|
||||
|
||||
const roster: FactionSwapSeat[] = [
|
||||
{ id: 'one', faction: 'atreides' },
|
||||
{ id: 'two', faction: 'harkonnen' },
|
||||
{ id: 'three', faction: 'guild' },
|
||||
];
|
||||
|
||||
void test('an offer snapshots the public identities and current faction assignments', () => {
|
||||
const players = structuredClone(roster);
|
||||
const offer = createFactionSwap(players, 'one', 'two', 'swap:1');
|
||||
assert.deepEqual(offer, {
|
||||
event: 'swap:1', from: 'one', to: 'two', fromFaction: 'atreides', toFaction: 'harkonnen',
|
||||
});
|
||||
assert.deepEqual(players, roster);
|
||||
validateFactionSwap(JSON.parse(JSON.stringify(offer)), players);
|
||||
});
|
||||
|
||||
void test('only the recipient may accept or reject and acceptance swaps precisely two factions', () => {
|
||||
const players = structuredClone(roster);
|
||||
const offer = createFactionSwap(players, 'one', 'two', 'swap:1');
|
||||
const original = structuredClone(offer);
|
||||
assert.deepEqual(answerFactionSwap(players, offer, 'two', true), {
|
||||
accepted: true, assignments: [
|
||||
{ player: 'one', faction: 'harkonnen' },
|
||||
{ player: 'two', faction: 'atreides' },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(answerFactionSwap(players, offer, 'two', false), { accepted: false, assignments: [] });
|
||||
assert.throws(() => answerFactionSwap(players, offer, 'one', true), FactionSwapError);
|
||||
assert.throws(() => answerFactionSwap(players, offer, 'three', false), FactionSwapError);
|
||||
assert.throws(() => answerFactionSwap(players, offer, 'two', 'yes' as unknown as boolean), FactionSwapError);
|
||||
assert.deepEqual(players, roster);
|
||||
assert.deepEqual(offer, original);
|
||||
});
|
||||
|
||||
void test('only the proposer can cancel an offer', () => {
|
||||
const offer = createFactionSwap(roster, 'one', 'two', 'swap:1');
|
||||
assert.doesNotThrow(() => cancelFactionSwap(roster, offer, 'one'));
|
||||
assert.throws(() => cancelFactionSwap(roster, offer, 'two'), FactionSwapError);
|
||||
assert.throws(() => cancelFactionSwap(roster, offer, 'three'), FactionSwapError);
|
||||
});
|
||||
|
||||
void test('invalid rosters and participants cannot create an offer', () => {
|
||||
assert.throws(() => createFactionSwap(roster, 'one', 'one', 'swap:1'), FactionSwapError);
|
||||
assert.throws(() => createFactionSwap(roster, 'one', 'missing', 'swap:1'), FactionSwapError);
|
||||
assert.throws(() => createFactionSwap(roster, 'one', 'two', ' '), FactionSwapError);
|
||||
assert.throws(() => createFactionSwap([roster[0], roster[0]], 'one', 'two', 'swap:1'), FactionSwapError);
|
||||
assert.throws(() => createFactionSwap([roster[0], { ...roster[1], faction: 'atreides' }], 'one', 'two', 'swap:1'), FactionSwapError);
|
||||
assert.throws(() => createFactionSwap([roster[0]], 'one', 'two', 'swap:1'), FactionSwapError);
|
||||
});
|
||||
|
||||
void test('saved offers cannot authorize stale or malformed faction assignments', () => {
|
||||
const offer = createFactionSwap(roster, 'one', 'two', 'swap:1');
|
||||
const changed = [roster[0], { ...roster[1], faction: 'fremen' as const }, roster[2]];
|
||||
assert.throws(() => answerFactionSwap(changed, offer, 'two', true), FactionSwapError);
|
||||
assert.throws(() => validateFactionSwap(offer, [roster[0], roster[2]]), FactionSwapError);
|
||||
assert.throws(() => validateFactionSwap({ ...offer, event: '' }, roster), FactionSwapError);
|
||||
assert.throws(() => validateFactionSwap({ ...offer, to: 'one' }, roster), FactionSwapError);
|
||||
assert.throws(() => validateFactionSwap({ ...offer, toFaction: 'invalid' }, roster), FactionSwapError);
|
||||
assert.throws(() => validateFactionSwap({ ...offer, secret: 'extra' }, roster), FactionSwapError);
|
||||
});
|
||||
Reference in new issue
Block a user