Files
Dune/tests/faction-allocation-engine.test.ts
T

147 lines
8.4 KiB
TypeScript

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');
});