Release Healer Man 0.1.38
This commit is contained in:
1 parent
361734bc41
commit
b417b7c3c3
48 files changed
+1982
-90
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user