Release Healer Man 0.1.12
This commit is contained in:
1 parent
13970d03c8
commit
65e24f644e
201 files changed
+5061
-123
No files matched your search
@@ -6,6 +6,7 @@ import { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
cloneDefaultManastormAdminConfig,
|
||||
normalizeManastormAdminConfig,
|
||||
stampManastormReviewMetadata,
|
||||
} from "./manastorm-config.mjs";
|
||||
|
||||
const scrypt = promisify(scryptCallback);
|
||||
@@ -263,6 +264,8 @@ export function openGameDatabase(options = {}) {
|
||||
throw new GameDatabaseError(error instanceof Error ? error.message : "Invalid Manastorm configuration.", 400, "invalid_manastorm_config");
|
||||
}
|
||||
const now = Date.now();
|
||||
const current = getManastormConfig();
|
||||
normalized = stampManastormReviewMetadata(current.config, normalized, account.username, now);
|
||||
const row = statements.updateManastormConfig.get(
|
||||
JSON.stringify(normalized),
|
||||
account.id,
|
||||
|
||||
@@ -106,10 +106,24 @@ test("persists revisioned Manastorm GM configuration and rejects stale or unauth
|
||||
const configured = {
|
||||
...initial.config,
|
||||
global: { ...initial.config.global, affixesEnabled: false },
|
||||
dungeons: {
|
||||
deadmines: {
|
||||
status: "not-ready",
|
||||
note: "The final door trigger needs fixing.",
|
||||
reviewedAt: 1,
|
||||
reviewedBy: "SpoofedGM",
|
||||
},
|
||||
},
|
||||
};
|
||||
const saved = database.putManastormConfig(gmAccount, 0, configured);
|
||||
assert.equal(saved.revision, 1);
|
||||
assert.equal(saved.config.global.affixesEnabled, false);
|
||||
assert.deepEqual(saved.config.dungeons.deadmines, {
|
||||
status: "not-ready",
|
||||
note: "The final door trigger needs fixing.",
|
||||
reviewedAt: saved.updatedAt,
|
||||
reviewedBy: "Phenom",
|
||||
});
|
||||
assert.equal(database.getManastormConfig().updatedBy, "Phenom");
|
||||
assert.throws(() => database.putManastormConfig(gmAccount, 0, configured), (error) => (
|
||||
error instanceof GameDatabaseError && error.status === 409
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION = 1;
|
||||
export const MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION = 2;
|
||||
export const GM_REVIEW_NOTE_MAX_LENGTH = 1_000;
|
||||
|
||||
export const DEFAULT_MANASTORM_ADMIN_CONFIG = Object.freeze({
|
||||
schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION,
|
||||
@@ -11,10 +12,11 @@ export const DEFAULT_MANASTORM_ADMIN_CONFIG = Object.freeze({
|
||||
milestoneEncountersEnabled: true,
|
||||
}),
|
||||
dungeonSpawns: Object.freeze({}),
|
||||
dungeons: Object.freeze({}),
|
||||
bosses: Object.freeze({}),
|
||||
});
|
||||
|
||||
const BOSS_STATUSES = new Set(["ready", "needs-tested", "not-ready"]);
|
||||
const READINESS_STATUSES = new Set(["ready", "needs-tested", "not-ready"]);
|
||||
const MAX_RECORDS = 10_000;
|
||||
const MAX_ID_LENGTH = 256;
|
||||
|
||||
@@ -42,6 +44,27 @@ function optionalIdentifier(value, label) {
|
||||
return value === undefined || value === null ? undefined : identifier(value, label);
|
||||
}
|
||||
|
||||
function reviewFields(value, label) {
|
||||
const fields = {};
|
||||
if (value.note !== undefined && value.note !== null) {
|
||||
if (typeof value.note !== "string" || value.note.length > GM_REVIEW_NOTE_MAX_LENGTH) {
|
||||
throw invalid(`${label}.note must be a string no longer than ${GM_REVIEW_NOTE_MAX_LENGTH} characters.`);
|
||||
}
|
||||
const note = value.note.replace(/\r\n?/g, "\n").trim();
|
||||
if (note) fields.note = note;
|
||||
}
|
||||
if (value.reviewedAt !== undefined && value.reviewedAt !== null) {
|
||||
if (!Number.isInteger(value.reviewedAt) || value.reviewedAt <= 0) {
|
||||
throw invalid(`${label}.reviewedAt must be a positive integer timestamp.`);
|
||||
}
|
||||
fields.reviewedAt = value.reviewedAt;
|
||||
}
|
||||
if (value.reviewedBy !== undefined && value.reviewedBy !== null) {
|
||||
fields.reviewedBy = identifier(value.reviewedBy, `${label}.reviewedBy`);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function finiteNumber(value, label) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw invalid(`${label} must be a finite number.`);
|
||||
@@ -131,18 +154,26 @@ function normalizeDictionary(value, label, normalizeValue) {
|
||||
|
||||
export function normalizeManastormAdminConfig(value) {
|
||||
const input = record(value, "config");
|
||||
if (input.schemaVersion !== MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION) {
|
||||
if (input.schemaVersion !== 1 && input.schemaVersion !== MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION) {
|
||||
throw invalid(`Unsupported Manastorm admin config schema ${String(input.schemaVersion)}.`);
|
||||
}
|
||||
const dungeonSpawns = normalizeDictionary(input.dungeonSpawns, "config.dungeonSpawns", worldSpawn);
|
||||
const dungeons = input.dungeons === undefined
|
||||
? {}
|
||||
: normalizeDictionary(input.dungeons, "config.dungeons", (rawDungeon, label) => {
|
||||
const dungeon = record(rawDungeon, label);
|
||||
if (!READINESS_STATUSES.has(dungeon.status)) throw invalid(`${label}.status is invalid.`);
|
||||
return { status: dungeon.status, ...reviewFields(dungeon, label) };
|
||||
});
|
||||
const bosses = normalizeDictionary(input.bosses, "config.bosses", (rawBoss, label) => {
|
||||
const boss = record(rawBoss, label);
|
||||
if (!BOSS_STATUSES.has(boss.status)) throw invalid(`${label}.status is invalid.`);
|
||||
if (!READINESS_STATUSES.has(boss.status)) throw invalid(`${label}.status is invalid.`);
|
||||
if (boss.status === "ready" && (boss.stage === undefined || boss.stage === null)) {
|
||||
throw invalid(`${label} cannot be Ready without a complete stage.`);
|
||||
}
|
||||
return {
|
||||
status: boss.status,
|
||||
...reviewFields(boss, label),
|
||||
...(boss.stage === undefined || boss.stage === null
|
||||
? {}
|
||||
: { stage: normalizeStage(boss.stage, `${label}.stage`) }),
|
||||
@@ -152,10 +183,55 @@ export function normalizeManastormAdminConfig(value) {
|
||||
schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION,
|
||||
global: normalizeGlobal(input.global),
|
||||
dungeonSpawns,
|
||||
dungeons,
|
||||
bosses,
|
||||
};
|
||||
}
|
||||
|
||||
export function publicManastormAdminConfig(config) {
|
||||
return {
|
||||
...config,
|
||||
dungeons: Object.fromEntries(Object.entries(config.dungeons).map(([key, record]) => [
|
||||
key,
|
||||
{ status: record.status },
|
||||
])),
|
||||
bosses: Object.fromEntries(Object.entries(config.bosses).map(([key, record]) => [
|
||||
key,
|
||||
{ status: record.status, ...(record.stage ? { stage: record.stage } : {}) },
|
||||
])),
|
||||
};
|
||||
}
|
||||
|
||||
function stampedReviewRecord(previous, next, reviewedBy, reviewedAt) {
|
||||
const changed = (previous?.status ?? "needs-tested") !== next.status
|
||||
|| (previous?.note ?? "") !== (next.note ?? "");
|
||||
const { reviewedAt: _incomingReviewedAt, reviewedBy: _incomingReviewedBy, ...record } = next;
|
||||
if (changed) return { ...record, reviewedAt, reviewedBy };
|
||||
return {
|
||||
...record,
|
||||
...(previous?.reviewedAt ? { reviewedAt: previous.reviewedAt } : {}),
|
||||
...(previous?.reviewedBy ? { reviewedBy: previous.reviewedBy } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function stampManastormReviewMetadata(previous, next, reviewedBy, reviewedAt) {
|
||||
const reviewer = identifier(reviewedBy, "reviewedBy");
|
||||
if (!Number.isInteger(reviewedAt) || reviewedAt <= 0) {
|
||||
throw invalid("reviewedAt must be a positive integer timestamp.");
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
dungeons: Object.fromEntries(Object.entries(next.dungeons).map(([key, record]) => [
|
||||
key,
|
||||
stampedReviewRecord(previous.dungeons[key], record, reviewer, reviewedAt),
|
||||
])),
|
||||
bosses: Object.fromEntries(Object.entries(next.bosses).map(([key, record]) => [
|
||||
key,
|
||||
stampedReviewRecord(previous.bosses[key], record, reviewer, reviewedAt),
|
||||
])),
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneDefaultManastormAdminConfig() {
|
||||
return JSON.parse(JSON.stringify(DEFAULT_MANASTORM_ADMIN_CONFIG));
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
GM_REVIEW_NOTE_MAX_LENGTH,
|
||||
MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION,
|
||||
normalizeManastormAdminConfig,
|
||||
stampManastormReviewMetadata,
|
||||
} from "./manastorm-config.mjs";
|
||||
|
||||
const global = {
|
||||
enabled: true,
|
||||
levelingEnabled: true,
|
||||
endgameEnabled: true,
|
||||
affixesEnabled: true,
|
||||
chaoticLinkEnabled: true,
|
||||
milestoneEncountersEnabled: true,
|
||||
};
|
||||
|
||||
test("migrates schema-v1 Manastorm configuration and preserves records", () => {
|
||||
const migrated = normalizeManastormAdminConfig({
|
||||
schemaVersion: 1,
|
||||
global,
|
||||
dungeonSpawns: {
|
||||
deadmines: { footPosition: [1, 2, 3], yaw: 0.25 },
|
||||
},
|
||||
bosses: {
|
||||
"boss:key": { status: "not-ready" },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(migrated.schemaVersion, MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION);
|
||||
assert.deepEqual(migrated.dungeons, {});
|
||||
assert.deepEqual(migrated.dungeonSpawns.deadmines, { footPosition: [1, 2, 3], yaw: 0.25 });
|
||||
assert.equal(migrated.bosses["boss:key"].status, "not-ready");
|
||||
});
|
||||
|
||||
test("accepts valid dungeon readiness and rejects invalid values", () => {
|
||||
const normalized = normalizeManastormAdminConfig({
|
||||
schemaVersion: 2,
|
||||
global,
|
||||
dungeonSpawns: {},
|
||||
dungeons: { deadmines: { status: "ready" } },
|
||||
bosses: {},
|
||||
});
|
||||
assert.equal(normalized.dungeons.deadmines.status, "ready");
|
||||
|
||||
assert.throws(() => normalizeManastormAdminConfig({
|
||||
schemaVersion: 2,
|
||||
global,
|
||||
dungeonSpawns: {},
|
||||
dungeons: { deadmines: { status: "unknown" } },
|
||||
bosses: {},
|
||||
}), /status is invalid/);
|
||||
});
|
||||
|
||||
test("normalizes review notes and rejects oversized comments", () => {
|
||||
const normalized = normalizeManastormAdminConfig({
|
||||
schemaVersion: 2,
|
||||
global,
|
||||
dungeonSpawns: {},
|
||||
dungeons: {
|
||||
deadmines: {
|
||||
status: "not-ready",
|
||||
note: " Door trigger clips.\r\nRetest after the fix. ",
|
||||
reviewedAt: 1_700_000_000_000,
|
||||
reviewedBy: "Phenom",
|
||||
},
|
||||
},
|
||||
bosses: {},
|
||||
});
|
||||
assert.deepEqual(normalized.dungeons.deadmines, {
|
||||
status: "not-ready",
|
||||
note: "Door trigger clips.\nRetest after the fix.",
|
||||
reviewedAt: 1_700_000_000_000,
|
||||
reviewedBy: "Phenom",
|
||||
});
|
||||
|
||||
assert.throws(() => normalizeManastormAdminConfig({
|
||||
schemaVersion: 2,
|
||||
global,
|
||||
dungeonSpawns: {},
|
||||
dungeons: { deadmines: { status: "not-ready", note: "x".repeat(GM_REVIEW_NOTE_MAX_LENGTH + 1) } },
|
||||
bosses: {},
|
||||
}), /note must be a string no longer/);
|
||||
});
|
||||
|
||||
test("stamps changed review records and ignores incoming metadata spoofing", () => {
|
||||
const previous = normalizeManastormAdminConfig({
|
||||
schemaVersion: 2,
|
||||
global,
|
||||
dungeonSpawns: {},
|
||||
dungeons: {
|
||||
deadmines: {
|
||||
status: "not-ready",
|
||||
note: "Existing note",
|
||||
reviewedAt: 100,
|
||||
reviewedBy: "OriginalGM",
|
||||
},
|
||||
},
|
||||
bosses: {},
|
||||
});
|
||||
const incoming = normalizeManastormAdminConfig({
|
||||
...previous,
|
||||
dungeons: {
|
||||
deadmines: {
|
||||
...previous.dungeons.deadmines,
|
||||
reviewedAt: 999,
|
||||
reviewedBy: "SpoofedGM",
|
||||
},
|
||||
stockades: {
|
||||
status: "needs-tested",
|
||||
note: "Check the prison doors.",
|
||||
reviewedAt: 999,
|
||||
reviewedBy: "SpoofedGM",
|
||||
},
|
||||
},
|
||||
});
|
||||
const stamped = stampManastormReviewMetadata(previous, incoming, "Phenom", 200);
|
||||
|
||||
assert.deepEqual(stamped.dungeons.deadmines, previous.dungeons.deadmines);
|
||||
assert.deepEqual(stamped.dungeons.stockades, {
|
||||
status: "needs-tested",
|
||||
note: "Check the prison doors.",
|
||||
reviewedAt: 200,
|
||||
reviewedBy: "Phenom",
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { createServer } from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
||||
import { publicManastormAdminConfig } from "./manastorm-config.mjs";
|
||||
import { createOnlineSessionManager } from "./online-session.mjs";
|
||||
|
||||
const MIME_TYPES = new Map([
|
||||
@@ -264,6 +265,19 @@ export function createGameServer(options = {}) {
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/manastorm-config" && request.method === "GET") {
|
||||
const current = gameDatabase.getManastormConfig();
|
||||
json(response, 200, {
|
||||
revision: current.revision,
|
||||
updatedAt: current.updatedAt,
|
||||
config: publicManastormAdminConfig(current.config),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/gm/manastorm-config" && request.method === "GET") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
if (!gameDatabase.isGameMaster(account)) {
|
||||
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
||||
}
|
||||
const current = gameDatabase.getManastormConfig();
|
||||
json(response, 200, {
|
||||
revision: current.revision,
|
||||
|
||||
+22
-2
@@ -64,6 +64,10 @@ test("serves the game and authenticated cloud-save API", async () => {
|
||||
body: JSON.stringify({ expectedRevision: 0, config: { schemaVersion: 1 } }),
|
||||
});
|
||||
assert.equal(denied.status, 403);
|
||||
const deniedRead = await fetch(`${origin}/api/gm/manastorm-config`, {
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
});
|
||||
assert.equal(deniedRead.status, 403);
|
||||
|
||||
const memberRegistration = await fetch(`${origin}/api/auth/register`, {
|
||||
method: "POST",
|
||||
@@ -157,11 +161,27 @@ test("serves the game and authenticated cloud-save API", async () => {
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
|
||||
body: JSON.stringify({
|
||||
expectedRevision: current.revision,
|
||||
config: { ...current.config, global: { ...current.config.global, chaoticLinkEnabled: false } },
|
||||
config: {
|
||||
...current.config,
|
||||
global: { ...current.config.global, chaoticLinkEnabled: false },
|
||||
dungeons: {
|
||||
deadmines: { status: "not-ready", note: "The final door trigger needs fixing." },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.equal(published.status, 200);
|
||||
assert.equal((await published.json()).config.global.chaoticLinkEnabled, false);
|
||||
const publishedPayload = await published.json();
|
||||
assert.equal(publishedPayload.config.global.chaoticLinkEnabled, false);
|
||||
assert.equal(publishedPayload.config.dungeons.deadmines.reviewedBy, "Phenom");
|
||||
|
||||
const playerConfig = await (await fetch(`${origin}/api/manastorm-config`)).json();
|
||||
assert.deepEqual(playerConfig.config.dungeons.deadmines, { status: "not-ready" });
|
||||
const gmConfig = await (await fetch(`${origin}/api/gm/manastorm-config`, {
|
||||
headers: { Authorization: `Bearer ${gmAuth.token}` },
|
||||
})).json();
|
||||
assert.equal(gmConfig.config.dungeons.deadmines.note, "The final door trigger needs fixing.");
|
||||
assert.equal(gmConfig.config.dungeons.deadmines.reviewedBy, "Phenom");
|
||||
} finally {
|
||||
await new Promise((resolve) => runtime.server.close(resolve));
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
|
||||
Reference in new issue
Block a user