Update Healer Man server
This commit is contained in:
1 parent
f8314e7de1
commit
1fedb9368d
76 files changed
+20556
-2523
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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}` },
|
||||
|
||||
Reference in new issue
Block a user