Add neutral administrator lobby configuration

Configure supported rules/modules and AI seats or assign an existing human as host without granting private-seat access. Use genuine unused-lobby checks, existing engine actions, live authorization/version/pause/seat fences and atomic audit receipts. Preserve exact account-bound retries across refresh and confirm completed operations after game start without resetting play.

Verified types, lint, 5260 offline tests, 52 HTTP tests and build. Eleven authenticated HTTP groups and independent privacy/persistence review passed. Browser QA confirmed one AI and audit after a lost response and refresh/retry, persisted rules and AI configuration, and readable 1280/390px layouts. All existing rooms and seats preserved. Actual authenticated production administrator acceptance remains pending authorized QA operator access.
This commit is contained in:
Quintine committed 2026-09-22 11:27:20 +10:00
1 parent e7d4efc3db
commit c0cc4de9e1
22 files changed
+3620 -18

No files matched your search

+91
View File
@@ -0,0 +1,91 @@
// Dedicated QA lobby only. Administrator configuration never returns a seat cookie.
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
export async function verifyLobbyConfiguration(request, adminCookie, report) {
try {
const created = await request('/api/rooms', { name: 'Admin lobby QA ' + Date.now(), faction: 'atreides', advanced: false, expansions: [] });
assert.equal(created.status, 201);
const code = created.data.code, path = '/api/rooms/' + code, url = '/api/admin/rooms/' + code + '/lobby';
report.lobbyRoom = code;
const hostCookie = created.cookie;
const joined = await request(path, { type: 'join', name: 'Lobby host assignment QA', faction: 'fremen' });
assert.equal(joined.status, 200);
const secondCookie = joined.cookie, secondId = joined.data.me;
assert.equal((await request(url)).status, 401);
assert.equal((await request(url, undefined, hostCookie)).status, 401);
let current = await request(url, undefined, adminCookie);
assert.equal(current.status, 200); assert.equal(current.rawCookie, null);
assert.equal(current.data.editable, true);
assert.deepEqual(Object.keys(current.data).sort(), ['advanced', 'blockedReason', 'code', 'control', 'editable', 'host', 'players', 'status', 'strongholdCards', 'techTokens', 'version']);
for (const player of current.data.players) assert.deepEqual(Object.keys(player).sort(), ['bot', 'faction', 'hostEligible', 'id', 'name', 'position', 'ready']);
const makeInput = action => ({ operationId: randomUUID(), expectedVersion: current.data.version, action, reason: 'Dedicated lobby configuration acceptance' });
const firstInput = makeInput({ type: 'rules', advanced: true });
for (const denied of [await request(url, firstInput, adminCookie, 'https://foreign.invalid'), await request(url, firstInput, adminCookie, undefined, randomUUID())]) {
assert.ok([401, 403].includes(denied.status)); assert.equal(denied.rawCookie, null);
}
async function apply(input) {
const result = await request(url, input, adminCookie);
assert.equal(result.status, 200); assert.equal(result.rawCookie, null);
assert.deepEqual(Object.keys(result.data).sort(), ['appliedVersion', 'lobby', 'operationId', 'replayed']);
assert.equal(result.data.operationId, input.operationId); assert.equal(result.data.appliedVersion, input.expectedVersion + 1);
current = { data: result.data.lobby };
return result;
}
await apply(firstInput); assert.equal(current.data.advanced, true);
await apply(makeInput({ type: 'techTokens', enabled: true }));
await apply(makeInput({ type: 'strongholdCards', enabled: true }));
await apply(makeInput({ type: 'addBot', faction: 'guild', difficulty: 'Easy' }));
const botId = current.data.players.find(player => player.bot).id;
await apply(makeInput({ type: 'configureBot', target: botId, faction: 'emperor', difficulty: 'Hard', position: 5 }));
assert.equal(current.data.players.find(player => player.id === botId).faction, 'emperor');
assert.equal(current.data.players.find(player => player.id === botId).position, 5);
await apply(makeInput({ type: 'removeBot', target: botId }));
assert.equal(current.data.players.length, 2);
await apply(makeInput({ type: 'rules', advanced: false })); assert.equal(current.data.strongholdCards, false);
await apply(makeInput({ type: 'techTokens', enabled: false }));
const stale = await request(url, { ...firstInput, operationId: randomUUID() }, adminCookie);
assert.equal(stale.status, 409); assert.equal(stale.rawCookie, null);
report.checks.push('Neutral admin rules/modules and AI add/configure/remove persist; public-only projection, origin/account/host denial and stale version rejection');
for (const cookie of [hostCookie, secondCookie]) {
const player = await request(path, undefined, cookie);
const ready = await request(path, { version: player.data.version, action: { type: 'ready' } }, cookie);
assert.equal(ready.status, 200);
}
current = await request(url, undefined, adminCookie);
const assignment = makeInput({ type: 'assignHost', target: secondId });
await apply(assignment); assert.equal(current.data.host, secondId);
assert.ok(current.data.players.every(player => !player.ready));
const afterAssignment = (await request(path, undefined, hostCookie)).data;
assert.notEqual(afterAssignment.me, secondId); assert.equal(afterAssignment.host, secondId);
assert.equal((await request(path, undefined, secondCookie)).data.me, secondId);
const noSeat = await request(path, undefined, adminCookie);
assert.ok([401, 409].includes(noSeat.status));
assert.equal(noSeat.rawCookie, null);
for (const field of ['players', 'me', 'hand', 'state']) assert.ok(!Object.hasOwn(noSeat.data, field));
const oldHostChange = await request(path, { version: afterAssignment.version, action: { type: 'rules', advanced: true } }, hostCookie);
assert.notEqual(oldHostChange.status, 200);
report.checks.push('Existing human host assignment clears readiness, preserves both seat cookies and grants no administrator private-seat access');
for (const cookie of [hostCookie, secondCookie]) {
const player = await request(path, undefined, cookie);
assert.equal((await request(path, { version: player.data.version, action: { type: 'ready' } }, cookie)).status, 200);
}
const beforeStart = (await request(path, undefined, secondCookie)).data;
const started = await request(path, { version: beforeStart.version, action: { type: 'start' } }, secondCookie);
assert.equal(started.status, 200); assert.notEqual(started.data.status, 'lobby');
const replay = await apply(assignment); assert.equal(replay.data.replayed, true); assert.equal(current.data.editable, false);
assert.equal(current.data.host, secondId);
assert.deepEqual((await request(path, undefined, secondCookie)).data, started.data);
const denied = await request(url, makeInput({ type: 'rules', advanced: true }), adminCookie);
assert.equal(denied.status, 409); assert.equal(denied.rawCookie, null);
assert.deepEqual((await request(path, undefined, secondCookie)).data, started.data);
report.checks.push('New host starts a real saved game; exact pre-start assignment retry confirms without resetting its started continuation');
} catch (error) {
report.failureRequest ??= report.lastRequest;
report.lobbyFailure = 'Administrator lobby configuration acceptance failed; dedicated QA room remains saved.';
report.lobbyFailureLocation = error instanceof Error ? error.stack?.split('\n').find(line => line.trim().startsWith('at ') && line.includes('admin-lobby-verification.mjs'))?.trim() : undefined;
throw error;
}
}
+3 -1
View File
@@ -7,6 +7,7 @@ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { verifyRoomLifecycle } from './admin-lifecycle-verification.mjs';
import { verifyRoomCreation } from './admin-creation-verification.mjs';
import { verifyLobbyConfiguration } from './admin-lobby-verification.mjs';
const options = {};
for (let n = 2; n < process.argv.length; n += 2) {
@@ -31,7 +32,7 @@ async function request(path, body, cookie, origin = base, expectedAdminId = admi
const response = await fetch(base + path, {
method: body === undefined ? 'GET' : 'POST',
headers: { ...(body === undefined ? {} : { 'content-type': 'application/json', origin }), ...(cookie ? { cookie } : {}),
...(expectedAdminId && (path === '/api/admin/rooms' && body !== undefined || /^\/api\/admin\/rooms\/[^/]+\/control$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) },
...(expectedAdminId && (path === '/api/admin/rooms' && body !== undefined || /^\/api\/admin\/rooms\/[^/]+\/(control|lobby)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000),
});
report.lastRequest = { path, status: response.status };
@@ -85,6 +86,7 @@ try {
session1 = lifecycleSession.cookie;
await verifyRoomLifecycle(request, session1, report);
await verifyRoomCreation(request, session1, report);
await verifyLobbyConfiguration(request, session1, report);
}
report.passed = true;
} catch (error) {