Support AI opponents in two-faction games

This commit is contained in:
Quintine committed 2026-10-28 00:02:02 +11:00
1 parent aa6f77372f
commit b00d720e40
13 files changed
+395 -28

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction, createGame, joinControlledFaction, newPlayer, viewGame, type Game,
} from '../game/engine';
import { runBots } from '../game/bots';
/** One real human controls two separately authenticated faction seats. */
function humanPair(): Game {
let g = createGame('TWOBOT01', newPlayer('human', 'Human', 'atreides'));
g = applyAction(g, 'human', { type: 'twoPlayerMode', enabled: true });
joinControlledFaction(g, newPlayer('second', 'Human second', 'harkonnen'), 'human');
return g;
}
function botPrimary(g: Game): { game: Game; id: string } {
const game = applyAction(g, 'human', { type: 'addBot', faction: 'emperor', difficulty: 'Medium' });
const bot = game.players.find(player => player.faction === 'emperor')!;
assert.ok(bot.bot);
return { game, id: bot.id };
}
function botPair(g: Game): { game: Game; primary: string; secondary: string } {
const first = botPrimary(g);
const game = applyAction(first.game, 'human', {
type: 'addControlledBot', controller: first.id, faction: 'guild', difficulty: 'Medium',
});
const secondary = game.players.find(player => player.faction === 'guild')!;
assert.ok(secondary.bot);
return { game, primary: first.id, secondary: secondary.id };
}
function unchanged(g: Game, action: () => unknown) {
const before = structuredClone(g);
assert.throws(action);
assert.deepEqual(g, before);
}
void test('a preexisting bot remains ready when enabling the two-controller lobby', () => {
let g = createGame('TWOBOT02', newPlayer('human', 'Human', 'atreides'));
g = applyAction(g, 'human', { type: 'addBot', faction: 'emperor', difficulty: 'Easy' });
const bot = g.players.find(player => player.bot)!;
assert.equal(bot.ready, true);
g = applyAction(g, 'human', { type: 'twoPlayerMode', enabled: true });
assert.equal(g.players.find(player => player.id === bot.id)!.ready, true);
assert.equal(g.players.find(player => player.id === 'human')!.ready, false);
assert.deepEqual(g.twoPlayerControllers!.controllers.map(controller => controller.seats), [['human'], [bot.id]]);
g = applyAction(g, 'human', { type: 'addControlledBot', controller: bot.id, faction: 'guild', difficulty: 'Easy' });
assert.equal(g.twoPlayerControllers!.controllers.find(controller => controller.id === bot.id)!.seats.length, 2);
});
void test('a bot controller gets two independent faction seats without giving the human their private views', () => {
const { game: g, primary, secondary } = botPair(humanPair());
assert.deepEqual(g.twoPlayerControllers!.controllers.map(controller => controller.seats), [
['human', 'second'], [primary, secondary],
]);
assert.deepEqual(viewGame(g, 'human').controlledFactionSeats.map(seat => seat.id), ['human', 'second']);
assert.deepEqual(viewGame(g, primary).controlledFactionSeats.map(seat => seat.id), [primary, secondary]);
for (const observer of ['human', 'second', primary, secondary])
for (const seat of viewGame(g, observer).players)
assert.equal('hand' in seat, seat.id === observer);
assert.equal(g.players.find(player => player.id === primary)!.ready, true);
assert.equal(g.players.find(player => player.id === secondary)!.ready, true);
});
void test('only the host can create bots, and bot and human controllers cannot be mixed', () => {
const initial = humanPair();
unchanged(initial, () => applyAction(initial, 'second', {
type: 'addBot', faction: 'emperor', difficulty: 'Easy',
}));
const { game, id } = botPrimary(initial);
unchanged(game, () => applyAction(game, 'second', {
type: 'addControlledBot', controller: id, faction: 'guild', difficulty: 'Easy',
}));
unchanged(game, () => applyAction(game, 'human', {
type: 'addControlledBot', controller: 'human', faction: 'guild', difficulty: 'Easy',
}));
unchanged(game, () => joinControlledFaction(game, newPlayer('forged', 'Forged', 'guild'), id));
unchanged(game, () => applyAction(game, 'human', {
type: 'addControlledBot', controller: id, faction: 'atreides', difficulty: 'Easy',
}));
unchanged(game, () => applyAction(game, 'human', {
type: 'addControlledBot', controller: id, faction: 'guild', difficulty: 'Impossible',
}));
});
void test('removing either bot updates its controller and promotes a surviving secondary', () => {
const { game, primary, secondary } = botPair(humanPair());
let g = applyAction(game, 'human', { type: 'removeBot', target: secondary });
assert.deepEqual(g.twoPlayerControllers!.controllers.find(controller => controller.id === primary)!.seats, [primary]);
assert.equal(g.players.some(player => player.id === secondary), false);
g = applyAction(g, 'human', { type: 'addControlledBot', controller: primary, faction: 'guild', difficulty: 'Easy' });
const replacement = g.players.find(player => player.faction === 'guild')!.id;
g = applyAction(g, 'human', { type: 'removeBot', target: primary });
assert.equal(g.players.some(player => player.id === primary), false);
assert.deepEqual(g.twoPlayerControllers!.controllers.find(controller => controller.id === replacement)!.seats, [replacement]);
g = applyAction(g, 'human', { type: 'addControlledBot', controller: replacement, faction: 'emperor', difficulty: 'Hard' });
assert.equal(g.twoPlayerControllers!.controllers.find(controller => controller.id === replacement)!.seats.length, 2);
assert.equal(viewGame(g, 'human').controlledFactionSeats.length, 2);
});
void test('per-seat bot configuration and human readiness remain usable in the paired lobby', () => {
const { game, primary, secondary } = botPair(humanPair());
const position = game.playerPositions![primary];
let g = applyAction(game, 'human', { type: 'ready' });
g = applyAction(g, 'second', { type: 'ready' });
g = applyAction(g, 'human', { type: 'configureBot', target: primary, faction: 'emperor', difficulty: 'Hard', position });
assert.equal(g.players.find(player => player.id === primary)!.bot, 'Hard');
assert.equal(g.players.find(player => player.id === secondary)!.bot, 'Medium');
assert.equal(g.players.find(player => player.id === 'human')!.ready, false);
assert.equal(g.players.find(player => player.id === 'second')!.ready, false);
assert.equal(g.players.find(player => player.id === primary)!.ready, true);
assert.equal(g.players.find(player => player.id === secondary)!.ready, true);
});
void test('real two-controller setup lets the scheduler make both AI faction decisions', () => {
const partial = botPrimary(humanPair());
let incomplete = applyAction(partial.game, 'human', { type: 'ready' });
incomplete = applyAction(incomplete, 'second', { type: 'ready' });
unchanged(incomplete, () => applyAction(incomplete, 'human', { type: 'start' }));
const complete = applyAction(incomplete, 'human', {
type: 'addControlledBot', controller: partial.id, faction: 'guild', difficulty: 'Medium',
});
const botIds = complete.players.filter(player => player.bot).map(player => player.id);
let g = complete;
for (const id of ['human', 'second']) g = applyAction(g, id, { type: 'ready' });
g = applyAction(g, 'human', { type: 'start' });
assert.equal(g.status, 'setup');
for (let n = 0; n < 8 && botIds.some(id => g.players.find(player => player.id === id)!.traitorChoices.length); n++)
g = runBots(g, 1);
for (const id of botIds) {
const bot = g.players.find(player => player.id === id)!;
assert.equal(bot.traitorChoices.length, 0);
assert.equal(bot.traitors.length, 1);
}
const human = g.players.find(player => player.id === 'human')!;
g = applyAction(g, 'human', { type: 'traitor', leader: human.traitorChoices[0] });
assert.equal(g.status, 'playing');
assert.equal(g.players.every(player => player.ally === null), true);
});
+94
View File
@@ -0,0 +1,94 @@
// @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; bot?: string; ready: boolean; hand?: unknown[] }[];
twoPlayerControllers?: { controllers: { id: string; seats: string[] }[] } | null;
controlledFactionSeats?: { id: 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-faction AI opponent starts with separate human credentials and no bot impersonation', async () => {
const host = await request('/api/rooms', {
name: 'Two-player AI 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 second = await request(path, {
type: 'addControlledFaction', version: enabled.data.version,
name: 'Human second', faction: 'harkonnen', entry: receipt(),
}, host.cookie, host.data.me);
assert.equal(second.status, 200, JSON.stringify(second.data));
assert.ok(second.cookie);
const cookies = `${host.cookie}; ${second.cookie}`;
const primary = await request(path, {
version: second.data.version, action: { type: 'addBot', faction: 'emperor', difficulty: 'Medium' },
}, cookies, host.data.me);
assert.equal(primary.status, 200, JSON.stringify(primary.data));
const bot = primary.data.players.find(player => player.faction === 'emperor' && player.bot === 'Medium');
assert.ok(bot);
const paired = await request(path, {
version: primary.data.version,
action: { type: 'addControlledBot', controller: bot.id, faction: 'guild', difficulty: 'Medium' },
}, cookies, host.data.me);
assert.equal(paired.status, 200, JSON.stringify(paired.data));
const botSecond = paired.data.players.find(player => player.faction === 'guild' && player.bot === 'Medium');
assert.ok(botSecond);
assert.deepEqual(paired.data.twoPlayerControllers?.controllers.map(controller => controller.seats), [
[host.data.me, second.data.me], [bot.id, botSecond.id],
]);
assert.deepEqual(paired.data.controlledFactionSeats?.map(seat => seat.id), [host.data.me, second.data.me]);
for (const botId of [bot.id, botSecond.id]) {
const deniedRead = await request(path, undefined, cookies, botId);
assert.equal(deniedRead.status, 409);
const deniedAction = await request(path, {
version: paired.data.version, action: { type: 'ready' },
}, cookies, botId);
assert.equal(deniedAction.status, 409);
}
const firstReady = await request(path, {
version: paired.data.version, action: { type: 'ready' },
}, cookies, host.data.me);
assert.equal(firstReady.status, 200, JSON.stringify(firstReady.data));
const secondReady = await request(path, {
version: firstReady.data.version, action: { type: 'ready' },
}, cookies, second.data.me);
assert.equal(secondReady.status, 200, JSON.stringify(secondReady.data));
assert.ok(secondReady.data.players.every(player => player.ready));
const started = await request(path, {
version: secondReady.data.version, action: { type: 'start' },
}, cookies, host.data.me);
assert.equal(started.status, 200, JSON.stringify(started.data));
assert.ok(started.data.status === 'setup' || started.data.status === 'playing');
for (const player of started.data.players)
assert.equal(Object.hasOwn(player, 'hand'), player.id === host.data.me);
});
+51
View File
@@ -25,6 +25,7 @@ function render(game: GameView, pendingEntry = false) {
return renderToStaticMarkup(createElement(TwoPlayerControls, {
game, disabled: pendingEntry, pendingEntry, entryRoom: game.code, entryProblem: '',
retryDisabled: false, onModeChange: noop, onSwitch: noop, onAdd: noop, onRetry: noop,
onAddBot: noop, onAddControlledBot: noop,
}));
}
@@ -51,3 +52,53 @@ void test('an unresolved request stays visible with an explicit retry', () => {
assert.match(html, /never retried automatically/);
assert.match(html, /<select[^>]*disabled/);
});
void test('host may add a computer opponent when the second controller slot is empty', () => {
const game = {
...base,
players: [base.players[0]],
twoPlayerControllers: { version: 1 as const, controllers: [{ id: 'a', seats: ['a'] }] },
controlledFactionSeats: [{ id: 'a', name: 'Ali', faction: 'atreides' as const }],
};
const html = render(game);
assert.match(html, /Add an AI player/);
assert.match(html, /The AI plays each faction independently/);
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'])
assert.match(html, new RegExp(`value="${difficulty}"`));
assert.match(html, /value="harkonnen"/);
assert.doesNotMatch(html, /<option[^>]*value="atreides"/);
});
void test('host adds the second AI faction only for an incomplete AI controller', () => {
const human = base.players[0];
const ai = { ...base.players[1], bot: 'Hard' as const };
const game = {
...base,
players: [human, ai],
twoPlayerControllers: { version: 1 as const, controllers: [
{ id: 'a', seats: ['a'] }, { id: 'b', seats: ['b'] },
] },
controlledFactionSeats: [{ id: 'a', name: 'Ali', faction: 'atreides' as const }],
};
const html = render(game);
assert.match(html, /Player 2 \(AI\): Harkonnen/);
assert.match(html, /Add the AI player’s second faction/);
assert.match(html, /Add AI second faction/);
assert.doesNotMatch(html, /<option[^>]*value="harkonnen"/);
const nonHost = render({ ...game, me: 'b' });
assert.doesNotMatch(nonHost, /Add AI second faction/);
const complete = render({ ...game, twoPlayerControllers: { version: 1, controllers: [
{ id: 'a', seats: ['a'] }, { id: 'b', seats: ['b', 'c'] },
] } });
assert.doesNotMatch(complete, /Add AI second faction/);
});
void test('a human opponent does not expose controls to add an AI faction to their seat', () => {
const game = {
...base,
twoPlayerControllers: { version: 1 as const, controllers: [
{ id: 'a', seats: ['a', 'c'] }, { id: 'b', seats: ['b'] },
] },
};
assert.doesNotMatch(render(game), /Add AI second faction|Add an AI player/);
});
+2 -2
View File
@@ -46,14 +46,14 @@ void test('two people can independently ready their two physical factions and fi
assert.equal(game.stormPending, 2);
});
void test('lobby preserves separate controller seats and refuses incomplete, excess and AI setup', () => {
void test('lobby preserves separate controller seats and refuses incomplete, excess and mixed ownership', () => {
let game = createGame('TWOPEOPLE', newPlayer('a', 'A', 'atreides'));
game = applyAction(game, 'a', { type: 'twoPlayerMode', enabled: true });
assert.throws(() => joinControlledFaction(game, newPlayer('x', 'X', 'emperor'), 'other'), /controller/);
joinControlledFaction(game, newPlayer('b', 'B', 'emperor'), 'a');
assert.throws(() => joinControlledFaction(game, newPlayer('x', 'X', 'guild'), 'b'), /two faction/);
assert.throws(() => applyAction(game, 'a', { type: 'twoPlayerMode', enabled: false }), /cannot be disabled/);
assert.throws(() => applyAction(game, 'a', { type: 'addBot', faction: 'guild', difficulty: 'Easy' }), /human/);
assert.throws(() => applyAction(game, 'a', { type: 'addControlledBot', controller: 'a', faction: 'guild', difficulty: 'Easy' }), /AI controller/);
game = applyAction(game, 'a', { type: 'ready' });
game = applyAction(game, 'b', { type: 'ready' });
assert.throws(() => applyAction(game, 'a', { type: 'start' }), /exactly two controllers/);