Configure existing AI seats in the lobby

Let the host change an existing permanent AI seat's difficulty, faction and player circle without removing and readding it. Shared strict validation limits configuration to the authenticated host in the lobby, existing profiles, enabled available factions and unoccupied circles. Preserve seat identity and initialize changed faction rosters consistently. Changes reset human readiness, keep bots ready and explain the changed fields in public history.

Use existing AI strategies without calibration or strength changes. Full AI development waits for all non-AI game features; the goal retains the three adjacent 75% targets, mandatory Git checkpoints and selective subagents without a usage cutoff. No mode or publication gate opens.

Independent implementation and review cover the quote, engine, controls and persistence tests. Types, lint, 4916 offline tests, production build and 46 HTTP checks pass on stable final source. Ten new offline cases cover all factions/profiles, strict guards, roster/identity, immutable rejection, restart/privacy and both configuration-versus-start race outcomes. HTTP verifies authentication, host authority, CSRF, stale/future requests, private views and saved settings. Identical direct actions preserve players/readiness/history but retain the generic action store's version increment; controls suppress them.

Browser QA changed difficulty, faction and circle, verified readiness reset and history, restored settings after refresh and used keyboard selection. Expanded mobile cards were widened after discovering cramped controls; measured selects/buttons are 272 by 44 pixels at a 390-pixel viewport without document overflow. Desktop controls measure 169 by 44 pixels. Screenshot capture timed out, so screenshot-based visual acceptance remains unverified. Configuration controls disappear after starting and saved AI settings persist in setup. The final source rerun follows a preliminary run invalidated by the mobile/readability edits.

All 1262 original rooms, 2644 seats and every prior recovery/entry/handover/delegation/migration row remain unchanged. SQLite integrity and foreign keys pass. No database reset, migration, server restart, usage check or automation was performed. Broader rules, components, durable unattended continuation and final AI acceptance remain unfinished.

Verified source tree: 8c1baf4602d000ee37f081b2a03f8a87e89a63ef14a7121f24268c575a1ac281
This commit is contained in:
Quintine committed 2026-09-20 16:57:38 +10:00
1 parent b21b2bc378
commit b9718acda8
13 files changed
+1018 -20

No files matched your search

