Enforce ordinary auction bidding deadlines online

This commit is contained in:
Quintine committed 2026-10-27 00:07:15 +11:00
1 parent f76c8dfb93
commit f548039cd8
17 files changed
+1363 -33

No files matched your search

+8 -3
View File
@@ -1,4 +1,4 @@
import { continueRoomAutomatic, continueRoomBots } from './rooms';
import { continueRoomAutomatic, continueRoomAuctionClock, continueRoomBots } from './rooms';
// Coalesce polls and control requests inside this isolate. Database CAS fences
// other isolates, restarts, seat recovery and human actions.
@@ -7,8 +7,13 @@ export function resumeRoom(code: string): Promise<void> {
const existing = continuations.get(code);
if (existing) return existing;
const pending = (async () => {
await continueRoomAutomatic(code);
await continueRoomBots(code);
await Promise.all([
(async () => {
await continueRoomAutomatic(code);
await continueRoomBots(code);
})(),
continueRoomAuctionClock(code, undefined, true),
]);
})().finally(() => {
if (continuations.get(code) === pending) continuations.delete(code);
});
+112 -22
View File
@@ -6,6 +6,9 @@ import {
joinGame,
newPlayer,
viewGame,
synchronizeAuctionClock,
advanceAuctionClock,
hasAuctionClockOpportunity,
RuleError,
type Action,
type Game,
@@ -266,16 +269,24 @@ export async function createRoom(
throw error;
}
}
type RoomControlRow = { room_control: string };
type RoomControlRow = { room_control: string; closure_revision?: number };
// SQLite constructs an explicit public allowlist, consistent with delegation views.
const roomControlProjection = `json_patch(json_object(
'paused', json(CASE WHEN c.paused = 1 THEN 'true' ELSE 'false' END),
'joinLocked', json(CASE WHEN c.join_locked = 1 THEN 'true' ELSE 'false' END),
'revision', COALESCE(c.revision, 0), 'updatedAt', c.updated_at
), CASE WHEN EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) THEN '{"closed":true}' ELSE '{}' END) AS room_control`;
const roomClosureRevision = `COALESCE((SELECT revision FROM room_closures WHERE room_code = rooms.code), 0) AS closure_revision`;
const roomClockControlFence = `AND COALESCE((SELECT revision FROM room_controls WHERE room_code = rooms.code), 0) = ?
AND COALESCE((SELECT revision FROM room_closures WHERE room_code = rooms.code), 0) = ?`;
function publicRoomControl(row: RoomControlRow): RoomControl {
return JSON.parse(row.room_control) as RoomControl;
}
function roomClockAuthority(row: RoomControlRow) {
const controlRevision = publicRoomControl(row).revision;
const closureRevision = row.closure_revision ?? 0;
return { controlRevision, closureRevision, controlEpoch: `${controlRevision}:${closureRevision}` };
}
function attachRoomControl(view: GameView, row: RoomControlRow) {
const control = publicRoomControl(row);
// Untouched rooms retain the ordinary game-view shape. Once an administrator
@@ -284,7 +295,7 @@ function attachRoomControl(view: GameView, row: RoomControlRow) {
}
async function readRoomSnapshot(code: string) {
const row = await db()
.prepare(`SELECT rooms.state, rooms.version, ${roomControlProjection}
.prepare(`SELECT rooms.state, rooms.version, ${roomControlProjection}, ${roomClosureRevision}
FROM rooms LEFT JOIN room_controls c ON c.room_code = rooms.code WHERE rooms.code = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)`)
.bind(code)
.first<{ state: string; version: number } & RoomControlRow>();
@@ -294,7 +305,7 @@ async function readRoomSnapshot(code: string) {
}
const g: Game = JSON.parse(row.state);
g.version = row.version;
return { game: g, control: publicRoomControl(row) };
return { game: g, control: publicRoomControl(row), ...roomClockAuthority(row) };
}
export async function readRoom(code: string) {
const row = await db()
@@ -442,7 +453,15 @@ export async function act(
action: Action,
clock: RoomsClock = roomsClock,
) {
const { game: current, control } = await readRoomSnapshot(code);
// Authenticate before a trusted preflight can advance the shared table.
const seat = await db().prepare('SELECT player_id FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0')
.bind(code, auth.playerId, auth.tokenHash).first();
if (!seat) {
await requireRoomNotRemoved(code);
throw new RuleError('Your seat could not be verified.');
}
await continueRoomAuctionClock(code, clock);
const { game: current, control, controlEpoch, controlRevision, closureRevision } = await readRoomSnapshot(code);
const takingBackControl =
action?.type === 'setAutopilot' && action.difficulty === null;
if (control.closed) throw new RoomClosedError();
@@ -460,14 +479,14 @@ export async function act(
throw new RuleError('Take back control before making a game decision.');
// This is a wake-up request, not another game action. The route schedules the
// persisted worker; repeated or early wake-ups cannot bypass its deadline.
if (action.type === 'advanceBots') return readSeatView(code, auth);
const next = applyAction(current, id, action);
queueRoomBots(next, current, clock.now());
next.version++;
if (action.type === 'advanceBots') return readSeatView(code, auth, clock);
const now = clock.now();
const next = synchronizeAuctionClock(applyAction(current, id, action), now, controlEpoch, control.paused || control.closed);
queueRoomBots(next, current, now);
next.version++;
const update = db()
.prepare(
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND (? = 1 OR NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0)',
`UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND (? = 1 OR NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) ${roomClockControlFence}`,
)
.bind(
JSON.stringify(next),
@@ -478,6 +497,8 @@ export async function act(
code,
auth.playerId,
auth.tokenHash,
controlRevision,
closureRevision,
);
const results =
action.type === 'setAutopilot'
@@ -497,14 +518,14 @@ export async function act(
'Another action arrived first. Your view has refreshed; try again.',
);
}
return readSeatView(code, auth);
return readSeatView(code, auth, clock);
}
/** Check the credential and load its private state in one database statement. */
export async function readSeatView(code: string, auth: SeatAuth) {
const row = await db()
/** Verify the credential before clock preflight, then project the current private row. */
export async function readSeatView(code: string, auth: SeatAuth, clock: Pick<RoomsClock, 'now'> = roomsClock) {
const load = () => db()
.prepare(
`SELECT rooms.state, rooms.version, ${roomControlProjection},
`SELECT rooms.state, rooms.version, ${roomControlProjection}, ${roomClosureRevision},
CASE WHEN COUNT(delegations.grant_id) = 0 THEN NULL ELSE
json_group_array(json_object(
'grantId', delegations.grant_id,
@@ -547,13 +568,25 @@ export async function readSeatView(code: string, auth: SeatAuth) {
seat_ai_delegations: string | null;
}
>();
let row = await load();
if (!row) {
await requireRoomNotRemoved(code);
throw new RuleError('Your seat could not be verified.');
}
// Never resume a room for an unverified viewer. Reload through the same
// credential boundary if arming or a due native action changes its version.
if (await continueRoomAuctionClock(code, { now: () => clock.now(), sleep: ms => roomsClock.sleep(ms) })) {
row = await load();
if (!row) {
await requireRoomNotRemoved(code);
throw new RuleError('Your seat could not be verified.');
}
}
const g: Game = JSON.parse(row.state);
g.version = row.version;
const view = viewGame(g, auth.playerId) as GameView;
const control = publicRoomControl(row), now = clock.now();
const projected = synchronizeAuctionClock(g, now, roomClockAuthority(row).controlEpoch, control.paused || control.closed);
const view = viewGame(projected, auth.playerId, now) as GameView;
attachRoomControl(view, row);
if (row.seat_ai_delegations) {
const delegations = JSON.parse(
@@ -571,9 +604,12 @@ export function needsAutomaticRoomRecovery(
battle?: { revealed: boolean; territory: string } | null;
automaticContinuationPending?: boolean;
grummanCollection?: { event: string; stage?: string } | null;
auction?: { clockVersion?: number } | null;
auctionClock?: unknown;
},
) {
return (
state.auction?.clockVersion === 1 || !!state.auctionClock ||
state.automaticContinuationPending === true ||
state.grummanCollection?.stage === 'waiting' ||
!!state.pendingTreacheryDiscard ||
@@ -603,6 +639,58 @@ async function readWorkerRoomSnapshot(code: string) {
}
}
/** One ordinary bid deadline through the existing trusted clock and room CAS.
* A worker may wait once for at most ten seconds, then rereads all authority.
* No durable alarm is claimed: a later authenticated read/action resumes a
* persisted deadline after the process stops. Administrative revision changes
* deliberately rearm a full budget after a pause or closure. A true result
* requests a fresh private read after a commit or a lost authority fence. */
export async function continueRoomAuctionClock(
code: string,
clock: RoomsClock = roomsClock,
wait = false,
): Promise<boolean> {
let changed = false, waited = false;
for (let attempt = 0; attempt < 6; attempt++) {
const snapshot = await readWorkerRoomSnapshot(code);
if (!snapshot || snapshot.control.paused || snapshot.control.closed) return changed;
const { game: current, controlEpoch, controlRevision, closureRevision } = snapshot;
if (!hasAuctionClockOpportunity(current) && !current.auctionClock) return changed;
const now = clock.now();
const next = advanceAuctionClock(current, now, controlEpoch);
const nativeProgress = JSON.stringify(next.auction) !== JSON.stringify(current.auction) ||
next.phase !== current.phase || next.turn !== current.turn;
if (JSON.stringify(next) !== JSON.stringify(current)) {
queueRoomBots(next, current, now);
next.version = current.version + 1;
const result = await db().prepare(
`UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ?
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1)
AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1)
AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)
${roomClockControlFence}`,
).bind(JSON.stringify(next), now, code, current.version, controlRevision, closureRevision).run();
if (result.meta.changes !== 1) {
// A lost game/control fence also requires an authenticated caller to
// reload its private row, even when this worker committed no write.
changed = true;
continue;
}
changed = true;
if (nativeProgress || !wait) return changed;
}
const timer = next.auctionClock;
if (!wait || waited || !timer || timer.startedAt === null) return changed;
const delay = Math.max(0, timer.remainingMs - Math.max(0, clock.now() - timer.startedAt));
if (delay <= 0) return changed;
waited = true;
await clock.sleep(Math.min(delay, 10000));
// A human bid, seat change or administrative revision may have won while
// asleep. Only a fresh snapshot can arm, suspend or expire its own event.
}
return changed;
}
/** Persist an old automatic continuation through the same version fence as current actions. */
export async function continueRoomAutomatic(
code: string,
@@ -611,7 +699,7 @@ export async function continueRoomAutomatic(
for (let attempt = 0; attempt < 4; attempt++) {
const snapshot = await readWorkerRoomSnapshot(code);
if (!snapshot) return;
const { game: current, control } = snapshot;
const { game: current, control, controlEpoch, controlRevision, closureRevision } = snapshot;
if (control.paused || control.closed) return;
if (
!needsAutomaticRoomRecovery(current) ||
@@ -621,15 +709,15 @@ export async function continueRoomAutomatic(
current.status === 'finished'))
)
return;
const next = normalizeAutomaticGame(current);
const next = synchronizeAuctionClock(normalizeAutomaticGame(current), clock.now(), controlEpoch);
if (JSON.stringify(next) === JSON.stringify(current)) return;
queueRoomBots(next, current, clock.now());
next.version = current.version + 1;
const result = await db()
.prepare(
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)',
`UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1) ${roomClockControlFence}`,
)
.bind(JSON.stringify(next), clock.now(), code, current.version)
.bind(JSON.stringify(next), clock.now(), code, current.version, controlRevision, closureRevision)
.run();
if (result.meta.changes === 1) return;
}
@@ -645,6 +733,7 @@ export async function continueRoomBots(
clock: RoomsClock = roomsClock,
) {
for (let batch = 0; batch < batches; batch++) {
await continueRoomAuctionClock(code, clock);
let snapshot = await readWorkerRoomSnapshot(code);
if (!snapshot) return;
let current = snapshot.game;
@@ -657,6 +746,7 @@ export async function continueRoomBots(
const delay = (current.botNextActionAt ?? clock.now()) - clock.now();
if (delay > 0) {
await clock.sleep(Math.min(delay, ONLINE_BOT_INTERVAL_MS));
await continueRoomAuctionClock(code, clock);
// A human may have taken control or another worker committed while asleep.
snapshot = await readWorkerRoomSnapshot(code);
if (!snapshot) return;
@@ -671,16 +761,16 @@ export async function continueRoomBots(
}
// Offline simulations retain runBots' fast default. Online persistence makes
// at most one AI decision visible per successful compare-and-swap.
const next = runBots(current, 1);
const next = synchronizeAuctionClock(runBots(current, 1), clock.now(), snapshot.controlEpoch);
if (next.botsPending)
next.botNextActionAt = clock.now() + ONLINE_BOT_INTERVAL_MS;
else delete next.botNextActionAt;
next.version = current.version + 1;
const result = await db()
.prepare(
'UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1)',
`UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1) ${roomClockControlFence}`,
)
.bind(JSON.stringify(next), clock.now(), code, current.version)
.bind(JSON.stringify(next), clock.now(), code, current.version, snapshot.controlRevision, snapshot.closureRevision)
.run();
// A different worker, seat recovery, or Take back control won. Re-read its authority.
if (result.meta.changes !== 1) continue;