Release Healer Man 0.1.58

This commit is contained in:
phenom committed 2026-09-20 16:46:17 -04:00
1 parent 14e105642c
commit 246b4832b0
38 files changed
+952 -93

No files matched your search

+14 -3
View File
@@ -739,19 +739,30 @@ export function openGameDatabase(options = {}) {
if (members.some((member) => !member.character_json || !["tank", "healer", "damage"].includes(member.role))) {
throw new GameDatabaseError("Every player must select a character and party role before the group can start.", 409, "group_not_ready");
}
const fillWithAi = input?.fillWithAi !== false;
const randomizedBiteSized = type === "dungeon"
&& contentId === "randomized-bite-sized-stormwind-stockade";
const fillWithAi = randomizedBiteSized || input?.fillWithAi !== false;
if (input?.includeBetaSpecs !== undefined && typeof input.includeBetaSpecs !== "boolean") {
throw new GameDatabaseError("includeBetaSpecs must be boolean.", 400, "invalid_beta_preference");
}
const requestedSize = Math.max(1, Math.min(5, Math.trunc(Number(input?.partySize) || 5)));
const includeBetaSpecs = randomizedBiteSized ? false : input?.includeBetaSpecs === true;
const requestedSize = randomizedBiteSized
? 5
: Math.max(1, Math.min(5, Math.trunc(Number(input?.partySize) || 5)));
const partySize = fillWithAi ? Math.max(members.length, requestedSize) : members.length;
const readiness = getCompanionSpecReadiness();
const companionSpecializationIds = Object.entries(readiness.config.statuses)
.filter(([, status]) => status === "ready" || (includeBetaSpecs && status === "beta"))
.map(([specializationId]) => specializationId);
const activity = {
id: `activity-${randomUUID()}`,
type,
contentId,
...(type === "dungeon" ? { difficultyId: input?.difficultyId, bossDifficulty: input?.bossDifficulty ?? 1 } : {}),
fillWithAi,
includeBetaSpecs: input?.includeBetaSpecs === true,
includeBetaSpecs,
companionReadinessRevision: readiness.revision,
companionSpecializationIds,
partySize,
startingLevel: type === "manastorm"
? Math.max(1, Math.trunc(Number(input?.startingLevel) || 1))
+18
View File
@@ -352,6 +352,24 @@ test("launches players-only and AI-filled group activities only when every membe
assert.equal(filled.group.activity.partySize, 5);
assert.equal(filled.group.activity.startingLevel, 10);
assert.equal(filled.group.activity.includeBetaSpecs, true);
const randomized = database.startOnlineGroupActivity(leader.id, {
type: "dungeon",
contentId: "randomized-bite-sized-stormwind-stockade",
fillWithAi: false,
includeBetaSpecs: true,
partySize: 2,
});
assert.equal(randomized.group.activity.partySize, 5);
assert.equal(randomized.group.activity.fillWithAi, true);
assert.equal(randomized.group.activity.includeBetaSpecs, false);
assert.equal(randomized.group.activity.companionReadinessRevision, 0);
assert.deepEqual(randomized.group.activity.companionSpecializationIds.sort(), [
"hunter:marksmanship",
"mage:fire",
"priest:discipline",
"rogue:assassination",
"warrior:protection",
]);
assert.throws(() => database.startOnlineGroupActivity(member.id, {
type: "dungeon", contentId: "wailing-caverns",
}), (error) => error instanceof GameDatabaseError && error.status === 403);
+11 -9
View File
@@ -58,16 +58,18 @@ test("validates final boss, dungeon/difficulty, duration and party without trust
assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "bad" }), { status: 400 });
assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", offset: -1 }), { status: 400 });
}));
test("accepts any rolled bite-sized Stockades boss as the run's full clear", () => withDatabase((database) => {
const dungeon = catalog.dungeons.find((entry) => entry.id === "bite-sized-stormwind-stockade");
const difficulty = dungeon.difficulties[0];
assert.equal(difficulty.finalBossIds.length, 5);
for (const [index, bossId] of difficulty.finalBossIds.entries()) {
const payload = run(`bite-sized-${index}-run`, 1000 + index, false, dungeon.id);
payload.killedBossIds = [bossId];
assert.equal(database.submitDungeonRun(null, payload).run.fullClear, true);
test("accepts any rolled Stockades boss as a full clear for both bite-sized modes", () => withDatabase((database) => {
for (const dungeonId of ["bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade"]) {
const dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId);
const difficulty = dungeon.difficulties[0];
assert.equal(difficulty.finalBossIds.length, 5);
for (const [index, bossId] of difficulty.finalBossIds.entries()) {
const payload = run(`${dungeonId}-${index}-run`, 1000 + index, false, dungeon.id);
payload.killedBossIds = [bossId];
assert.equal(database.submitDungeonRun(null, payload).run.fullClear, true);
}
assert.equal(database.listDungeonLeaderboard({ dungeonId: dungeon.id, clear: "full" }).total, 5);
}
assert.equal(database.listDungeonLeaderboard({ dungeonId: dungeon.id, clear: "full" }).total, 5);
}));
test("public HTTP leaderboards accept completion records and return results with rank while rejecting unfinished runs", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-leaderboard-http-"));
+43
View File
@@ -4,6 +4,7 @@ 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",
"forsaken-abbey",
...Object.keys(availability.enabled),
]);
@@ -31,6 +32,15 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
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");
@@ -84,6 +94,39 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
}
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" || !input.creatureKey.startsWith("stormwind-stockade:")
|| 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,
+35
View File
@@ -27,9 +27,11 @@ test("validates comments and dungeon IDs and stamps new reports as 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.equal(database.submitDungeonReport(null, { dungeonId: "bite-sized-stormwind-stockade", comment: "Wall issue" }).status, "Submitted");
assert.equal(database.submitDungeonReport(null, { dungeonId: "randomized-bite-sized-stormwind-stockade", comment: "Random mob issue" }).status, "Submitted");
assert.deepEqual(database.listDungeonReports().openCounts, {
"bite-sized-stormwind-stockade": 1,
"forsaken-abbey": 1,
"randomized-bite-sized-stormwind-stockade": 1,
"stormwind-stockade": 1,
});
}));
@@ -98,6 +100,29 @@ test("persists boss readiness and structured boss reports without changing ordin
} finally { database?.close(); rmSync(directory, { recursive: true, force: true }); }
});
test("persists randomized mob and boss readiness and requires a server-authenticated GM", () => withDatabase(async (database) => {
const gm = (await database.register("Phenom", "readiness-admin")).account;
const player = (await database.register("ReadinessPlayer", "readiness-player")).account;
const creature = {
creatureKey: "stormwind-stockade:entity-1711",
dungeonId: "randomized-bite-sized-stormwind-stockade",
creatureName: "Defias Convict",
creatureKind: "mob",
};
assert.throws(() => database.saveRandomizedCreatureReadiness(player, { ...creature, status: "ready" }), { status: 403 });
for (const status of ["ready", "needs-testing", "not-ready"]) {
assert.equal(database.saveRandomizedCreatureReadiness(gm, { ...creature, status }).status, status);
}
assert.throws(() => database.saveRandomizedCreatureReadiness(gm, { ...creature, status: "Closed" }), { code: "invalid_creature_status" });
assert.throws(() => database.saveRandomizedCreatureReadiness(gm, { ...creature, creatureKind: "pet", status: "ready" }), { code: "invalid_creature" });
assert.deepEqual(database.listRandomizedCreatureReadiness().reviews[0], {
...creature,
status: "not-ready",
reviewedBy: "Phenom",
reviewedAt: database.listRandomizedCreatureReadiness().reviews[0].reviewedAt,
});
}));
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 });
@@ -115,6 +140,16 @@ test("serves public reports and GM-only status API with shelved modes disabled",
const labels = await (await fetch(`${origin}/api/boss-testing/reviews`)).json();
assert.equal(labels.reviews[0].status, "ready");
const gm = await runtime.database.register("Phenom", "report-admin");
const creature = { creatureKey: "stormwind-stockade:entity-1711", dungeonId: "randomized-bite-sized-stormwind-stockade",
creatureName: "Defias Convict", creatureKind: "mob", status: "ready" };
const playerDenied = await fetch(`${origin}/api/gm/randomized-creature-readiness`, { method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${player.token}` }, body: JSON.stringify(creature) });
assert.equal(playerDenied.status, 403);
const readinessSaved = await fetch(`${origin}/api/gm/randomized-creature-readiness`, { method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gm.token}` }, body: JSON.stringify(creature) });
assert.equal(readinessSaved.status, 200);
const readiness = await (await fetch(`${origin}/api/randomized-creature-readiness`)).json();
assert.equal(readiness.reviews[0].status, "ready");
const created = await request("", "POST", { dungeonId: "stormwind-stockade", comment: "Issue via API" }, player.token);
assert.equal(created.status, 201);
const { report } = await created.json();
+12
View File
@@ -336,6 +336,18 @@ export function createGameServer(options = {}) {
json(response, 200, { review: gameDatabase.saveBossTestingReview(account, await readJson(request, 8 * 1024)) });
return;
}
if (url.pathname === "/api/randomized-creature-readiness" && request.method === "GET") {
json(response, 200, gameDatabase.listRandomizedCreatureReadiness());
return;
}
if (url.pathname === "/api/gm/randomized-creature-readiness" && request.method === "PUT") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, { review: gameDatabase.saveRandomizedCreatureReadiness(
account,
await readJson(request, 8 * 1024),
) });
return;
}
if (url.pathname === "/api/dungeon-reports" && request.method === "GET") {
json(response, 200, gameDatabase.listDungeonReports({
status: url.searchParams.get("status"),