Update Healer Man server

This commit is contained in:
phenom committed 2026-09-20 15:05:14 -04:00
1 parent 8b9c95b24f
commit 14e105642c
13 files changed
+355 -12

No files matched your search

+72 -1
View File
@@ -1,18 +1,22 @@
import { describe, expect, it } from "vitest";
import {
activeDungeonCompletedObjectiveIds,
advanceDungeonEncounters,
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounters,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import { BITE_SIZED_STOCKADES_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID } from "./dungeonContentSource";
import { DUNGEON_REGISTRY, dungeonDefinitionById } from "./dungeonRegistry";
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
import { useDungeonRunStore } from "./dungeonRunStore";
import { completedDungeonRun, useDungeonRunStore } from "./dungeonRunStore";
import {
BITE_SIZED_STOCKADES_BOSS_CANDIDATES,
createBiteSizedStockadesDefinition,
} from "./dungeons/biteSizedStockades";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
import { dungeonEnemyScaling } from "./dungeonBossScaling";
const source = DUNGEON_REGISTRY[STORMWIND_STOCKADE_DUNGEON_ID];
@@ -32,6 +36,7 @@ describe("bite-sized Stormwind Stockades", () => {
const bossId = biteSized.bosses[0].id;
expect(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((boss) => boss.sourceSpawnId)).toContain(bossId);
expect(biteSized.objectiveOrder).toEqual(biteSized.bosses);
expect(biteSized.finalBossDurability).toBe("standard");
expect(biteSized.staticSpawns.filter((spawn) => spawn.id === bossId)).toHaveLength(1);
expect(biteSized.staticSpawns.every((spawn) => Math.abs(spawn.position[2]) <= 15)).toBe(true);
expect(biteSized.navigationLinks).toEqual(source.navigationLinks);
@@ -71,6 +76,32 @@ describe("bite-sized Stormwind Stockades", () => {
.toBe(first.staticSpawns.find((spawn) => spawn.id === first.bosses[0].id)?.entityId);
});
it("uses ordinary boss durability for every level-17 roll", () => {
const variants = new Map<string, ReturnType<typeof createBiteSizedStockadesDefinition>>();
for (let index = 0; index < 1_000 && variants.size < BITE_SIZED_STOCKADES_BOSS_CANDIDATES.length; index += 1) {
const definition = createBiteSizedStockadesDefinition(source, `level-17-run-${index}`);
variants.set(definition.bosses[0].id, definition);
}
expect(variants.size).toBe(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.length);
const ordinaryBoss = source.bosses[0];
const ordinarySpawn = source.staticSpawns.find((spawn) => spawn.id === ordinaryBoss.id)!;
const ordinaryEntity = source.entities[ordinarySpawn.entityId];
for (const characterSource of ["wow", "rom", "coa"] as const) {
const ordinaryHealth = dungeonEnemyScaling(
source, ordinaryEntity, ordinaryBoss.id, 17, "normal", 1, characterSource,
).maxHealth;
for (const definition of variants.values()) {
const boss = definition.bosses[0];
const spawn = definition.staticSpawns.find((entry) => entry.id === boss.id)!;
const entity = definition.entities[spawn.entityId];
expect(dungeonEnemyScaling(
definition, entity, boss.id, 17, "normal", 1, characterSource,
).maxHealth, `${characterSource}:${boss.name}`).toBeCloseTo(ordinaryHealth);
}
}
});
it("uses the run identity for fresh, restarted, and shared sessions", () => {
expect(activateDungeonSession(BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "run-a" })).toBe(true);
const firstBoss = dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].name;
@@ -103,4 +134,44 @@ describe("bite-sized Stormwind Stockades", () => {
expect(reduced.flags["dungeon-complete"]).toBe(true);
expect(reduced.phase).toBe("complete");
});
it("rebuilds seeded boss rules and records the original timer after a wipe and retry", () => {
expect(activateDungeonSession(BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "stale-provider-a" })).toBe(true);
const firstBossId = dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].id;
let nextRunId = "stale-provider-b";
for (let index = 0; index < 1_000; index += 1) {
const candidate = `stale-provider-${index}`;
if (createBiteSizedStockadesDefinition(source, candidate).bosses[0].id !== firstBossId) {
nextRunId = candidate;
break;
}
}
expect(activateDungeonSession(BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: nextRunId })).toBe(true);
const rerolledBossId = dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].id;
expect(rerolledBossId).not.toBe(firstBossId);
const party = [{ name: "Tester", classId: "priest", level: 17, role: "healer", npc: false }] as const;
const run = useDungeonRunStore.getState();
run.start(1_000, party);
advanceDungeonEncounters(BITE_SIZED_STOCKADES_DUNGEON_ID, {
kind: "tick",
now: 2_000,
facts: { registeredEntityIds: [rerolledBossId], deadEntityIds: [] },
});
resetDungeonEncounters("boss-wipe", BITE_SIZED_STOCKADES_DUNGEON_ID, 3_000);
expect(useDungeonRunStore.getState().progress?.startedAt).toBe(1_000);
advanceDungeonEncounters(BITE_SIZED_STOCKADES_DUNGEON_ID, {
kind: "tick",
now: 9_000,
facts: { registeredEntityIds: [rerolledBossId], deadEntityIds: [rerolledBossId] },
});
run.advance(activeDungeonCompletedObjectiveIds(BITE_SIZED_STOCKADES_DUNGEON_ID), [rerolledBossId], 9_000, party);
expect(completedDungeonRun(useDungeonRunStore.getState().progress!)).toMatchObject({
id: nextRunId,
durationMs: 8_000,
killedBossIds: [rerolledBossId],
fullClear: true,
});
});
});
+2 -1
View File
@@ -81,7 +81,8 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
// Match durability to the character's game, including when crossing dungeon catalogs.
const partyHealthFloor = partyBossHealthFloor(level, characterSource, testing);
const bossFloor = partyHealthFloor * (finalBoss ? FINAL_BOSS_HEALTH_FLOOR_MULTIPLIER : 1);
const finaleFloor = finalBoss && dungeon.finalBossDurability !== "standard";
const bossFloor = partyHealthFloor * (finaleFloor ? FINAL_BOSS_HEALTH_FLOOR_MULTIPLIER : 1);
// A test room's sole objective must not promote every boss to a campaign
// finale. A reduced floor still prevents high-level source creatures with
// tiny extrapolated level-two health from dying before mechanics can run.
+8
View File
@@ -4,6 +4,7 @@ import {
DUNGEON_CATEGORIES,
dungeonById,
dungeonsByCategory,
randomDungeonByCategory,
} from "./dungeonCatalog";
import { DUNGEON_MANIFEST } from "./dungeonManifest";
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated";
@@ -51,6 +52,13 @@ describe("dungeon catalog", () => {
.toEqual(["bite-sized-stormwind-stockade"]);
});
it("selects another available category dungeon and falls back to the sole bite-sized entry", () => {
expect(randomDungeonByCategory("bite-sized", "bite-sized-stormwind-stockade", () => 0)?.id)
.toBe("bite-sized-stormwind-stockade");
expect(randomDungeonByCategory("wow", "stormwind-stockade", () => 0)?.id)
.not.toBe("stormwind-stockade");
});
it("keeps stable unique ids and resolves only catalogued dungeons", () => {
const ids = DUNGEON_CATALOG.map((dungeon) => dungeon.id);
expect(new Set(ids).size).toBe(ids.length);
+16
View File
@@ -63,3 +63,19 @@ export function dungeonById(id: string): DungeonCatalogEntry | null {
export function dungeonsByCategory(category: DungeonCategoryId): readonly DungeonCatalogEntry[] {
return DUNGEON_CATALOG.filter((dungeon) => dungeon.category === category);
}
/** Selects from enterable category content and avoids an immediate repeat when alternatives exist. */
export function randomDungeonByCategory(
category: DungeonCategoryId,
currentDungeonId?: string,
random: () => number = Math.random,
eligible: (dungeon: DungeonCatalogEntry) => boolean = () => true,
): DungeonCatalogEntry | null {
const available = dungeonsByCategory(category).filter((dungeon) => dungeon.available && eligible(dungeon));
const candidates = available.length > 1
? available.filter((dungeon) => dungeon.id !== currentDungeonId)
: available;
if (candidates.length === 0) return null;
const roll = Math.max(0, Math.min(0.9999999999999999, random()));
return candidates[Math.floor(roll * candidates.length)] ?? candidates[0];
}
+11 -2
View File
@@ -396,6 +396,8 @@ export interface DungeonEncounterProvider {
readonly definition: DungeonEncounterDefinition;
readonly encounterId: string;
readonly dungeonId: string;
/** Framework-created providers may be rebuilt when a seeded run changes its definition. */
readonly runtimeManaged?: boolean;
readonly getSnapshot: () => DungeonEncounterSnapshot;
readonly dispatch: (event: DungeonEncounterEvent) => readonly DungeonEncounterEffect[];
readonly reset: (scope: DungeonEncounterResetScope, now?: number) => void;
@@ -564,8 +566,14 @@ export function activateDungeonEncounterDefinitions(
const existing = providers.get(definition.id);
if (existing) {
if (existing.dungeonId !== dungeonId) throw new Error(`Encounter ${definition.id} has conflicting dungeon owners`);
existing.setContext?.({ difficultyId });
continue;
if (!existing.runtimeManaged || existing.definition === definition) {
existing.setContext?.({ difficultyId });
continue;
}
// Seeded variants can keep a stable encounter id while changing the
// boss/objective rules. Reusing that provider would leave completion
// listening for the previous run's boss.
providers.delete(definition.id);
}
let snapshot = createDungeonEncounterSnapshot(definition);
let context: DungeonEncounterRuntimeContext = { difficultyId };
@@ -573,6 +581,7 @@ export function activateDungeonEncounterDefinitions(
definition,
encounterId: definition.id,
dungeonId,
runtimeManaged: true,
getSnapshot: () => snapshot,
dispatch: (event) => {
const result = reduceDungeonEncounter(definition, snapshot, event, context);
+2
View File
@@ -258,6 +258,8 @@ export interface DungeonDefinition {
readonly roamingPacks: readonly MobPackDefinition[];
readonly bosses: readonly DungeonBossObjective[];
readonly objectiveOrder: readonly DungeonBossObjective[];
/** Defaults to finale; short-form dungeons can keep their last objective at standard boss durability. */
readonly finalBossDurability?: "standard" | "finale";
readonly navigationLinks: readonly DungeonNavigationLink[];
readonly traversalRamps?: readonly DungeonTraversalRamp[];
/** Serializable dungeon-wide orchestration kept separate from render state. */
+2
View File
@@ -133,6 +133,7 @@ export function createBiteSizedStockadesDefinition(
roamingPacks: [],
bosses: [boss],
objectiveOrder: [boss],
finalBossDurability: "standard",
// Keep the complete reviewed Stockades navigation graph connected. The
// physical branch walls, not navmesh pruning, close the unused wings.
navigationLinks: source.navigationLinks,
@@ -150,6 +151,7 @@ export function createBiteSizedStockadesDefinition(
reviewedBehavior: "A single seeded Stockades boss completes the bite-sized run in the first boss room.",
adaptations: [
"Each seeded run retains the selected source boss objective ID so saves, loot, guides, and leaderboards report the correct variant.",
"The one-boss run uses standard boss durability instead of the campaign-finale health floor.",
"The side-wing stairs retain their navigation bridges so the central route stays on the retained navmesh component; physical walls seal both unused wings.",
],
remainingGaps: [],