Release Healer Man 0.1.58

This commit is contained in:
phenom committed 2026-09-20 16:46:17 -04:00
1 parent 14e105642c
commit 246b4832b0
38 files changed
+952 -93

No files matched your search

+19 -5
View File
@@ -9,6 +9,7 @@ import { onlineGroupAiPartySize, useOnlineGroupStore } from "../app/onlineGroupS
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { useCompanionSpecReadinessStore } from "./companionSpecReadinessStore";
import { useCompanionPartyPreferences } from "./companionPartyPreferences";
import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "./dungeonContentSource";
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
export function PartyBridge() {
@@ -17,6 +18,7 @@ export function PartyBridge() {
const gameMode = useGameStore((state) => state.gameMode);
const manastormPartySize = useManastormStore((state) => state.partySize);
const onlineActivity = useOnlineGroupStore((state) => state.activeActivity);
const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group);
const level = useCombatStore((state) => state.level);
const simulationMode = useDungeonSimulationStore((state) => state.sessionMode);
const simulationSeed = useDungeonSimulationStore((state) => state.seed);
@@ -35,20 +37,30 @@ export function PartyBridge() {
&& onlineActivity.type === (gameMode === "manastorm" ? "manastorm" : "dungeon")
? onlineGroupAiPartySize(onlineActivity.partySize)
: requestedPartySize;
const sharedOnlineParty = onlineActivity && onlineGroup?.activity?.id === onlineActivity.id
? onlineGroup.members
: [];
const randomizedReadyOnly = onlineActivity?.type === "dungeon"
&& onlineActivity.contentId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID;
const companionOptions = {
readinessPolicy,
readinessPolicyRevision,
includeBetaSpecs: onlineActivity?.includeBetaSpecs ?? deviceIncludeBetaSpecs,
readinessPolicyRevision: onlineActivity?.companionReadinessRevision ?? readinessPolicyRevision,
includeBetaSpecs: randomizedReadyOnly ? false : onlineActivity?.includeBetaSpecs ?? deviceIncludeBetaSpecs,
excludedPlayerSpecializationId: character.specializationId,
excludedPlayerSpecializationIds: sharedOnlineParty.flatMap((member) => (
member.character?.specializationId ? [member.character.specializationId] : []
)),
allowedCompanionSpecializationIds: onlineActivity?.companionSpecializationIds,
occupiedHumanRoles: sharedOnlineParty.flatMap((member) => member.role ? [member.role] : []),
};
if (simulationMode === "play") {
party.initializeParty(
character.id,
onlineActivity?.id ?? character.id,
combat.level,
combat.health,
playerRole,
aiPartySize,
character.categoryId ?? "wow",
randomizedReadyOnly ? "wow" : character.categoryId ?? "wow",
companionOptions,
);
} else {
@@ -59,7 +71,9 @@ export function PartyBridge() {
combat.applyDefaultPartyBuffs();
combat.applyPrecombatPreparation();
resetPartyRuntimeClock();
}, [character?.id, deviceIncludeBetaSpecs, gameMode, manastormPartySize, onlineActivity?.id, onlineActivity?.includeBetaSpecs, playerRole, readinessPolicy, readinessPolicyRevision, simulationMode, simulationSeed]);
}, [character?.id, deviceIncludeBetaSpecs, gameMode, manastormPartySize, onlineActivity?.id,
onlineActivity?.includeBetaSpecs, onlineGroup?.updatedAt, playerRole, readinessPolicy,
readinessPolicyRevision, simulationMode, simulationSeed]);
useEffect(() => {
const party = usePartyStore.getState();
+1 -1
View File
@@ -72,7 +72,7 @@ const WDT_PLACEMENT_DUNGEON_IDS = [
describe("generated dungeon campaign catalog", () => {
it("covers only reviewed five-player instances with loadable assets and combat content", () => {
expect(generated.definitions).toHaveLength(54);
expect(DUNGEON_DEFINITIONS).toHaveLength(85);
expect(DUNGEON_DEFINITIONS).toHaveLength(86);
expect(generated.definitions.map((dungeon) => dungeon.id))
.toEqual(campaign.dungeons.map((dungeon) => dungeon.slug));
for (const dungeon of generated.definitions) {
+10 -6
View File
@@ -12,9 +12,9 @@ import { compareDungeonReleaseOrder } from "./dungeonReleaseStage";
import availability from "./generated/dungeonAvailability.json";
describe("dungeon catalog", () => {
it("lists the complete campaign plus the bite-sized Stockades variant", () => {
expect(DUNGEON_CATALOG).toHaveLength(85);
expect(DUNGEON_CATALOG[0]).toMatchObject({
it("lists the complete campaign plus both bite-sized Stockades variants", () => {
expect(DUNGEON_CATALOG).toHaveLength(86);
expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "stormwind-stockade")).toMatchObject({
id: "stormwind-stockade",
releaseStage: "beta",
available: true,
@@ -74,9 +74,13 @@ describe("dungeon catalog", () => {
}
});
it("keeps both Stockades experiences in Beta and alphabetizes each release group", () => {
it("keeps all Stockades experiences in Beta and alphabetizes each release group", () => {
expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.releaseStage === "beta").map((dungeon) => dungeon.id))
.toEqual(["stormwind-stockade", "bite-sized-stormwind-stockade"]);
.toEqual([
"randomized-bite-sized-stormwind-stockade",
"stormwind-stockade",
"bite-sized-stormwind-stockade",
]);
expect(DUNGEON_CATALOG.filter((dungeon) => !dungeon.id.includes("stormwind-stockade"))
.every((dungeon) => dungeon.releaseStage === "alpha"))
.toBe(true);
@@ -98,6 +102,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(["bite-sized-stormwind-stockade", "forsaken-abbey", ...Object.keys(availability.enabled)].sort());
.toEqual(["bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade", "forsaken-abbey", ...Object.keys(availability.enabled)].sort());
});
});
+8 -2
View File
@@ -1,7 +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";
import {
BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
} from "./dungeonContentSource";
const SOURCE_DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[];
const DUNGEON_CATEGORY_IDS = ["wow", "rom", "bite-sized"] as const;
@@ -40,6 +43,7 @@ export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze(
DUNGEON_DEFINITIONS.map<DungeonCatalogEntry>((definition) => ({
id: definition.id as DungeonId,
category: definition.id === BITE_SIZED_STOCKADES_DUNGEON_ID
|| definition.id === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID
? "bite-sized"
: definition.expansion === "runewaker" ? "rom" : "wow",
title: definition.title,
@@ -61,7 +65,9 @@ export function dungeonById(id: string): DungeonCatalogEntry | null {
}
export function dungeonsByCategory(category: DungeonCategoryId): readonly DungeonCatalogEntry[] {
return DUNGEON_CATALOG.filter((dungeon) => dungeon.category === category);
return DUNGEON_CATALOG.filter((dungeon) => (
dungeon.category === category && dungeon.id !== RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID
));
}
/** Selects from enterable category content and avoids an immediate repeat when alternatives exist. */
+3
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID,
dungeonContentPackId,
dungeonContentSourceId,
@@ -10,6 +11,8 @@ 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(dungeonContentSourceId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe(STORMWIND_STOCKADE_DUNGEON_ID);
expect(dungeonContentPackId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe("dungeon:stormwind-stockade");
expect(dungeonContentPackId("deadmines")).toBe("dungeon:deadmines");
});
});
+7
View File
@@ -1,10 +1,17 @@
export const STORMWIND_STOCKADE_DUNGEON_ID = "stormwind-stockade";
export const BITE_SIZED_STOCKADES_DUNGEON_ID = "bite-sized-stormwind-stockade";
export const RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID = "randomized-bite-sized-stormwind-stockade";
const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.freeze({
[BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
[RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
});
export function isDerivedStockadesDungeonId(dungeonId: string): boolean {
return dungeonId === BITE_SIZED_STOCKADES_DUNGEON_ID
|| dungeonId === RANDOMIZED_BITE_SIZED_STOCKADES_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;
+7 -7
View File
@@ -3,7 +3,7 @@ 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 { isDerivedStockadesDungeonId, 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)!;
@@ -15,19 +15,19 @@ 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: dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID
finalBossId: isDerivedStockadesDungeonId(dungeon.id)
? biteSizedBosses[0].id
: finalDungeonBoss(dungeon)?.id ?? "",
bosses: (dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID ? biteSizedBosses : dungeon.bosses)
bosses: (isDerivedStockadesDungeonId(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: dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID
bossIds: isDerivedStockadesDungeonId(dungeon.id)
? biteSizedBosses.map((boss) => boss.id)
: dungeonBossesForDifficulty(dungeon, difficulty.id).map((boss) => boss.id),
finalBossId: dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID
finalBossId: isDerivedStockadesDungeonId(dungeon.id)
? biteSizedBosses[0].id
: finalDungeonBoss(dungeon, difficulty.id)?.id ?? "",
...(dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID
...(isDerivedStockadesDungeonId(dungeon.id)
? { finalBossIds: biteSizedBosses.map((boss) => boss.id) }
: {}),
})),
@@ -35,7 +35,7 @@ describe("dungeon leaderboard catalog", () => {
})).sort((a, b) => a.id.localeCompare(b.id)),
classes: CLASSES.map((entry) => ({ id: entry.id, name: entry.name })),
};
expect(catalog.dungeons).toHaveLength(85);
expect(catalog.dungeons).toHaveLength(86);
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`);
+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(85);
expect(DUNGEON_DEFINITIONS).toHaveLength(86);
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);
+10 -2
View File
@@ -23,10 +23,12 @@ import { withWowTbcEncounterBatchTwoContent } from "./wowTbcEncounterBatchTwoCat
import { withWowDungeonEnvironment } from "./wowDungeonEnvironment";
import {
BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID,
dungeonContentSourceId,
} from "./dungeonContentSource";
import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades";
import { createRandomizedBiteSizedStockadesDefinition } from "./dungeons/randomizedBiteSizedStockades";
export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults";
@@ -200,9 +202,11 @@ 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 randomizedBiteSizedStockadesTemplate = createRandomizedBiteSizedStockadesDefinition(stockadesDefinition, "catalog-template");
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
...adaptedSourceEntries,
[biteSizedStockadesTemplate.id, biteSizedStockadesTemplate],
[randomizedBiteSizedStockadesTemplate.id, randomizedBiteSizedStockadesTemplate],
];
export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = Object.freeze(
@@ -235,8 +239,12 @@ export function clearBossTestingDungeons(): void {
/** 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);
if (id !== BITE_SIZED_STOCKADES_DUNGEON_ID && id !== RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) {
return dungeonDefinitionById(id);
}
const definition = id === BITE_SIZED_STOCKADES_DUNGEON_ID
? createBiteSizedStockadesDefinition(stockadesDefinition, seed)
: createRandomizedBiteSizedStockadesDefinition(stockadesDefinition, seed);
sessionDungeonDefinitions.set(id, definition);
return definition;
}
+5 -1
View File
@@ -8,7 +8,11 @@ export const DUNGEON_RELEASE_STAGE_LABELS: Readonly<Record<DungeonReleaseStage,
};
export function dungeonReleaseStage(id: string): DungeonReleaseStage {
return id === "stormwind-stockade" || id === "bite-sized-stormwind-stockade" ? "beta" : "alpha";
return id === "stormwind-stockade"
|| id === "bite-sized-stormwind-stockade"
|| id === "randomized-bite-sized-stormwind-stockade"
? "beta"
: "alpha";
}
export function compareDungeonReleaseOrder(
@@ -0,0 +1,127 @@
import type { DungeonDefinition } from "../dungeonTypes";
import type { PopulationEntityDefinition } from "../mobPopulation";
import {
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID,
} from "../dungeonContentSource";
import {
BITE_SIZED_STOCKADES_BOSS_CANDIDATES,
createBiteSizedStockadesDefinition,
} from "./biteSizedStockades";
export type RandomizedCreatureKind = "mob" | "boss";
export interface RandomizedBiteSizedCreature {
readonly key: string;
readonly sourceDungeonId: string;
readonly sourceDungeonTitle: string;
readonly entityId: string;
readonly name: string;
readonly kind: RandomizedCreatureKind;
readonly serverEntry: number | null;
}
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;
}
function placedEntityIds(source: DungeonDefinition): ReadonlySet<string> {
return new Set([
...source.staticSpawns.map((spawn) => spawn.entityId),
...source.roamingPacks.flatMap((pack) => pack.members.map((member) => member.entityId)),
]);
}
/** The immutable review catalog used by both seeded generation and the GM readiness screen. */
export function randomizedBiteSizedCreatureCatalog(
source: DungeonDefinition,
): readonly RandomizedBiteSizedCreature[] {
if (source.id !== STORMWIND_STOCKADE_DUNGEON_ID) {
throw new Error(`Randomized Bite-Sized Stockades requires ${STORMWIND_STOCKADE_DUNGEON_ID}.`);
}
const placed = placedEntityIds(source);
const bossEntityIds = new Set(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((candidate) => {
const spawn = source.staticSpawns.find((entry) => entry.id === candidate.sourceSpawnId);
if (!spawn) throw new Error(`Stockades boss source is missing: ${candidate.sourceSpawnId}`);
return spawn.entityId;
}));
return Object.values(source.entities)
.filter((entity) => placed.has(entity.id) && (entity.kind === "mob" || bossEntityIds.has(entity.id)))
.map((entity) => ({
key: `${source.id}:${entity.id}`,
sourceDungeonId: source.id,
sourceDungeonTitle: source.title,
entityId: entity.id,
name: entity.name,
kind: entity.kind,
serverEntry: entity.combat?.serverEntry ?? null,
}))
.sort((left, right) => left.kind.localeCompare(right.kind) || left.name.localeCompare(right.name));
}
/**
* Rerolls every ordinary corridor spawn from the complete placed Stockades mob
* pool. The existing bite-sized builder remains the authority for geometry,
* navigation, sealed wings, and the independently seeded five-boss roll.
*/
export function createRandomizedBiteSizedStockadesDefinition(
source: DungeonDefinition,
seed: string,
): DungeonDefinition {
const base = createBiteSizedStockadesDefinition(source, seed);
const catalog = randomizedBiteSizedCreatureCatalog(source);
const mobCandidates = catalog.filter((candidate) => candidate.kind === "mob");
if (!mobCandidates.length) throw new Error("Randomized Bite-Sized Stockades has no ordinary mob candidates.");
const bossSpawnId = base.bosses[0]?.id;
const randomizedSpawns = base.staticSpawns.map((spawn) => {
if (spawn.id === bossSpawnId) return spawn;
const candidate = mobCandidates[seedHash(`${seed}:${spawn.id}`) % mobCandidates.length];
return { ...spawn, entityId: candidate.entityId };
});
const usedEntityIds = new Set(randomizedSpawns.map((spawn) => spawn.entityId));
const entities = Object.fromEntries(
Object.entries(source.entities).filter(([entityId]) => usedEntityIds.has(entityId)),
) as Readonly<Record<string, PopulationEntityDefinition>>;
return {
...base,
id: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
title: "Randomized Bite-Sized: Cellblock Shuffle",
presentation: {
...base.presentation,
location: "Stormwind Stockade · Shifting Cellblock",
theme: "Randomized Bite-Sized",
summary: "Every enemy slot and the final warden are rerolled for a fast, unpredictable one-boss run.",
loadingMessage: "Shuffling prisoners, guards, and the final warden...",
},
entities,
staticSpawns: randomizedSpawns,
encounters: (base.encounters ?? []).map((encounter) => ({
...encounter,
id: `${RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID}:boss-progression`,
dungeonId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
source: encounter.source ? {
...encounter.source,
files: ["src/game/dungeons/randomizedBiteSizedStockades.ts"],
reviewedBehavior: "Every corridor slot and the final Stockades boss are deterministic rolls from the run seed.",
adaptations: [
...encounter.source.adaptations,
"Each ordinary spawn slot independently selects a placed Stockades mob archetype from the immutable review catalog.",
],
} : encounter.source,
})),
validation: {
...base.validation,
warnings: [
...base.validation.warnings,
"Randomized creature identities are Healer Man-authored and deterministic per run seed.",
],
},
};
}
@@ -2092,6 +2092,55 @@
],
"defaultDifficultyId": "normal"
},
{
"id": "randomized-bite-sized-stormwind-stockade",
"title": "Randomized Bite-Sized: Cellblock Shuffle",
"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": "razorfen-downs",
"title": "Razorfen Downs",
+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(85);
expect(DUNGEON_DEFINITIONS).toHaveLength(86);
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(85);
expect(DUNGEON_DEFINITIONS).toHaveLength(86);
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(31);
expect(enabled).toHaveLength(32);
expect(fallback).toHaveLength(54);
});
+36
View File
@@ -86,6 +86,42 @@ describe("dungeon party composition", () => {
.toEqual(["hunter:marksmanship", "rogue:assassination"]);
});
it("fills only missing roles around every real player in an online five-player party", () => {
const options = {
occupiedHumanRoles: ["healer", "damage"] as const,
excludedPlayerSpecializationIds: ["priest:discipline", "mage:fire"],
allowedCompanionSpecializationIds: [
"warrior:protection",
"priest:discipline",
"hunter:marksmanship",
"mage:fire",
"rogue:assassination",
],
};
const leaderParty = createDungeonParty("shared-activity", 20, "healer", 4, "wow", options);
const memberParty = createDungeonParty("shared-activity", 20, "damage", 4, "wow", options);
expect(memberParty).toEqual(leaderParty);
expect(leaderParty).toHaveLength(3);
expect(leaderParty.map((member) => member.role).sort()).toEqual(["damage", "damage", "tank"]);
expect(leaderParty.every((member) => options.allowedCompanionSpecializationIds.includes(member.specializationId!))).toBe(true);
});
it("uses two damage NPCs when three real players already cover tank, healer, and damage", () => {
const party = createDungeonParty("three-player-activity", 20, "tank", 3, "wow", {
occupiedHumanRoles: ["tank", "healer", "damage"],
allowedCompanionSpecializationIds: [
"warrior:protection",
"priest:discipline",
"hunter:marksmanship",
"mage:fire",
"rogue:assassination",
],
});
expect(party).toHaveLength(2);
expect(party.every((member) => member.role === "damage")).toBe(true);
});
it("keeps RoM generation unchanged when WoW readiness is restrictive", () => {
const baseline = createDungeonParty("rom-readiness", 40, "damage", 5, "rom");
const filtered = createDungeonParty("rom-readiness", 40, "damage", 5, "rom", {
+49 -31
View File
@@ -124,6 +124,10 @@ export interface CompanionPartyGenerationOptions {
readonly readinessPolicyRevision?: number;
readonly includeBetaSpecs?: boolean;
readonly excludedPlayerSpecializationId?: string | null;
readonly excludedPlayerSpecializationIds?: readonly string[];
readonly allowedCompanionSpecializationIds?: readonly string[];
/** All real-player roles in a shared party, including the local player. */
readonly occupiedHumanRoles?: readonly PartyRole[];
}
function companionTemplateFor(specializationId: string): PartyTemplate {
@@ -146,9 +150,18 @@ function wowTemplates(
seats: number,
): readonly PartyTemplate[] {
const policy = options.readinessPolicy ?? DEFAULT_COMPANION_SPEC_READINESS_POLICY;
const definitions = companionSpecializationsForPolicy(policy, options.includeBetaSpecs === true, role);
const withoutPlayer = definitions.filter((definition) => definition.id !== options.excludedPlayerSpecializationId);
const candidates = withoutPlayer.length >= seats ? withoutPlayer : definitions;
const allowed = options.allowedCompanionSpecializationIds
? new Set(options.allowedCompanionSpecializationIds)
: null;
const definitions = allowed
? WOW_COMPANION_SPECIALIZATIONS.filter((definition) => definition.role === role && allowed.has(definition.id))
: companionSpecializationsForPolicy(policy, options.includeBetaSpecs === true, role);
const excluded = new Set([
...(options.excludedPlayerSpecializationIds ?? []),
...(options.excludedPlayerSpecializationId ? [options.excludedPlayerSpecializationId] : []),
]);
const withoutPlayers = definitions.filter((definition) => !excluded.has(definition.id));
const candidates = withoutPlayers.length >= seats ? withoutPlayers : definitions;
return candidates.map((definition) => companionTemplateFor(definition.id));
}
@@ -410,35 +423,40 @@ export function createDungeonParty(
options: CompanionPartyGenerationOptions = {},
): readonly PartyMember[] {
const random = { value: hashSeed(seed || "healer-man") };
const tankTemplates = categoryId === "rom" ? ROM_TANK_TEMPLATES : wowTemplates("tank", options, 1);
const healerTemplates = categoryId === "rom" ? ROM_HEALER_TEMPLATES : wowTemplates("healer", options, 1);
const damageSeats = playerRole === "damage" ? 2 : 3;
const damageTemplates = categoryId === "rom" ? ROM_DAMAGE_TEMPLATES : wowTemplates("damage", options, damageSeats);
const tank = tankTemplates[Math.floor(nextRandom(random) * tankTemplates.length)] ?? tankTemplates[0];
const damagePool = [...damageTemplates];
for (let index = damagePool.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(nextRandom(random) * (index + 1));
[damagePool[index], damagePool[swapIndex]] = [damagePool[swapIndex], damagePool[index]];
const npcSeats = Math.max(0, partySize - 1);
const humanRoleCounts: Record<PartyRole, number> = { tank: 0, healer: 0, damage: 0 };
for (const role of options.occupiedHumanRoles?.length ? options.occupiedHumanRoles : [playerRole]) {
humanRoleCounts[role] += 1;
}
const healer = healerTemplates[Math.floor(nextRandom(random) * healerTemplates.length)]
?? healerTemplates[0];
if (playerRole === "tank") {
return [
createMember(healer, "healer", level, 0, seed),
...damagePool.slice(0, 3).map((template, index) => createMember(template, "damage", level, index + 1, seed)),
].slice(0, Math.max(0, partySize - 1));
}
if (playerRole === "damage") {
return [
createMember(tank, "tank", level, 0, seed),
createMember(healer, "healer", level, 1, seed),
...damagePool.slice(0, 2).map((template, index) => createMember(template, "damage", level, index + 2, seed)),
].slice(0, Math.max(0, partySize - 1));
}
return [
createMember(tank, "tank", level, 0, seed),
...damagePool.slice(0, 3).map((template, index) => createMember(template, "damage", level, index + 1, seed)),
].slice(0, Math.max(0, partySize - 1));
const npcRoles = (["tank", "healer", "damage", "damage", "damage"] as const).filter((role) => {
if (humanRoleCounts[role] <= 0) return true;
humanRoleCounts[role] -= 1;
return false;
}).slice(0, npcSeats);
const seatsByRole: Record<PartyRole, number> = { tank: 0, healer: 0, damage: 0 };
for (const role of npcRoles) seatsByRole[role] += 1;
const sourceForRole = (role: PartyRole): readonly PartyTemplate[] => {
if (categoryId === "rom") {
return role === "tank" ? ROM_TANK_TEMPLATES : role === "healer" ? ROM_HEALER_TEMPLATES : ROM_DAMAGE_TEMPLATES;
}
return wowTemplates(role, options, seatsByRole[role]);
};
const pools = Object.fromEntries((["tank", "healer", "damage"] as const).map((role) => {
const pool = [...sourceForRole(role)];
for (let index = pool.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(nextRandom(random) * (index + 1));
[pool[index], pool[swapIndex]] = [pool[swapIndex], pool[index]];
}
return [role, pool] as const;
})) as Record<PartyRole, PartyTemplate[]>;
const usedByRole: Record<PartyRole, number> = { tank: 0, healer: 0, damage: 0 };
return npcRoles.map((role, index) => {
const pool = pools[role];
if (!pool.length) throw new Error(`No ${role} companion is available for this party.`);
const template = pool[usedByRole[role] % pool.length];
usedByRole[role] += 1;
return createMember(template, role, level, index, seed);
});
}
/** Builds a complete deterministic NPC group for GM spectator simulations. */
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID,
} from "./dungeonContentSource";
import { DUNGEON_REGISTRY, dungeonDefinitionById } from "./dungeonRegistry";
import { activateDungeonSession } from "./dungeonSession";
import { BITE_SIZED_STOCKADES_BOSS_CANDIDATES } from "./dungeons/biteSizedStockades";
import {
createRandomizedBiteSizedStockadesDefinition,
randomizedBiteSizedCreatureCatalog,
} from "./dungeons/randomizedBiteSizedStockades";
const source = DUNGEON_REGISTRY[STORMWIND_STOCKADE_DUNGEON_ID];
describe("Randomized Bite-Sized Stockades", () => {
it("keeps source geometry and both existing Stockades definitions immutable", () => {
const originalSpawns = source.staticSpawns.map((spawn) => ({ ...spawn }));
const randomized = createRandomizedBiteSizedStockadesDefinition(source, "random-layout-a");
expect(randomized.id).toBe(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
expect(randomized.navigationLinks).toEqual(source.navigationLinks);
expect(randomized.traversalRamps).toEqual(source.traversalRamps);
expect(randomized.roamingPacks).toEqual([]);
expect(randomized.serverGameObjects.filter((object) => object.id.startsWith("bite-sized-stockades-"))).toHaveLength(2);
expect(source.staticSpawns).toEqual(originalSpawns);
});
it("rerolls every ordinary slot from the complete placed Stockades mob pool", () => {
const catalog = randomizedBiteSizedCreatureCatalog(source);
const mobIds = catalog.filter((candidate) => candidate.kind === "mob").map((candidate) => candidate.entityId);
const observed = new Set<string>();
for (let index = 0; index < 2_000; index += 1) {
const definition = createRandomizedBiteSizedStockadesDefinition(source, `mob-roll-${index}`);
const bossId = definition.bosses[0].id;
for (const spawn of definition.staticSpawns.filter((candidate) => candidate.id !== bossId)) {
expect(mobIds).toContain(spawn.entityId);
observed.add(spawn.entityId);
}
}
expect([...observed].sort()).toEqual([...mobIds].sort());
});
it("reconstructs the same party lineup from a run id and can select every boss", () => {
const first = createRandomizedBiteSizedStockadesDefinition(source, "shared-run");
const second = createRandomizedBiteSizedStockadesDefinition(source, "shared-run");
expect(second.staticSpawns).toEqual(first.staticSpawns);
expect(second.bosses).toEqual(first.bosses);
const bosses = new Set<string>();
for (let index = 0; index < 1_000; index += 1) {
bosses.add(createRandomizedBiteSizedStockadesDefinition(source, `boss-roll-${index}`).bosses[0].id);
}
expect([...bosses].sort()).toEqual(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((candidate) => candidate.sourceSpawnId).sort());
expect(activateDungeonSession(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "shared-run" })).toBe(true);
expect(dungeonDefinitionById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)?.staticSpawns).toEqual(first.staticSpawns);
});
});
+2 -2
View File
@@ -4,11 +4,11 @@ import { FORSAKEN_ABBEY_DEFINITION } from "./dungeons/forsakenAbbey";
import { WOW_DUNGEON_LIGHTING, withWowDungeonEnvironment, wowDungeonLightingProfile } from "./wowDungeonEnvironment";
import stairs from "./generated/wowDungeonStairs.generated.json";
import stockadesOverride from "../../dungeon-pipeline/runtime-overrides/stormwind-stockade.json";
import { BITE_SIZED_STOCKADES_DUNGEON_ID } from "./dungeonContentSource";
import { isDerivedStockadesDungeonId } from "./dungeonContentSource";
describe("fleet-wide WoW dungeon environment policy", () => {
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
dungeon.expansion !== "runewaker" && dungeon.id !== BITE_SIZED_STOCKADES_DUNGEON_ID
dungeon.expansion !== "runewaker" && !isDerivedStockadesDungeonId(dungeon.id)
));
it("gives every WoW dungeon readable ambient and hemisphere fill without changing fog draw distance or material roles", () => {