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 }; }, }; }