Release Healer Man 0.1.38

This commit is contained in:
phenom committed 2026-09-12 23:55:24 -04:00
1 parent 361734bc41
commit b417b7c3c3
48 files changed
+1982 -90

No files matched your search

+2
View File
@@ -10,6 +10,7 @@ import {
} from "./manastorm-config.mjs";
import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena-rules.mjs";
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
import { createDungeonReportRepository } from "./dungeon-reports.mjs";
const scrypt = promisify(scryptCallback);
@@ -895,6 +896,7 @@ export function openGameDatabase(options = {}) {
return {
databasePath,
...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
register,
login,
authenticate,
+94
View File
@@ -0,0 +1,94 @@
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(["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);
`);
const insert = database.prepare(`
INSERT INTO dungeon_reports (
id, dungeon_id, comment, status, submitted_by_account_id, submitted_by_username, created_at, updated_at
) 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 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),
};
}
return {
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 (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);
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);
},
};
}
+104
View File
@@ -0,0 +1,104 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { openGameDatabase } from "./database.mjs";
import { createGameServer } from "./server.mjs";
async function withDatabase(run) {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-reports-test-"));
const database = openGameDatabase({ dataDir: directory });
try { await run(database); } finally { database.close(); rmSync(directory, { recursive: true, force: true }); }
}
test("validates comments and dungeon IDs and stamps new reports as Submitted", () => withDatabase(async (database) => {
assert.deepEqual(database.listDungeonReports(), { reports: [], openCounts: {} });
for (const comment of ["", " \n ", "x".repeat(501), null, 123]) {
assert.throws(() => database.submitDungeonReport(null, { dungeonId: "stormwind-stockade", comment }), { code: "invalid_report_comment" });
}
assert.throws(() => database.submitDungeonReport(null, { dungeonId: "fake", comment: "Broken" }), { code: "invalid_dungeon" });
const account = (await database.register("Reporter", "report-pass")).account;
const report = database.submitDungeonReport(account, {
dungeonId: "stormwind-stockade", comment: "x".repeat(500), status: "Closed", submittedBy: "Phenom",
});
assert.equal(report.comment.length, 500);
assert.equal(report.status, "Submitted");
assert.equal(report.submittedBy, "Reporter");
assert.equal(database.submitDungeonReport(null, { dungeonId: "forsaken-abbey", comment: " Door is stuck\r\nAfter a wipe " }).comment, "Door is stuck\nAfter a wipe");
assert.deepEqual(database.listDungeonReports().openCounts, { "forsaken-abbey": 1, "stormwind-stockade": 1 });
}));
test("enforces GM updates, counts all open statuses, supports reopening, and prevents stale changes", () => withDatabase(async (database) => {
const gm = (await database.register("Phenom", "report-admin")).account;
const player = (await database.register("Tester", "report-player")).account;
let report = database.submitDungeonReport(player, { dungeonId: "stormwind-stockade", comment: "Gate is stuck" });
database.submitDungeonReport(player, { dungeonId: "wailing-caverns", comment: "Boss stuck" });
assert.throws(() => database.updateDungeonReport({ ...player, roles: ["gm"] }, report.id, { status: "Closed", expectedRevision: 0 }), { status: 403 });
assert.throws(() => database.updateDungeonReport(gm, report.id, { status: "Other", expectedRevision: 0 }), { code: "invalid_report_status" });
assert.throws(() => database.updateDungeonReport(gm, report.id, { status: "Closed" }), { code: "invalid_revision" });
assert.throws(() => database.updateDungeonReport(gm, "missing", { status: "Closed", expectedRevision: 0 }), { status: 404 });
for (const status of ["In-Progress", "Testing Fixes"]) {
report = database.updateDungeonReport(gm, report.id, { status, expectedRevision: report.revision });
assert.equal(report.updatedBy, "Phenom");
assert.equal(database.listDungeonReports().openCounts[report.dungeonId], 1);
}
assert.throws(() => database.updateDungeonReport(gm, report.id, { status: "Closed", expectedRevision: 0 }), { status: 409 });
report = database.updateDungeonReport(gm, report.id, { status: "Closed", expectedRevision: report.revision });
assert.deepEqual(database.listDungeonReports().openCounts, { "wailing-caverns": 1 });
assert.equal(database.listDungeonReports().reports.length, 2);
const filtered = database.listDungeonReports({ status: "Closed", dungeonId: report.dungeonId });
assert.equal(filtered.reports.length, 1);
assert.deepEqual(filtered.openCounts, { "wailing-caverns": 1 });
report = database.updateDungeonReport(gm, report.id, { status: "Submitted", expectedRevision: report.revision });
assert.equal(database.listDungeonReports().openCounts[report.dungeonId], 1);
}));
test("persists reports and status changes across database restarts", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-report-restart-"));
let database;
try {
database = openGameDatabase({ dataDir: directory });
const gm = (await database.register("Phenom", "report-admin")).account;
const report = database.submitDungeonReport(null, { dungeonId: "stormwind-stockade", comment: "Entrance issue" });
const updated = database.updateDungeonReport(gm, report.id, { status: "Testing Fixes", expectedRevision: 0 });
database.close();
database = openGameDatabase({ dataDir: directory });
assert.deepEqual(database.listDungeonReports().reports, [updated]);
assert.equal(database.listDungeonReports().openCounts[report.dungeonId], 1);
} finally { database?.close(); rmSync(directory, { recursive: true, force: true }); }
});
test("serves public reports and GM-only status API with shelved modes disabled", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-report-api-"));
const runtime = createGameServer({ dataDir: directory, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
const origin = `http://127.0.0.1:${runtime.server.address().port}`;
const request = (route, method = "GET", body, token) => fetch(`${origin}/api/dungeon-reports${route}`, {
method, headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
try {
const player = await runtime.database.register("ApiPlayer", "report-player");
const gm = await runtime.database.register("Phenom", "report-admin");
const created = await request("", "POST", { dungeonId: "stormwind-stockade", comment: "Issue via API" }, player.token);
assert.equal(created.status, 201);
const { report } = await created.json();
assert.equal((await request("", "POST", { dungeonId: "wailing-caverns", comment: "Guest issue" })).status, 201);
assert.equal((await request(`/${report.id}`, "PATCH", { status: "Closed", expectedRevision: 0 })).status, 401);
assert.equal((await request(`/${report.id}`, "PATCH", { status: "Closed", expectedRevision: 0 }, player.token)).status, 403);
assert.equal((await request("", "POST", { dungeonId: "stormwind-stockade", comment: "x".repeat(501) })).status, 400);
const closed = await request(`/${report.id}`, "PATCH", { status: "Closed", expectedRevision: 0 }, gm.token);
assert.equal(closed.status, 200);
assert.equal((await closed.json()).report.status, "Closed");
const all = await (await request("")).json();
assert.equal(all.reports.length, 2);
assert.deepEqual(all.openCounts, { "wailing-caverns": 1 });
assert.equal((await (await request("?status=Closed&dungeon=stormwind-stockade")).json()).reports.length, 1);
assert.equal((await request("?status=Bad")).status, 400);
assert.equal((await request("?dungeon=Bad")).status, 400);
} finally {
await new Promise((resolve) => runtime.server.close(resolve));
rmSync(directory, { recursive: true, force: true });
}
});
+25
View File
@@ -181,6 +181,7 @@ export function createGameServer(options = {}) {
const onlineSessions = createOnlineSessionManager();
const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes });
const allowAuthRequest = createAuthLimiter();
const allowReportRequest = createAuthLimiter();
gameDatabase.pruneExpiredSessions();
const server = createServer(async (request, response) => {
@@ -239,6 +240,30 @@ export function createGameServer(options = {}) {
json(response, 200, { account: gameDatabase.authenticate(bearerToken(request)) });
return;
}
if (url.pathname === "/api/dungeon-reports" && request.method === "GET") {
json(response, 200, gameDatabase.listDungeonReports({
status: url.searchParams.get("status"),
dungeonId: url.searchParams.get("dungeon"),
}));
return;
}
if (url.pathname === "/api/dungeon-reports" && request.method === "POST") {
const token = bearerToken(request);
const account = token ? gameDatabase.authenticate(token) : null;
if (!allowReportRequest(account?.id ?? request.socket.remoteAddress)) {
throw new GameDatabaseError("Too many reports. Please wait a minute and try again.", 429, "rate_limited");
}
const body = await readJson(request, 8 * 1024);
json(response, 201, { report: gameDatabase.submitDungeonReport(account, body) });
return;
}
const reportMatch = /^\/api\/dungeon-reports\/([^/]+)$/.exec(url.pathname);
if (reportMatch && request.method === "PATCH") {
const account = gameDatabase.authenticate(bearerToken(request));
const body = await readJson(request, 8 * 1024);
json(response, 200, { report: gameDatabase.updateDungeonReport(account, reportMatch[1], body) });
return;
}
if (url.pathname === "/api/cloud-save" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, gameDatabase.getCloudSave(account.id));