+129
View File
@@ -0,0 +1,129 @@
// @dune-suite integration
import test from 'node:test';
import assert from 'node:assert/strict';
import type { GameView } from '../game/engine';
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
async function request(
path: string,
body?: unknown,
cookie?: string,
origin?: string,
) {
const response = await fetch(base + path, {
method: body === undefined ? 'GET' : 'POST',
signal: AbortSignal.timeout(15_000),
headers: {
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(cookie ? { cookie } : {}),
...(origin ? { origin } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return {
status: response.status,
data: (await response
.json()
.catch(() => ({ error: 'Non-JSON response' }))) as GameView & {
error?: string;
},
cookie: response.headers.get('set-cookie')?.split(';')[0],
};
}
void test('lobby AI configuration HTTP keeps host authority, saved identity, readiness and private views', async () => {
const host = await request('/api/rooms', {
name: 'Configuration host',
faction: 'atreides',
advanced: false,
expansions: [],
});
assert.equal(host.status, 201);
assert.ok(host.cookie);
const path = `/api/rooms/${host.data.code}`;
const peer = await request(path, {
type: 'join',
name: 'Configuration peer',
faction: 'emperor',
});
assert.equal(peer.status, 200);
assert.ok(peer.cookie);
let result = await request(
path,
{
version: peer.data.version,
action: { type: 'addBot', faction: 'harkonnen', difficulty: 'Easy' },
},
host.cookie,
);
assert.equal(result.status, 200);
const bot = result.data.players.find((player) => player.bot)!;
for (const cookie of [host.cookie, peer.cookie]) {
result = await request(
path,
{ version: result.data.version, action: { type: 'ready' } },
cookie,
);
assert.equal(result.status, 200);
}
const version = result.data.version;
const action = {
type: 'configureBot',
target: bot.id,
difficulty: 'Hard',
faction: 'guild',
position: 6,
};
for (const rejected of [
await request(path, { version, action }),
await request(path, { version, action }, peer.cookie),
await request(
path,
{ version, action },
host.cookie,
'https://other.invalid',
),
await request(
path,
{ version, action: { ...action, target: peer.data.me } },
host.cookie,
),
await request(path, { version: version + 1, action }, host.cookie),
])
assert.ok(rejected.status >= 400);
assert.equal(
(await request(path, undefined, host.cookie)).data.version,
version,
);
const changed = await request(path, { version, action }, host.cookie);
assert.equal(changed.status, 200);
assert.equal(changed.data.version, version + 1);
assert.ok(
(await request(path, { version, action }, host.cookie)).status >= 400,
);
for (const [cookie, me] of [
[host.cookie, host.data.me],
[peer.cookie, peer.data.me],
]) {
const restored = await request(path, undefined, cookie);
assert.equal(restored.status, 200);
assert.equal(restored.data.me, me);
const configured = restored.data.players.find(
(player) => player.id === bot.id,
)!;
assert.equal(configured.faction, 'guild');
assert.equal(configured.bot, 'Hard');
assert.equal(configured.ready, true);
assert.equal(restored.data.playerPositions[bot.id], 6);
assert.ok(
restored.data.players
.filter((player) => !player.bot)
.every((player) => !player.ready),
);
for (const rival of restored.data.players.filter(
(player) => player.id !== me,
))
for (const secret of ['hand', 'spice', 'traitors', 'traitorChoices'])
assert.equal(Object.hasOwn(rival, secret), false);
}
});
@@ -0,0 +1,162 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { unitStore } from './fixture-nexus-room-store';
async function fixture() {
const store = unitStore();
assert.ok(store.sqlite instanceof DatabaseSync);
const host = await store.rooms.createRoom('Host', 'atreides', false, []);
const code = host.view.code;
const peer = await store.rooms.joinRoom(code, 'Peer', 'emperor');
assert.ok(peer.token);
const hostAuth = await store.rooms.authenticate(code, host.token);
const peerAuth = await store.rooms.authenticate(code, peer.token);
let current = await store.rooms.readRoom(code);
await store.rooms.act(code, hostAuth, current.version, {
type: 'addBot',
faction: 'harkonnen',
difficulty: 'Easy',
});
for (const auth of [hostAuth, peerAuth]) {
current = await store.rooms.readRoom(code);
await store.rooms.act(code, auth, current.version, { type: 'ready' });
}
current = await store.rooms.readRoom(code);
const bot = current.players.find((player) => player.bot)!;
const action = {
type: 'configureBot',
target: bot.id,
difficulty: 'Brutal',
faction: 'guild',
position: 6,
};
return { ...store, code, hostAuth, peerAuth, current, bot, action };
}
void test('AI configuration persists the same seat and roster, clears readiness and survives a room module restart', async () => {
const f = await fixture();
try {
const seats = f.sqlite.prepare('SELECT * FROM seats').all();
const result = await f.rooms.act(
f.code,
f.hostAuth,
f.current.version,
f.action,
);
assert.equal(result.version, f.current.version + 1);
const restarted = f.restart();
const restored = await restarted.readRoom(f.code);
const bot = restored.players.find((player) => player.id === f.bot.id)!;
assert.equal(bot.name, 'Spacing Guild AI');
assert.equal(bot.bot, 'Brutal');
assert.equal(bot.faction, 'guild');
assert.equal(restored.playerPositions![bot.id], 6);
assert.ok(bot.leaders.every((leader) => leader.id.startsWith('guild-')));
assert.equal(bot.ready, true);
assert.ok(
restored.players
.filter((player) => !player.bot)
.every((player) => !player.ready),
);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
for (const auth of [f.hostAuth, f.peerAuth]) {
const view = await restarted.readSeatView(f.code, auth);
assert.equal(
view.players.find((player) => player.id === bot.id)!.bot,
'Brutal',
);
for (const rival of view.players.filter(
(player) => player.id !== auth.playerId,
)) {
for (const secret of ['spice', 'hand', 'traitors', 'traitorChoices'])
assert.equal(Object.hasOwn(rival, secret), false);
}
}
await assert.rejects(
restarted.act(f.code, f.hostAuth, restored.version, { type: 'start' }),
/ready/i,
);
assert.deepEqual(await restarted.readRoom(f.code), restored);
for (const auth of [f.hostAuth, f.peerAuth]) {
const before = await restarted.readRoom(f.code);
await restarted.act(f.code, auth, before.version, { type: 'ready' });
}
const beforeStart = await restarted.readRoom(f.code);
await restarted.act(f.code, f.hostAuth, beforeStart.version, {
type: 'start',
});
const started = await restarted.readRoom(f.code);
assert.equal(started.status, 'setup');
assert.equal(
started.players.find((player) => player.id === bot.id)!.bot,
'Brutal',
);
await assert.rejects(
restarted.act(f.code, f.hostAuth, started.version, f.action),
);
assert.deepEqual(await restarted.readRoom(f.code), started);
} finally {
f.sqlite.close();
}
});
void test('unauthorized, stale, future and human-target configuration cannot mutate saved lobby state', async () => {
const f = await fixture();
try {
for (const [auth, version, action] of [
[f.peerAuth, f.current.version, f.action],
[f.hostAuth, f.current.version - 1, f.action],
[f.hostAuth, f.current.version + 1, f.action],
[
f.hostAuth,
f.current.version,
{ ...f.action, target: f.peerAuth.playerId },
],
] as const) {
await assert.rejects(f.rooms.act(f.code, auth, version, action));
assert.deepEqual(await f.rooms.readRoom(f.code), f.current);
}
const noChange = {
...f.action,
difficulty: f.bot.bot,
faction: f.bot.faction,
position: f.current.playerPositions![f.bot.id],
};
await f.rooms.act(f.code, f.hostAuth, f.current.version, noChange);
const after = await f.restart().readRoom(f.code);
assert.equal(after.version, f.current.version + 1);
assert.deepEqual(after.players, f.current.players);
assert.deepEqual(after.log, f.current.log);
} finally {
f.sqlite.close();
}
});
void test('configuration and start races cannot overwrite the winning saved state in either order', async () => {
for (const first of ['configure', 'start']) {
const f = await fixture();
try {
const winner = first === 'configure' ? f.action : { type: 'start' };
const loser = first === 'configure' ? { type: 'start' } : f.action;
f.hooks.beforeWrite = async () => {
f.hooks.beforeWrite = undefined;
await f.rooms.act(f.code, f.hostAuth, f.current.version, winner);
};
await assert.rejects(
f.rooms.act(f.code, f.hostAuth, f.current.version, loser),
/changed|again|stale/i,
);
const saved = await f.restart().readRoom(f.code);
assert.equal(saved.version, f.current.version + 1);
assert.equal(saved.status, first === 'configure' ? 'lobby' : 'setup');
assert.equal(
saved.players.find((player) => player.id === f.bot.id)!.bot,
first === 'configure' ? 'Brutal' : 'Easy',
);
assert.equal(f.writes.at(-1)!.changes, 0);
} finally {
f.sqlite.close();
}
}
});
+303
View File
@@ -0,0 +1,303 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DIFFICULTIES } from '../game/bot-profiles';
import { FACTIONS, type FactionId } from '../game/catalog';
import {
applyAction,
createGame,
joinGame,
newPlayer,
type Game,
} from '../game/engine';
import { quoteLobbyBotConfiguration } from '../game/lobby-bot-configuration';
const allExpansions = ['ix', 'choam', 'ecaz'];
function lobby() {
const game = createGame(
'BOTCONFIG',
newPlayer('host', 'Host', 'atreides'),
false,
allExpansions,
);
const bot = newPlayer('bot', 'Harkonnen AI', 'harkonnen');
bot.bot = 'Easy';
joinGame(game, bot);
const human = newPlayer('human', 'Human', 'emperor');
joinGame(game, human);
const otherBot = newPlayer('other-bot', 'Fremen AI', 'fremen');
otherBot.bot = 'Medium';
joinGame(game, otherBot);
return game;
}
function action(
overrides: Partial<{
type: string;
target: string;
difficulty: string;
faction: string;
position: number;
}> = {},
) {
return {
type: 'configureBot',
target: 'bot',
difficulty: 'Hard',
faction: 'harkonnen',
position: 2,
...overrides,
};
}
function context(game: Game) {
return {
status: game.status,
host: game.host,
players: game.players,
expansions: game.expansions,
positions: game.playerPositions!,
};
}
void test('quote admits every faction with its expansion and all four current AI profiles', () => {
for (const [index, entry] of FACTIONS.entries()) {
const target = {
id: 'bot',
faction: entry.id,
bot: DIFFICULTIES[index % DIFFICULTIES.length],
};
const quote = quoteLobbyBotConfiguration(
{
status: 'lobby',
host: 'host',
players: [
{
id: 'host',
faction: entry.id === 'atreides' ? 'guild' : 'atreides',
},
target,
],
expansions: allExpansions,
positions: { host: 1, bot: 2 },
},
'host',
action({
difficulty: DIFFICULTIES[(index + 1) % DIFFICULTIES.length],
faction: entry.id,
}),
);
assert.equal(quote.ok, true, entry.id);
if (quote.ok) {
assert.equal(quote.configuration.faction, entry.id);
assert.equal(quote.changed, true);
}
}
});
void test('quote is strict, host-only, lobby-only and requires a permanent bot', () => {
const game = lobby();
const valid = action();
const checks: Array<[unknown, string, string]> = [
[valid, 'human', 'Only the host'],
[{ ...valid, extra: true }, 'host', 'only the AI seat'],
[{ ...valid, type: 'ready' }, 'host', 'only the AI seat'],
[{ ...valid, target: 'human' }, 'host', 'permanent AI'],
[{ ...valid, target: 'missing' }, 'host', 'permanent AI'],
[{ ...valid, difficulty: 'Impossible' }, 'host', 'valid AI difficulty'],
[{ ...valid, faction: 'unknown' }, 'host', 'valid faction'],
[{ ...valid, position: 2.5 }, 'host', 'integer'],
[{ ...valid, position: 7 }, 'host', 'integer'],
];
for (const [input, actor, reason] of checks) {
const quote = quoteLobbyBotConfiguration(context(game), actor, input);
assert.equal(quote.ok, false);
if (!quote.ok) assert.match(quote.reason, new RegExp(reason, 'i'));
}
for (const status of ['setup', 'playing', 'finished']) {
const quote = quoteLobbyBotConfiguration(
{ ...context(game), status },
'host',
valid,
);
assert.deepEqual(quote, {
ok: false,
reason: 'AI seats can be configured only in the lobby.',
});
}
});
void test('quote permits the bot current faction and circle but rejects occupied or disabled choices', () => {
const game = lobby();
const same = quoteLobbyBotConfiguration(
context(game),
'host',
action({ difficulty: 'Easy' }),
);
assert.deepEqual(same, {
ok: true,
configuration: {
target: 'bot',
difficulty: 'Easy',
faction: 'harkonnen',
position: 2,
},
changed: false,
});
for (const [input, reason] of [
[action({ faction: 'emperor' }), 'taken'],
[action({ position: 3 }), 'occupied'],
] as const) {
const quote = quoteLobbyBotConfiguration(context(game), 'host', input);
assert.equal(quote.ok, false);
if (!quote.ok) assert.match(quote.reason, new RegExp(reason, 'i'));
}
const disabled = quoteLobbyBotConfiguration(
{ ...context(game), expansions: [] },
'host',
action({ faction: 'ixians' }),
);
assert.equal(disabled.ok, false);
if (!disabled.ok) assert.match(disabled.reason, /disabled/i);
});
void test('engine applies all configured fields, preserves custody and records readiness publicly', () => {
const game = lobby();
const target = game.players.find((player) => player.id === 'bot')!;
target.spice = 17;
target.reserves = 13;
target.tanks = 2;
target.forces = { 'arrakeen:9': 5 };
target.traitors = ['atreides-0'];
target.ready = false;
game.players.find((player) => player.id === 'host')!.ready = true;
game.players.find((player) => player.id === 'human')!.ready = true;
game.players.find((player) => player.id === 'other-bot')!.ready = false;
const beforeOtherBot = structuredClone(
game.players.find((player) => player.id === 'other-bot')!,
);
const configured = applyAction(
game,
'host',
action({ difficulty: 'Brutal', faction: 'ixians', position: 5 }),
);
const changed = configured.players.find((player) => player.id === 'bot')!;
assert.equal(changed.id, target.id);
assert.equal(changed.name, 'Ixians AI');
assert.equal(changed.bot, 'Brutal');
assert.equal(changed.faction, 'ixians');
assert.equal(configured.playerPositions![changed.id], 5);
assert.deepEqual(
{
spice: changed.spice,
reserves: changed.reserves,
tanks: changed.tanks,
forces: changed.forces,
traitors: changed.traitors,
},
{
spice: 17,
reserves: 13,
tanks: 2,
forces: { 'arrakeen:9': 5 },
traitors: ['atreides-0'],
},
);
assert.equal(
configured.players.find((player) => player.id === 'host')!.ready,
false,
);
assert.equal(
configured.players.find((player) => player.id === 'human')!.ready,
false,
);
assert.equal(changed.ready, true);
assert.deepEqual(
configured.players.find((player) => player.id === 'other-bot'),
{ ...beforeOtherBot, ready: true },
);
assert.match(
configured.log.at(-1)!.text,
/Host configured Harkonnen AI: difficulty Easy to Brutal; faction Harkonnen to Ixians; player circle 2 to 5\. Human readiness was cleared; AI seats remain ready\./,
);
assert.notDeepEqual(configured, game);
assert.equal(target.faction, 'harkonnen');
assert.equal(target.bot, 'Easy');
});
void test('every faction change installs the same physical leader and elite roster as a new seat', () => {
for (const entry of FACTIONS) {
const hostFaction: FactionId =
entry.id === 'atreides' ? 'harkonnen' : 'atreides';
const initialFaction: FactionId =
entry.id === 'emperor' ? 'fremen' : 'emperor';
const game = createGame(
`ROSTER${entry.id}`,
newPlayer('host', 'Host', hostFaction),
false,
allExpansions,
);
const bot = newPlayer('bot', `${initialFaction} AI`, initialFaction);
bot.bot = 'Medium';
joinGame(game, bot);
const configured = applyAction(
game,
'host',
action({ faction: entry.id, difficulty: 'Medium' }),
);
const changed = configured.players.find((player) => player.id === 'bot')!;
const fresh = newPlayer('fresh', 'Fresh', entry.id);
assert.deepEqual(changed.leaders, fresh.leaders, entry.id);
assert.deepEqual(changed.elites, fresh.elites, entry.id);
}
});
void test('difficulty or circle changes preserve the bot name until its faction changes', () => {
const game = lobby();
game.players.find((player) => player.id === 'bot')!.name = 'Custom AI name';
const configured = applyAction(
game,
'host',
action({ difficulty: 'Brutal', position: 5 }),
);
const bot = configured.players.find((player) => player.id === 'bot')!;
assert.equal(bot.name, 'Custom AI name');
assert.match(
configured.log.at(-1)!.text,
/difficulty Easy to Brutal; player circle 2 to 5/,
);
assert.doesNotMatch(configured.log.at(-1)!.text, /faction/i);
});
void test('identical configuration is a clone-only no-op and rejected engine actions are immutable', () => {
const game = lobby();
game.players.find((player) => player.id === 'host')!.ready = true;
game.players.find((player) => player.id === 'bot')!.ready = false;
const same = applyAction(
game,
'host',
action({ difficulty: 'Easy', faction: 'harkonnen', position: 2 }),
);
assert.notEqual(same, game);
assert.deepEqual(same, game);
for (const [actor, attempted] of [
['human', action()],
['host', action({ target: 'human' })],
['host', { ...action(), extra: true }],
] as const) {
const before = JSON.stringify(game);
assert.throws(
() => applyAction(game, actor, attempted),
/host|permanent|only/i,
);
assert.equal(JSON.stringify(game), before);
}
for (const status of ['setup', 'playing', 'finished'] as const) {
const outside = structuredClone(game);
outside.status = status;
const before = JSON.stringify(outside);
assert.throws(() => applyAction(outside, 'host', action()));
assert.equal(JSON.stringify(outside), before);
}
});