Files
healer-man/server/dungeon-reports.mjs
T
2026-09-26 22:02:18 -04:00

183 lines
9.4 KiB
JavaScript

import { randomUUID } from "node:crypto";
import availability from "../src/game/generated/dungeonAvailability.json" with { type: "json" };
import policy from "../shared/dungeon-report-policy.json" with { type: "json" };
export const REPORT_DUNGEON_IDS = new Set([
"bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade-available",
"forsaken-abbey",
...Object.keys(availability.enabled),
]);
export function createDungeonReportRepository(database, { isGameMaster, ErrorType }) {
database.exec(`
CREATE TABLE IF NOT EXISTS dungeon_reports (
id TEXT PRIMARY KEY,
dungeon_id TEXT NOT NULL,
comment TEXT NOT NULL CHECK (length(comment) BETWEEN 1 AND 500),
status TEXT NOT NULL CHECK (status IN ('Submitted', 'In-Progress', 'Testing Fixes', 'Closed')),
submitted_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
submitted_by_username TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
updated_by_username TEXT,
revision INTEGER NOT NULL DEFAULT 0
) STRICT;
CREATE INDEX IF NOT EXISTS dungeon_reports_dungeon_status_idx ON dungeon_reports(dungeon_id, status);
CREATE TABLE IF NOT EXISTS boss_testing_reviews (
boss_key TEXT PRIMARY KEY,
dungeon_id TEXT NOT NULL,
boss_name TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('ready', 'needs-testing', 'not-ready')),
reviewed_by TEXT NOT NULL,
reviewed_at INTEGER NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS randomized_creature_readiness (
creature_key TEXT PRIMARY KEY,
dungeon_id TEXT NOT NULL,
creature_name TEXT NOT NULL,
creature_kind TEXT NOT NULL CHECK (creature_kind IN ('mob', 'boss')),
status TEXT NOT NULL CHECK (status IN ('ready', 'needs-testing', 'not-ready')),
reviewed_by TEXT NOT NULL,
reviewed_at INTEGER NOT NULL
) STRICT;
`);
const columns = new Set(database.prepare("PRAGMA table_info(dungeon_reports)").all().map((column) => column.name));
if (!columns.has("boss_key")) database.exec("ALTER TABLE dungeon_reports ADD COLUMN boss_key TEXT");
if (!columns.has("boss_name")) database.exec("ALTER TABLE dungeon_reports ADD COLUMN boss_name TEXT");
const insert = database.prepare(`
INSERT INTO dungeon_reports (
id, dungeon_id, comment, status, submitted_by_account_id, submitted_by_username, created_at, updated_at, boss_key, boss_name
) VALUES (?, ?, ?, 'Submitted', ?, ?, ?, ?, ?, ?)
`);
const byId = database.prepare("SELECT * FROM dungeon_reports WHERE id = ?");
const list = database.prepare(`
SELECT * FROM dungeon_reports
WHERE (? IS NULL OR status = ?) AND (? IS NULL OR dungeon_id = ?)
ORDER BY created_at DESC, id ASC
`);
const counts = database.prepare(`
SELECT dungeon_id, COUNT(*) AS count FROM dungeon_reports WHERE status != 'Closed' GROUP BY dungeon_id
`);
const update = database.prepare(`
UPDATE dungeon_reports SET status = ?, updated_at = ?, updated_by_username = ?, revision = revision + 1
WHERE id = ? AND revision = ? RETURNING *
`);
function validateDungeon(id) {
if (!REPORT_DUNGEON_IDS.has(id)) throw new ErrorType("Choose a valid dungeon.", 400, "invalid_dungeon");
}
function validateStatus(status) {
if (!policy.statuses.includes(status)) throw new ErrorType("Choose a valid report status.", 400, "invalid_report_status");
}
function validateBoss(input) {
validateDungeon(input?.dungeonId);
if (typeof input.bossKey !== "string" || !input.bossKey.startsWith(`${input.dungeonId}/`)
|| input.bossKey.length <= input.dungeonId.length + 1 || input.bossKey.length > 256
|| typeof input.bossName !== "string" || !input.bossName.trim() || input.bossName.length > 128) {
throw new ErrorType("Choose a valid boss.", 400, "invalid_boss");
}
}
function publicReport(row) {
return {
id: row.id,
dungeonId: row.dungeon_id,
comment: row.comment,
status: row.status,
submittedBy: row.submitted_by_username,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
updatedBy: row.updated_by_username,
revision: Number(row.revision),
...(row.boss_key ? { bossKey: row.boss_key, bossName: row.boss_name } : {}),
};
}
return {
listRandomizedCreatureReadiness() {
return { reviews: database.prepare("SELECT * FROM randomized_creature_readiness ORDER BY creature_kind, creature_name").all().map((row) => ({
creatureKey: row.creature_key, dungeonId: row.dungeon_id, creatureName: row.creature_name,
creatureKind: row.creature_kind, status: row.status, reviewedBy: row.reviewed_by,
reviewedAt: Number(row.reviewed_at),
})) };
},
saveRandomizedCreatureReadiness(account, input) {
if (!isGameMaster(account)) throw new ErrorType("Game Master access is required.", 403, "gm_required");
if (input?.dungeonId !== "randomized-bite-sized-stormwind-stockade"
|| typeof input?.creatureKey !== "string" || !/^(wow|rom):(mob|boss):[1-9][0-9]*$/.test(input.creatureKey)
|| input.creatureKey.length > 256 || typeof input?.creatureName !== "string"
|| !input.creatureName.trim() || input.creatureName.length > 128
|| !["mob", "boss"].includes(input.creatureKind)) {
throw new ErrorType("Choose a valid randomized creature.", 400, "invalid_creature");
}
if (!["ready", "needs-testing", "not-ready"].includes(input.status)) {
throw new ErrorType("Choose a valid creature readiness status.", 400, "invalid_creature_status");
}
const review = {
creatureKey: input.creatureKey, dungeonId: input.dungeonId,
creatureName: input.creatureName.trim(), creatureKind: input.creatureKind,
status: input.status, reviewedBy: account.username, reviewedAt: Date.now(),
};
database.prepare(`INSERT INTO randomized_creature_readiness VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(creature_key) DO UPDATE SET dungeon_id = excluded.dungeon_id,
creature_name = excluded.creature_name, creature_kind = excluded.creature_kind,
status = excluded.status, reviewed_by = excluded.reviewed_by,
reviewed_at = excluded.reviewed_at`).run(
review.creatureKey, review.dungeonId, review.creatureName, review.creatureKind,
review.status, review.reviewedBy, review.reviewedAt);
return review;
},
listBossTestingReviews() {
return { reviews: database.prepare("SELECT * FROM boss_testing_reviews ORDER BY boss_key").all().map((row) => ({
bossKey: row.boss_key, dungeonId: row.dungeon_id, bossName: row.boss_name,
status: row.status, reviewedBy: row.reviewed_by, reviewedAt: Number(row.reviewed_at),
})) };
},
saveBossTestingReview(account, input) {
validateBoss(input);
if (!["ready", "needs-testing", "not-ready"].includes(input.status)) {
throw new ErrorType("Choose a valid boss readiness status.", 400, "invalid_boss_status");
}
const review = { bossKey: input.bossKey, dungeonId: input.dungeonId, bossName: input.bossName.trim(),
status: input.status, reviewedBy: account?.username ?? "Guest player", reviewedAt: Date.now() };
database.prepare(`INSERT INTO boss_testing_reviews VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(boss_key) DO UPDATE SET boss_name = excluded.boss_name, status = excluded.status,
reviewed_by = excluded.reviewed_by, reviewed_at = excluded.reviewed_at`).run(
review.bossKey, review.dungeonId, review.bossName, review.status, review.reviewedBy, review.reviewedAt);
return review;
},
listDungeonReports({ status = null, dungeonId = null } = {}) {
if (status !== null) validateStatus(status);
if (dungeonId !== null) validateDungeon(dungeonId);
return {
reports: list.all(status, status, dungeonId, dungeonId).map(publicReport),
openCounts: Object.fromEntries(counts.all().map((row) => [row.dungeon_id, Number(row.count)])),
};
},
submitDungeonReport(account, input) {
validateDungeon(input?.dungeonId);
if (input?.bossKey !== undefined || input?.bossName !== undefined) validateBoss(input);
if (typeof input?.comment !== "string" || !input.comment.trim() || input.comment.length > policy.commentMaxLength) {
throw new ErrorType("Describe the issue in 1-500 characters.", 400, "invalid_report_comment");
}
const id = randomUUID();
const now = Date.now();
insert.run(id, input.dungeonId, input.comment.replace(/\r\n?/g, "\n").trim(), account?.id ?? null,
account?.username ?? "Guest player", now, now, input.bossKey ?? null, input.bossName?.trim() ?? null);
return publicReport(byId.get(id));
},
updateDungeonReport(account, id, input) {
if (!isGameMaster(account)) throw new ErrorType("Game Master access is required.", 403, "gm_required");
validateStatus(input?.status);
if (!Number.isInteger(input?.expectedRevision) || input.expectedRevision < 0) {
throw new ErrorType("A valid expected revision is required.", 400, "invalid_revision");
}
if (!byId.get(id)) throw new ErrorType("Report not found.", 404, "report_not_found");
const row = update.get(input.status, Date.now(), account.username, id, input.expectedRevision);
if (!row) throw new ErrorType("This report has changed. Refresh reports and try again.", 409, "report_conflict");
return publicReport(row);
},
};
}