Update Healer Man server
This commit is contained in:
1 parent
97ee0c13f9
commit
f864b5aea7
20 files changed
+468
-211
No files matched your search
@@ -5,6 +5,8 @@ import {
|
||||
dungeonById,
|
||||
dungeonsByCategory,
|
||||
randomDungeonByCategory,
|
||||
randomizedBiteSizedDungeons,
|
||||
randomRandomizedBiteSizedDungeon,
|
||||
} from "./dungeonCatalog";
|
||||
import { DUNGEON_MANIFEST } from "./dungeonManifest";
|
||||
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated";
|
||||
@@ -59,6 +61,13 @@ describe("dungeon catalog", () => {
|
||||
.not.toBe("stormwind-stockade");
|
||||
});
|
||||
|
||||
it("keeps randomized replay in the separate randomized Bite-Sized location pool", () => {
|
||||
expect(randomizedBiteSizedDungeons().map((dungeon) => dungeon.id))
|
||||
.toEqual(["randomized-bite-sized-stormwind-stockade"]);
|
||||
expect(randomRandomizedBiteSizedDungeon("randomized-bite-sized-stormwind-stockade", () => 0)?.id)
|
||||
.toBe("randomized-bite-sized-stormwind-stockade");
|
||||
});
|
||||
|
||||
it("keeps stable unique ids and resolves only catalogued dungeons", () => {
|
||||
const ids = DUNGEON_CATALOG.map((dungeon) => dungeon.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { contentCategoryById, type ContentCategoryId } from "../app/contentCateg
|
||||
import { compareDungeonReleaseOrder, dungeonReleaseStage, type DungeonReleaseStage } from "./dungeonReleaseStage";
|
||||
import {
|
||||
BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
isRandomizedBiteSizedDungeonId,
|
||||
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
} from "./dungeonContentSource";
|
||||
|
||||
@@ -66,10 +67,32 @@ export function dungeonById(id: string): DungeonCatalogEntry | null {
|
||||
|
||||
export function dungeonsByCategory(category: DungeonCategoryId): readonly DungeonCatalogEntry[] {
|
||||
return DUNGEON_CATALOG.filter((dungeon) => (
|
||||
dungeon.category === category && dungeon.id !== RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID
|
||||
dungeon.category === category && !isRandomizedBiteSizedDungeonId(dungeon.id)
|
||||
));
|
||||
}
|
||||
|
||||
/** The separately launched randomized mode, including every eligible location added later. */
|
||||
export function randomizedBiteSizedDungeons(): readonly DungeonCatalogEntry[] {
|
||||
return DUNGEON_CATALOG.filter((dungeon) => (
|
||||
dungeon.category === "bite-sized" && isRandomizedBiteSizedDungeonId(dungeon.id)
|
||||
));
|
||||
}
|
||||
|
||||
function randomAvailableDungeon(
|
||||
dungeons: readonly DungeonCatalogEntry[],
|
||||
currentDungeonId: string | undefined,
|
||||
random: () => number,
|
||||
eligible: (dungeon: DungeonCatalogEntry) => boolean,
|
||||
): DungeonCatalogEntry | null {
|
||||
const available = dungeons.filter((dungeon) => dungeon.available && eligible(dungeon));
|
||||
const candidates = available.length > 1
|
||||
? available.filter((dungeon) => dungeon.id !== currentDungeonId)
|
||||
: available;
|
||||
if (candidates.length === 0) return null;
|
||||
const roll = Math.max(0, Math.min(0.9999999999999999, random()));
|
||||
return candidates[Math.floor(roll * candidates.length)] ?? candidates[0];
|
||||
}
|
||||
|
||||
/** Selects from enterable category content and avoids an immediate repeat when alternatives exist. */
|
||||
export function randomDungeonByCategory(
|
||||
category: DungeonCategoryId,
|
||||
@@ -77,11 +100,14 @@ export function randomDungeonByCategory(
|
||||
random: () => number = Math.random,
|
||||
eligible: (dungeon: DungeonCatalogEntry) => boolean = () => true,
|
||||
): DungeonCatalogEntry | null {
|
||||
const available = dungeonsByCategory(category).filter((dungeon) => dungeon.available && eligible(dungeon));
|
||||
const candidates = available.length > 1
|
||||
? available.filter((dungeon) => dungeon.id !== currentDungeonId)
|
||||
: available;
|
||||
if (candidates.length === 0) return null;
|
||||
const roll = Math.max(0, Math.min(0.9999999999999999, random()));
|
||||
return candidates[Math.floor(roll * candidates.length)] ?? candidates[0];
|
||||
return randomAvailableDungeon(dungeonsByCategory(category), currentDungeonId, random, eligible);
|
||||
}
|
||||
|
||||
/** Selects the next location without crossing from randomized mode into authored Bite-Sized mode. */
|
||||
export function randomRandomizedBiteSizedDungeon(
|
||||
currentDungeonId?: string,
|
||||
random: () => number = Math.random,
|
||||
eligible: (dungeon: DungeonCatalogEntry) => boolean = () => true,
|
||||
): DungeonCatalogEntry | null {
|
||||
return randomAvailableDungeon(randomizedBiteSizedDungeons(), currentDungeonId, random, eligible);
|
||||
}
|
||||
@@ -3,6 +3,11 @@ export const BITE_SIZED_STOCKADES_DUNGEON_ID = "bite-sized-stormwind-stockade";
|
||||
export const RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID = "randomized-bite-sized-stormwind-stockade";
|
||||
export const RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID = "activity:randomized-bite-sized";
|
||||
|
||||
/** Randomized Bite-Sized locations share this id namespace as the mode expands. */
|
||||
export function isRandomizedBiteSizedDungeonId(dungeonId: string): boolean {
|
||||
return dungeonId.startsWith("randomized-bite-sized-");
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -12,15 +12,24 @@ export interface RandomizedBiteSizedCreature {
|
||||
readonly entityId: string;
|
||||
readonly name: string;
|
||||
readonly kind: RandomizedCreatureKind;
|
||||
readonly serverEntry: number;
|
||||
/** Source-catalog identity; RuneWaker entries never leak into AzerothCore combat ids. */
|
||||
readonly sourceEntry: number;
|
||||
readonly definition: PopulationEntityDefinition;
|
||||
}
|
||||
|
||||
function creatureSourceEntry(source: DungeonDefinition, entity: PopulationEntityDefinition): number | null {
|
||||
if (source.expansion !== "runewaker") return entity.combat?.serverEntry ?? null;
|
||||
const encodedEntry = /-([1-9][0-9]*)$/.exec(entity.id)?.[1];
|
||||
if (!encodedEntry) return null;
|
||||
const sourceEntry = Number(encodedEntry);
|
||||
return Number.isSafeInteger(sourceEntry) ? sourceEntry : null;
|
||||
}
|
||||
|
||||
function creatureKey(source: DungeonDefinition, entity: PopulationEntityDefinition): string | null {
|
||||
const serverEntry = entity.combat?.serverEntry;
|
||||
if (!serverEntry) return null;
|
||||
const sourceEntry = creatureSourceEntry(source, entity);
|
||||
if (!sourceEntry) return null;
|
||||
const catalog = source.expansion === "runewaker" ? "rom" : "wow";
|
||||
return `${catalog}:${entity.kind}:${serverEntry}`;
|
||||
return `${catalog}:${entity.kind}:${sourceEntry}`;
|
||||
}
|
||||
|
||||
function seedHash(seed: string): number {
|
||||
@@ -64,8 +73,8 @@ export function randomizedBiteSizedCreatureCatalog(
|
||||
const candidates = sources
|
||||
.flatMap((source) => Object.values(source.entities).flatMap((entity) => {
|
||||
const key = creatureKey(source, entity);
|
||||
const serverEntry = entity.combat?.serverEntry;
|
||||
if (!key || !serverEntry || !entity.visual.model?.url) return [];
|
||||
const sourceEntry = creatureSourceEntry(source, entity);
|
||||
if (!key || !sourceEntry || !entity.visual.model?.url) return [];
|
||||
return [{
|
||||
key,
|
||||
sourceDungeonId: source.id,
|
||||
@@ -73,13 +82,13 @@ export function randomizedBiteSizedCreatureCatalog(
|
||||
entityId: entity.id,
|
||||
name: entity.name,
|
||||
kind: entity.kind,
|
||||
serverEntry,
|
||||
sourceEntry,
|
||||
definition: entity,
|
||||
} satisfies RandomizedBiteSizedCreature];
|
||||
}))
|
||||
.sort((left, right) => left.kind.localeCompare(right.kind)
|
||||
|| left.name.localeCompare(right.name)
|
||||
|| left.serverEntry - right.serverEntry
|
||||
|| left.sourceEntry - right.sourceEntry
|
||||
|| left.sourceDungeonId.localeCompare(right.sourceDungeonId));
|
||||
return [...new Map(candidates.map((candidate) => [candidate.key, candidate])).values()];
|
||||
}
|
||||
|
||||
@@ -19,10 +19,10 @@ function create(seed: string) {
|
||||
return createRandomizedBiteSizedStockadesDefinition(source, seed, catalog);
|
||||
}
|
||||
|
||||
function bossEntityServerEntry(definition: ReturnType<typeof create>): number | undefined {
|
||||
function bossEntityId(definition: ReturnType<typeof create>): string | undefined {
|
||||
const boss = definition.bosses[0];
|
||||
const spawn = definition.staticSpawns.find((candidate) => candidate.id === boss.id);
|
||||
return spawn ? definition.entities[spawn.entityId]?.combat?.serverEntry : undefined;
|
||||
return spawn?.entityId;
|
||||
}
|
||||
|
||||
describe("Randomized Bite-Sized Stockades", () => {
|
||||
@@ -50,7 +50,13 @@ describe("Randomized Bite-Sized Stockades", () => {
|
||||
expect(catalog.some((candidate) => candidate.definition.visual.model?.url.startsWith("/assets/game/dungeons/")
|
||||
&& candidate.sourceDungeonId !== STORMWIND_STOCKADE_DUNGEON_ID)).toBe(true);
|
||||
expect(catalog.some((candidate) => candidate.sourceDungeonId === STORMWIND_STOCKADE_DUNGEON_ID)).toBe(true);
|
||||
expect(catalog.some((candidate) => candidate.key.startsWith("wow:"))).toBe(true);
|
||||
expect(catalog.some((candidate) => candidate.key.startsWith("rom:"))).toBe(true);
|
||||
expect(catalog.filter((candidate) => candidate.key.startsWith("rom:")).length).toBeGreaterThan(400);
|
||||
expect(catalog.every((candidate) => /^(wow|rom):(mob|boss):[1-9][0-9]*$/.test(candidate.key))).toBe(true);
|
||||
const romCandidate = catalog.find((candidate) => candidate.key.startsWith("rom:"))!;
|
||||
expect(romCandidate.sourceEntry).toBeGreaterThan(0);
|
||||
expect(romCandidate.definition.combat).not.toHaveProperty("serverEntry");
|
||||
});
|
||||
|
||||
it("replaces every corridor spawn and deals distinct mobs without replacement", () => {
|
||||
@@ -82,21 +88,21 @@ describe("Randomized Bite-Sized Stockades", () => {
|
||||
expect(second.staticSpawns).toEqual(first.staticSpawns);
|
||||
expect(second.bosses).toEqual(first.bosses);
|
||||
|
||||
const bossEntries = new Set(catalog.filter((candidate) => candidate.kind === "boss").map((candidate) => candidate.serverEntry));
|
||||
const observedBossEntries = new Set<number>();
|
||||
const bossEntityIds = new Set(catalog.filter((candidate) => candidate.kind === "boss").map((candidate) => candidate.entityId));
|
||||
const observedBossEntityIds = new Set<string>();
|
||||
const observedTrashLineups = new Set<string>();
|
||||
for (let index = 0; index < 2_000; index += 1) {
|
||||
const definition = create(`roll-${index}`);
|
||||
const entry = bossEntityServerEntry(definition);
|
||||
if (entry) observedBossEntries.add(entry);
|
||||
const entityId = bossEntityId(definition);
|
||||
if (entityId) observedBossEntityIds.add(entityId);
|
||||
const bossId = definition.bosses[0].id;
|
||||
observedTrashLineups.add(definition.staticSpawns
|
||||
.filter((spawn) => spawn.id !== bossId)
|
||||
.map((spawn) => spawn.entityId)
|
||||
.join("|"));
|
||||
}
|
||||
expect([...observedBossEntries].every((entry) => bossEntries.has(entry))).toBe(true);
|
||||
expect(observedBossEntries.size).toBeGreaterThan(100);
|
||||
expect([...observedBossEntityIds].every((entityId) => bossEntityIds.has(entityId))).toBe(true);
|
||||
expect(observedBossEntityIds.size).toBeGreaterThan(100);
|
||||
expect(observedTrashLineups.size).toBeGreaterThan(1_900);
|
||||
|
||||
expect(activateDungeonSession(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "shared-run" })).toBe(true);
|
||||
|
||||
Reference in new issue
Block a user