Update Healer Man server

This commit is contained in:
phenom committed 2026-09-26 19:55:08 -04:00
1 parent 83eac6d476
commit 4957b69e86
21 files changed
+170 -40

No files matched your search

+2 -1
View File
@@ -538,7 +538,8 @@ function scoreAbility(
// summons are valid player buttons but are not autonomous combat actions.
if (categories.has("summon") && !damaging && !petSummon && !offensiveSummon && !maintenanceBuff) return null;
if (categories.has("buff") && !damaging && !defensive && !maintenanceBuff
&& !categories.has("resource") && !petSummon && !offensiveSummon) return null;
&& !categories.has("resource") && !petSummon && !offensiveSummon
&& !(offensiveMajorCooldown && profileIndex >= 0)) return null;
if (categories.has("movement") && !damaging && !context.moving) return null;
if (context.requiredControl && categories.has("control")) {
return { ability, score: 1_016_000, ruleId: "reactive-control", reason: "encounter requested control", targetId: context.hostileTarget?.id ?? null };
+2
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
clearPartyRuntimeRegistry,
@@ -1039,6 +1040,7 @@ describe("combat store", () => {
expect(useCombatStore.getState().mobs[id].statuses).toContainEqual({
kind: "fear",
sourceAbilityId: "priest-psychic-scream",
sourceActorId: PLAYER_AGGRO_ID,
endsAt: 9000,
});
}
+6 -6
View File
@@ -1,6 +1,5 @@
import { afterAll, describe, expect, it } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs";
import { BASE_CLASS_IDS, COA_CLASS_IDS, ROM_CLASS_IDS } from "../app/characterCatalog";
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
import { runDungeonBossBenchmark } from "./benchmark/dungeonBossBenchmark";
import { BENCHMARK_POLICY_ID } from "./benchmark/benchmarkController";
@@ -14,16 +13,17 @@ const samples: unknown[] = [];
afterAll(() => {
if (process.env.BOSS_BALANCE_REPORT !== "1") return;
mkdirSync("artifacts/boss-difficulty", { recursive: true });
writeFileSync("artifacts/boss-difficulty/wow-rom-coa-balance.json", `${JSON.stringify({
writeFileSync("artifacts/boss-difficulty/ready-five-balance.json", `${JSON.stringify({
difficulty: 1, dungeon: dungeon.id, durationLimitSeconds: 60,
scope: "ready-five",
targets: { ordinary: 25, final: 45 }, policy: BENCHMARK_POLICY_ID, gear: "resolved starter heirlooms", samples,
}, null, 2)}\n`);
});
describe("default boss difficulty across WoW, RoM, and CoA classes", () => {
describe("default boss difficulty for the Ready five", () => {
for (const level of [8, 20, 40, 80]) {
it(`benchmarks every class-role build at level ${level}`, async () => {
const builds = await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...ROM_CLASS_IDS, ...COA_CLASS_IDS]);
it(`benchmarks the Ready builds at level ${level}`, async () => {
const builds = await loadCanonicalBenchmarkBuilds(level);
const tooFast: string[] = [];
for (const build of builds) {
// Let the test worker deliver progress between CPU-heavy simulations.
@@ -43,7 +43,7 @@ describe("default boss difficulty across WoW, RoM, and CoA classes", () => {
for (const level of [8, 80]) {
it(`records healer outcomes with the final boss attacking at level ${level}`, async () => {
const builds = (await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...ROM_CLASS_IDS, ...COA_CLASS_IDS]))
const builds = (await loadCanonicalBenchmarkBuilds(level))
.filter((build) => build.role === "healer");
for (const build of builds) {
const last = await runDungeonBossBenchmark(build, final.id, "class-balance-2", true);
+5 -3
View File
@@ -161,12 +161,14 @@ describe("dungeon boss durability and difficulty", () => {
expect(boss.seconds!, JSON.stringify({ level, boss, last })).toBeGreaterThanOrEqual(25);
expect(last.seconds!, JSON.stringify({ level, boss, last })).toBeGreaterThanOrEqual(45);
}
});
}, 60_000);
for (const level of [1, 8, 20, 40, 80]) {
it(`gives a RoM party time to play Ragefire boss mechanics at level ${level}`, () => {
it(`gives a RoM party time to play Ragefire boss mechanics at level ${level}`, async () => {
const ordinary = dungeon.bosses.find((boss) => boss.id !== final.id)!;
for (let index = 0; index < 12; index += 1) {
// Give Vitest's worker RPC a turn between CPU-heavy simulation seeds.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
const seed = `rom-health-${index}`;
const boss = fight(ordinary.id, level, 1, seed);
const last = fight(final.id, level, 1, seed);
@@ -175,6 +177,6 @@ describe("dungeon boss durability and difficulty", () => {
expect(boss.seconds!).toBeGreaterThanOrEqual(25);
expect(last.seconds!).toBeGreaterThanOrEqual(45);
}
}, 30000);
}, 60_000);
}
});
+1 -1
View File
@@ -72,7 +72,7 @@ const WDT_PLACEMENT_DUNGEON_IDS = [
describe("generated dungeon campaign catalog", () => {
it("covers only reviewed five-player instances with loadable assets and combat content", () => {
expect(generated.definitions).toHaveLength(54);
expect(DUNGEON_DEFINITIONS).toHaveLength(86);
expect(DUNGEON_DEFINITIONS).toHaveLength(89);
expect(generated.definitions.map((dungeon) => dungeon.id))
.toEqual(campaign.dungeons.map((dungeon) => dungeon.slug));
for (const dungeon of generated.definitions) {
+5 -1
View File
@@ -23,7 +23,11 @@ export function scaledDungeonEnemyLevel(characterLevel: number, boss: boolean):
export function dungeonEnemyHealthBaseline(level: number, partySource: ContentCategoryId): number {
const safeLevel = clampPlayerLevel(level);
if (partySource === "wow") return standardWowDungeonCreatureHealth(safeLevel);
const romBaseline = 1_000 + safeLevel * 100 + safeLevel * safeLevel * 8;
// Companion damage stops tracking the quadratic health curve near the level cap.
// Ease the last ten levels so a slow but valid RoM party still finishes trash
// within the intended thirty-second combat window.
const highLevelTaper = 1 - Math.max(0, safeLevel - 70) * 0.006;
const romBaseline = Math.round((1_000 + safeLevel * 100 + safeLevel * safeLevel * 8) * highLevelTaper);
if (partySource === "rom") return romBaseline;
return Math.round(romBaseline * COA_PARTY_HEALTH_SCALE / ROM_PARTY_HEALTH_SCALE);
}
+1 -1
View File
@@ -18,7 +18,7 @@ describe("dungeon pipeline runtime foundation", () => {
});
it("registers every reviewed instanced dungeon with WC first", () => {
expect(DUNGEON_DEFINITIONS).toHaveLength(86);
expect(DUNGEON_DEFINITIONS).toHaveLength(89);
expect(DUNGEON_DEFINITIONS[0].id).toBe("wailing-caverns");
expect(DUNGEON_DEFINITIONS.some((definition) => definition.id === "deadmines")).toBe(true);
expect(DUNGEON_DEFINITIONS.some((definition) => definition.id === "forsaken-abbey")).toBe(true);
+14 -12
View File
@@ -19,7 +19,6 @@ import {
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
} from "../dungeonContentSource";
const DEADMINES_ROUTE_WIDTH = 34;
const SKADI_ROUTE_WIDTH = 32;
function normalizedName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
@@ -331,14 +330,17 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
const skadi = source.objectiveOrder.find((objective) => normalizedName(objective.name) === "skaditheruthless");
const sourceSkadiGroup = source.encounterSpawnGroups?.find((group) => group.id === "utgarde-pinnacle:skadi-ground");
const firstWave = source.encounterSpawnGroups?.find((group) => group.id === "utgarde-pinnacle:skadi-first-wave");
const skadiStart = source.serverGameObjects.find((object) => object.id === "utgarde-pinnacle:skadi-start");
if (!skadi || !sourceSkadiGroup?.spawns[0] || !firstWave?.spawns[0] || !skadiStart) {
if (!skadi || !sourceSkadiGroup?.spawns[0] || !firstWave?.spawns[7]) {
throw new Error("Utgarde Pinnacle is missing the Skadi room content.");
}
// The source start object is across a collision gap from the Skadi corridor.
const startPosition: Vector3Tuple = [-511, 104.8, 370];
const bossPosition = firstWave.spawns[0].position;
const route: readonly Vector3Tuple[] = [skadiStart.position, bossPosition];
const staticSpawns = routeSpawns(source, route, SKADI_ROUTE_WIDTH);
if (staticSpawns.length === 0) throw new Error("Skadi's approach has no room mobs.");
const staticSpawns: readonly StaticMobSpawnDefinition[] = [
firstWave.spawns[7],
firstWave.spawns[6],
firstWave.spawns[5],
];
const skadiSpawn: StaticMobSpawnDefinition = {
...sourceSkadiGroup.spawns[0],
id: skadi.id,
@@ -358,10 +360,10 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
fidelity: "healer-man-authored",
scope: "boss-progression",
files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/wowWrathEncounterBatchTwoCatalog.ts"],
reviewedBehavior: "Clear the three imported mobs near Eagle's Eye, then confront Skadi on the same walkable approach.",
reviewedBehavior: "Clear three source wave guards along the walkable Skadi corridor, then confront Skadi at its end.",
adaptations: [
"The start interaction, flying phase, harpoons, and long wave sequence are omitted from this short run.",
"Skadi uses the source boss combat data and objective ID at a floor-snapped source wave position near the entrance.",
"Skadi uses the source boss combat data and objective ID at a floor-snapped source wave position at the corridor's end.",
],
remainingGaps: ["The shortened encounter still needs an in-game combat and completion pass."],
},
@@ -393,9 +395,9 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
entrance: {
...source.entrance,
name: "Eagle’s Eye Gauntlet",
footPosition: skadiStart.position,
forward: [-0.167, 0, -0.986],
yaw: -2.974,
footPosition: startPosition,
forward: [0, 0, 1],
yaw: 0,
},
presentation: {
...source.presentation,
@@ -406,7 +408,7 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
unchartedAreaName: "Eagle’s Eye",
},
areas: [
routeArea("entrance", "Eagle’s Eye Gauntlet", skadiStart.position, 30),
routeArea("entrance", "Eagle’s Eye Gauntlet", startPosition, 30),
routeArea("skadi", "Skadi’s Arena", bossPosition, 34),
],
entities: entitiesFor(source, staticSpawns, [], [skadiGroup]),
+2
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
@@ -139,6 +140,7 @@ describe("enemy casting and interrupts", () => {
expect(useCombatStore.getState().mobs.caster.statuses).toContainEqual({
kind: "stun",
sourceAbilityId: "rogue-gouge",
sourceActorId: PLAYER_AGGRO_ID,
endsAt: 5_100,
});
});
+1
View File
@@ -60,6 +60,7 @@ describe("server-backed enemy ability runtime", () => {
expect(useCombatStore.getState().mobs.kresh.statuses).toContainEqual({
kind: "fear",
sourceAbilityId: "priest-psychic-scream",
sourceActorId: PLAYER_AGGRO_ID,
endsAt: 9_000,
});
expect(useCombatStore.getState().mobs.vancleef.statuses).toEqual([]);
+1 -1
View File
@@ -108,7 +108,7 @@ describe("looped mob path sampling", () => {
});
it("keeps every packaged dungeon patrol on a usable loop", () => {
expect(DUNGEON_DEFINITIONS).toHaveLength(86);
expect(DUNGEON_DEFINITIONS).toHaveLength(89);
for (const dungeon of DUNGEON_DEFINITIONS) {
for (const pack of dungeon.roamingPacks) {
expect(hasUsableLoopedMobPath(pack.waypoints), `${dungeon.id}:${pack.id}`).toBe(true);
+5 -4
View File
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { DUNGEON_DEFINITIONS, dungeonCanEnter } from "./dungeonRegistry";
import { dungeonContentSourceId } from "./dungeonContentSource";
import manastormRegistry from "./generated/manastormAssetPackages.json";
import type { ManastormStageAssetPackage } from "./manastormStageAssets";
import {
@@ -70,7 +71,7 @@ function regularNavigationPath(
"assets",
"game",
"dungeons",
dungeonId,
dungeonContentSourceId(dungeonId),
navigation.fileName,
);
}
@@ -84,7 +85,7 @@ describe("party navigation fleet safety", () => {
const enabled = [];
const fallback = [];
expect(DUNGEON_DEFINITIONS).toHaveLength(86);
expect(DUNGEON_DEFINITIONS).toHaveLength(89);
for (const dungeon of DUNGEON_DEFINITIONS) {
expect(dungeonCanEnter(dungeon.id), dungeon.id).toBe(true);
const navigation = dungeon.assets.navigation;
@@ -119,8 +120,8 @@ describe("party navigation fleet safety", () => {
// Stockades has a reviewed navigation override without claiming that its
// broader encounter/reset validation is complete.
expect(enabled).toContain("stormwind-stockade");
expect(enabled).toHaveLength(32);
expect(fallback).toHaveLength(54);
expect(enabled).toHaveLength(33);
expect(fallback).toHaveLength(56);
});
it("covers every Manastorm package and keeps actual synchronous geometry under budget", () => {
+7 -2
View File
@@ -78,17 +78,22 @@ function measurePull(level: number, seed: string, maxHealth: number, isBoss = fa
describe("RoM dungeon enemy durability", () => {
for (const level of [1, 2, 4, 8, 9, 20, 40, 70, 80]) {
it(`Barren Caves trash survives a full RoM party's opening volley at level ${level}`, () => {
const results = [];
for (let seed = 0; seed < 12; seed += 1) {
const partySeed = `rom-health-${seed}`;
const before = measurePull(level, partySeed, 72 + level * 22);
const after = measurePull(level, partySeed, dungeonEnemyHealthBaseline(level, "rom"));
report.push({ level, seed: partySeed, before, after });
results.push({ seed: partySeed, before, after });
expect(after.openingHealth, JSON.stringify({ level, before, after })).toBeGreaterThan(0);
expect(after.seconds, JSON.stringify({ level, before, after })).not.toBeNull();
expect(after.seconds!, JSON.stringify({ level, before, after })).toBeGreaterThanOrEqual(3);
expect(after.seconds!, JSON.stringify({ level, before, after })).toBeLessThanOrEqual(30);
}
});
const slowest = results.reduce((longest, candidate) => (
candidate.after.seconds! > longest.after.seconds! ? candidate : longest
));
expect(slowest.after.seconds!, JSON.stringify({ level, ...slowest })).toBeLessThanOrEqual(30);
}, 60_000);
}
it("retains Barren Caves' authored boss health multiplier", () => {
+2 -2
View File
@@ -4,11 +4,11 @@ import { FORSAKEN_ABBEY_DEFINITION } from "./dungeons/forsakenAbbey";
import { WOW_DUNGEON_LIGHTING, withWowDungeonEnvironment, wowDungeonLightingProfile } from "./wowDungeonEnvironment";
import stairs from "./generated/wowDungeonStairs.generated.json";
import stockadesOverride from "../../dungeon-pipeline/runtime-overrides/stormwind-stockade.json";
import { isDerivedStockadesDungeonId } from "./dungeonContentSource";
import { isBiteSizedDungeonId } from "./dungeonContentSource";
describe("fleet-wide WoW dungeon environment policy", () => {
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
dungeon.expansion !== "runewaker" && !isDerivedStockadesDungeonId(dungeon.id)
dungeon.expansion !== "runewaker" && !isBiteSizedDungeonId(dungeon.id)
));
it("gives every WoW dungeon readable ambient and hemisphere fill without changing fog draw distance or material roles", () => {
+1 -1
View File
@@ -121,7 +121,7 @@ function PlayerController({
verticalMotionRef,
animationEventRef,
}: PlayerControllerProps) {
const paused = useGameStore((state) => state.paused || state.dungeonIntroActive);
const paused = useGameStore((state) => state.paused || state.dungeonIntroActive || state.raceStartBlocked);
const mapOpen = useGameStore((state) => state.mapOpen);
const companionOpen = useGameStore((state) => state.companionOpen);
const resetRevision = useGameStore((state) => state.resetRevision);
+2 -2
View File
@@ -3,7 +3,7 @@ import path from "node:path";
import { MeshStandardMaterial } from "three";
import { describe, expect, it } from "vitest";
import { DUNGEON_DEFINITIONS } from "../game/dungeonRegistry";
import { isDerivedStockadesDungeonId } from "../game/dungeonContentSource";
import { isBiteSizedDungeonId } from "../game/dungeonContentSource";
import { prepareDungeonMaterial } from "./productionDungeonMaterials";
interface GltfMaterial {
@@ -28,7 +28,7 @@ function shippedMaterials(file: string): GltfMaterial[] {
}
const wowDungeons = DUNGEON_DEFINITIONS.filter((definition) => (
definition.expansion !== "runewaker" && !isDerivedStockadesDungeonId(definition.id)
definition.expansion !== "runewaker" && !isBiteSizedDungeonId(definition.id)
));
describe("shipped WoW dungeon material policies", () => {
+1 -1
View File
@@ -59,7 +59,7 @@ describe("gameplay panel loading integration", () => {
it("groups primary adventures separately and keeps testing utilities in the GM menu", () => {
expect(mainMenuSource).toContain('className="main-menu-card main-menu-card--adventures"');
expect(mainMenuSource).toContain('["dungeons", "random-dungeon", "random-bite-sized", "randomized-bite-sized"]');
expect(mainMenuSource).toContain('["dungeons", "random-dungeon", "random-bite-sized", "randomized-bite-sized", "dungeon-race"]');
expect(mainMenuSource).toContain('controlId="mode-gm-menu"');
for (const controlId of [
"gm-menu-gm-tools",