Add random faction deal and prediction draft
This commit is contained in:
1 parent
a244aeb5f8
commit
4dc2df2513
16 files changed
+819
-12
No files matched your search
@@ -0,0 +1,88 @@
|
||||
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 { FactionAllocation } from '../components/faction-allocation';
|
||||
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', host: 'a', me: 'a', expansions: [], factionDraft: null,
|
||||
players: [
|
||||
{ id: 'a', name: 'Alice', faction: 'atreides', ready: true },
|
||||
{ id: 'b', name: 'Basil', faction: 'harkonnen', ready: false },
|
||||
{ id: 'c', name: 'Computer', faction: 'guild', bot: 'Medium', ready: true },
|
||||
],
|
||||
} as unknown as GameView;
|
||||
const draft = {
|
||||
event: 'draft-event-1',
|
||||
order: [{ player: 'b', card: 9 }, { player: 'a', card: 6 }, { player: 'c', card: 2 }],
|
||||
picks: [{ player: 'b', faction: 'atreides' }],
|
||||
};
|
||||
const send = async (_action: Action) => {};
|
||||
const render = (game: GameView, busy = false) =>
|
||||
renderToStaticMarkup(createElement(FactionAllocation, { game, send, busy }));
|
||||
|
||||
void test('host sees both allocation methods and readiness/commit semantics', () => {
|
||||
const html = render(base);
|
||||
assert.match(html, /Deal random factions/);
|
||||
assert.match(html, /Draft factions by prediction card/);
|
||||
assert.match(html, /only after everyone has picked/);
|
||||
assert.match(html, /clears human readiness/);
|
||||
assert.doesNotMatch(render({ ...base, me: 'b' }), /Deal random factions|Draft factions by prediction card/);
|
||||
assert.doesNotMatch(render({ ...base, status: 'playing' }), /Faction allocation/);
|
||||
assert.match(render({ ...base, players: base.players.slice(0, 1) }), /At least two faction seats/);
|
||||
});
|
||||
|
||||
void test('draft shows public numbered order and only unpicked enabled factions to the active player', () => {
|
||||
const active = { ...base, factionDraft: draft } as unknown as GameView;
|
||||
const html = render(active);
|
||||
assert.match(html, /Basil · prediction 9: Atreides/);
|
||||
assert.match(html, /Alice · prediction 6: choosing now/);
|
||||
assert.match(html, /Computer \(AI\) · prediction 2: waiting/);
|
||||
assert.match(html, /Choose your faction/);
|
||||
assert.match(html, />Harkonnen<\/button>/);
|
||||
assert.doesNotMatch(html, />Atreides<\/button>|>Ixians<\/button>/);
|
||||
const expanded = render({ ...active, expansions: ['ix'] });
|
||||
assert.match(expanded, />Ixians<\/button>/);
|
||||
const waiting = render({ ...active, me: 'b' });
|
||||
assert.match(waiting, /Waiting for Alice to choose a faction/);
|
||||
assert.doesNotMatch(waiting, /Choose your faction|Cancel faction draft/);
|
||||
});
|
||||
|
||||
void test('allocation controls send the exact owner-initiated actions and draft event', () => {
|
||||
const calls: Action[] = [];
|
||||
const sendAction = async (action: Action) => { calls.push(action); };
|
||||
const initial = elements(FactionAllocation({ game: base, send: sendAction, busy: false }));
|
||||
const click = (nodes: Element[], text: string) => {
|
||||
const button = nodes.find((node) => node.props.children === text && typeof node.props.onClick === 'function');
|
||||
assert.ok(button, `Expected action ${text}`);
|
||||
(button.props.onClick as () => void)();
|
||||
};
|
||||
click(initial, 'Deal random factions');
|
||||
click(initial, 'Draft factions by prediction card');
|
||||
const inDraft = elements(FactionAllocation({ game: { ...base, factionDraft: draft } as unknown as GameView, send: sendAction, busy: false }));
|
||||
click(inDraft, 'Harkonnen');
|
||||
click(inDraft, 'Cancel faction draft');
|
||||
assert.deepEqual(calls, [
|
||||
{ type: 'factionAllocation', mode: 'random' },
|
||||
{ type: 'factionAllocation', mode: 'draft' },
|
||||
{ type: 'factionDraftPick', event: 'draft-event-1', faction: 'harkonnen' },
|
||||
{ type: 'factionDraftCancel', event: 'draft-event-1' },
|
||||
]);
|
||||
});
|
||||
|
||||
void test('busy draft disables selection and a bot turn explains automatic progress', () => {
|
||||
const game = { ...base, factionDraft: { ...draft, picks: [
|
||||
{ player: 'b', faction: 'atreides' }, { player: 'a', faction: 'emperor' },
|
||||
] } } as unknown as GameView;
|
||||
assert.match(render(game), /Waiting for Computer \(AI\).*AI will choose automatically/);
|
||||
const nodes = elements(FactionAllocation({ game: { ...base, factionDraft: draft } as unknown as GameView, send, busy: true }));
|
||||
for (const button of nodes.filter((node) => typeof node.props.onClick === 'function'))
|
||||
assert.equal(button.props.disabled, true);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FACTIONS, type FactionId } from '../game/catalog';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
|
||||
function lobby(): Game {
|
||||
const game = createGame('ALLOCATE', newPlayer('host', 'Host', 'atreides'));
|
||||
joinGame(game, newPlayer('guest', 'Guest', 'harkonnen'));
|
||||
joinGame(game, newPlayer('other', 'Other', 'emperor'));
|
||||
return game;
|
||||
}
|
||||
function rejectUnchanged(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected allocation cannot mutate the shared lobby');
|
||||
}
|
||||
function identity(game: Game) {
|
||||
return game.players.map(player => ({ id: player.id, name: player.name, bot: player.bot }));
|
||||
}
|
||||
function selected(game: Game) {
|
||||
return game.players.map(player => ({ id: player.id, faction: player.faction,
|
||||
leaders: player.leaders.map(leader => ({ id: leader.id, faction: leader.faction })) }));
|
||||
}
|
||||
function nextFaction(used: readonly string[]): FactionId {
|
||||
return FACTIONS.find(faction => faction.expansion === 'base' && !used.includes(faction.id))!.id;
|
||||
}
|
||||
|
||||
void test('host random allocation assigns distinct enabled factions while preserving seat identity and clearing readiness', () => {
|
||||
let game = lobby();
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
const beforeSeats = identity(game), beforeHost = game.host, beforePositions = structuredClone(game.playerPositions);
|
||||
rejectUnchanged(game, 'guest', { type: 'factionAllocation', mode: 'random' });
|
||||
rejectUnchanged(game, 'host', { type: 'factionAllocation', mode: 'invalid' });
|
||||
const next = applyAction(game, 'host', { type: 'factionAllocation', mode: 'random' });
|
||||
assert.equal(next.status, 'lobby');
|
||||
assert.equal(next.host, beforeHost);
|
||||
assert.deepEqual(identity(next), beforeSeats);
|
||||
assert.deepEqual(next.playerPositions, beforePositions);
|
||||
assert.equal(new Set(next.players.map(player => player.faction)).size, next.players.length);
|
||||
assert.ok(next.players.every(player => FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base')));
|
||||
assert.ok(next.players.every(player => !player.ready));
|
||||
assert.ok(next.players.every(player => player.leaders.every(leader => leader.faction === player.faction)));
|
||||
assert.ok(!next.factionDraft);
|
||||
let ready = next;
|
||||
for (const player of ready.players) ready = applyAction(ready, player.id, { type: 'ready' });
|
||||
const started = applyAction(ready, 'host', { type: 'start' });
|
||||
assert.equal(started.status, 'setup');
|
||||
});
|
||||
|
||||
void test('draft gives distinct descending order cards and commits faction choices only after the last pick', () => {
|
||||
let game = lobby();
|
||||
const original = selected(game), seats = identity(game), positions = structuredClone(game.playerPositions);
|
||||
game = applyAction(game, 'host', { type: 'factionAllocation', mode: 'draft' });
|
||||
const draft = game.factionDraft!;
|
||||
assert.ok(draft && typeof draft.event === 'string' && draft.event.length);
|
||||
assert.equal(draft.order.length, game.players.length);
|
||||
assert.deepEqual(new Set(draft.order.map(item => item.player)), new Set(game.players.map(player => player.id)));
|
||||
assert.equal(new Set(draft.order.map(item => item.card)).size, draft.order.length);
|
||||
assert.ok(draft.order.every(item => Number.isInteger(item.card) && item.card >= 1 && item.card <= 10));
|
||||
assert.deepEqual(draft.order.map(item => item.card), draft.order.map(item => item.card).sort((a, b) => b - a));
|
||||
assert.deepEqual(draft.picks, []);
|
||||
assert.deepEqual(selected(game), original);
|
||||
assert.ok(game.players.every(player => !player.ready));
|
||||
assert.equal(game.host, 'host');
|
||||
assert.deepEqual(identity(game), seats);
|
||||
assert.deepEqual(game.playerPositions, positions);
|
||||
const current = draft.order[0].player;
|
||||
const other = draft.order.find(item => item.player !== current)!.player;
|
||||
rejectUnchanged(game, other, { type: 'factionDraftPick', event: draft.event, faction: 'guild' });
|
||||
rejectUnchanged(game, current, { type: 'factionDraftPick', event: `${draft.event}:stale`, faction: 'guild' });
|
||||
rejectUnchanged(game, current, { type: 'factionDraftPick', event: draft.event, faction: 'ixians' });
|
||||
rejectUnchanged(game, 'host', { type: 'faction', faction: 'guild' });
|
||||
rejectUnchanged(game, 'host', { type: 'start' });
|
||||
rejectUnchanged(game, 'host', { type: 'rules', advanced: true });
|
||||
const beforeJoin = structuredClone(game);
|
||||
assert.throws(() => joinGame(game, newPlayer('late', 'Late', 'guild')));
|
||||
assert.deepEqual(game, beforeJoin);
|
||||
|
||||
let used: FactionId[] = [];
|
||||
for (let index = 0; index < draft.order.length; index++) {
|
||||
const state = game.factionDraft!;
|
||||
const actor = draft.order[index].player;
|
||||
const chosen = nextFaction(used);
|
||||
game = applyAction(game, actor, { type: 'factionDraftPick', event: state.event, faction: chosen });
|
||||
used = [...used, chosen];
|
||||
if (index + 1 < draft.order.length) {
|
||||
assert.deepEqual(selected(game), original, 'An incomplete draft cannot partly change factions');
|
||||
assert.deepEqual(game.factionDraft!.picks.map(pick => pick.faction), used);
|
||||
const next = draft.order[index + 1].player;
|
||||
rejectUnchanged(game, next, { type: 'factionDraftPick', event: state.event, faction: chosen });
|
||||
}
|
||||
}
|
||||
assert.ok(!game.factionDraft);
|
||||
assert.deepEqual(identity(game), seats);
|
||||
assert.deepEqual(game.playerPositions, positions);
|
||||
for (let index = 0; index < draft.order.length; index++)
|
||||
assert.equal(game.players.find(player => player.id === draft.order[index].player)!.faction, used[index]);
|
||||
assert.ok(game.players.every(player => !player.ready));
|
||||
assert.ok(game.players.every(player => player.leaders.every(leader => leader.faction === player.faction)));
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
assert.equal(applyAction(game, 'host', { type: 'start' }).status, 'setup');
|
||||
});
|
||||
|
||||
void test('host can cancel an in-progress draft without changing factions or transferring seats', () => {
|
||||
let game = lobby();
|
||||
const original = selected(game), seats = identity(game);
|
||||
game = applyAction(game, 'host', { type: 'factionAllocation', mode: 'draft' });
|
||||
const event = game.factionDraft!.event;
|
||||
const actor = game.factionDraft!.order[0].player;
|
||||
game = applyAction(game, actor, { type: 'factionDraftPick', event, faction: 'guild' });
|
||||
rejectUnchanged(game, 'guest', { type: 'factionDraftCancel', event });
|
||||
rejectUnchanged(game, 'host', { type: 'factionDraftCancel', event: `${event}:stale` });
|
||||
const canceled = applyAction(game, 'host', { type: 'factionDraftCancel', event });
|
||||
assert.ok(!canceled.factionDraft);
|
||||
assert.deepEqual(selected(canceled), original);
|
||||
assert.deepEqual(identity(canceled), seats);
|
||||
rejectUnchanged(canceled, actor, { type: 'factionDraftPick', event, faction: 'guild' });
|
||||
});
|
||||
|
||||
void test('bots choose a legal available faction during the same draft and remain ready', () => {
|
||||
let game = createGame('ALLOCBOT', newPlayer('host', 'Host', 'atreides'));
|
||||
game = applyAction(game, 'host', { type: 'addBot', faction: 'harkonnen', difficulty: 'Easy' });
|
||||
joinGame(game, newPlayer('guest', 'Guest', 'emperor'));
|
||||
const bot = game.players.find(player => player.bot)!.id;
|
||||
const seats = game.players.map(player => player.id);
|
||||
game = applyAction(game, 'host', { type: 'factionAllocation', mode: 'draft' });
|
||||
let botPicked = game.factionDraft!.picks.some(pick => pick.player === bot);
|
||||
for (let n = 0; game.factionDraft && n < seats.length; n++) {
|
||||
const draft = game.factionDraft!;
|
||||
const actor = draft.order[draft.picks.length].player;
|
||||
assert.notEqual(actor, bot, 'A pending draft must advance past every automatic AI turn');
|
||||
const botNext = draft.order[draft.picks.length + 1]?.player === bot;
|
||||
const used = draft.picks.map(pick => pick.faction);
|
||||
game = applyAction(game, actor, { type: 'factionDraftPick', event: draft.event, faction: nextFaction(used) });
|
||||
if (botNext) {
|
||||
if (game.factionDraft) assert.ok(game.factionDraft.picks.some(pick => pick.player === bot));
|
||||
botPicked = true;
|
||||
}
|
||||
}
|
||||
assert.equal(botPicked, true);
|
||||
assert.ok(!game.factionDraft);
|
||||
assert.deepEqual(game.players.map(player => player.id), seats);
|
||||
assert.equal(new Set(game.players.map(player => player.faction)).size, game.players.length);
|
||||
assert.equal(game.players.find(player => player.id === bot)!.ready, true);
|
||||
assert.equal(viewGame(game, 'host').players.find(player => player.id === bot)!.bot, 'Easy');
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// @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;
|
||||
status: string;
|
||||
players: { id: string; faction: string; ready: boolean }[];
|
||||
factionDraft?: { event: string; order: { player: string; card: number }[]; picks: { player: string; faction: 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('two controllers draft with four independently authenticated faction seats', async () => {
|
||||
const host = await request('/api/rooms', { name: 'Draft 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 firstSecond = await request(path, { type: 'addControlledFaction', version: enabled.data.version, name: 'Host second', faction: 'harkonnen', entry: receipt() }, host.cookie, host.data.me);
|
||||
assert.equal(firstSecond.status, 200, JSON.stringify(firstSecond.data));
|
||||
assert.ok(firstSecond.cookie);
|
||||
const firstCookies = `${host.cookie}; ${firstSecond.cookie}`;
|
||||
const other = await request(path, { type: 'join', name: 'Other person', faction: 'emperor', entry: receipt() });
|
||||
assert.equal(other.status, 200, JSON.stringify(other.data));
|
||||
assert.ok(other.cookie);
|
||||
const otherSecond = await request(path, { type: 'addControlledFaction', version: other.data.version, name: 'Other second', faction: 'fremen', entry: receipt() }, other.cookie, other.data.me);
|
||||
assert.equal(otherSecond.status, 200, JSON.stringify(otherSecond.data));
|
||||
assert.ok(otherSecond.cookie);
|
||||
const otherCookies = `${other.cookie}; ${otherSecond.cookie}`;
|
||||
const seats = [
|
||||
{ id: host.data.me, cookie: firstCookies },
|
||||
{ id: firstSecond.data.me, cookie: firstCookies },
|
||||
{ id: other.data.me, cookie: otherCookies },
|
||||
{ id: otherSecond.data.me, cookie: otherCookies },
|
||||
];
|
||||
const original = otherSecond.data.players.map(player => ({ id: player.id, faction: player.faction }));
|
||||
const opened = await request(path, { version: otherSecond.data.version, action: { type: 'factionAllocation', mode: 'draft' } }, firstCookies, host.data.me);
|
||||
assert.equal(opened.status, 200, JSON.stringify(opened.data));
|
||||
assert.ok(opened.data.factionDraft);
|
||||
assert.deepEqual(opened.data.players.map(player => ({ id: player.id, faction: player.faction })), original);
|
||||
const event = opened.data.factionDraft.event;
|
||||
const order = opened.data.factionDraft.order.map(item => item.player);
|
||||
assert.deepEqual(new Set(order), new Set(seats.map(seat => seat.id)));
|
||||
const deniedJoin = await request(path, { type: 'join', name: 'Late player', faction: 'guild', entry: receipt() });
|
||||
assert.equal(deniedJoin.status, 409, JSON.stringify(deniedJoin.data));
|
||||
assert.match(deniedJoin.data.error ?? '', /draft/i);
|
||||
assert.equal(deniedJoin.cookie, undefined);
|
||||
|
||||
let latest = opened.data;
|
||||
const choices = ['guild', 'beneGesserit', 'fremen', 'emperor'];
|
||||
for (let index = 0; index < order.length; index++) {
|
||||
const seat = seats.find(candidate => candidate.id === order[index])!;
|
||||
// Switching is a selected-seat read using the matching HttpOnly credential.
|
||||
const selected = await request(path, undefined, seat.cookie, seat.id);
|
||||
assert.equal(selected.status, 200, JSON.stringify(selected.data));
|
||||
assert.equal(selected.data.me, seat.id);
|
||||
assert.equal(selected.data.version, latest.version);
|
||||
const pick = await request(path, { version: selected.data.version, action: {
|
||||
type: 'factionDraftPick', event, faction: choices[index],
|
||||
} }, seat.cookie, seat.id);
|
||||
assert.equal(pick.status, 200, JSON.stringify(pick.data));
|
||||
assert.equal(pick.data.me, seat.id);
|
||||
latest = pick.data;
|
||||
if (index < order.length - 1)
|
||||
assert.deepEqual(latest.players.map(player => ({ id: player.id, faction: player.faction })), original);
|
||||
}
|
||||
assert.ok(!latest.factionDraft);
|
||||
assert.deepEqual(latest.players.map(player => player.id), original.map(player => player.id));
|
||||
for (let index = 0; index < order.length; index++)
|
||||
assert.equal(latest.players.find(player => player.id === order[index])!.faction, choices[index]);
|
||||
for (const seat of seats) {
|
||||
const ready = await request(path, { version: latest.version, action: { type: 'ready' } }, seat.cookie, seat.id);
|
||||
assert.equal(ready.status, 200, JSON.stringify(ready.data));
|
||||
assert.equal(ready.data.me, seat.id);
|
||||
latest = ready.data;
|
||||
}
|
||||
assert.ok(latest.players.every(player => player.ready));
|
||||
const started = await request(path, { version: latest.version, action: { type: 'start' } }, firstCookies, host.data.me);
|
||||
assert.equal(started.status, 200, JSON.stringify(started.data));
|
||||
assert.ok(started.data.status === 'setup' || started.data.status === 'playing');
|
||||
assert.deepEqual(started.data.players.map(player => player.id), original.map(player => player.id));
|
||||
for (const seat of seats) {
|
||||
const view = await request(path, undefined, seat.cookie, 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));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
chooseFaction, createFactionDraft, currentFactionDrafter,
|
||||
FactionDraftError, validateFactionDraft,
|
||||
} from '../game/faction-draft';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
|
||||
const seats = ['human', 'opponent', 'bot'];
|
||||
const eligible: FactionId[] = ['atreides', 'harkonnen', 'guild', 'fremen'];
|
||||
|
||||
void test('prediction numbers determine descending public draft order without changing inputs', () => {
|
||||
const numbers = [3, 10, 6];
|
||||
const draft = createFactionDraft(seats, numbers, 'draft:one');
|
||||
assert.deepEqual(draft, { event: 'draft:one', order: [
|
||||
{ player: 'opponent', card: 10 }, { player: 'bot', card: 6 }, { player: 'human', card: 3 },
|
||||
], picks: [] });
|
||||
assert.deepEqual(seats, ['human', 'opponent', 'bot']);
|
||||
assert.deepEqual(numbers, [3, 10, 6]);
|
||||
validateFactionDraft(JSON.parse(JSON.stringify(draft)), seats, eligible);
|
||||
assert.equal(currentFactionDrafter(draft), 'opponent');
|
||||
});
|
||||
|
||||
void test('each seated player chooses one distinct enabled faction in actual prediction-card order', () => {
|
||||
const initial = createFactionDraft(seats, [3, 10, 6], 'draft:one');
|
||||
const first = chooseFaction(initial, seats, eligible, 'opponent', 'guild');
|
||||
assert.deepEqual(initial.picks, []);
|
||||
assert.deepEqual(first.picks, [{ player: 'opponent', faction: 'guild' }]);
|
||||
assert.notEqual(first.order, initial.order);
|
||||
assert.notEqual(first.order[0], initial.order[0]);
|
||||
assert.equal(currentFactionDrafter(first), 'bot');
|
||||
const second = chooseFaction(first, seats, eligible, 'bot', 'fremen');
|
||||
const final = chooseFaction(second, seats, eligible, 'human', 'atreides');
|
||||
validateFactionDraft(final, seats, eligible);
|
||||
assert.equal(currentFactionDrafter(final), null);
|
||||
assert.deepEqual(final.picks.map(pick => pick.faction), ['guild', 'fremen', 'atreides']);
|
||||
assert.throws(() => chooseFaction(final, seats, eligible, 'human', 'harkonnen'), FactionDraftError);
|
||||
assert.deepEqual(first.picks, [{ player: 'opponent', faction: 'guild' }]);
|
||||
});
|
||||
|
||||
void test('wrong actor, occupied and disabled faction choices reject without mutating accepted state', () => {
|
||||
const initial = createFactionDraft(seats, [3, 10, 6], 'draft:one');
|
||||
const draft = chooseFaction(initial, seats, eligible, 'opponent', 'guild');
|
||||
const before = structuredClone(draft);
|
||||
assert.throws(() => chooseFaction(draft, seats, eligible, 'human', 'atreides'), FactionDraftError);
|
||||
assert.throws(() => chooseFaction(draft, seats, eligible, 'bot', 'guild'), FactionDraftError);
|
||||
assert.throws(() => chooseFaction(draft, seats, eligible, 'bot', 'ixians'), FactionDraftError);
|
||||
assert.throws(() => chooseFaction(draft, seats, eligible, 'bot', 'nonexistent' as FactionId), FactionDraftError);
|
||||
assert.deepEqual(draft, before);
|
||||
});
|
||||
|
||||
void test('invalid deals reject duplicate seats, repeated numbers and noncanonical ranges', () => {
|
||||
for (const [roster, numbers, event] of [
|
||||
[['a'], [1], 'e'], [['a', 'a'], [1, 2], 'e'], [['a', ' b'], [1, 2], 'e'],
|
||||
[['a', 'b'], [1, 1], 'e'], [['a', 'b'], [0, 2], 'e'],
|
||||
[['a', 'b'], [1, 11], 'e'], [['a', 'b'], [1, 2.5], 'e'],
|
||||
[['a', 'b'], [1], 'e'], [['a', 'b'], [1, 2], ''],
|
||||
] as [string[], number[], string][]) {
|
||||
assert.throws(() => createFactionDraft(roster, numbers, event), FactionDraftError);
|
||||
}
|
||||
assert.throws(() => createFactionDraft(['a', 'b', 'c', 'd', 'e', 'f', 'g'], [1, 2, 3, 4, 5, 6, 7], 'e'), FactionDraftError);
|
||||
});
|
||||
|
||||
void test('saved draft rejects changed roster, ordering, cards, picks and enabled faction pool', () => {
|
||||
const draft = createFactionDraft(seats, [3, 10, 6], 'draft:one');
|
||||
const one = chooseFaction(draft, seats, eligible, 'opponent', 'guild');
|
||||
for (const bad of [
|
||||
{ ...draft, extra: true },
|
||||
{ ...draft, order: [draft.order[1], draft.order[0], draft.order[2]] },
|
||||
{ ...draft, order: [{ player: 'opponent', card: 10 }, { player: 'bot', card: 10 }, draft.order[2]] },
|
||||
{ ...draft, order: [{ player: 'opponent', card: 10 }, { player: 'foreign', card: 6 }, draft.order[2]] },
|
||||
{ ...draft, picks: [{ player: 'bot', faction: 'guild' }] },
|
||||
{ ...draft, picks: [{ player: 'opponent', faction: 'ixians' }] },
|
||||
{ ...one, picks: [...one.picks, { player: 'bot', faction: 'guild' }] },
|
||||
{ ...one, picks: [...one.picks, { player: 'bot', faction: 'fremen', leaked: true }] },
|
||||
]) assert.throws(() => validateFactionDraft(bad, seats, eligible), FactionDraftError);
|
||||
assert.throws(() => validateFactionDraft(one, ['opponent', 'bot', 'else'], eligible), FactionDraftError);
|
||||
assert.throws(() => validateFactionDraft(one, seats, ['atreides', 'harkonnen']), FactionDraftError);
|
||||
assert.throws(() => validateFactionDraft(one, seats, ['atreides', 'guild', 'guild']), FactionDraftError);
|
||||
assert.throws(() => validateFactionDraft(one, seats, ['atreides', 'harkonnen', 'fremen']), FactionDraftError);
|
||||
});
|
||||
@@ -53,6 +53,17 @@ void test('an unresolved request stays visible with an explicit retry', () => {
|
||||
assert.match(html, /<select[^>]*disabled/);
|
||||
});
|
||||
|
||||
void test('a faction draft keeps the owned-seat switch usable while locking lobby changes', () => {
|
||||
const html = renderToStaticMarkup(createElement(TwoPlayerControls, {
|
||||
game: base, disabled: false, draftLocked: true, pendingEntry: false,
|
||||
entryRoom: base.code, entryProblem: '', retryDisabled: false,
|
||||
onModeChange: noop, onSwitch: noop, onAdd: noop, onRetry: noop,
|
||||
onAddBot: noop, onAddControlledBot: noop,
|
||||
}));
|
||||
assert.match(html, /Active faction<select(?![^>]*disabled)/);
|
||||
assert.match(html, /type="checkbox"[^>]*disabled/);
|
||||
});
|
||||
|
||||
void test('host may add a computer opponent when the second controller slot is empty', () => {
|
||||
const game = {
|
||||
...base,
|
||||
|
||||
Reference in new issue
Block a user