Files
healer-man/server/dungeon-simulation.mjs
2026-09-16 14:30:58 -04:00

332 lines
15 KiB
JavaScript

import { randomUUID } from "node:crypto";
import { gunzipSync, gzipSync } from "node:zlib";
import { readFileSync } from "node:fs";
const routeCatalog = JSON.parse(readFileSync(new URL("../src/game/generated/dungeonRouteCatalog.generated.json", import.meta.url), "utf8"));
const generatedRoutes = new Map(routeCatalog.routes.map((route) => [`${route.dungeonId}:${route.id}:${route.hash}`, route]));
export const DUNGEON_SIMULATION_REPLAY_SCHEMA_VERSION = 1;
export const DUNGEON_ROUTE_REVIEW_STATUSES = Object.freeze(["Candidate", "Approved", "NeedsWork"]);
export const DUNGEON_SIMULATION_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
export const DUNGEON_SIMULATION_MAX_DURATION_MS = 2 * 60 * 60 * 1_000;
export const DUNGEON_SIMULATION_MAX_COMPRESSED_BYTES = 32 * 1024 * 1024;
export const DUNGEON_SIMULATION_MAX_DECOMPRESSED_BYTES = 256 * 1024 * 1024;
const MAX_ID_LENGTH = 256;
const MAX_NOTE_LENGTH = 1_000;
const HASH_PATTERN = /^[a-f0-9]{64}$/;
function text(value, label, ErrorType, maximum = MAX_ID_LENGTH) {
if (typeof value !== "string" || !value.trim() || value.length > maximum) {
throw new ErrorType(`${label} must be a non-empty string no longer than ${maximum} characters.`, 400, "invalid_simulation_payload");
}
return value.trim();
}
function optionalNote(value, label, ErrorType) {
if (value === undefined || value === null) return null;
if (typeof value !== "string" || value.length > MAX_NOTE_LENGTH) {
throw new ErrorType(`${label} must be a string no longer than ${MAX_NOTE_LENGTH} characters.`, 400, "invalid_simulation_payload");
}
return value.replace(/\r\n?/g, "\n").trim() || null;
}
function routeHash(value, ErrorType) {
const normalized = text(value, "routeHash", ErrorType, 64).toLowerCase();
if (!HASH_PATTERN.test(normalized)) {
throw new ErrorType("routeHash must be a lowercase SHA-256 hash.", 400, "invalid_route_hash");
}
return normalized;
}
function integer(value, label, ErrorType, minimum, maximum) {
if (!Number.isInteger(value) || value < minimum || value > maximum) {
throw new ErrorType(`${label} must be an integer between ${minimum} and ${maximum}.`, 400, "invalid_simulation_payload");
}
return value;
}
function object(value, label, ErrorType) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ErrorType(`${label} must be an object.`, 400, "invalid_simulation_payload");
}
return value;
}
function publicReplay(row) {
return {
id: row.id,
dungeonId: row.dungeon_id,
routeId: row.route_id,
routeHash: row.route_hash,
mode: row.mode,
seed: row.seed,
result: row.result,
startedAt: Number(row.started_at),
durationMs: Number(row.duration_ms),
criticalFindings: Number(row.critical_findings),
submittedBy: row.submitted_by_username,
createdAt: Number(row.created_at),
expiresAt: Number(row.expires_at),
compressedBytes: Number(row.compressed_bytes),
};
}
function publicReview(row) {
return {
dungeonId: row.dungeon_id,
routeId: row.route_id,
routeHash: row.route_hash,
status: row.status,
note: row.note,
criticalOverrideNote: row.critical_override_note,
reviewedBy: row.reviewed_by_username,
reviewedAt: Number(row.reviewed_at),
revision: Number(row.revision),
};
}
function normalizeReplay(input, ErrorType) {
const source = object(input, "replay", ErrorType);
if (source.schemaVersion !== DUNGEON_SIMULATION_REPLAY_SCHEMA_VERSION) {
throw new ErrorType("Unsupported dungeon simulation replay schema.", 400, "invalid_replay_schema");
}
const mode = source.mode;
if (mode !== "full-combat" && mode !== "navigation-only") {
throw new ErrorType("Choose a valid simulation mode.", 400, "invalid_simulation_mode");
}
const result = source.result;
if (!["completed", "wiped", "stopped", "recording-limit"].includes(result)) {
throw new ErrorType("Choose a valid simulation result.", 400, "invalid_simulation_result");
}
const report = object(source.report, "report", ErrorType);
const findings = Array.isArray(report.findings) ? report.findings : [];
const criticalFindings = findings.filter((finding) => finding?.severity === "critical").length;
const normalized = {
...source,
dungeonId: text(source.dungeonId, "dungeonId", ErrorType),
routeId: text(source.routeId, "routeId", ErrorType),
routeHash: routeHash(source.routeHash, ErrorType),
seed: text(source.seed, "seed", ErrorType),
startedAt: integer(source.startedAt, "startedAt", ErrorType, 1, Number.MAX_SAFE_INTEGER),
durationMs: integer(source.durationMs, "durationMs", ErrorType, 0, DUNGEON_SIMULATION_MAX_DURATION_MS),
mode,
result,
report,
};
const serialized = Buffer.from(JSON.stringify(normalized));
if (serialized.length > DUNGEON_SIMULATION_MAX_DECOMPRESSED_BYTES) {
throw new ErrorType("Replay is too large after decompression.", 413, "replay_too_large");
}
const compressed = gzipSync(serialized, { level: 9 });
if (compressed.length > DUNGEON_SIMULATION_MAX_COMPRESSED_BYTES) {
throw new ErrorType("Replay is too large after compression.", 413, "replay_too_large");
}
return { normalized, compressed, criticalFindings };
}
export function createDungeonSimulationRepository(database, { isGameMaster, ErrorType }) {
database.exec(`
CREATE TABLE IF NOT EXISTS dungeon_route_reviews (
dungeon_id TEXT NOT NULL,
route_id TEXT NOT NULL,
route_hash TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('Candidate', 'Approved', 'NeedsWork')),
note TEXT,
critical_override_note TEXT,
reviewed_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
reviewed_by_username TEXT NOT NULL,
reviewed_at INTEGER NOT NULL,
revision INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (dungeon_id, route_id)
) STRICT;
CREATE TABLE IF NOT EXISTS dungeon_simulation_replays (
id TEXT PRIMARY KEY,
schema_version INTEGER NOT NULL,
dungeon_id TEXT NOT NULL,
route_id TEXT NOT NULL,
route_hash TEXT NOT NULL,
mode TEXT NOT NULL CHECK (mode IN ('full-combat', 'navigation-only')),
seed TEXT NOT NULL,
result TEXT NOT NULL CHECK (result IN ('completed', 'wiped', 'stopped', 'recording-limit')),
started_at INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
critical_findings INTEGER NOT NULL,
report_json TEXT NOT NULL,
payload_gzip BLOB NOT NULL,
compressed_bytes INTEGER NOT NULL,
submitted_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
submitted_by_username TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS dungeon_simulation_replays_route_idx
ON dungeon_simulation_replays(dungeon_id, route_id, route_hash, created_at DESC);
CREATE INDEX IF NOT EXISTS dungeon_simulation_replays_expiry_idx
ON dungeon_simulation_replays(expires_at);
`);
const replayById = database.prepare("SELECT * FROM dungeon_simulation_replays WHERE id = ?");
const reviewByRoute = database.prepare("SELECT * FROM dungeon_route_reviews WHERE dungeon_id = ? AND route_id = ?");
function requireGm(account) {
if (!isGameMaster(account)) {
throw new ErrorType("Game Master access is required.", 403, "gm_required");
}
}
function pruneExpiredDungeonSimulationReplays(now = Date.now()) {
return Number(database.prepare("DELETE FROM dungeon_simulation_replays WHERE expires_at <= ?").run(now).changes);
}
return {
pruneExpiredDungeonSimulationReplays,
listDungeonRouteApprovals() {
return {
approvals: database.prepare(`
SELECT dungeon_id, route_id, route_hash, reviewed_at
FROM dungeon_route_reviews WHERE status = 'Approved'
ORDER BY dungeon_id, route_id
`).all().filter((row) => generatedRoutes.get(`${row.dungeon_id}:${row.route_id}:${row.route_hash}`)?.generationStatus === "Candidate").map((row) => ({
dungeonId: row.dungeon_id,
routeId: row.route_id,
routeHash: row.route_hash,
reviewedAt: Number(row.reviewed_at),
})),
};
},
listDungeonRouteReviews(account) {
requireGm(account);
pruneExpiredDungeonSimulationReplays();
const reviews = database.prepare("SELECT * FROM dungeon_route_reviews ORDER BY dungeon_id, route_id").all();
const latest = database.prepare(`
SELECT * FROM (
SELECT dungeon_simulation_replays.*,
ROW_NUMBER() OVER (
PARTITION BY dungeon_id, route_id, route_hash
ORDER BY created_at DESC, id DESC
) AS route_rank
FROM dungeon_simulation_replays
) WHERE route_rank = 1
`).all();
return {
reviews: reviews.map(publicReview),
latestReplays: latest
.filter((row) => generatedRoutes.has(`${row.dungeon_id}:${row.route_id}:${row.route_hash}`))
.map(publicReplay),
};
},
putDungeonRouteReview(account, dungeonIdValue, routeIdValue, input) {
requireGm(account);
const dungeonId = text(dungeonIdValue, "dungeonId", ErrorType);
const routeId = text(routeIdValue, "routeId", ErrorType);
const hash = routeHash(input?.routeHash, ErrorType);
const status = input?.status;
if (!DUNGEON_ROUTE_REVIEW_STATUSES.includes(status)) {
throw new ErrorType("Choose a valid route review status.", 400, "invalid_route_status");
}
const expectedRevision = integer(input?.expectedRevision, "expectedRevision", ErrorType, 0, Number.MAX_SAFE_INTEGER);
const note = optionalNote(input?.note, "note", ErrorType);
const criticalOverrideNote = optionalNote(input?.criticalOverrideNote, "criticalOverrideNote", ErrorType);
const current = reviewByRoute.get(dungeonId, routeId);
const currentRevision = Number(current?.revision ?? 0);
if (currentRevision !== expectedRevision) {
throw new ErrorType("This route review changed. Reload and try again.", 409, "route_review_conflict");
}
const generatedRoute = generatedRoutes.get(`${dungeonId}:${routeId}:${hash}`);
if (!generatedRoute) {
throw new ErrorType("The route hash does not match the bundled route catalog.", 409, "route_hash_unknown");
}
if (status === "Approved") {
if (generatedRoute.generationStatus !== "Candidate") {
throw new ErrorType("This generated route failed offline navigation validation and cannot be approved.", 409, "route_generation_rejected");
}
const latest = database.prepare(`
SELECT critical_findings, result FROM dungeon_simulation_replays
WHERE dungeon_id = ? AND route_id = ? AND route_hash = ?
ORDER BY created_at DESC, id DESC LIMIT 1
`).get(dungeonId, routeId, hash);
if (!latest) {
throw new ErrorType("Run and save this exact route before approving it.", 409, "route_run_required");
}
if (latest.result !== "completed") {
throw new ErrorType("Approval requires a completed run for this exact route hash.", 409, "route_completed_run_required");
}
if (Number(latest.critical_findings) > 0 && !criticalOverrideNote) {
throw new ErrorType("Critical findings require an override note before approval.", 409, "route_override_required");
}
}
const reviewedAt = Date.now();
if (current) {
database.prepare(`
UPDATE dungeon_route_reviews SET route_hash = ?, status = ?, note = ?, critical_override_note = ?,
reviewed_by_account_id = ?, reviewed_by_username = ?, reviewed_at = ?, revision = revision + 1
WHERE dungeon_id = ? AND route_id = ? AND revision = ?
`).run(hash, status, note, criticalOverrideNote, account.id, account.username, reviewedAt,
dungeonId, routeId, expectedRevision);
} else {
database.prepare(`
INSERT INTO dungeon_route_reviews (
dungeon_id, route_id, route_hash, status, note, critical_override_note,
reviewed_by_account_id, reviewed_by_username, reviewed_at, revision
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
`).run(dungeonId, routeId, hash, status, note, criticalOverrideNote,
account.id, account.username, reviewedAt);
}
return publicReview(reviewByRoute.get(dungeonId, routeId));
},
listDungeonSimulationReplays(account, { dungeonId = null } = {}) {
requireGm(account);
pruneExpiredDungeonSimulationReplays();
const rows = database.prepare(`
SELECT id, dungeon_id, route_id, route_hash, mode, seed, result, started_at, duration_ms,
critical_findings, submitted_by_username, created_at, expires_at, compressed_bytes
FROM dungeon_simulation_replays
WHERE (? IS NULL OR dungeon_id = ?)
ORDER BY created_at DESC, id DESC LIMIT 500
`).all(dungeonId, dungeonId);
return { replays: rows.map(publicReplay) };
},
saveDungeonSimulationReplay(account, input) {
requireGm(account);
pruneExpiredDungeonSimulationReplays();
const { normalized, compressed, criticalFindings } = normalizeReplay(input, ErrorType);
const id = randomUUID();
const createdAt = Date.now();
const expiresAt = createdAt + DUNGEON_SIMULATION_RETENTION_MS;
database.prepare(`
INSERT INTO dungeon_simulation_replays (
id, schema_version, dungeon_id, route_id, route_hash, mode, seed, result,
started_at, duration_ms, critical_findings, report_json, payload_gzip, compressed_bytes,
submitted_by_account_id, submitted_by_username, created_at, expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, normalized.schemaVersion, normalized.dungeonId, normalized.routeId, normalized.routeHash,
normalized.mode, normalized.seed, normalized.result, normalized.startedAt, normalized.durationMs,
criticalFindings, JSON.stringify(normalized.report), compressed, compressed.length,
account.id, account.username, createdAt, expiresAt);
return publicReplay(replayById.get(id));
},
getDungeonSimulationReplay(account, id) {
requireGm(account);
pruneExpiredDungeonSimulationReplays();
const row = replayById.get(text(id, "replay id", ErrorType));
if (!row) throw new ErrorType("Replay not found.", 404, "replay_not_found");
const payload = gunzipSync(row.payload_gzip, { maxOutputLength: DUNGEON_SIMULATION_MAX_DECOMPRESSED_BYTES });
return JSON.parse(payload.toString("utf8"));
},
getDungeonSimulationReport(account, id) {
requireGm(account);
pruneExpiredDungeonSimulationReplays();
const row = replayById.get(text(id, "replay id", ErrorType));
if (!row) throw new ErrorType("Replay not found.", 404, "replay_not_found");
return JSON.parse(row.report_json);
},
deleteDungeonSimulationReplay(account, id) {
requireGm(account);
const result = database.prepare("DELETE FROM dungeon_simulation_replays WHERE id = ?")
.run(text(id, "replay id", ErrorType));
if (!result.changes) throw new ErrorType("Replay not found.", 404, "replay_not_found");
return { ok: true };
},
};
}