Release Healer Man 0.1.58
This commit is contained in:
1 parent
14e105642c
commit
246b4832b0
38 files changed
+952
-93
No files matched your search
+14
-3
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in new issue
Block a user