Update Healer Man server
This commit is contained in:
1 parent
cc3b2fa4d5
commit
daa417ec17
46 files changed
+1334
-172
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 +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());
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
Reference in new issue
Block a user