forked from phenom/healer-man
332 lines
15 KiB
JavaScript
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 };
|
|
},
|
|
};
|
|
}
|