Release Healer Man 0.1.40

This commit is contained in:
phenom committed 2026-09-13 13:21:27 -04:00
1 parent ed0d2fea3d
commit 197a64117c
215 files changed
+5131 -181

No files matched your search

+11
View File
@@ -12,6 +12,7 @@ import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
import { createDungeonReportRepository } from "./dungeon-reports.mjs";
import { createDungeonLeaderboardRepository } from "./dungeon-leaderboards.mjs";
import dungeonLeaderboardCatalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
const scrypt = promisify(scryptCallback);
@@ -602,6 +603,15 @@ export function openGameDatabase(options = {}) {
}
const contentId = String(input?.contentId ?? "").trim().slice(0, 120);
if (!contentId) throw new GameDatabaseError("Choose content before starting.", 400, "missing_content");
if (type === "dungeon" && input?.bossDifficulty !== undefined
&& (!Number.isInteger(input.bossDifficulty) || input.bossDifficulty < 1 || input.bossDifficulty > 10)) {
throw new GameDatabaseError("Choose a boss difficulty from 1 to 10.", 400, "invalid_boss_difficulty");
}
if (type === "dungeon" && input?.difficultyId !== undefined
&& !dungeonLeaderboardCatalog.dungeons.find((dungeon) => dungeon.id === contentId)
?.difficulties.some((difficulty) => difficulty.id === input.difficultyId)) {
throw new GameDatabaseError("Choose a supported dungeon difficulty.", 400, "invalid_dungeon_difficulty");
}
const members = database.prepare(`
SELECT account_id, character_json, role FROM online_group_members WHERE group_id = ? ORDER BY joined_at
`).all(group.id);
@@ -615,6 +625,7 @@ export function openGameDatabase(options = {}) {
id: `activity-${randomUUID()}`,
type,
contentId,
...(type === "dungeon" ? { difficultyId: input?.difficultyId, bossDifficulty: input?.bossDifficulty ?? 1 } : {}),
fillWithAi,
partySize,
startingLevel: type === "manastorm"
+25 -12
View File
@@ -22,12 +22,19 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
CREATE INDEX IF NOT EXISTS dungeon_runs_full_times_idx
ON dungeon_runs(dungeon_id, difficulty_id, full_clear, duration_ms);
`);
// Zero archives pre-slider runs so their old boss balance cannot win the new Easy board.
if (!database.prepare("PRAGMA table_info(dungeon_runs)").all().some((column) => column.name === "boss_difficulty")) {
database.exec("ALTER TABLE dungeon_runs ADD COLUMN boss_difficulty INTEGER NOT NULL DEFAULT 0 CHECK (boss_difficulty BETWEEN 0 AND 10)");
}
database.exec(`CREATE INDEX IF NOT EXISTS dungeon_runs_boss_difficulty_idx
ON dungeon_runs(dungeon_id, difficulty_id, boss_difficulty, full_clear, duration_ms, finished_at, id)`);
const byId = database.prepare("SELECT * FROM dungeon_runs WHERE id = ?");
const insert = database.prepare(`INSERT INTO dungeon_runs
(id, dungeon_id, difficulty_id, duration_ms, full_clear, finished_at, party_json, bosses_json, submitted_by_account_id, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
(id, dungeon_id, difficulty_id, duration_ms, full_clear, finished_at, party_json, bosses_json, submitted_by_account_id, received_at, boss_difficulty)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
const fail = (message) => { throw new ErrorType(message, 400, "invalid_dungeon_run"); };
const record = (row) => ({ id: row.id, dungeonId: row.dungeon_id, difficultyId: row.difficulty_id,
...(row.boss_difficulty > 0 ? { bossDifficulty: Number(row.boss_difficulty) } : {}),
durationMs: Number(row.duration_ms), finishedAt: Number(row.finished_at), fullClear: row.full_clear === 1,
party: JSON.parse(row.party_json), killedBossIds: JSON.parse(row.bosses_json) });
function selection(input) {
@@ -36,22 +43,26 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
const difficultyId = input.difficultyId || dungeon.defaultDifficultyId;
if (!dungeon.difficulties.some((entry) => entry.id === difficultyId)) fail("Choose a supported dungeon difficulty.");
const difficulty = dungeon.difficulties.find((entry) => entry.id === difficultyId);
return { dungeon, difficultyId, difficulty };
const bossDifficulty = input.bossDifficulty ?? 0;
if (!Number.isInteger(bossDifficulty) || bossDifficulty < 0 || bossDifficulty > 10)
fail("Choose a boss difficulty from 1 to 10.");
return { dungeon, difficultyId, difficulty, bossDifficulty };
}
function placement(run) {
const counts = database.prepare(`SELECT COUNT(*) AS total,
COALESCE(SUM(CASE WHEN duration_ms < ? THEN 1 ELSE 0 END), 0) AS faster,
COALESCE(SUM(full_clear), 0) AS full_total,
COALESCE(SUM(CASE WHEN full_clear = 1 AND duration_ms < ? THEN 1 ELSE 0 END), 0) AS full_faster
FROM dungeon_runs WHERE dungeon_id = ? AND difficulty_id = ?`)
.get(run.durationMs, run.durationMs, run.dungeonId, run.difficultyId);
FROM dungeon_runs WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ?`)
.get(run.durationMs, run.durationMs, run.dungeonId, run.difficultyId, run.bossDifficulty ?? 0);
return { finalRank: Number(counts.faster) + 1, finalTotal: Number(counts.total),
fullRank: run.fullClear ? Number(counts.full_faster) + 1 : null, fullTotal: Number(counts.full_total) };
}
return {
submitDungeonRun(account, input) {
if (!input || typeof input !== "object" || Array.isArray(input)) fail("Provide a completed dungeon run.");
const { dungeon, difficultyId, difficulty } = selection(input);
const { dungeon, difficultyId, difficulty, bossDifficulty } = selection(input);
if (input.bossDifficulty !== undefined && bossDifficulty === 0) fail("Choose a boss difficulty from 1 to 10.");
if (!difficulty.finalBossId) fail("This dungeon does not have a configured final boss yet.");
if (typeof input.id !== "string" || !/^[A-Za-z0-9_-]{8,100}$/.test(input.id)) fail("Provide a valid run id.");
if (!Number.isSafeInteger(input.durationMs) || input.durationMs < 1 || input.durationMs > 7 * 24 * 60 * 60 * 1000)
@@ -75,15 +86,17 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
});
const fullClear = difficulty.bossIds.every((id) => killedBossIds.includes(id));
const run = { id: input.id, dungeonId: dungeon.id, difficultyId, durationMs: input.durationMs,
...(bossDifficulty > 0 ? { bossDifficulty } : {}),
finishedAt: input.finishedAt, killedBossIds, party, fullClear };
const existing = byId.get(run.id);
if (existing) {
if (JSON.stringify(record(existing)) !== JSON.stringify({ id: run.id, dungeonId: run.dungeonId,
difficultyId: run.difficultyId, durationMs: run.durationMs, finishedAt: run.finishedAt,
difficultyId: run.difficultyId, ...(bossDifficulty > 0 ? { bossDifficulty } : {}),
durationMs: run.durationMs, finishedAt: run.finishedAt,
fullClear: run.fullClear, party: run.party, killedBossIds: run.killedBossIds }))
throw new ErrorType("That run id already belongs to a different result.", 409, "run_conflict");
} else insert.run(run.id, run.dungeonId, run.difficultyId, run.durationMs, fullClear ? 1 : 0,
run.finishedAt, JSON.stringify(party), JSON.stringify(killedBossIds), account?.id ?? null, Date.now());
run.finishedAt, JSON.stringify(party), JSON.stringify(killedBossIds), account?.id ?? null, Date.now(), bossDifficulty);
return { run, placement: placement(run) };
},
getDungeonRun(id) {
@@ -93,7 +106,7 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
return { run, placement: placement(run) };
},
listDungeonLeaderboard(input) {
const { dungeon, difficultyId } = selection(input);
const { dungeon, difficultyId, bossDifficulty } = selection(input);
const filter = input.clear || "final";
if (!["full", "final"].includes(filter)) fail("Choose full dungeon clears or final boss clears.");
const offset = Number(input.offset ?? 0);
@@ -102,10 +115,10 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
|| !Number.isSafeInteger(limit) || limit < 1 || limit > 100) fail("Choose a valid leaderboard page.");
const condition = filter === "full" ? "AND full_clear = 1" : "";
const rows = database.prepare(`SELECT *, RANK() OVER (ORDER BY duration_ms) AS place FROM dungeon_runs
WHERE dungeon_id = ? AND difficulty_id = ? ${condition}
ORDER BY duration_ms, finished_at, id LIMIT ? OFFSET ?`).all(dungeon.id, difficultyId, limit, offset);
WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition}
ORDER BY duration_ms, finished_at, id LIMIT ? OFFSET ?`).all(dungeon.id, difficultyId, bossDifficulty, limit, offset);
const total = database.prepare(`SELECT COUNT(*) AS total FROM dungeon_runs
WHERE dungeon_id = ? AND difficulty_id = ? ${condition}`).get(dungeon.id, difficultyId).total;
WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition}`).get(dungeon.id, difficultyId, bossDifficulty).total;
return { entries: rows.map((row) => ({ ...record(row), rank: Number(row.place) })), total: Number(total) };
},
};
+51
View File
@@ -6,6 +6,8 @@ import test from "node:test";
import { openGameDatabase } from "./database.mjs";
import { createGameServer } from "./server.mjs";
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
import { DatabaseSync } from "node:sqlite";
import { createDungeonLeaderboardRepository } from "./dungeon-leaderboards.mjs";
function run(id, durationMs, fullClear = true, dungeonId = "deadmines", difficultyId) {
const dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId);
@@ -69,6 +71,15 @@ test("public HTTP leaderboards accept completion records and return results with
const leaderboard = await fetch(`${base}/api/dungeon-leaderboards?dungeon=barren-caves&clear=full`);
assert.equal(leaderboard.status, 200); assert.equal((await leaderboard.json()).entries[0].durationMs, 15555);
assert.deepEqual(await (await fetch(`${base}/api/dungeon-runs/${payload.id}`)).json(), result);
const slider = { ...payload, id: "http-slider-clear", bossDifficulty: 10 };
const sliderResponse = await fetch(`${base}/api/dungeon-runs`, { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify(slider) });
assert.equal(sliderResponse.status, 201);
const sliderBoard = await fetch(`${base}/api/dungeon-leaderboards?dungeon=barren-caves&clear=full&bossDifficulty=10`);
assert.equal(sliderBoard.status, 200);
assert.deepEqual((await sliderBoard.json()).entries.map((entry) => entry.id), [slider.id]);
assert.equal((await (await fetch(`${base}/api/dungeon-leaderboards?dungeon=barren-caves&bossDifficulty=1`)).json()).total, 0);
assert.equal((await fetch(`${base}/api/dungeon-leaderboards?dungeon=barren-caves&bossDifficulty=11`)).status, 400);
const bad = await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...payload, id: "unfinished-run", killedBossIds: [] }) });
assert.equal(bad.status, 400);
const unauthorized = await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { Authorization: "Bearer invalid", "Content-Type": "application/json" }, body: JSON.stringify(payload) });
@@ -87,3 +98,43 @@ test("separates normal and heroic times and counts only bosses that exist on eac
assert.equal(database.listDungeonLeaderboard({ dungeonId: dungeon.id, difficultyId: difficulty.id, clear: "full" }).total, 1);
}
}));
test("isolates ten boss difficulties and legacy scores, preserving retries and ranks after restart", () => withDatabase((database, reopen) => {
database.submitDungeonRun(null, run("legacy-fastest-run", 1));
for (let difficulty = 1; difficulty <= 10; difficulty += 1) {
const payload = { ...run(`slider-level-${difficulty}`, 10000 + difficulty), bossDifficulty: difficulty };
const submitted = database.submitDungeonRun(null, payload);
assert.deepEqual(submitted.placement, { finalRank: 1, finalTotal: 1, fullRank: 1, fullTotal: 1 });
assert.deepEqual(database.submitDungeonRun(null, payload), submitted);
assert.throws(() => database.submitDungeonRun(null, { ...payload, bossDifficulty: difficulty === 10 ? 1 : 10 }), { status: 409 });
}
database = reopen();
for (let bossDifficulty = 1; bossDifficulty <= 10; bossDifficulty += 1) {
const page = database.listDungeonLeaderboard({ dungeonId: "deadmines", bossDifficulty, clear: "full" });
assert.equal(page.total, 1); assert.equal(page.entries[0].bossDifficulty, bossDifficulty);
assert.equal(database.getDungeonRun(page.entries[0].id).placement.finalRank, 1);
}
assert.equal(database.listDungeonLeaderboard({ dungeonId: "deadmines" }).total, 1);
for (const bossDifficulty of [0, -1, 11, 1.5, "2", NaN]) {
assert.throws(() => database.submitDungeonRun(null, { ...run("invalid-slider-run", 1000), bossDifficulty }), { status: 400 });
}
}));
test("migrates the old leaderboard schema and keeps its scores outside Easy", () => {
const database = new DatabaseSync(":memory:");
try {
database.exec(`CREATE TABLE accounts (id TEXT PRIMARY KEY);
CREATE TABLE dungeon_runs (id TEXT PRIMARY KEY, dungeon_id TEXT NOT NULL, difficulty_id TEXT NOT NULL,
duration_ms INTEGER NOT NULL, full_clear INTEGER NOT NULL, finished_at INTEGER NOT NULL,
party_json TEXT NOT NULL, bosses_json TEXT NOT NULL, submitted_by_account_id TEXT, received_at INTEGER NOT NULL)`);
const legacy = run("pre-slider-result", 5000);
database.prepare("INSERT INTO dungeon_runs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(legacy.id,
legacy.dungeonId, legacy.difficultyId, legacy.durationMs, 1, legacy.finishedAt,
JSON.stringify(legacy.party), JSON.stringify(legacy.killedBossIds), null, Date.now());
const repository = createDungeonLeaderboardRepository(database, { ErrorType: Error });
assert.equal(repository.listDungeonLeaderboard({ dungeonId: "deadmines" }).total, 1);
assert.equal(repository.listDungeonLeaderboard({ dungeonId: "deadmines", bossDifficulty: 1 }).total, 0);
createDungeonLeaderboardRepository(database, { ErrorType: Error });
assert.equal(repository.getDungeonRun(legacy.id).run.durationMs, 5000);
} finally { database.close(); }
});
+1
View File
@@ -244,6 +244,7 @@ export function createGameServer(options = {}) {
if (url.pathname === "/api/dungeon-leaderboards" && request.method === "GET") {
json(response, 200, gameDatabase.listDungeonLeaderboard({ dungeonId: url.searchParams.get("dungeon"),
difficultyId: url.searchParams.get("difficulty"), clear: url.searchParams.get("clear"),
bossDifficulty: url.searchParams.has("bossDifficulty") ? Number(url.searchParams.get("bossDifficulty")) : undefined,
offset: url.searchParams.get("offset"), limit: url.searchParams.get("limit") }));
return;
}