Support AI opponents in two-faction games
This commit is contained in:
1 parent
aa6f77372f
commit
b00d720e40
13 files changed
+395
-28
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
Reference in new issue
Block a user