Update Healer Man server
This commit is contained in:
1 parent
83eac6d476
commit
4957b69e86
21 files changed
+170
-40
No files matched your search
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user