Update Healer Man server

This commit is contained in:
phenom committed 2026-09-20 13:40:23 -04:00
1 parent cc3b2fa4d5
commit daa417ec17
46 files changed
+1334 -172

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
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 {
BITE_SIZED_STOCKADES_BOSS_CANDIDATES,
createBiteSizedStockadesDefinition,
} from "./dungeons/biteSizedStockades";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
const source = DUNGEON_REGISTRY[STORMWIND_STOCKADE_DUNGEON_ID];
describe("bite-sized Stormwind Stockades", () => {
it("creates a separate central-corridor dungeon without changing Stockades", () => {
const originalBossIds = source.bosses.map((boss) => boss.id);
const originalSpawnCount = source.staticSpawns.length;
const biteSized = createBiteSizedStockadesDefinition(source, "run-a");
expect(biteSized.id).toBe(BITE_SIZED_STOCKADES_DUNGEON_ID);
expect(biteSized).not.toBe(source);
expect(source.bosses.map((boss) => boss.id)).toEqual(originalBossIds);
expect(source.staticSpawns).toHaveLength(originalSpawnCount);
expect(source.bosses).toHaveLength(5);
expect(source.serverGameObjects.some((object) => object.id.startsWith("bite-sized-"))).toBe(false);
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.staticSpawns.filter((spawn) => spawn.id === bossId)).toHaveLength(1);
expect(biteSized.staticSpawns.every((spawn) => Math.abs(spawn.position[2]) <= 15)).toBe(true);
expect(biteSized.navigationLinks.map((link) => link.id)).toEqual(["entrance-to-central-stairs"]);
expect(biteSized.traversalRamps?.map((ramp) => ramp.id)).toEqual(["entrance-stair-ramp"]);
});
it("seals both side wings with visible movement and sight blockers", () => {
const biteSized = createBiteSizedStockadesDefinition(source, "run-b");
const walls = biteSized.serverGameObjects.filter((object) => object.id.startsWith("bite-sized-stockades-"));
expect(walls.map((wall) => wall.id).sort()).toEqual([
"bite-sized-stockades-north-wall",
"bite-sized-stockades-south-wall",
]);
for (const wall of walls) {
expect(() => validateDungeonWorldObject(wall)).not.toThrow();
expect(wall.states).toEqual([expect.objectContaining({
id: "sealed",
visible: true,
blocksMovement: true,
blocksSight: true,
})]);
}
});
it("chooses all five source bosses deterministically across fresh run ids", () => {
const selected = new Set<string>();
for (let index = 0; index < 1_000; index += 1) {
selected.add(createBiteSizedStockadesDefinition(source, `fresh-run-${index}`).bosses[0].name);
}
expect([...selected].sort()).toEqual(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((boss) => boss.name).sort());
const first = createBiteSizedStockadesDefinition(source, "same-run");
const second = createBiteSizedStockadesDefinition(source, "same-run");
expect(second.bosses[0]).toEqual(first.bosses[0]);
expect(second.staticSpawns.find((spawn) => spawn.id === second.bosses[0].id)?.entityId)
.toBe(first.staticSpawns.find((spawn) => spawn.id === first.bosses[0].id)?.entityId);
});
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;
expect(useDungeonRunStore.getState().progress?.id).toBe("run-a");
expect(restartActiveDungeonSession()).toBe(true);
expect(dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].name).toBe(firstBoss);
expect(activateDungeonSession(BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "run-b" })).toBe(true);
expect(useDungeonRunStore.getState().progress?.id).toBe("run-b");
expect(dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].name).not.toBe(firstBoss);
});
it("completes the one-boss run only through its stable objective", () => {
const biteSized = createBiteSizedStockadesDefinition(source, "completion-run");
const bossId = biteSized.bosses[0].id;
const encounter = biteSized.encounters![0];
expect(() => validateDungeonEncounterDefinition(encounter)).not.toThrow();
const snapshot = createDungeonEncounterSnapshot(encounter, 1_000);
const reduced = reduceDungeonEncounter(encounter, snapshot, {
kind: "tick",
now: 2_000,
facts: {
registeredEntityIds: [bossId],
deadEntityIds: [bossId],
},
}).snapshot;
expect(reduced.completedObjectiveIds).toEqual([bossId]);
expect(reduced.flags["dungeon-complete"]).toBe(true);
expect(reduced.phase).toBe("complete");
});
});
+4 -2
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(84);
expect(DUNGEON_DEFINITIONS).toHaveLength(85);
expect(generated.definitions.map((dungeon) => dungeon.id))
.toEqual(campaign.dungeons.map((dungeon) => dungeon.slug));
for (const dungeon of generated.definitions) {
@@ -327,7 +327,9 @@ describe("generated dungeon campaign catalog", () => {
});
it("compiles every sourced WoW gameobject row with stable source provenance", () => {
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
campaign.dungeons.some((entry) => entry.slug === dungeon.id)
));
const objects = wowDungeons.flatMap((dungeon) => dungeon.serverGameObjects);
const sourceRows = objects.filter((object) => (
object.source?.templateId !== undefined && object.source?.spawnId !== undefined
+11 -8
View File
@@ -11,8 +11,8 @@ import { compareDungeonReleaseOrder } from "./dungeonReleaseStage";
import availability from "./generated/dungeonAvailability.json";
describe("dungeon catalog", () => {
it("lists the complete generated campaign with Stormwind Stockades in Beta first", () => {
expect(DUNGEON_CATALOG).toHaveLength(84);
it("lists the complete campaign plus the bite-sized Stockades variant", () => {
expect(DUNGEON_CATALOG).toHaveLength(85);
expect(DUNGEON_CATALOG[0]).toMatchObject({
id: "stormwind-stockade",
releaseStage: "beta",
@@ -37,8 +37,8 @@ describe("dungeon catalog", () => {
});
});
it("separates WoW and RoM dungeons with all RuneWaker imports under RoM", () => {
expect(DUNGEON_CATEGORIES.map((category) => category.id)).toEqual(["wow", "rom"]);
it("separates WoW, RoM, and bite-sized dungeons", () => {
expect(DUNGEON_CATEGORIES.map((category) => category.id)).toEqual(["wow", "rom", "bite-sized"]);
expect(dungeonsByCategory("rom").map((dungeon) => dungeon.id).sort()).toEqual([
"forsaken-abbey",
...RUNEWAKER_DUNGEON_DEFINITIONS.map((definition) => definition.id),
@@ -47,6 +47,8 @@ describe("dungeon catalog", () => {
expect.objectContaining({ id: "forsaken-abbey" }),
);
expect(dungeonsByCategory("rom")).toHaveLength(30);
expect(dungeonsByCategory("bite-sized").map((dungeon) => dungeon.id))
.toEqual(["bite-sized-stormwind-stockade"]);
});
it("keeps stable unique ids and resolves only catalogued dungeons", () => {
@@ -64,10 +66,11 @@ describe("dungeon catalog", () => {
}
});
it("keeps only Stockades in Beta and alphabetizes each release group", () => {
it("keeps both Stockades experiences in Beta and alphabetizes each release group", () => {
expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.releaseStage === "beta").map((dungeon) => dungeon.id))
.toEqual(["stormwind-stockade"]);
expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.id !== "stormwind-stockade").every((dungeon) => dungeon.releaseStage === "alpha"))
.toEqual(["stormwind-stockade", "bite-sized-stormwind-stockade"]);
expect(DUNGEON_CATALOG.filter((dungeon) => !dungeon.id.includes("stormwind-stockade"))
.every((dungeon) => dungeon.releaseStage === "alpha"))
.toBe(true);
for (const category of DUNGEON_CATEGORIES) {
const dungeons = dungeonsByCategory(category.id);
@@ -87,6 +90,6 @@ describe("dungeon catalog", () => {
it("shares the complete dungeon identity list used to validate reports on the server", () => {
expect(DUNGEON_CATALOG.map((dungeon) => dungeon.id).sort())
.toEqual(["forsaken-abbey", ...Object.keys(availability.enabled)].sort());
.toEqual(["bite-sized-stormwind-stockade", "forsaken-abbey", ...Object.keys(availability.enabled)].sort());
});
});
+8 -3
View File
@@ -1,8 +1,10 @@
import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonRegistry";
import { contentCategoryById, type ContentCategoryId } from "../app/contentCategories";
import { compareDungeonReleaseOrder, dungeonReleaseStage, type DungeonReleaseStage } from "./dungeonReleaseStage";
import { BITE_SIZED_STOCKADES_DUNGEON_ID } from "./dungeonContentSource";
const DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[];
const SOURCE_DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[];
const DUNGEON_CATEGORY_IDS = ["wow", "rom", "bite-sized"] as const;
export type DungeonCategoryId = (typeof DUNGEON_CATEGORY_IDS)[number];
export interface DungeonCategory {
@@ -12,10 +14,11 @@ export interface DungeonCategory {
}
export const DUNGEON_CATEGORIES: readonly DungeonCategory[] = Object.freeze([
...DUNGEON_CATEGORY_IDS.map((id) => {
...SOURCE_DUNGEON_CATEGORY_IDS.map((id) => {
const category = contentCategoryById(id);
return { id, label: category.label, fullName: category.name };
}),
{ id: "bite-sized", label: "Bite-Sized", fullName: "Bite-Sized Dungeons" },
]);
export interface DungeonCatalogEntry {
@@ -36,7 +39,9 @@ export interface DungeonCatalogEntry {
export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze(
DUNGEON_DEFINITIONS.map<DungeonCatalogEntry>((definition) => ({
id: definition.id as DungeonId,
category: definition.expansion === "runewaker" ? "rom" : "wow",
category: definition.id === BITE_SIZED_STOCKADES_DUNGEON_ID
? "bite-sized"
: definition.expansion === "runewaker" ? "rom" : "wow",
title: definition.title,
mapId: definition.mapId,
entranceName: definition.entrance.name,
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import {
BITE_SIZED_STOCKADES_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID,
dungeonContentPackId,
dungeonContentSourceId,
} from "./dungeonContentSource";
describe("derived dungeon content sources", () => {
it("reuses Stockades files for the bite-sized variant without changing ordinary pack ids", () => {
expect(dungeonContentSourceId(BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe(STORMWIND_STOCKADE_DUNGEON_ID);
expect(dungeonContentPackId(BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe("dungeon:stormwind-stockade");
expect(dungeonContentPackId("deadmines")).toBe("dungeon:deadmines");
});
});
+15
View File
@@ -0,0 +1,15 @@
export const STORMWIND_STOCKADE_DUNGEON_ID = "stormwind-stockade";
export const BITE_SIZED_STOCKADES_DUNGEON_ID = "bite-sized-stormwind-stockade";
const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.freeze({
[BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
});
/** Derived dungeon variants share the installed assets of their source dungeon. */
export function dungeonContentSourceId(dungeonId: string): string {
return DUNGEON_CONTENT_SOURCE_IDS[dungeonId] ?? dungeonId;
}
export function dungeonContentPackId(dungeonId: string): string {
return `dungeon:${dungeonContentSourceId(dungeonId)}`;
}
+23 -5
View File
@@ -3,21 +3,39 @@ import { describe, expect, it } from "vitest";
import { CLASSES } from "../app/characterCatalog";
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
import { dungeonBossesForDifficulty, finalDungeonBoss } from "./dungeonRunStore";
import { BITE_SIZED_STOCKADES_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID } from "./dungeonContentSource";
import { BITE_SIZED_STOCKADES_BOSS_CANDIDATES } from "./dungeons/biteSizedStockades";
const stockades = DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === STORMWIND_STOCKADE_DUNGEON_ID)!;
const biteSizedBosses = BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((candidate) => (
stockades.bosses.find((boss) => boss.id === candidate.sourceSpawnId)!
));
describe("dungeon leaderboard catalog", () => {
it("keeps the server's final bosses, boss lists, difficulties, and classes aligned with the game", () => {
const catalog = {
dungeons: DUNGEON_DEFINITIONS.map((dungeon) => ({ id: dungeon.id, title: dungeon.title,
finalBossId: finalDungeonBoss(dungeon)?.id ?? "",
bosses: dungeon.bosses.map((boss) => ({ id: boss.id, name: boss.name })),
finalBossId: dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID
? biteSizedBosses[0].id
: finalDungeonBoss(dungeon)?.id ?? "",
bosses: (dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID ? biteSizedBosses : dungeon.bosses)
.map((boss) => ({ id: boss.id, name: boss.name })),
difficulties: dungeon.difficultyVariants.map((difficulty) => ({ id: difficulty.id, name: difficulty.difficulty,
bossIds: dungeonBossesForDifficulty(dungeon, difficulty.id).map((boss) => boss.id),
finalBossId: finalDungeonBoss(dungeon, difficulty.id)?.id ?? "" })),
bossIds: dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID
? biteSizedBosses.map((boss) => boss.id)
: dungeonBossesForDifficulty(dungeon, difficulty.id).map((boss) => boss.id),
finalBossId: dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID
? biteSizedBosses[0].id
: finalDungeonBoss(dungeon, difficulty.id)?.id ?? "",
...(dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID
? { finalBossIds: biteSizedBosses.map((boss) => boss.id) }
: {}),
})),
defaultDifficultyId: dungeon.defaultDifficultyId,
})).sort((a, b) => a.id.localeCompare(b.id)),
classes: CLASSES.map((entry) => ({ id: entry.id, name: entry.name })),
};
expect(catalog.dungeons).toHaveLength(84);
expect(catalog.dungeons).toHaveLength(85);
expect(catalog.dungeons.filter((dungeon) => dungeon.bosses.length > 0 && !dungeon.finalBossId)).toEqual([]);
const path = "src/game/generated/dungeonLeaderboardCatalog.generated.json";
if (process.env.WRITE_DUNGEON_LEADERBOARD_CATALOG === "1") writeFileSync(path, `${JSON.stringify(catalog, null, 2)}\n`);
+18
View File
@@ -0,0 +1,18 @@
export interface DungeonLeaderboardDifficultyRule {
readonly bossIds: readonly string[];
readonly finalBossId: string;
readonly finalBossIds?: readonly string[];
}
export function leaderboardFinalBossIds(rule: DungeonLeaderboardDifficultyRule): readonly string[] {
return rule.finalBossIds?.length ? rule.finalBossIds : [rule.finalBossId];
}
export function isLeaderboardFullClear(
rule: DungeonLeaderboardDifficultyRule,
killedBossIds: readonly string[],
): boolean {
return rule.finalBossIds?.length
? rule.finalBossIds.some((id) => killedBossIds.includes(id))
: rule.bossIds.every((id) => killedBossIds.includes(id));
}
+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(84);
expect(DUNGEON_DEFINITIONS).toHaveLength(85);
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);
+35 -9
View File
@@ -21,6 +21,12 @@ import { withWowClassicEncounterBatchTwo } from "./wowClassicEncounterBatchTwoCa
import { withWowClassicEncounterBatchThree } from "./wowClassicEncounterBatchThreeCatalog";
import { withWowTbcEncounterBatchTwoContent } from "./wowTbcEncounterBatchTwoCatalog";
import { withWowDungeonEnvironment } from "./wowDungeonEnvironment";
import {
BITE_SIZED_STOCKADES_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID,
dungeonContentSourceId,
} from "./dungeonContentSource";
import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades";
export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults";
@@ -177,7 +183,7 @@ function applyDungeonContentAdapters(definition: DungeonDefinition): DungeonDefi
);
}
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
const sourceDungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
[authoredWailingCaverns.id, authoredWailingCaverns],
[FORSAKEN_ABBEY_DEFINITION.id, FORSAKEN_ABBEY_DEFINITION],
...RUNEWAKER_DUNGEON_DEFINITIONS.map((definition) => [definition.id, definition] as const),
@@ -186,13 +192,21 @@ const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[]
.map((definition) => [definition.id, definition] as const),
];
const adaptedSourceEntries = sourceDungeonRegistryEntries.map(
([id, definition]) => [id, applyDungeonContentAdapters(definition)] as const,
);
const adaptedSourceRegistry = Object.fromEntries(adaptedSourceEntries) as Readonly<Record<string, DungeonDefinition>>;
const stockadesDefinition = adaptedSourceRegistry[STORMWIND_STOCKADE_DUNGEON_ID];
if (!stockadesDefinition) throw new Error("Stormwind Stockade is required for its bite-sized variant.");
const biteSizedStockadesTemplate = createBiteSizedStockadesDefinition(stockadesDefinition, "catalog-template");
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
...adaptedSourceEntries,
[biteSizedStockadesTemplate.id, biteSizedStockadesTemplate],
];
export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = Object.freeze(
Object.fromEntries(dungeonRegistryEntries.map(
([id, definition]) => [
id,
applyDungeonContentAdapters(definition),
] as const,
)),
Object.fromEntries(dungeonRegistryEntries),
);
export type DungeonId = string;
@@ -201,6 +215,7 @@ export const DUNGEON_DEFINITIONS = Object.freeze(Object.values(DUNGEON_REGISTRY)
// Test rooms are session-only definitions; the shipped dungeon catalog stays immutable.
const bossTestingDefinitions = new Map<string, { definition: DungeonDefinition; sourceDungeonId: string }>();
const sessionDungeonDefinitions = new Map<string, DungeonDefinition>();
export function registerBossTestingDungeon(definition: DungeonDefinition, sourceDungeonId: string): void {
if (!definition.id.startsWith("boss-testing:") || !DUNGEON_REGISTRY[sourceDungeonId]) {
@@ -218,8 +233,19 @@ export function clearBossTestingDungeons(): void {
bossTestingDefinitions.clear();
}
/** Materializes seeded variants after a run id exists; ordinary dungeons keep their catalog definition. */
export function prepareDungeonSessionDefinition(id: string, seed: string): DungeonDefinition | null {
if (id !== BITE_SIZED_STOCKADES_DUNGEON_ID) return dungeonDefinitionById(id);
const definition = createBiteSizedStockadesDefinition(stockadesDefinition, seed);
sessionDungeonDefinitions.set(id, definition);
return definition;
}
export function dungeonDefinitionById(id: string): DungeonDefinition | null {
return bossTestingDefinitions.get(id)?.definition ?? DUNGEON_REGISTRY[id] ?? null;
return bossTestingDefinitions.get(id)?.definition
?? sessionDungeonDefinitions.get(id)
?? DUNGEON_REGISTRY[id]
?? null;
}
export function requireDungeonDefinition(id: string): DungeonDefinition {
@@ -239,7 +265,7 @@ export function dungeonIsEnabled(id: string): boolean {
const definition = dungeonDefinitionById(id);
if (!definition) return false;
const configured = (availability.enabled as Readonly<Record<string, boolean>>)[id];
const configured = (availability.enabled as Readonly<Record<string, boolean>>)[dungeonContentSourceId(id)];
if (definition.expansion !== "runewaker") return configured === true;
if (!dungeonPackagePassesOfflineGate(definition)) return false;
if (configured !== undefined) return configured;
+1 -1
View File
@@ -8,7 +8,7 @@ export const DUNGEON_RELEASE_STAGE_LABELS: Readonly<Record<DungeonReleaseStage,
};
export function dungeonReleaseStage(id: string): DungeonReleaseStage {
return id === "stormwind-stockade" ? "beta" : "alpha";
return id === "stormwind-stockade" || id === "bite-sized-stormwind-stockade" ? "beta" : "alpha";
}
export function compareDungeonReleaseOrder(
+4 -3
View File
@@ -30,7 +30,8 @@ export function completedDungeonRun(progress: DungeonRunProgress): DungeonRunRec
interface DungeonRunState {
readonly progress: DungeonRunProgress | null;
begin: (dungeonId: string, difficultyId: string, resume?: DungeonRunProgress, legacyResume?: boolean, bossDifficulty?: number) => void;
begin: (dungeonId: string, difficultyId: string, resume?: DungeonRunProgress, legacyResume?: boolean,
bossDifficulty?: number, runId?: string) => void;
start: (now: number, party: readonly DungeonRunMember[]) => void;
advance: (completedObjectiveIds: readonly string[], deadBossIds: readonly string[], now: number,
party: readonly DungeonRunMember[]) => void;
@@ -38,10 +39,10 @@ interface DungeonRunState {
export const useDungeonRunStore = create<DungeonRunState>((set, get) => ({
progress: null,
begin: (dungeonId, difficultyId, resume, legacyResume = false, bossDifficulty = 1) => set({
begin: (dungeonId, difficultyId, resume, legacyResume = false, bossDifficulty = 1, runId) => set({
progress: resume?.dungeonId === dungeonId && resume.difficultyId === difficultyId
&& resume.bossDifficulty === bossDifficulty ? resume : {
id: globalThis.crypto.randomUUID(), dungeonId, difficultyId, startedAt: null,
id: runId ?? globalThis.crypto.randomUUID(), dungeonId, difficultyId, startedAt: null,
finishedAt: null, durationMs: null, killedBossIds: [], party: [], timed: !legacyResume, bossDifficulty,
},
}),
+13 -3
View File
@@ -6,7 +6,13 @@ import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { resetPartyRuntimeClock } from "./partyRuntime";
import { usePartyStore } from "./partyStore";
import { useGameStore, type BossTestSession, type GameMode } from "./store";
import { bossTestingSourceDungeonId, dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
import {
bossTestingSourceDungeonId,
dungeonCanEnter,
dungeonDefinitionById,
prepareDungeonSessionDefinition,
type DungeonId,
} from "./dungeonRegistry";
import type { WorldSpawn } from "./dungeonTypes";
import type { GmReviewTarget } from "./gmContentReview";
import { shelvedDungeonSpawn } from "@shelvedDungeonSession";
@@ -39,6 +45,8 @@ export interface DungeonSessionOptions {
readonly encounterSnapshots?: readonly DungeonEncounterSnapshot[];
readonly runProgress?: DungeonRunProgress;
readonly legacyResume?: boolean;
/** Shared online activity id, or another stable fresh-run identity used by seeded variants. */
readonly runId?: string;
}
/**
@@ -51,7 +59,7 @@ export function activateDungeonSession(
difficultyId?: string,
options: DungeonSessionOptions = {},
): boolean {
const definition = dungeonDefinitionById(dungeonId);
let definition = dungeonDefinitionById(dungeonId);
const testingSource = bossTestingSourceDungeonId(dungeonId);
const testing = Boolean(testingSource && options.gmTestMode && options.bossTest?.sourceDungeonId === testingSource);
const dungeonSimulation = Boolean(options.dungeonSimulation && options.gmTestMode && dungeonRouteDefinition(dungeonId));
@@ -67,8 +75,10 @@ export function activateDungeonSession(
if (previousDefinition) releaseDungeonGltfCache(previousDefinition);
if (!options.preserveCharacter) {
useDungeonRunStore.getState().begin(dungeonId, difficultyId ?? definition.defaultDifficultyId,
options.runProgress, options.legacyResume, bossDifficulty);
options.runProgress, options.legacyResume, bossDifficulty, options.runId);
}
const runSeed = useDungeonRunStore.getState().progress?.id ?? options.runId ?? "catalog-template";
definition = prepareDungeonSessionDefinition(dungeonId, runSeed) ?? definition;
activateDungeonEncounterDefinitions(
dungeonId,
definition.encounters ?? [],
+189
View File
@@ -0,0 +1,189 @@
import type { DungeonDefinition, DungeonBossObjective } from "../dungeonTypes";
import type { PopulationEntityDefinition, StaticMobSpawnDefinition } from "../mobPopulation";
import type { DungeonWorldObjectDefinition } from "../dungeonWorldObjects";
import {
BITE_SIZED_STOCKADES_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID,
} from "../dungeonContentSource";
const BOSS_ROOM_POSITION = [-159.582, -25.6062, 1.2531] as const;
const CENTRAL_CORRIDOR_HALF_WIDTH = 15;
export interface BiteSizedStockadesBossCandidate {
readonly sourceSpawnId: string;
readonly name: string;
}
export const BITE_SIZED_STOCKADES_BOSS_CANDIDATES: readonly BiteSizedStockadesBossCandidate[] = Object.freeze([
{ sourceSpawnId: "creature-84027", name: "Targorr the Dread" },
{ sourceSpawnId: "creature-79088", name: "Kam Deepfury" },
{ sourceSpawnId: "creature-79098", name: "Hamhock" },
{ sourceSpawnId: "creature-79124", name: "Bazil Thredd" },
{ sourceSpawnId: "creature-79116", name: "Dextren Ward" },
]);
function seedHash(seed: string): number {
let hash = 0x811c9dc5;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
export function biteSizedStockadesBossIndex(seed: string): number {
return seedHash(seed) % BITE_SIZED_STOCKADES_BOSS_CANDIDATES.length;
}
function branchWall(id: string, name: string, z: number): DungeonWorldObjectDefinition {
return {
id,
name,
kind: "gate",
position: [-129.17, -22.1, z],
halfExtents: [6, 5.2, 0.65],
initialState: "sealed",
states: [{
id: "sealed",
label: "Sealed",
visible: true,
blocksMovement: true,
blocksSight: true,
interactionEnabled: false,
}],
visual: { color: "#332f2b" },
source: {
fidelity: "healer-man-authored",
files: ["src/game/dungeons/biteSizedStockades.ts"],
note: "Permanent wall for the bite-sized route; the source Stockades geometry remains unchanged.",
},
};
}
function sourceBoss(
source: DungeonDefinition,
candidate: BiteSizedStockadesBossCandidate,
): { readonly spawn: StaticMobSpawnDefinition; readonly entity: PopulationEntityDefinition } {
const spawn = source.staticSpawns.find((entry) => entry.id === candidate.sourceSpawnId);
const entity = spawn ? source.entities[spawn.entityId] : undefined;
if (!spawn || !entity || entity.kind !== "boss") {
throw new Error(`Stockades boss source is missing: ${candidate.sourceSpawnId}`);
}
return { spawn, entity };
}
/**
* Builds a one-boss Stockades variant without mutating the generated source definition.
* The seed controls only boss identity, so saves and online party members can reconstruct
* the same run while a fresh run receives a new random selection.
*/
export function createBiteSizedStockadesDefinition(
source: DungeonDefinition,
seed: string,
): DungeonDefinition {
if (source.id !== STORMWIND_STOCKADE_DUNGEON_ID) {
throw new Error(`Bite-sized Stockades requires ${STORMWIND_STOCKADE_DUNGEON_ID}.`);
}
const candidate = BITE_SIZED_STOCKADES_BOSS_CANDIDATES[biteSizedStockadesBossIndex(seed)];
const selected = sourceBoss(source, candidate);
const sourceBossIds = new Set(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((entry) => entry.sourceSpawnId));
const corridorSpawns = source.staticSpawns.filter((spawn) => (
!sourceBossIds.has(spawn.id)
&& Math.abs(spawn.position[2]) <= CENTRAL_CORRIDOR_HALF_WIDTH
));
const usedEntityIds = new Set(corridorSpawns.map((spawn) => spawn.entityId));
const entities = Object.fromEntries([
...Object.entries(source.entities).filter(([entityId]) => usedEntityIds.has(entityId)),
[selected.spawn.entityId, selected.entity],
]);
const boss: DungeonBossObjective = {
id: candidate.sourceSpawnId,
name: candidate.name,
position: BOSS_ROOM_POSITION,
};
return {
...source,
id: BITE_SIZED_STOCKADES_DUNGEON_ID,
title: "Stormwind Stockade: Cellblock Rush",
lfgDungeonIds: [],
presentation: {
...source.presentation,
location: "Stormwind Stockade · Central Cellblock",
theme: "Bite-Sized Dungeon",
summary: "Race through the central cellblock and defeat one surprise warden chosen from the five Stockades bosses.",
loadingMessage: "Sealing the side wings and choosing a cellblock warden...",
unchartedAreaName: "Central Cellblock",
},
areas: [
{ ...source.entrance, id: "entrance", center: source.entrance.footPosition, radius: 35 },
{ id: "bite-sized-boss-room", name: `${candidate.name}'s Cell`, center: BOSS_ROOM_POSITION, radius: 32 },
],
entities,
staticSpawns: [
...corridorSpawns,
{
id: candidate.sourceSpawnId,
entityId: selected.spawn.entityId,
position: BOSS_ROOM_POSITION,
yaw: selected.spawn.yaw,
spawnMask: selected.spawn.spawnMask,
},
],
roamingPacks: [],
bosses: [boss],
objectiveOrder: [boss],
navigationLinks: source.navigationLinks.filter((link) => link.id === "entrance-to-central-stairs"),
traversalRamps: source.traversalRamps?.filter((ramp) => ramp.id === "entrance-stair-ramp"),
encounters: [{
id: `${BITE_SIZED_STOCKADES_DUNGEON_ID}:boss-progression`,
dungeonId: BITE_SIZED_STOCKADES_DUNGEON_ID,
initialPhase: "progression",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "progression", checkpointPhase: "progression" },
source: {
fidelity: "healer-man-authored",
scope: "boss-progression",
files: ["src/game/dungeons/biteSizedStockades.ts"],
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 two side-wing stairs are permanently sealed and omitted from party navigation.",
],
remainingGaps: [],
},
rules: [
{
id: "boss-defeated",
trigger: { kind: "entities-dead", entityIds: [candidate.sourceSpawnId] },
actions: [
{ id: "record-boss", kind: "complete-objective", objectiveId: candidate.sourceSpawnId, persistence: "run" },
{ id: "checkpoint-boss", kind: "set-checkpoint", checkpointId: candidate.sourceSpawnId, persistence: "checkpoint" },
],
},
{
id: "complete-run",
trigger: { kind: "tick" },
guards: [{ kind: "objective-complete", objectiveId: candidate.sourceSpawnId, equals: true }],
actions: [
{ id: "mark-dungeon-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: "enter-complete-phase", kind: "set-phase", phase: "complete", persistence: "run" },
],
},
],
}],
encounterSpawnGroups: [],
serverGameObjects: [
...source.serverGameObjects.filter((object) => Math.abs(object.position[2]) <= CENTRAL_CORRIDOR_HALF_WIDTH),
branchWall("bite-sized-stockades-north-wall", "Sealed North Wing", 16.4),
branchWall("bite-sized-stockades-south-wall", "Sealed South Wing", -16.4),
],
validation: {
...source.validation,
warnings: [
...source.validation.warnings,
"Bite-sized layout and branch walls are Healer Man-authored; environment and combat assets are shared with Stormwind Stockade.",
],
},
};
}
@@ -216,6 +216,55 @@
],
"defaultDifficultyId": "normal"
},
{
"id": "bite-sized-stormwind-stockade",
"title": "Stormwind Stockade: Cellblock Rush",
"finalBossId": "creature-84027",
"bosses": [
{
"id": "creature-84027",
"name": "Targorr the Dread"
},
{
"id": "creature-79088",
"name": "Kam Deepfury"
},
{
"id": "creature-79098",
"name": "Hamhock"
},
{
"id": "creature-79124",
"name": "Bazil Thredd"
},
{
"id": "creature-79116",
"name": "Dextren Ward"
}
],
"difficulties": [
{
"id": "normal",
"name": "normal",
"bossIds": [
"creature-84027",
"creature-79088",
"creature-79098",
"creature-79124",
"creature-79116"
],
"finalBossId": "creature-84027",
"finalBossIds": [
"creature-84027",
"creature-79088",
"creature-79098",
"creature-79124",
"creature-79116"
]
}
],
"defaultDifficultyId": "normal"
},
{
"id": "black-morass",
"title": "The Black Morass",
+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(84);
expect(DUNGEON_DEFINITIONS).toHaveLength(85);
for (const dungeon of DUNGEON_DEFINITIONS) {
for (const pack of dungeon.roamingPacks) {
expect(hasUsableLoopedMobPath(pack.waypoints), `${dungeon.id}:${pack.id}`).toBe(true);
+2 -2
View File
@@ -84,7 +84,7 @@ describe("party navigation fleet safety", () => {
const enabled = [];
const fallback = [];
expect(DUNGEON_DEFINITIONS).toHaveLength(84);
expect(DUNGEON_DEFINITIONS).toHaveLength(85);
for (const dungeon of DUNGEON_DEFINITIONS) {
expect(dungeonCanEnter(dungeon.id), dungeon.id).toBe(true);
const navigation = dungeon.assets.navigation;
@@ -119,7 +119,7 @@ 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(30);
expect(enabled).toHaveLength(31);
expect(fallback).toHaveLength(54);
});
+4 -1
View File
@@ -4,9 +4,12 @@ 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 { BITE_SIZED_STOCKADES_DUNGEON_ID } from "./dungeonContentSource";
describe("fleet-wide WoW dungeon environment policy", () => {
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
dungeon.expansion !== "runewaker" && dungeon.id !== BITE_SIZED_STOCKADES_DUNGEON_ID
));
it("gives every WoW dungeon readable ambient and hemisphere fill without changing fog draw distance or material roles", () => {
expect(wowDungeons).toHaveLength(54);