Update Healer Man server

This commit is contained in:
phenom committed 2026-09-19 21:01:51 -04:00
1 parent f8314e7de1
commit 1fedb9368d
76 files changed
+20556 -2523

No files matched your search

+32 -2
View File
@@ -27,6 +27,8 @@ const ARTIFACTS = new Map([
["party_configurations.csv", "text/csv; charset=utf-8"],
["class_specs_across_parties.csv", "text/csv; charset=utf-8"],
["class_abilities_across_parties.csv", "text/csv; charset=utf-8"],
["npc_class_specs_across_parties.csv", "text/csv; charset=utf-8"],
["npc_class_abilities_across_parties.csv", "text/csv; charset=utf-8"],
["class_spec_by_boss.csv", "text/csv; charset=utf-8"],
["bosses_across_parties.csv", "text/csv; charset=utf-8"],
["boss_by_party.csv", "text/csv; charset=utf-8"],
@@ -55,6 +57,9 @@ export function normalizeBalanceLabJobSpec(input) {
if (!SPEC_SELECTIONS.has(specSelection)) {
throw new GameDatabaseError("Class/spec selection must be random, all, or specific.", 400, "invalid_balance_lab_job");
}
if (input?.includeBetaSpecs !== undefined && typeof input.includeBetaSpecs !== "boolean") {
throw new GameDatabaseError("Include Beta Specs must be boolean.", 400, "invalid_balance_lab_job");
}
const rawBossKeys = Array.isArray(input?.bossKeys) ? input.bossKeys : [];
if (rawBossKeys.length > 500) {
throw new GameDatabaseError("Select no more than 500 bosses.", 400, "invalid_balance_lab_job");
@@ -85,6 +90,8 @@ export function normalizeBalanceLabJobSpec(input) {
partySeed: integer(input?.partySeed ?? 1, "Party seed", -2_147_483_648, 2_147_483_647),
bossCategory,
bossKeys: Object.freeze(bossKeys),
seatSweep: input?.seatSweep === true,
includeBetaSpecs: input?.includeBetaSpecs === true,
});
const sourceCount = spec.source === "all" ? 3 : 1;
const bossEstimate = Math.max(1, spec.bossKeys.length || (spec.bossCategory === "all" ? 500 : 300));
@@ -93,7 +100,7 @@ export function normalizeBalanceLabJobSpec(input) {
: spec.specSelection === "specific"
? Math.max(spec.partyConfigurations, spec.selectedBuildIds.length)
: Math.max(spec.partyConfigurations, spec.source === "all" ? 100 : 50);
const estimatedRuns = partyEstimate * spec.simulationsPerParty * bossEstimate;
const estimatedRuns = partyEstimate * (spec.seatSweep ? 5 : 1) * spec.simulationsPerParty * bossEstimate;
if (estimatedRuns > 250_000) {
throw new GameDatabaseError(
"That job is too large for one server request. Reduce parties, simulations, or selected bosses.",
@@ -136,6 +143,10 @@ export function balanceLabCommand(spec, outputDir, options = {}) {
];
for (const buildId of spec.selectedBuildIds) args.push("--spec", buildId);
for (const bossKey of spec.bossKeys) args.push("--boss", bossKey);
if (spec.seatSweep) args.push("--seat-sweep");
if (spec.includeBetaSpecs) args.push("--include-beta-specs");
if (options.cooldownPolicyPath) args.push("--cooldown-policy", options.cooldownPolicyPath);
if (options.readinessPolicyPath) args.push("--companion-readiness-policy", options.readinessPolicyPath);
return { executable, args, cwd: projectRoot };
}
@@ -198,6 +209,8 @@ function reportSummary(outputDir) {
runs: Number(summary.runs) || 0,
classSpecs: Number(summary.classSpecs) || 0,
classAbilities: Number(summary.classAbilities) || 0,
npcClassSpecs: Number(summary.npcClassSpecs) || 0,
npcClassAbilities: Number(summary.npcClassAbilities) || 0,
bossPressureMedianDtps: Number(summary.bossPressureMedianDtps) || 0,
};
} catch {
@@ -206,9 +219,10 @@ function reportSummary(outputDir) {
}
function expectedPartyConfigurations(spec) {
return spec.specSelection === "random"
const parties = spec.specSelection === "random"
? spec.partyConfigurations * (spec.source === "all" ? 3 : 1)
: spec.partyConfigurations;
return parties * (spec.seatSweep ? 5 : 1);
}
function lineCollector(onLine) {
@@ -249,6 +263,8 @@ export function createBalanceLabJobService(options = {}) {
? Math.min(200, Math.floor(configuredQueueLimit))
: 100;
const launch = options.spawnProcess ?? spawn;
const cooldownPolicyProvider = options.cooldownPolicyProvider;
const readinessPolicyProvider = options.readinessPolicyProvider;
const pythonExecutable = String(options.pythonExecutable
?? process.env.BALANCE_LAB_PYTHON
?? (process.platform === "win32" ? "python" : "python3"));
@@ -390,6 +406,8 @@ export function createBalanceLabJobService(options = {}) {
const command = balanceLabCommand(job.spec, job.outputDir, {
projectRoot,
pythonExecutable,
cooldownPolicyPath: job.cooldownPolicyPath,
readinessPolicyPath: job.readinessPolicyPath,
});
const child = launch(command.executable, command.args, {
cwd: command.cwd,
@@ -467,6 +485,16 @@ export function createBalanceLabJobService(options = {}) {
const id = randomUUID();
const outputDir = path.join(jobsDir, id);
mkdirSync(outputDir, { recursive: true });
const cooldownPolicyPath = path.join(outputDir, "companion-cooldown-policy.json");
if (cooldownPolicyProvider) {
const published = cooldownPolicyProvider();
writeFileSync(cooldownPolicyPath, `${JSON.stringify(published.config ?? published, null, 2)}\n`, "utf8");
}
const readinessPolicyPath = path.join(outputDir, "companion-spec-readiness.json");
if (readinessPolicyProvider) {
const published = readinessPolicyProvider();
writeFileSync(readinessPolicyPath, `${JSON.stringify(published, null, 2)}\n`, "utf8");
}
const totalParties = expectedPartyConfigurations(spec);
const job = {
id,
@@ -488,6 +516,8 @@ export function createBalanceLabJobService(options = {}) {
logTail: [],
summary: null,
outputDir,
cooldownPolicyPath: cooldownPolicyProvider ? cooldownPolicyPath : null,
readinessPolicyPath: readinessPolicyProvider ? readinessPolicyPath : null,
};
jobs.set(id, job);
persistJob(job);
+56 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
@@ -20,6 +20,12 @@ test("disables public assets and filesystem watching in Balance Lab Vite mode",
assert.deepEqual(config.server.watch?.ignored, ["**/*"]);
});
test("enables the local Balance Lab GUI without watching its generated reports", async () => {
const config = await resolveConfig({}, "serve", "balance-lab-gui");
assert.equal(config.define["import.meta.env.VITE_LOCAL_BALANCE_LAB_GUI"], '"true"');
assert.ok(config.server.watch?.ignored?.includes("**/artifacts/balance-lab-gui-runtime/**"));
});
test("normalizes and bounds server Balance Lab requests", () => {
const spec = normalizeBalanceLabJobSpec({
source: "ALL",
@@ -30,6 +36,8 @@ test("normalizes and bounds server Balance Lab requests", () => {
simulationsPerParty: 3,
durationSeconds: 45,
partySeed: 17,
seatSweep: true,
includeBetaSpecs: true,
bossCategory: "all",
bossKeys: ["gundrak/entry-29932", "gundrak/entry-29932"],
});
@@ -37,6 +45,8 @@ test("normalizes and bounds server Balance Lab requests", () => {
assert.equal(spec.specSelection, "specific");
assert.deepEqual(spec.selectedBuildIds, ["balance-wow-priest-healer-holy-level-40"]);
assert.deepEqual(spec.bossKeys, ["gundrak/entry-29932"]);
assert.equal(spec.seatSweep, true);
assert.equal(spec.includeBetaSpecs, true);
assert.throws(
() => normalizeBalanceLabJobSpec({ source: "wow", level: 81 }),
/Level must be a whole number/,
@@ -69,6 +79,8 @@ test("builds an argument-array command without a shell", () => {
const spec = normalizeBalanceLabJobSpec({
source: "rom", level: 60, partyConfigurations: 4, simulationsPerParty: 2,
durationSeconds: 30, partySeed: 9, bossCategory: "rom", bossKeys: ["abbey/boss-1"],
seatSweep: true,
includeBetaSpecs: true,
specSelection: "specific", selectedBuildIds: ["balance-rom-mage-damage-primary-level-60"],
});
const command = balanceLabCommand(spec, "C:/reports/job", {
@@ -81,6 +93,8 @@ test("builds an argument-array command without a shell", () => {
assert.equal(command.args.includes("specific"), true);
assert.equal(command.args.includes("balance-rom-mage-damage-primary-level-60"), true);
assert.equal(command.args.includes("abbey/boss-1"), true);
assert.equal(command.args.includes("--seat-sweep"), true);
assert.equal(command.args.includes("--include-beta-specs"), true);
assert.equal(command.cwd, path.resolve("C:/app"));
});
@@ -162,6 +176,47 @@ test("queues a job, reports progress, and exposes only allow-listed artifacts",
}
});
test("snapshots the published companion readiness policy for each server job", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-readiness-"));
try {
let launchedArgs = [];
const spawnProcess = (_executable, args) => {
launchedArgs = args;
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = () => child.emit("close", null);
return child;
};
const policy = {
revision: 7,
updatedAt: 123,
updatedBy: "Phenom",
config: { schemaVersion: 1, statuses: { "warrior:protection": "ready" } },
};
const service = createBalanceLabJobService({
dataDir: directory,
projectRoot: directory,
spawnProcess,
pythonExecutable: "python-test",
readinessPolicyProvider: () => policy,
});
const job = service.start({
source: "wow", level: 40, partyConfigurations: 1, simulationsPerParty: 1,
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
includeBetaSpecs: true,
}, { username: "Phenom" });
const policyPath = path.join(directory, "balance-lab", "jobs", job.id, "companion-spec-readiness.json");
assert.equal(JSON.parse(readFileSync(policyPath, "utf8")).revision, 7);
assert.equal(launchedArgs.includes("--companion-readiness-policy"), true);
assert.equal(launchedArgs.includes(policyPath), true);
assert.equal(launchedArgs.includes("--include-beta-specs"), true);
service.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("reports exhausted file watchers as a specific startup failure", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-watchers-"));
try {
+87
View File
@@ -0,0 +1,87 @@
export const DEFAULT_COMPANION_COOLDOWN_POLICY = Object.freeze({
schemaVersion: 1,
generic: Object.freeze({
defensiveHealthPercent: 55,
emergencyHealthPercent: 35,
healerMitigationHealthPercent: 80,
offensiveMajorCooldownSeconds: 60,
conserveOffensiveCooldownsForDurableTargets: true,
}),
mechanics: Object.freeze([]),
});
function invalid(message) {
const error = new Error(message);
error.code = "invalid_companion_cooldown_policy";
return error;
}
function number(value, label, minimum, maximum) {
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
throw invalid(`${label} must be a number from ${minimum} through ${maximum}.`);
}
return value;
}
function string(value, label, allowEmpty = false) {
if (typeof value !== "string" || (!allowEmpty && !value.trim()) || value.length > 256) {
throw invalid(`${label} must be ${allowEmpty ? "a" : "a non-empty"} string no longer than 256 characters.`);
}
return value.trim();
}
export function normalizeCompanionCooldownPolicy(value) {
if (!value || typeof value !== "object" || Array.isArray(value) || value.schemaVersion !== 1) {
throw invalid("Companion cooldown policy schemaVersion must be 1.");
}
const generic = value.generic;
if (!generic || typeof generic !== "object" || Array.isArray(generic)) throw invalid("generic rules are required.");
if (typeof generic.conserveOffensiveCooldownsForDurableTargets !== "boolean") {
throw invalid("generic.conserveOffensiveCooldownsForDurableTargets must be boolean.");
}
if (!Array.isArray(value.mechanics) || value.mechanics.length > 1_000) throw invalid("mechanics must be an array of at most 1,000 rules.");
const seen = new Set();
const mechanics = value.mechanics.map((rule, index) => {
if (!rule || typeof rule !== "object" || Array.isArray(rule)) throw invalid(`mechanics[${index}] must be an object.`);
const id = string(rule.id, `mechanics[${index}].id`);
if (seen.has(id)) throw invalid(`mechanic rule id ${id} is duplicated.`);
seen.add(id);
if (typeof rule.enabled !== "boolean") throw invalid(`mechanics[${index}].enabled must be boolean.`);
if (!new Set(["tank", "party"]).has(rule.scope)) throw invalid(`mechanics[${index}].scope is invalid.`);
const serverEntry = rule.bossServerEntry;
if (serverEntry !== null && (!Number.isInteger(serverEntry) || serverEntry <= 0)) {
throw invalid(`mechanics[${index}].bossServerEntry must be null or a positive integer.`);
}
const bossName = string(rule.bossName, `mechanics[${index}].bossName`, true);
if (serverEntry === null && !bossName) {
throw invalid(`mechanics[${index}].bossName is required when bossServerEntry is null.`);
}
return {
id,
enabled: rule.enabled,
bossKey: string(rule.bossKey, `mechanics[${index}].bossKey`, true),
bossName,
bossServerEntry: serverEntry,
attackId: string(rule.attackId, `mechanics[${index}].attackId`),
attackName: string(rule.attackName, `mechanics[${index}].attackName`),
scope: rule.scope,
leadTimeMs: number(rule.leadTimeMs, `mechanics[${index}].leadTimeMs`, 0, 15_000),
minimumCooldownSeconds: number(rule.minimumCooldownSeconds, `mechanics[${index}].minimumCooldownSeconds`, 0, 600),
};
});
return {
schemaVersion: 1,
generic: {
defensiveHealthPercent: number(generic.defensiveHealthPercent, "generic.defensiveHealthPercent", 1, 100),
emergencyHealthPercent: number(generic.emergencyHealthPercent, "generic.emergencyHealthPercent", 1, 100),
healerMitigationHealthPercent: number(generic.healerMitigationHealthPercent, "generic.healerMitigationHealthPercent", 1, 100),
offensiveMajorCooldownSeconds: number(generic.offensiveMajorCooldownSeconds, "generic.offensiveMajorCooldownSeconds", 0, 600),
conserveOffensiveCooldownsForDurableTargets: generic.conserveOffensiveCooldownsForDurableTargets,
},
mechanics,
};
}
export function cloneDefaultCompanionCooldownPolicy() {
return JSON.parse(JSON.stringify(DEFAULT_COMPANION_COOLDOWN_POLICY));
}
+44
View File
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEFAULT_COMPANION_COOLDOWN_POLICY,
normalizeCompanionCooldownPolicy,
} from "./companion-cooldown-policy.mjs";
test("normalizes valid generic and boss-mechanic cooldown rules", () => {
const policy = normalizeCompanionCooldownPolicy({
...DEFAULT_COMPANION_COOLDOWN_POLICY,
generic: { ...DEFAULT_COMPANION_COOLDOWN_POLICY.generic, defensiveHealthPercent: 62 },
mechanics: [{
id: "tank-buster", enabled: true, bossKey: "test/boss", bossName: "Test Boss",
bossServerEntry: 123, attackId: "heavy-strike", attackName: "Heavy Strike",
scope: "tank", leadTimeMs: 2_500, minimumCooldownSeconds: 20,
}],
});
assert.equal(policy.generic.defensiveHealthPercent, 62);
assert.equal(policy.mechanics[0].attackId, "heavy-strike");
});
test("rejects duplicate mechanic ids and out-of-range thresholds", () => {
const rule = {
id: "duplicate", enabled: true, bossKey: "", bossName: "Boss", bossServerEntry: null,
attackId: "attack", attackName: "Attack", scope: "party", leadTimeMs: 1_000,
minimumCooldownSeconds: 20,
};
assert.throws(() => normalizeCompanionCooldownPolicy({
...DEFAULT_COMPANION_COOLDOWN_POLICY,
mechanics: [rule, rule],
}), /duplicated/);
assert.throws(() => normalizeCompanionCooldownPolicy({
...DEFAULT_COMPANION_COOLDOWN_POLICY,
generic: { ...DEFAULT_COMPANION_COOLDOWN_POLICY.generic, defensiveHealthPercent: 101 },
}), /1 through 100/);
assert.throws(() => normalizeCompanionCooldownPolicy({
...DEFAULT_COMPANION_COOLDOWN_POLICY,
generic: { ...DEFAULT_COMPANION_COOLDOWN_POLICY.generic, conserveOffensiveCooldownsForDurableTargets: "yes" },
}), /must be boolean/);
assert.throws(() => normalizeCompanionCooldownPolicy({
...DEFAULT_COMPANION_COOLDOWN_POLICY,
mechanics: [{ ...rule, id: "missing-boss", bossName: "" }],
}), /bossName is required/);
});
+61
View File
@@ -0,0 +1,61 @@
const WOW_SPECIALIZATIONS = Object.freeze([
["warrior:arms", "damage"], ["warrior:fury", "damage"], ["warrior:protection", "tank"],
["paladin:holy", "healer"], ["paladin:protection", "tank"], ["paladin:retribution", "damage"],
["hunter:beast-mastery", "damage"], ["hunter:marksmanship", "damage"], ["hunter:survival", "damage"],
["rogue:assassination", "damage"], ["rogue:combat", "damage"], ["rogue:subtlety", "damage"],
["priest:discipline", "healer"], ["priest:holy", "healer"], ["priest:shadow", "damage"],
["death-knight:blood", "tank"], ["death-knight:frost", "damage"], ["death-knight:unholy", "damage"],
["shaman:elemental", "damage"], ["shaman:enhancement", "damage"], ["shaman:restoration", "healer"],
["mage:arcane", "damage"], ["mage:fire", "damage"], ["mage:frost", "damage"],
["warlock:affliction", "damage"], ["warlock:demonology", "damage"], ["warlock:destruction", "damage"],
["druid:balance", "damage"], ["druid:feral-bear", "tank"], ["druid:feral-cat", "damage"], ["druid:restoration", "healer"],
]);
const READY_IDS = new Set([
"warrior:protection",
"priest:discipline",
"hunter:marksmanship",
"mage:fire",
"rogue:assassination",
]);
const SPECIALIZATION_ROLE = new Map(WOW_SPECIALIZATIONS);
const VALID_STATUSES = new Set(["ready", "beta", "needs-testing"]);
export const DEFAULT_COMPANION_SPEC_READINESS_POLICY = Object.freeze({
schemaVersion: 1,
statuses: Object.freeze(Object.fromEntries(WOW_SPECIALIZATIONS.map(([id]) => [
id,
READY_IDS.has(id) ? "ready" : "needs-testing",
]))),
});
function invalid(message) {
const error = new Error(message);
error.code = "invalid_companion_spec_readiness";
return error;
}
export function normalizeCompanionSpecReadinessPolicy(value) {
if (!value || typeof value !== "object" || Array.isArray(value) || value.schemaVersion !== 1) {
throw invalid("Companion readiness policy schemaVersion must be 1.");
}
if (!value.statuses || typeof value.statuses !== "object" || Array.isArray(value.statuses)) {
throw invalid("Companion readiness statuses are required.");
}
for (const [id, status] of Object.entries(value.statuses)) {
if (!SPECIALIZATION_ROLE.has(id)) throw invalid(`Unknown WoW specialization: ${id}.`);
if (!VALID_STATUSES.has(status)) throw invalid(`Invalid readiness status for ${id}.`);
}
const statuses = Object.fromEntries(WOW_SPECIALIZATIONS.map(([id]) => [id, value.statuses[id] ?? "needs-testing"]));
const ready = WOW_SPECIALIZATIONS.filter(([id]) => statuses[id] === "ready");
if (!ready.some(([, role]) => role === "tank")) throw invalid("At least one tank specialization must be Ready.");
if (!ready.some(([, role]) => role === "healer")) throw invalid("At least one healer specialization must be Ready.");
if (ready.filter(([, role]) => role === "damage").length < 3) {
throw invalid("At least three distinct damage specializations must be Ready.");
}
return { schemaVersion: 1, statuses };
}
export function cloneDefaultCompanionSpecReadinessPolicy() {
return JSON.parse(JSON.stringify(DEFAULT_COMPANION_SPEC_READINESS_POLICY));
}
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
cloneDefaultCompanionSpecReadinessPolicy,
normalizeCompanionSpecReadinessPolicy,
} from "./companion-spec-readiness.mjs";
test("default companion readiness policy certifies the five focused specs", () => {
const policy = normalizeCompanionSpecReadinessPolicy(cloneDefaultCompanionSpecReadinessPolicy());
assert.deepEqual(Object.entries(policy.statuses).filter(([, status]) => status === "ready").map(([id]) => id), [
"warrior:protection",
"hunter:marksmanship",
"rogue:assassination",
"priest:discipline",
"mage:fire",
]);
});
test("companion readiness validation rejects invalid coverage and unknown specs", () => {
assert.throws(() => normalizeCompanionSpecReadinessPolicy({ schemaVersion: 1, statuses: {} }), /tank specialization/);
assert.throws(() => normalizeCompanionSpecReadinessPolicy({
schemaVersion: 1,
statuses: { ...cloneDefaultCompanionSpecReadinessPolicy().statuses, "future:spec": "ready" },
}), /Unknown WoW specialization/);
});
+128
View File
@@ -8,6 +8,14 @@ import {
normalizeManastormAdminConfig,
stampManastormReviewMetadata,
} from "./manastorm-config.mjs";
import {
cloneDefaultCompanionCooldownPolicy,
normalizeCompanionCooldownPolicy,
} from "./companion-cooldown-policy.mjs";
import {
cloneDefaultCompanionSpecReadinessPolicy,
normalizeCompanionSpecReadinessPolicy,
} from "./companion-spec-readiness.mjs";
import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena-rules.mjs";
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
import { createDungeonReportRepository } from "./dungeon-reports.mjs";
@@ -93,6 +101,24 @@ export function openGameDatabase(options = {}) {
updated_by_username TEXT,
updated_at INTEGER
) STRICT;
CREATE TABLE IF NOT EXISTS companion_cooldown_policy (
id INTEGER PRIMARY KEY CHECK (id = 1),
schema_version INTEGER NOT NULL,
revision INTEGER NOT NULL,
config_json TEXT NOT NULL,
updated_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
updated_by_username TEXT,
updated_at INTEGER
) STRICT;
CREATE TABLE IF NOT EXISTS companion_spec_readiness (
id INTEGER PRIMARY KEY CHECK (id = 1),
schema_version INTEGER NOT NULL,
revision INTEGER NOT NULL,
config_json TEXT NOT NULL,
updated_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
updated_by_username TEXT,
updated_at INTEGER
) STRICT;
CREATE TABLE IF NOT EXISTS online_groups (
id TEXT PRIMARY KEY,
leader_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
@@ -184,6 +210,16 @@ export function openGameDatabase(options = {}) {
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
) VALUES (1, 1, 0, ?, NULL, NULL, NULL)
`).run(JSON.stringify(cloneDefaultManastormAdminConfig()));
database.prepare(`
INSERT OR IGNORE INTO companion_cooldown_policy (
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
) VALUES (1, 1, 0, ?, NULL, NULL, NULL)
`).run(JSON.stringify(cloneDefaultCompanionCooldownPolicy()));
database.prepare(`
INSERT OR IGNORE INTO companion_spec_readiness (
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
) VALUES (1, 1, 0, ?, NULL, NULL, NULL)
`).run(JSON.stringify(cloneDefaultCompanionSpecReadinessPolicy()));
const statements = {
accountByUsername: database.prepare("SELECT * FROM accounts WHERE username_key = ?"),
@@ -215,6 +251,22 @@ export function openGameDatabase(options = {}) {
WHERE id = 1 AND revision = ?
RETURNING revision, updated_at
`),
companionCooldownPolicy: database.prepare("SELECT * FROM companion_cooldown_policy WHERE id = 1"),
updateCompanionCooldownPolicy: database.prepare(`
UPDATE companion_cooldown_policy
SET schema_version = 1, revision = revision + 1, config_json = ?,
updated_by_account_id = ?, updated_by_username = ?, updated_at = ?
WHERE id = 1 AND revision = ?
RETURNING revision, updated_at
`),
companionSpecReadiness: database.prepare("SELECT * FROM companion_spec_readiness WHERE id = 1"),
updateCompanionSpecReadiness: database.prepare(`
UPDATE companion_spec_readiness
SET schema_version = 1, revision = revision + 1, config_json = ?,
updated_by_account_id = ?, updated_by_username = ?, updated_at = ?
WHERE id = 1 AND revision = ?
RETURNING revision, updated_at
`),
arenaLoadouts: database.prepare(`
SELECT * FROM arena_loadouts WHERE account_id = ? AND character_id = ? ORDER BY slot
`),
@@ -364,6 +416,74 @@ export function openGameDatabase(options = {}) {
};
}
function getCompanionCooldownPolicy() {
const row = statements.companionCooldownPolicy.get();
try {
return {
revision: Number(row?.revision ?? 0),
updatedAt: row?.updated_at == null ? null : Number(row.updated_at),
updatedBy: row?.updated_by_username ?? null,
config: normalizeCompanionCooldownPolicy(JSON.parse(row?.config_json ?? "null")),
};
} catch (error) {
console.error("Healer Man companion cooldown policy is corrupt; using safe defaults", error);
return { revision: 0, updatedAt: null, updatedBy: null, config: cloneDefaultCompanionCooldownPolicy() };
}
}
function putCompanionCooldownPolicy(account, expectedRevision, config) {
if (!isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
throw new GameDatabaseError("A valid expected revision is required.", 400, "invalid_revision");
}
let normalized;
try {
normalized = normalizeCompanionCooldownPolicy(config);
} catch (error) {
throw new GameDatabaseError(error instanceof Error ? error.message : "Invalid companion cooldown policy.", 400, "invalid_companion_cooldown_policy");
}
const now = Date.now();
const row = statements.updateCompanionCooldownPolicy.get(
JSON.stringify(normalized), account.id, account.username, now, expectedRevision,
);
if (!row) throw new GameDatabaseError("The companion cooldown policy changed. Reload and try again.", 409, "revision_conflict");
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), updatedBy: account.username, config: normalized };
}
function getCompanionSpecReadiness() {
const row = statements.companionSpecReadiness.get();
try {
return {
revision: Number(row?.revision ?? 0),
updatedAt: row?.updated_at == null ? null : Number(row.updated_at),
updatedBy: row?.updated_by_username ?? null,
config: normalizeCompanionSpecReadinessPolicy(JSON.parse(row?.config_json ?? "null")),
};
} catch (error) {
console.error("Healer Man companion readiness policy is corrupt; using shipped defaults", error);
return { revision: 0, updatedAt: null, updatedBy: null, config: cloneDefaultCompanionSpecReadinessPolicy() };
}
}
function putCompanionSpecReadiness(account, expectedRevision, config) {
if (!isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
throw new GameDatabaseError("A valid expected revision is required.", 400, "invalid_revision");
}
let normalized;
try {
normalized = normalizeCompanionSpecReadinessPolicy(config);
} catch (error) {
throw new GameDatabaseError(error instanceof Error ? error.message : "Invalid companion readiness policy.", 400, "invalid_companion_spec_readiness");
}
const now = Date.now();
const row = statements.updateCompanionSpecReadiness.get(
JSON.stringify(normalized), account.id, account.username, now, expectedRevision,
);
if (!row) throw new GameDatabaseError("The companion readiness policy changed. Reload and try again.", 409, "revision_conflict");
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), updatedBy: account.username, config: normalized };
}
function pruneExpiredGroupInvites() {
database.prepare(`
UPDATE online_group_invites SET status = 'expired'
@@ -620,6 +740,9 @@ export function openGameDatabase(options = {}) {
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;
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 partySize = fillWithAi ? Math.max(members.length, requestedSize) : members.length;
const activity = {
@@ -628,6 +751,7 @@ export function openGameDatabase(options = {}) {
contentId,
...(type === "dungeon" ? { difficultyId: input?.difficultyId, bossDifficulty: input?.bossDifficulty ?? 1 } : {}),
fillWithAi,
includeBetaSpecs: input?.includeBetaSpecs === true,
partySize,
startingLevel: type === "manastorm"
? Math.max(1, Math.trunc(Number(input?.startingLevel) || 1))
@@ -921,6 +1045,10 @@ export function openGameDatabase(options = {}) {
isGameMaster,
getManastormConfig,
putManastormConfig,
getCompanionCooldownPolicy,
putCompanionCooldownPolicy,
getCompanionSpecReadiness,
putCompanionSpecReadiness,
onlineGroupState,
inviteOnlineGroupMember,
respondToOnlineGroupInvite,
+61 -1
View File
@@ -142,6 +142,65 @@ test("persists revisioned Manastorm GM configuration and rejects stale or unauth
}), (error) => error instanceof GameDatabaseError && error.status === 400);
}));
test("persists revisioned companion cooldown policy and rejects stale or unauthorized writes", () => withDatabase(async (database) => {
const gm = await database.register("Phenom", "cooldown-admin");
const player = await database.register("Ordinary", "cooldown-player");
const initial = database.getCompanionCooldownPolicy();
const configured = {
...initial.config,
generic: { ...initial.config.generic, defensiveHealthPercent: 61 },
mechanics: [{
id: "tank-buster", enabled: true, bossKey: "test/boss", bossName: "Test Boss",
bossServerEntry: 123, attackId: "heavy-strike", attackName: "Heavy Strike",
scope: "tank", leadTimeMs: 2_500, minimumCooldownSeconds: 20,
}],
};
const saved = database.putCompanionCooldownPolicy(database.authenticate(gm.token), 0, configured);
assert.equal(saved.revision, 1);
assert.equal(database.getCompanionCooldownPolicy().config.generic.defensiveHealthPercent, 61);
assert.throws(() => database.putCompanionCooldownPolicy(database.authenticate(gm.token), 0, configured), (error) => (
error instanceof GameDatabaseError && error.status === 409
));
assert.throws(() => database.putCompanionCooldownPolicy(database.authenticate(player.token), 1, configured), (error) => (
error instanceof GameDatabaseError && error.status === 403
));
}));
test("persists validated companion readiness with authorization, revisions, and corrupt-data fallback", () => withDatabase(async (database) => {
const gm = await database.register("Phenom", "readiness-admin");
const player = await database.register("ReadinessPlayer", "readiness-player");
const initial = database.getCompanionSpecReadiness();
assert.equal(initial.revision, 0);
assert.equal(initial.config.statuses["warrior:protection"], "ready");
assert.equal(Object.values(initial.config.statuses).filter((status) => status === "ready").length, 5);
const configured = {
...initial.config,
statuses: { ...initial.config.statuses, "paladin:retribution": "beta" },
};
const account = database.authenticate(gm.token);
const saved = database.putCompanionSpecReadiness(account, 0, configured);
assert.equal(saved.revision, 1);
assert.equal(database.getCompanionSpecReadiness().config.statuses["paladin:retribution"], "beta");
assert.throws(() => database.putCompanionSpecReadiness(account, 0, configured), (error) => (
error instanceof GameDatabaseError && error.status === 409
));
assert.throws(() => database.putCompanionSpecReadiness(database.authenticate(player.token), 1, configured), (error) => (
error instanceof GameDatabaseError && error.status === 403
));
assert.throws(() => database.putCompanionSpecReadiness(account, 1, {
schemaVersion: 1,
statuses: { ...configured.statuses, "warrior:protection": "needs-testing" },
}), (error) => error instanceof GameDatabaseError && error.status === 400);
const raw = new DatabaseSync(database.databasePath);
raw.prepare("UPDATE companion_spec_readiness SET config_json = ? WHERE id = 1").run("{not-json");
raw.close();
const fallback = database.getCompanionSpecReadiness();
assert.equal(fallback.revision, 0);
assert.equal(fallback.config.statuses["warrior:protection"], "ready");
}));
test("stores GM simulation replays and gates exact-hash route approval", () => withDatabase(async (database) => {
const gmAuth = await database.register("Phenom", "storm-admin");
const playerAuth = await database.register("RoutePlayer", "route-player");
@@ -288,10 +347,11 @@ test("launches players-only and AI-filled group activities only when every membe
assert.equal(playersOnly.group.activity.fillWithAi, false);
const filled = database.startOnlineGroupActivity(leader.id, {
type: "manastorm", contentId: "manastorm", fillWithAi: true, partySize: 5, startingLevel: 10,
type: "manastorm", contentId: "manastorm", fillWithAi: true, includeBetaSpecs: true, partySize: 5, startingLevel: 10,
});
assert.equal(filled.group.activity.partySize, 5);
assert.equal(filled.group.activity.startingLevel, 10);
assert.equal(filled.group.activity.includeBetaSpecs, true);
assert.throws(() => database.startOnlineGroupActivity(member.id, {
type: "dungeon", contentId: "wailing-caverns",
}), (error) => error instanceof GameDatabaseError && error.status === 403);
+164
View File
@@ -0,0 +1,164 @@
import { createReadStream, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createBalanceLabJobService } from "./balance-lab-jobs.mjs";
import { cloneDefaultCompanionCooldownPolicy } from "./companion-cooldown-policy.mjs";
import {
cloneDefaultCompanionSpecReadinessPolicy,
normalizeCompanionSpecReadinessPolicy,
} from "./companion-spec-readiness.mjs";
import { GameDatabaseError } from "./database.mjs";
const API_ROOT = "/api/local/balance-lab";
function json(response, status, body) {
const content = Buffer.from(JSON.stringify(body));
response.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": content.length,
"Cache-Control": "no-store",
});
response.end(content);
}
async function readJson(request, limit) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > limit) throw new GameDatabaseError("Request body is too large.", 413, "body_too_large");
chunks.push(chunk);
}
if (size === 0) return {};
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw new GameDatabaseError("Request body must be valid JSON.", 400, "invalid_json");
}
}
function isLoopback(address) {
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
}
export function createLocalBalanceLabApi(options = {}) {
const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url)));
const dataDir = path.resolve(options.dataDir ?? path.join(projectRoot, "artifacts", "balance-lab-gui-runtime"));
const readinessPath = path.join(dataDir, "companion-spec-readiness.json");
mkdirSync(dataDir, { recursive: true });
let readiness = { revision: 0, updatedAt: null, updatedBy: null, config: cloneDefaultCompanionSpecReadinessPolicy() };
try {
const stored = JSON.parse(readFileSync(readinessPath, "utf8"));
readiness = {
revision: Number.isInteger(stored.revision) ? stored.revision : 0,
updatedAt: Number.isFinite(stored.updatedAt) ? stored.updatedAt : null,
updatedBy: typeof stored.updatedBy === "string" ? stored.updatedBy : null,
config: normalizeCompanionSpecReadinessPolicy(stored.config),
};
} catch { /* missing or corrupt local policy falls back to shipped defaults */ }
const persistReadiness = () => {
const temporaryPath = `${readinessPath}.tmp`;
writeFileSync(temporaryPath, `${JSON.stringify(readiness, null, 2)}\n`, "utf8");
renameSync(temporaryPath, readinessPath);
};
const service = createBalanceLabJobService({
dataDir,
projectRoot,
pythonExecutable: options.pythonExecutable,
spawnProcess: options.spawnProcess,
runtimeProbe: options.runtimeProbe,
maxConcurrent: options.maxConcurrent,
maxQueued: options.maxQueued,
cooldownPolicyProvider: () => ({ config: cloneDefaultCompanionCooldownPolicy() }),
readinessPolicyProvider: () => readiness,
});
const account = Object.freeze({ username: "Local GUI" });
return {
service,
async handle(request, response) {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
if (url.pathname !== API_ROOT && !url.pathname.startsWith(`${API_ROOT}/`)) return false;
if (!isLoopback(request.socket?.remoteAddress)) {
json(response, 403, { error: "The local Balance Lab API accepts loopback requests only.", code: "loopback_required" });
return true;
}
try {
if (url.pathname === `${API_ROOT}/companion-spec-readiness` && request.method === "GET") {
json(response, 200, readiness);
return true;
}
if (url.pathname === `${API_ROOT}/companion-spec-readiness` && request.method === "PUT") {
const body = await readJson(request, 256 * 1024);
if (!Number.isInteger(body.expectedRevision) || body.expectedRevision !== readiness.revision) {
throw new GameDatabaseError("The companion readiness policy changed. Reload and try again.", 409, "revision_conflict");
}
let config;
try {
config = normalizeCompanionSpecReadinessPolicy(body.config);
} catch (error) {
throw new GameDatabaseError(
error instanceof Error ? error.message : "Invalid companion readiness policy.",
400,
"invalid_companion_spec_readiness",
);
}
readiness = {
revision: readiness.revision + 1,
updatedAt: Date.now(),
updatedBy: "Local GUI",
config,
};
persistReadiness();
json(response, 200, readiness);
return true;
}
if (url.pathname === `${API_ROOT}/jobs` && request.method === "GET") {
json(response, 200, { jobs: service.list() });
return true;
}
if (url.pathname === `${API_ROOT}/runtime` && request.method === "GET") {
json(response, 200, { runtime: service.runtime() });
return true;
}
if (url.pathname === `${API_ROOT}/jobs/batch` && request.method === "POST") {
json(response, 202, { jobs: service.startMany(await readJson(request, 4 * 1024 * 1024), account) });
return true;
}
if (url.pathname === `${API_ROOT}/jobs` && request.method === "POST") {
json(response, 202, { job: service.start(await readJson(request, 32 * 1024), account) });
return true;
}
const artifactMatch = new RegExp(`^${API_ROOT}/jobs/([0-9a-f-]{36})/artifacts/([A-Za-z0-9._-]+)$`).exec(url.pathname);
if (artifactMatch && request.method === "GET") {
const artifact = service.artifact(artifactMatch[1], artifactMatch[2]);
response.writeHead(200, {
"Content-Type": artifact.contentType,
"Content-Length": artifact.size,
"Cache-Control": "no-store",
"Content-Disposition": `inline; filename="${artifactMatch[2]}"`,
"X-Content-Type-Options": "nosniff",
});
createReadStream(artifact.filePath).pipe(response);
return true;
}
const jobMatch = new RegExp(`^${API_ROOT}/jobs/([0-9a-f-]{36})$`).exec(url.pathname);
if (jobMatch && request.method === "GET") {
json(response, 200, { job: service.get(jobMatch[1]) });
return true;
}
json(response, 404, { error: "Local Balance Lab route not found.", code: "not_found" });
} catch (error) {
const known = error instanceof GameDatabaseError;
json(response, known ? error.status : 500, {
error: known ? error.message : "The local Balance Lab request failed.",
code: known ? error.code : "internal_error",
});
}
return true;
},
close() {
service.close();
},
};
}
+143
View File
@@ -0,0 +1,143 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import test from "node:test";
import { createLocalBalanceLabApi } from "./local-balance-lab-api.mjs";
test("serves loopback-only local Balance Lab jobs without account authentication", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-local-balance-gui-"));
const spawnProcess = (_executable, args) => {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = () => child.emit("close", null);
const outputDir = args[args.indexOf("--output-dir") + 1];
setImmediate(() => {
mkdirSync(outputDir, { recursive: true });
writeFileSync(path.join(outputDir, "suite-report.json"), JSON.stringify({
summary: { partyConfigurations: 1, bosses: 1, runs: 1, classSpecs: 5, classAbilities: 5 },
}));
child.stdout.write("Party 1/1: wow-party-001\n");
child.stdout.end();
child.stderr.end();
child.emit("close", 0);
});
return child;
};
const api = createLocalBalanceLabApi({
dataDir: directory,
projectRoot: directory,
spawnProcess,
runtimeProbe: () => ({ available: true, executable: "python-test", version: "Python 3.test", error: null }),
});
const server = createServer((request, response) => {
void api.handle(request, response).then((handled) => {
if (!handled) response.writeHead(404).end();
});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
try {
const runtime = await (await fetch(`${origin}/api/local/balance-lab/runtime`)).json();
assert.equal(runtime.runtime.available, true);
const startedResponse = await fetch(`${origin}/api/local/balance-lab/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source: "wow", level: 20, partyConfigurations: 1, simulationsPerParty: 1,
durationSeconds: 5, partySeed: 1, bossCategory: "all",
bossKeys: ["stormwind-stockade/entry-1717"],
}),
});
assert.equal(startedResponse.status, 202);
const started = (await startedResponse.json()).job;
assert.equal(started.requestedBy, "Local GUI");
await new Promise((resolve) => setImmediate(resolve));
const jobs = await (await fetch(`${origin}/api/local/balance-lab/jobs`)).json();
assert.equal(jobs.jobs[0].status, "complete");
const artifact = await fetch(`${origin}/api/local/balance-lab/jobs/${started.id}/artifacts/suite-report.json`);
assert.equal(artifact.status, 200);
assert.equal((await artifact.json()).summary.runs, 1);
assert.equal((await fetch(`${origin}/api/not-local-balance-lab`)).status, 404);
} finally {
await new Promise((resolve) => server.close(resolve));
api.close();
rmSync(directory, { recursive: true, force: true });
}
});
test("persists revisioned local companion readiness and recovers from a corrupt policy file", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-local-readiness-"));
const open = async () => {
const api = createLocalBalanceLabApi({
dataDir: directory,
projectRoot: directory,
runtimeProbe: () => ({ available: true, executable: "python-test", version: "Python 3.test", error: null }),
});
const server = createServer((request, response) => {
void api.handle(request, response).then((handled) => {
if (!handled) response.writeHead(404).end();
});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
return {
api,
server,
origin: `http://127.0.0.1:${server.address().port}`,
close: async () => {
await new Promise((resolve) => server.close(resolve));
api.close();
},
};
};
try {
const first = await open();
const initial = await (await fetch(`${first.origin}/api/local/balance-lab/companion-spec-readiness`)).json();
const configured = {
...initial.config,
statuses: { ...initial.config.statuses, "paladin:retribution": "beta" },
};
const savedResponse = await fetch(`${first.origin}/api/local/balance-lab/companion-spec-readiness`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expectedRevision: 0, config: configured }),
});
assert.equal(savedResponse.status, 200);
assert.equal((await savedResponse.json()).revision, 1);
const staleResponse = await fetch(`${first.origin}/api/local/balance-lab/companion-spec-readiness`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expectedRevision: 0, config: configured }),
});
assert.equal(staleResponse.status, 409);
const invalidResponse = await fetch(`${first.origin}/api/local/balance-lab/companion-spec-readiness`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
expectedRevision: 1,
config: { ...configured, statuses: { ...configured.statuses, "warrior:protection": "needs-testing" } },
}),
});
assert.equal(invalidResponse.status, 400);
await first.close();
const second = await open();
const restored = await (await fetch(`${second.origin}/api/local/balance-lab/companion-spec-readiness`)).json();
assert.equal(restored.revision, 1);
assert.equal(restored.config.statuses["paladin:retribution"], "beta");
await second.close();
writeFileSync(path.join(directory, "companion-spec-readiness.json"), "{not-json");
const third = await open();
const fallback = await (await fetch(`${third.origin}/api/local/balance-lab/companion-spec-readiness`)).json();
assert.equal(fallback.revision, 0);
assert.equal(fallback.config.statuses["warrior:protection"], "ready");
await third.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
+58
View File
@@ -183,6 +183,8 @@ export function createGameServer(options = {}) {
dataDir: options.dataDir ?? path.dirname(gameDatabase.databasePath),
projectRoot: options.projectRoot,
pythonExecutable: options.pythonExecutable,
cooldownPolicyProvider: () => gameDatabase.getCompanionCooldownPolicy(),
readinessPolicyProvider: () => gameDatabase.getCompanionSpecReadiness(),
});
const onlineSessions = createOnlineSessionManager();
const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes });
@@ -498,6 +500,62 @@ export function createGameServer(options = {}) {
json(response, 200, result);
return;
}
if (url.pathname === "/api/companion-cooldown-policy" && request.method === "GET") {
const current = gameDatabase.getCompanionCooldownPolicy();
json(response, 200, {
revision: current.revision,
updatedAt: current.updatedAt,
config: current.config,
});
return;
}
if (url.pathname === "/api/gm/companion-cooldown-policy" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) {
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
}
json(response, 200, gameDatabase.getCompanionCooldownPolicy());
return;
}
if (url.pathname === "/api/gm/companion-cooldown-policy" && request.method === "PUT") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) {
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
}
const body = await readJson(request, bodyLimit);
const result = gameDatabase.putCompanionCooldownPolicy(account, body.expectedRevision, body.config);
console.info(`Companion cooldown policy revision ${result.revision} published by ${account.username}`);
json(response, 200, result);
return;
}
if (url.pathname === "/api/companion-spec-readiness" && request.method === "GET") {
const current = gameDatabase.getCompanionSpecReadiness();
json(response, 200, {
revision: current.revision,
updatedAt: current.updatedAt,
config: current.config,
});
return;
}
if (url.pathname === "/api/gm/companion-spec-readiness" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) {
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
}
json(response, 200, gameDatabase.getCompanionSpecReadiness());
return;
}
if (url.pathname === "/api/gm/companion-spec-readiness" && request.method === "PUT") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) {
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
}
const body = await readJson(request, bodyLimit);
const result = gameDatabase.putCompanionSpecReadiness(account, body.expectedRevision, body.config);
console.info(`Companion readiness revision ${result.revision} published by ${account.username}`);
json(response, 200, result);
return;
}
if (url.pathname === "/api/gm/balance-lab/jobs" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) {
+50
View File
@@ -87,6 +87,20 @@ test("serves the game and authenticated cloud-save API", async () => {
headers: { Authorization: `Bearer ${auth.token}` },
});
assert.equal(deniedRead.status, 403);
const publicCooldownPolicy = await (await fetch(`${origin}/api/companion-cooldown-policy`)).json();
assert.equal(publicCooldownPolicy.revision, 0);
assert.equal(publicCooldownPolicy.config.generic.defensiveHealthPercent, 55);
const deniedCooldownPolicy = await fetch(`${origin}/api/gm/companion-cooldown-policy`, {
headers: { Authorization: `Bearer ${auth.token}` },
});
assert.equal(deniedCooldownPolicy.status, 403);
const publicReadiness = await (await fetch(`${origin}/api/companion-spec-readiness`)).json();
assert.equal(publicReadiness.revision, 0);
assert.equal(publicReadiness.config.statuses["warrior:protection"], "ready");
const deniedReadiness = await fetch(`${origin}/api/gm/companion-spec-readiness`, {
headers: { Authorization: `Bearer ${auth.token}` },
});
assert.equal(deniedReadiness.status, 403);
const memberRegistration = await fetch(`${origin}/api/auth/register`, {
method: "POST",
@@ -237,6 +251,42 @@ test("serves the game and authenticated cloud-save API", async () => {
assert.equal(gmRegistration.status, 201);
const gmAuth = await gmRegistration.json();
assert.deepEqual(gmAuth.account.roles, ["gm"]);
const publishedCooldownPolicy = await fetch(`${origin}/api/gm/companion-cooldown-policy`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
body: JSON.stringify({
expectedRevision: publicCooldownPolicy.revision,
config: {
...publicCooldownPolicy.config,
generic: { ...publicCooldownPolicy.config.generic, defensiveHealthPercent: 61 },
},
}),
});
assert.equal(publishedCooldownPolicy.status, 200);
const publishedCooldownPolicyPayload = await publishedCooldownPolicy.json();
assert.equal(publishedCooldownPolicyPayload.revision, 1);
assert.equal(publishedCooldownPolicyPayload.config.generic.defensiveHealthPercent, 61);
const playerCooldownPolicy = await (await fetch(`${origin}/api/companion-cooldown-policy`)).json();
assert.equal(playerCooldownPolicy.config.generic.defensiveHealthPercent, 61);
const publishedReadiness = await fetch(`${origin}/api/gm/companion-spec-readiness`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
body: JSON.stringify({
expectedRevision: publicReadiness.revision,
config: {
...publicReadiness.config,
statuses: { ...publicReadiness.config.statuses, "paladin:retribution": "beta" },
},
}),
});
assert.equal(publishedReadiness.status, 200);
assert.equal((await publishedReadiness.json()).config.statuses["paladin:retribution"], "beta");
const staleReadiness = await fetch(`${origin}/api/gm/companion-spec-readiness`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
body: JSON.stringify({ expectedRevision: 0, config: publicReadiness.config }),
});
assert.equal(staleReadiness.status, 409);
const deniedBalanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },