Prototype paused admin participant AI support
Enable an existing AI difficulty for a human seat through admin controls without granting private access or advancing paused play. Preserve custody, credentials, takeback and separate paced resume. Bind atomic writes and outgoing-grant revocation to live authority, room/pause versions and active seat access with durable exact receipts. Validation: types/lint, 5408 offline tests, build and 52 HTTP tests passed on source fingerprint 2157de0f4bb0b74d9760d52102601c52344cf674dee8ccd1d559f0a246a1d4ff; 22 authenticated admin HTTP groups, independent SQL/privacy/UI review, desktop/mobile and refresh checks. All 2098 baseline rooms and 4455 seats preserved. Migration 0015 is additive. Positive production admin QA awaits authorized access; full admin/rules scope remains unfinished.
This commit is contained in:
1 parent
137f9223f1
commit
2cddf03932
23 files changed
+3187
-10
No files matched your search
@@ -13,7 +13,7 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
|
||||
assert.ok(cookie);
|
||||
const room = (await created.json() as { code: string }).code;
|
||||
for (const headers of [{}, { cookie }, { cookie: 'dune_admin_session=' + 'a'.repeat(64) }, { 'oai-authenticated-user-id': 'owner', 'oai-authenticated-user-email': 'owner@example.test' }] as Record<string, string>[]) {
|
||||
for (const path of ['/api/admin/session', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`, `/api/admin/rooms/${room}/archive`]) {
|
||||
for (const path of ['/api/admin/session', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`, `/api/admin/rooms/${room}/archive`, `/api/admin/rooms/${room}/seat-ai`]) {
|
||||
const result = await fetch(base + path, { headers, signal: AbortSignal.timeout(15000) });
|
||||
assert.equal(result.status, 401, path);
|
||||
assert.equal(result.headers.get('cache-control'), 'no-store');
|
||||
@@ -57,6 +57,13 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
|
||||
assert.equal(archive.status, 401);
|
||||
assert.equal(archive.headers.get('set-cookie'), null);
|
||||
assert.deepEqual(Object.keys(await archive.json()), ['error']);
|
||||
const participant = await fetch(`${base}/api/admin/rooms/${room}/seat-ai`, {
|
||||
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedVersion: 0, expectedControlRevision: 0, target: 'owner', difficulty: 'Easy', reason: 'Denied participant QA' }),
|
||||
});
|
||||
assert.equal(participant.status, 401);
|
||||
assert.equal(participant.headers.get('set-cookie'), null);
|
||||
assert.deepEqual(Object.keys(await participant.json()), ['error']);
|
||||
const create = await fetch(base + '/api/admin/rooms', {
|
||||
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ operationId: crypto.randomUUID(), sessionToken: 'b'.repeat(64), name: 'Denied creation QA', faction: 'atreides', advanced: false, techTokens: false, strongholdCards: false, bots: [], reason: 'Must not create a room' }),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { adminSeatAiConfirmation, adminSeatAiResponse, clearAdminSeatAiRequest, newAdminSeatAiRequest, readAdminSeatAiRequest, saveAdminSeatAiRequest } from '../lib/admin-seat-ai-client';
|
||||
import type { AdminSeatAiView } from '../lib/admin-seat-ai';
|
||||
|
||||
const view = (): AdminSeatAiView => ({ code: 'SEATQAAB', version: 7, controlRevision: 2, status: 'playing', paused: true, closed: false, removed: false, archived: false,
|
||||
editable: true, blockedReason: null, players: [{ id: 'owner', name: 'QA Owner', faction: 'atreides', control: 'human', difficulty: null, eligible: true }] });
|
||||
function storage() {
|
||||
const values = new Map<string, string>();
|
||||
return { values, getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } };
|
||||
}
|
||||
void test('participant retry stores exact intent before dispatch, survives refresh, and isolates room/account', () => {
|
||||
const tab = storage(), room = view(), input = newAdminSeatAiRequest(room, 'owner', 'Hard', ' QA support ');
|
||||
assert.equal(input.reason, 'QA support');
|
||||
saveAdminSeatAiRequest(tab, 'admin', room.code, input);
|
||||
assert.deepEqual(readAdminSeatAiRequest(tab, 'admin', room.code), input);
|
||||
assert.equal(readAdminSeatAiRequest(tab, 'other', room.code), null);
|
||||
assert.equal(readAdminSeatAiRequest(tab, 'admin', 'OTHERQAA'), null);
|
||||
for (const change of [{ operationId: crypto.randomUUID() }, { expectedVersion: 8 }, { expectedControlRevision: 3 }, { target: 'other' }, { difficulty: 'Easy' as const }, { reason: 'Different' }])
|
||||
assert.throws(() => saveAdminSeatAiRequest(tab, 'admin', room.code, { ...input, ...change }), /Resolve the saved/);
|
||||
saveAdminSeatAiRequest(tab, 'admin', room.code, input);
|
||||
clearAdminSeatAiRequest(tab, 'admin', room.code); assert.equal(readAdminSeatAiRequest(tab, 'admin', room.code), null);
|
||||
});
|
||||
void test('bad participant retry storage cannot lose or overwrite unresolved intent', () => {
|
||||
const tab = storage(), room = view(), input = newAdminSeatAiRequest(room, 'owner', 'Easy', 'QA');
|
||||
assert.throws(() => saveAdminSeatAiRequest({ ...tab, setItem() {} }, 'admin', room.code, input), /Nothing was sent/);
|
||||
saveAdminSeatAiRequest(tab, 'admin', room.code, input);
|
||||
const key = [...tab.values.keys()][0];
|
||||
for (const raw of ['{broken', JSON.stringify({ ...input, token: 'not accepted' }), ' '.repeat(2049), JSON.stringify({ ...input, expectedControlRevision: -1 })]) {
|
||||
tab.values.set(key, raw);
|
||||
assert.throws(() => readAdminSeatAiRequest(tab, 'admin', room.code), /unreadable/);
|
||||
assert.throws(() => saveAdminSeatAiRequest(tab, 'admin', room.code, input), /unreadable/);
|
||||
assert.equal(tab.values.get(key), raw);
|
||||
}
|
||||
assert.throws(() => clearAdminSeatAiRequest({ ...tab, removeItem() {} }, 'admin', room.code), /could not be cleared/);
|
||||
clearAdminSeatAiRequest(tab, 'admin', room.code);
|
||||
for (const reason of [' ', 'a'.repeat(301), 'two\nlines', 'control\u007f']) assert.throws(() => newAdminSeatAiRequest(room, 'owner', 'Easy', reason));
|
||||
assert.throws(() => newAdminSeatAiRequest({ ...room, editable: false, blockedReason: 'Pause first' }, 'owner', 'Easy', 'QA'), /Pause first/);
|
||||
assert.throws(() => newAdminSeatAiRequest(room, 'absent', 'Easy', 'QA'), /eligible/);
|
||||
});
|
||||
void test('participant confirmation binds original operation/version but accepts later human takeback', () => {
|
||||
const room = view(), input = newAdminSeatAiRequest(room, 'owner', 'Hard', 'QA');
|
||||
const result = { operationId: input.operationId, appliedVersion: 8, replayed: true, room: { ...room, version: 10, paused: false, editable: false, blockedReason: 'Pause first' } };
|
||||
assert.deepEqual(adminSeatAiConfirmation(result, room.code, input), result);
|
||||
for (const bad of [{ ...result, operationId: crypto.randomUUID() }, { ...result, appliedVersion: 9 }, { ...result, replayed: 'yes' },
|
||||
{ ...result, room: { ...room, version: 7 } }, { ...result, token: 'unexpected' }]) assert.throws(() => adminSeatAiConfirmation(bad, room.code, input));
|
||||
});
|
||||
void test('participant responses reject unexpected private fields and malformed operational values', () => {
|
||||
const room = view(); assert.deepEqual(adminSeatAiResponse(room, room.code), room);
|
||||
for (const bad of [null, [], { ...room, state: {} }, { ...room, auditReason: 'private' }, { ...room, code: 'bad' },
|
||||
{ ...room, version: -1 }, { ...room, paused: 'yes' }, { ...room, controlRevision: 1.2 },
|
||||
{ ...room, players: [{ ...room.players[0], hand: [] }] }, { ...room, players: [{ ...room.players[0], difficulty: 'Impossible' }] }])
|
||||
assert.throws(() => adminSeatAiResponse(bad, room.code));
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { enableAdminSeatAi, projectAdminSeatAi, seatAiGame, seatAiUnavailable } from '../game/admin-seat-ai';
|
||||
import { adminSeatAiCoreShape } from '../game/admin-seat-ai-shape';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
|
||||
const restore = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
const base = { code: 'SEATQAAB', version: 2, controlRevision: 1, paused: true, closed: false, removed: false, archived: false };
|
||||
const factions: FactionId[] = ['atreides','harkonnen','emperor','fremen','guild','beneGesserit'];
|
||||
function start(roster: FactionId[] = ['atreides','emperor'], advanced = false) {
|
||||
let game = createGame(base.code, newPlayer(roster[0], roster[0], roster[0]), advanced);
|
||||
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
return applyAction(game, game.host, { type: 'start', ...(advanced ? { advancedPreview: true } : {}) });
|
||||
}
|
||||
|
||||
void test('participant AI preserves genuine Basic and Advanced preview setup and saved continuation', () => {
|
||||
for (const advanced of [false, true]) for (const roster of [['atreides','emperor'], factions] as FactionId[][]) {
|
||||
let game = start(roster, advanced), steps = 0;
|
||||
do {
|
||||
assert.ok(seatAiGame(restore(game), base.code), `${advanced}: ${game.setupStage}`);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const snapshot = restore(game), before = restore(snapshot);
|
||||
snapshot.botsPending = true; snapshot.botNextActionAt = 50_000;
|
||||
const next = enableAdminSeatAi(snapshot, game.host, difficulty);
|
||||
assert.equal(next.players[0].autopilot, difficulty);
|
||||
assert.equal(next.botsPending, true); assert.equal(next.botNextActionAt, 50_000);
|
||||
const expected = restore(snapshot);
|
||||
expected.players[0].autopilot = difficulty;
|
||||
expected.log.push(next.log.at(-1)!);
|
||||
assert.deepEqual(next, expected, 'Only the chosen controller and truthful event change');
|
||||
assert.deepEqual(snapshot, { ...before, botsPending: true, botNextActionAt: 50_000 }, 'Source stays immutable');
|
||||
assert.match(next.log.at(-1)!.text, /administrator enabled.*room remains paused/);
|
||||
assert.doesNotThrow(() => viewGame(restore(next), game.host));
|
||||
}
|
||||
if (game.status !== 'setup') break;
|
||||
const owner = viewGame(game, game.host).setupPending[0];
|
||||
assert.ok(owner);
|
||||
const view = viewGame(game, owner);
|
||||
view.players.find(player => player.id === owner)!.bot = 'Easy';
|
||||
let next: Game | undefined;
|
||||
for (const action of botActions(view)) { try { next = applyAction(game, owner, action); break; } catch { /* Choose a legal setup action. */ } }
|
||||
assert.ok(next, 'Every setup obligation has a legal continuation');
|
||||
game = restore(next);
|
||||
} while (++steps < 30);
|
||||
assert.equal(game.status, 'playing');
|
||||
}
|
||||
});
|
||||
|
||||
void test('malformed saved core and continuation fail closed without exposing or repairing private state', () => {
|
||||
const genuine = start();
|
||||
const cases: [string, (game: Record<string, unknown>) => void][] = [
|
||||
['deck', game => { delete game.deck; }], ['private hand', game => { delete (game.players as Record<string, unknown>[])[0].hand; }],
|
||||
['negative reserves', game => { (game.players as Record<string, unknown>[])[0].reserves = -5; }],
|
||||
['phase', game => { game.phase = 999; }], ['storm 0', game => { game.storm = 0; }], ['storm 19', game => { game.storm = 19; }],
|
||||
['unknown decision', game => { game.decision = { kind: 'private-invalid', player: genuine.host }; }],
|
||||
...['auction','stormResolution','spiceWindow','spiceSequence','spiceResolution','pendingCapture','karamaShipping'].map(key => [key, (game: Record<string, unknown>) => { game[key] = {}; }] as [string, (game: Record<string, unknown>) => void]),
|
||||
['aid', game => { game.aid = { [genuine.host]: null }; }],
|
||||
];
|
||||
for (const [name, corrupt] of cases) {
|
||||
const game = restore(genuine); corrupt(game as unknown as Record<string, unknown>);
|
||||
const before = restore(game);
|
||||
assert.equal(seatAiGame(game, base.code), false, name);
|
||||
assert.throws(() => enableAdminSeatAi(game, game.host, 'Easy'), { message: seatAiUnavailable }, name);
|
||||
assert.deepEqual(game, before, name);
|
||||
assert.deepEqual(projectAdminSeatAi(base, game, new Set([game.host])), { ...base, status: 'unreadable', players: [], editable: false, blockedReason: seatAiUnavailable });
|
||||
}
|
||||
});
|
||||
|
||||
void test('core domain permits sector 18 and repeated territory ride destinations and storm force locations', () => {
|
||||
const game = start(); game.storm = 18;
|
||||
assert.ok(game.spiceDeck.some(card => 'sector' in card && card.sector === 18));
|
||||
game.wormRides = ['red_chasm','red_chasm'];
|
||||
game.stormResolution = { from: 18, distance: 3, traversed: 1, pending: ['red_chasm:1'] };
|
||||
assert.equal(adminSeatAiCoreShape(restore(game)), true);
|
||||
});
|
||||
|
||||
void test('admin projection includes only public participant fields and eligibility requires current access', () => {
|
||||
const game = start(); game.players[1].autopilot = 'Medium';
|
||||
const view = projectAdminSeatAi(base, game, new Set([game.host]));
|
||||
assert.equal(view.editable, true);
|
||||
assert.deepEqual(view.players[0], { id: game.host, name: game.players[0].name, faction: game.players[0].faction, control: 'human', difficulty: null, eligible: true });
|
||||
assert.equal(view.players[1].eligible, false);
|
||||
assert.deepEqual(Object.keys(view.players[1]).sort(), ['control','difficulty','eligible','faction','id','name']);
|
||||
assert.equal(projectAdminSeatAi(base, game, new Set()).players[0].eligible, false);
|
||||
for (const flags of [{ paused: false }, { removed: true }, { closed: true }, { archived: true }]) {
|
||||
const blocked = projectAdminSeatAi({ ...base, ...flags }, game, new Set([game.host]));
|
||||
assert.equal(blocked.editable, false); assert.ok(blocked.blockedReason);
|
||||
}
|
||||
for (const status of ['lobby','finished'] as const) {
|
||||
const blocked = projectAdminSeatAi(base, { ...game, status }, new Set([game.host]));
|
||||
assert.equal(blocked.editable, false);
|
||||
}
|
||||
});
|
||||
|
||||
void test('participant controller change preserves capped chronicle sequence and cannot replace an existing controller', () => {
|
||||
const game = start(); game.log = Array.from({ length: 250 }, (_, index) => ({ seq: index + 20, text: `Saved event ${index}` }));
|
||||
const next = enableAdminSeatAi(game, game.host, 'Brutal');
|
||||
assert.equal(next.log.length, 250); assert.deepEqual(next.log.slice(0, -1), game.log.slice(1));
|
||||
assert.equal(next.log.at(-1)!.seq, 270);
|
||||
assert.throws(() => enableAdminSeatAi(next, game.host, 'Easy'), { message: seatAiUnavailable });
|
||||
const bot = restore(game); bot.players[0].bot = 'Easy';
|
||||
assert.throws(() => enableAdminSeatAi(bot, game.host, 'Easy'), { message: seatAiUnavailable });
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { adminStore, sha256 } from './admin-access-fixture';
|
||||
import { adminLogin, requireAdmin, AdminError, type AdminRole } from '../db/admin-access';
|
||||
import { applyAdminSeatAi, readAdminSeatAi } from '../db/admin-seat-ai';
|
||||
import { applyAdminRoomControl } from '../db/admin-lifecycle';
|
||||
import { createGame, joinGame, newPlayer, applyAction, type Game } from '../game/engine';
|
||||
import { adminSeatAiConfirmation } from '../lib/admin-seat-ai-client';
|
||||
import type { AdminSeatAiInput } from '../lib/admin-seat-ai';
|
||||
|
||||
const now = 10_000, code = 'SEATSQLS';
|
||||
const status = (...wanted: number[]) => (error: unknown) => error instanceof AdminError && wanted.includes(error.status);
|
||||
async function fixture(role: AdminRole = 'operator') {
|
||||
const store = adminStore(); assert.ok(store.sqlite instanceof DatabaseSync);
|
||||
const account = store.provision(role, 'Private administrator identity');
|
||||
const login = await adminLogin(store.database, account.key, now);
|
||||
const identity = await requireAdmin(store.database, login.token, undefined, now);
|
||||
const owner = newPlayer(randomUUID(), 'QA Owner', 'atreides'), other = newPlayer(randomUUID(), 'QA Other', 'emperor');
|
||||
let game = createGame(code, owner); joinGame(game, other);
|
||||
game = applyAction(game, owner.id, { type: 'ready' });
|
||||
game = applyAction(game, other.id, { type: 'ready' });
|
||||
game = applyAction(game, owner.id, { type: 'start' });
|
||||
game.botsPending = true; game.botNextActionAt = 50_000;
|
||||
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run(code, JSON.stringify(game), 0, 1);
|
||||
store.sqlite.prepare('INSERT INTO room_controls(room_code,paused,revision,updated_at) VALUES(?,1,1,1)').run(code);
|
||||
const tokenHash = sha256('QA-owner-seat');
|
||||
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,0)').run(tokenHash, code, owner.id);
|
||||
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,0)').run(sha256('QA-other-seat'), code, other.id);
|
||||
const input: AdminSeatAiInput = { operationId: randomUUID(), expectedVersion: 0, expectedControlRevision: 1, target: owner.id, difficulty: 'Hard', reason: 'Private operational reason' };
|
||||
const apply = (request = input) => applyAdminSeatAi(store.database, identity, code, request, now);
|
||||
const row = () => store.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(code)! as { code: string; state: string; version: number; updated_at: number };
|
||||
const saved = (): Game => JSON.parse(row().state);
|
||||
const auditCount = () => Number(store.sqlite.prepare('SELECT COUNT(*) AS n FROM admin_seat_ai_operations').get()!.n);
|
||||
return { ...store, identity, input, owner, other, tokenHash, apply, row, saved, auditCount };
|
||||
}
|
||||
type Fixture = Awaited<ReturnType<typeof fixture>>;
|
||||
function pauseCommit(f: Fixture) {
|
||||
let enter!: () => void, release!: () => void, calls = 0;
|
||||
const waiting = new Promise<void>(resolve => { enter = resolve; }), gate = new Promise<void>(resolve => { release = resolve; });
|
||||
f.hooks.beforeBatch = async () => { if (++calls === 2) { delete f.hooks.beforeBatch; enter(); await gate; } };
|
||||
return { waiting, release };
|
||||
}
|
||||
|
||||
void test('participant AI transaction preserves private custody, credentials, pause pacing and unrelated rooms', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const insert = f.sqlite.prepare('INSERT INTO seat_ai_delegations(room_code,owner_id,grant_id,delegate_id,difficulty,owner_session_hash,delegate_session_hash,expires_at,used_at,revoked_at) VALUES(?,?,?,?,?,?,?,?,?,?)');
|
||||
const grants: [string,string,string,number|null,number|null][] = [
|
||||
['unused', f.owner.id, f.other.id, null, null], ['used', f.owner.id, f.other.id, 99, null],
|
||||
['revoked', f.owner.id, f.other.id, null, 99], ['incoming', f.other.id, f.owner.id, null, null],
|
||||
];
|
||||
for (const [grant, owner, delegate, used, revoked] of grants) insert.run(code, owner, grant, delegate, 'Easy', f.tokenHash, sha256('QA-other-seat'), now + 100, used, revoked);
|
||||
f.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run('OTHERQAA', f.row().state, 20, 50);
|
||||
const otherRoom = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get('OTHERQAA');
|
||||
const seats = f.sqlite.prepare('SELECT * FROM seats').all(), original = f.saved();
|
||||
const result = await f.apply(); adminSeatAiConfirmation(result, code, f.input);
|
||||
assert.equal(result.replayed, false); assert.equal(result.appliedVersion, 1);
|
||||
const next = f.saved(), expected = structuredClone(original);
|
||||
expected.players[0].autopilot = 'Hard'; expected.version = 1; expected.log.push(next.log.at(-1)!);
|
||||
assert.deepEqual(next, expected);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get('OTHERQAA'), otherRoom);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT grant_id,used_at,revoked_at FROM seat_ai_delegations ORDER BY grant_id').all().map(row => ({ ...row })), [
|
||||
{ grant_id: 'incoming', used_at: null, revoked_at: null }, { grant_id: 'revoked', used_at: null, revoked_at: 99 },
|
||||
{ grant_id: 'unused', used_at: null, revoked_at: now }, { grant_id: 'used', used_at: 99, revoked_at: null },
|
||||
]);
|
||||
assert.deepEqual(Object.keys(result.room.players[0]).sort(), ['control','difficulty','eligible','faction','id','name']);
|
||||
assert.doesNotMatch(JSON.stringify(result), /Private administrator identity|Private operational reason|token_hash|traitor|hand|recovery/i);
|
||||
assert.equal(f.auditCount(), 1);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('exact participant retry after takeback, resume and removal never reapplies controller change', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.apply();
|
||||
const later = applyAction(f.saved(), f.owner.id, { type: 'setAutopilot', difficulty: null }); later.version = 2;
|
||||
f.sqlite.prepare('UPDATE rooms SET state=?,version=2 WHERE code=?').run(JSON.stringify(later), code);
|
||||
f.sqlite.prepare('UPDATE room_controls SET paused=0,revision=2 WHERE room_code=?').run(code);
|
||||
f.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES(?,1,1,1,1)').run(code);
|
||||
const stable = f.row(), replay = await f.apply(); adminSeatAiConfirmation(replay, code, f.input);
|
||||
assert.equal(replay.replayed, true); assert.equal(replay.room.removed, true); assert.equal(replay.room.players[0].control, 'human');
|
||||
assert.deepEqual(f.row(), stable); assert.equal(f.auditCount(), 1);
|
||||
await assert.rejects(() => f.apply({ ...f.input, difficulty: 'Easy' }), status(409));
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
for (const [name, sql] of [
|
||||
['seat revocation', 'UPDATE seats SET revoked=1'], ['version change', 'UPDATE rooms SET version=version+1'],
|
||||
['resume', 'UPDATE room_controls SET paused=0,revision=revision+1'], ['control revision', 'UPDATE room_controls SET revision=revision+1'],
|
||||
['removal', "INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES('SEATSQLS',1,1,1,1)"],
|
||||
['closure', "INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES('SEATSQLS',1,1,1,1)"],
|
||||
['archive', "INSERT INTO room_archives(room_code,archived,revision,archived_at,updated_at) VALUES('SEATSQLS',1,1,1,1)"],
|
||||
['role demotion', "UPDATE admin_accounts SET role='viewer'"], ['session revocation', 'UPDATE admin_sessions SET revoked_at=1'],
|
||||
['session generation', 'UPDATE admin_accounts SET session_generation=session_generation+1'], ['session expiry', 'UPDATE admin_sessions SET expires_at=1'],
|
||||
]) void test(`participant AI commit fences ${name}`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const original = f.row().state; let calls = 0;
|
||||
f.hooks.beforeBatch = async () => { if (++calls === 2) { delete f.hooks.beforeBatch; f.sqlite.exec(sql); } };
|
||||
await assert.rejects(f.apply, status(401, 403, 409));
|
||||
assert.equal(f.row().state, original); assert.equal(f.auditCount(), 0);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('participant AI receipt failure rolls back game write and pending permissions', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const before = f.row();
|
||||
f.sqlite.exec("CREATE TRIGGER reject_receipt BEFORE INSERT ON admin_seat_ai_operations BEGIN SELECT RAISE(ABORT,'fixture receipt failure'); END;");
|
||||
await assert.rejects(f.apply, /fixture receipt failure/); assert.deepEqual(f.row(), before); assert.equal(f.auditCount(), 0);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
for (const different of [false, true]) void test(`racing ${different ? 'conflicting' : 'identical'} participant request retains one operation`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const barrier = pauseCommit(f), pending = f.apply(); await barrier.waiting;
|
||||
const winner = await f.apply(different ? { ...f.input, difficulty: 'Easy' } : f.input);
|
||||
assert.equal(winner.replayed, false); const state = f.row(); barrier.release();
|
||||
if (different) await assert.rejects(() => pending, status(409)); else assert.equal((await pending).replayed, true);
|
||||
assert.deepEqual(f.row(), state); assert.equal(f.auditCount(), 1);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('viewer reads public participant availability but cannot change it', async () => {
|
||||
const f = await fixture('viewer');
|
||||
try {
|
||||
assert.equal((await readAdminSeatAi(f.database, f.identity, code, now)).players.length, 2);
|
||||
const before = f.row(); await assert.rejects(f.apply, status(403)); assert.deepEqual(f.row(), before);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('resume starts a fresh paced continuation only after the administrator enables AI', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.apply(); assert.equal(f.saved().botNextActionAt, 50_000);
|
||||
await applyAdminRoomControl(f.database, f.identity, code, { operationId: randomUUID(), expectedRevision: 1, paused: false, joinLocked: false, reason: 'QA resume' }, now);
|
||||
assert.equal(f.saved().botsPending, true); assert.equal(f.saved().botNextActionAt, now + 1500);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
Reference in new issue
Block a user