Update Healer Man server
This commit is contained in:
1 parent
3e30af4c98
commit
97ee0c13f9
25 files changed
+576
-179
No files matched your search
@@ -12,7 +12,7 @@ describe("derived dungeon content sources", () => {
|
||||
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(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe("activity:randomized-bite-sized");
|
||||
expect(dungeonContentPackId("deadmines")).toBe("dungeon:deadmines");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
export const RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID = "activity:randomized-bite-sized";
|
||||
|
||||
const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.freeze({
|
||||
[BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
|
||||
@@ -18,5 +19,8 @@ export function dungeonContentSourceId(dungeonId: string): string {
|
||||
}
|
||||
|
||||
export function dungeonContentPackId(dungeonId: string): string {
|
||||
if (dungeonId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) {
|
||||
return RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID;
|
||||
}
|
||||
return `dungeon:${dungeonContentSourceId(dungeonId)}`;
|
||||
}
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
dungeonContentSourceId,
|
||||
} from "./dungeonContentSource";
|
||||
import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades";
|
||||
import { createRandomizedBiteSizedStockadesDefinition } from "./dungeons/randomizedBiteSizedStockades";
|
||||
import {
|
||||
createRandomizedBiteSizedStockadesDefinition,
|
||||
randomizedBiteSizedCreatureCatalog,
|
||||
} from "./dungeons/randomizedBiteSizedStockades";
|
||||
|
||||
export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults";
|
||||
|
||||
@@ -201,8 +204,19 @@ const adaptedSourceRegistry = Object.fromEntries(adaptedSourceEntries) as Readon
|
||||
const stockadesDefinition = adaptedSourceRegistry[STORMWIND_STOCKADE_DUNGEON_ID];
|
||||
if (!stockadesDefinition) throw new Error("Stormwind Stockade is required for its bite-sized variant.");
|
||||
|
||||
export const RANDOMIZED_BITE_SIZED_CREATURE_CATALOG = Object.freeze(
|
||||
randomizedBiteSizedCreatureCatalog(adaptedSourceEntries.map(([, definition]) => definition)),
|
||||
);
|
||||
|
||||
export function randomizedBiteSizedDefinitionForSeed(seed: string): DungeonDefinition {
|
||||
return createRandomizedBiteSizedStockadesDefinition(
|
||||
stockadesDefinition,
|
||||
seed,
|
||||
RANDOMIZED_BITE_SIZED_CREATURE_CATALOG,
|
||||
);
|
||||
}
|
||||
const biteSizedStockadesTemplate = createBiteSizedStockadesDefinition(stockadesDefinition, "catalog-template");
|
||||
const randomizedBiteSizedStockadesTemplate = createRandomizedBiteSizedStockadesDefinition(stockadesDefinition, "catalog-template");
|
||||
const randomizedBiteSizedStockadesTemplate = randomizedBiteSizedDefinitionForSeed("catalog-template");
|
||||
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
|
||||
...adaptedSourceEntries,
|
||||
[biteSizedStockadesTemplate.id, biteSizedStockadesTemplate],
|
||||
@@ -244,7 +258,7 @@ export function prepareDungeonSessionDefinition(id: string, seed: string): Dunge
|
||||
}
|
||||
const definition = id === BITE_SIZED_STOCKADES_DUNGEON_ID
|
||||
? createBiteSizedStockadesDefinition(stockadesDefinition, seed)
|
||||
: createRandomizedBiteSizedStockadesDefinition(stockadesDefinition, seed);
|
||||
: randomizedBiteSizedDefinitionForSeed(seed);
|
||||
sessionDungeonDefinitions.set(id, definition);
|
||||
return definition;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
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";
|
||||
import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../dungeonContentSource";
|
||||
import { createBiteSizedStockadesDefinition } from "./biteSizedStockades";
|
||||
|
||||
export type RandomizedCreatureKind = "mob" | "boss";
|
||||
|
||||
@@ -18,7 +12,15 @@ export interface RandomizedBiteSizedCreature {
|
||||
readonly entityId: string;
|
||||
readonly name: string;
|
||||
readonly kind: RandomizedCreatureKind;
|
||||
readonly serverEntry: number | null;
|
||||
readonly serverEntry: number;
|
||||
readonly definition: PopulationEntityDefinition;
|
||||
}
|
||||
|
||||
function creatureKey(source: DungeonDefinition, entity: PopulationEntityDefinition): string | null {
|
||||
const serverEntry = entity.combat?.serverEntry;
|
||||
if (!serverEntry) return null;
|
||||
const catalog = source.expansion === "runewaker" ? "rom" : "wow";
|
||||
return `${catalog}:${entity.kind}:${serverEntry}`;
|
||||
}
|
||||
|
||||
function seedHash(seed: string): number {
|
||||
@@ -30,64 +32,133 @@ function seedHash(seed: string): number {
|
||||
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)),
|
||||
]);
|
||||
function seededRandom(seed: string): () => number {
|
||||
let state = seedHash(seed) || 0x6d2b79f5;
|
||||
return () => {
|
||||
state += 0x6d2b79f5;
|
||||
let value = state;
|
||||
value = Math.imul(value ^ (value >>> 15), value | 1);
|
||||
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 0x1_0000_0000;
|
||||
};
|
||||
}
|
||||
|
||||
/** 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}.`);
|
||||
function seededShuffle<T>(values: readonly T[], seed: string): T[] {
|
||||
const shuffled = [...values];
|
||||
const random = seededRandom(seed);
|
||||
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
||||
const swapIndex = Math.floor(random() * (index + 1));
|
||||
[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]];
|
||||
}
|
||||
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));
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The immutable cross-dungeon review catalog used by seeded generation and the
|
||||
* GM readiness screen. Installed state never limits this pool; selected model
|
||||
* packs are resolved and downloaded from the content manifest before entry.
|
||||
*/
|
||||
export function randomizedBiteSizedCreatureCatalog(
|
||||
sources: readonly DungeonDefinition[],
|
||||
): readonly RandomizedBiteSizedCreature[] {
|
||||
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 [];
|
||||
return [{
|
||||
key,
|
||||
sourceDungeonId: source.id,
|
||||
sourceDungeonTitle: source.title,
|
||||
entityId: entity.id,
|
||||
name: entity.name,
|
||||
kind: entity.kind,
|
||||
serverEntry,
|
||||
definition: entity,
|
||||
} satisfies RandomizedBiteSizedCreature];
|
||||
}))
|
||||
.sort((left, right) => left.kind.localeCompare(right.kind)
|
||||
|| left.name.localeCompare(right.name)
|
||||
|| left.serverEntry - right.serverEntry
|
||||
|| left.sourceDungeonId.localeCompare(right.sourceDungeonId));
|
||||
return [...new Map(candidates.map((candidate) => [candidate.key, candidate])).values()];
|
||||
}
|
||||
|
||||
function dealMobs(
|
||||
source: DungeonDefinition,
|
||||
spawns: readonly { readonly entityId: string }[],
|
||||
candidates: readonly RandomizedBiteSizedCreature[],
|
||||
seed: string,
|
||||
): readonly RandomizedBiteSizedCreature[] {
|
||||
const dealt = seededShuffle(candidates, seed);
|
||||
for (let index = 0; index < spawns.length; index += 1) {
|
||||
const original = source.entities[spawns[index].entityId];
|
||||
const originalKey = original ? creatureKey(source, original) : null;
|
||||
if (dealt[index]?.key !== originalKey) continue;
|
||||
const swapIndex = dealt.findIndex((candidate, candidateIndex) => (
|
||||
candidateIndex > index && candidate.key !== originalKey
|
||||
));
|
||||
if (swapIndex < 0) throw new Error("Randomized Bite-Sized Stockades could not replace an original mob.");
|
||||
[dealt[index], dealt[swapIndex]] = [dealt[swapIndex], dealt[index]];
|
||||
}
|
||||
return dealt.slice(0, spawns.length);
|
||||
}
|
||||
|
||||
function runtimeEntity(candidate: RandomizedBiteSizedCreature): PopulationEntityDefinition {
|
||||
// Boss source ids remain intact so their existing loot table and collection
|
||||
// card can still resolve. Trash ids are namespaced away from the location.
|
||||
const id = candidate.kind === "boss"
|
||||
? candidate.entityId
|
||||
: `randomized:${candidate.key}`;
|
||||
const combat = candidate.definition.combat ? {
|
||||
...candidate.definition.combat,
|
||||
// The identity and attacks are randomized, while durability and pressure
|
||||
// remain normalized to this short leveling dungeon's current party level.
|
||||
healthMultiplier: 1,
|
||||
damageMultiplier: 1,
|
||||
} : undefined;
|
||||
return { ...candidate.definition, id, ...(combat ? { combat } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the reviewed Stockades geometry and navigation, then deals a different
|
||||
* cross-dungeon creature to every corridor slot and rolls the boss separately.
|
||||
*/
|
||||
export function createRandomizedBiteSizedStockadesDefinition(
|
||||
source: DungeonDefinition,
|
||||
seed: string,
|
||||
catalog: readonly RandomizedBiteSizedCreature[],
|
||||
): 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 base = createBiteSizedStockadesDefinition(source, `${seed}:objective-slot`);
|
||||
const bossSpawnId = base.bosses[0]?.id;
|
||||
if (!bossSpawnId) throw new Error("Randomized Bite-Sized Stockades has no final boss slot.");
|
||||
const trashSpawns = base.staticSpawns.filter((spawn) => spawn.id !== bossSpawnId);
|
||||
const mobPool = catalog.filter((candidate) => candidate.kind === "mob");
|
||||
if (mobPool.length < trashSpawns.length) {
|
||||
throw new Error(`Randomized Bite-Sized Stockades needs ${trashSpawns.length} distinct mob candidates.`);
|
||||
}
|
||||
const mobCandidates = dealMobs(source, trashSpawns, mobPool, `${seed}:trash`);
|
||||
const originalBossSpawn = source.staticSpawns.find((spawn) => spawn.id === bossSpawnId);
|
||||
const originalBoss = originalBossSpawn ? source.entities[originalBossSpawn.entityId] : undefined;
|
||||
const originalBossKey = originalBoss ? creatureKey(source, originalBoss) : null;
|
||||
const bossCandidate = seededShuffle(
|
||||
catalog.filter((candidate) => candidate.kind === "boss"),
|
||||
`${seed}:boss`,
|
||||
).find((candidate) => candidate.key !== originalBossKey);
|
||||
if (!bossCandidate) throw new Error("Randomized Bite-Sized Stockades has no boss candidates.");
|
||||
|
||||
const selectedMobs = new Map(trashSpawns.map((spawn, index) => [spawn.id, mobCandidates[index]]));
|
||||
const bossEntity = runtimeEntity(bossCandidate);
|
||||
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 candidate = selectedMobs.get(spawn.id);
|
||||
return { ...spawn, entityId: candidate ? runtimeEntity(candidate).id : bossEntity.id };
|
||||
});
|
||||
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>>;
|
||||
const selectedCandidates = [...selectedMobs.values(), bossCandidate];
|
||||
const entities = Object.fromEntries(selectedCandidates.map((candidate) => {
|
||||
const entity = runtimeEntity(candidate);
|
||||
return [entity.id, entity];
|
||||
})) as Readonly<Record<string, PopulationEntityDefinition>>;
|
||||
const boss = { ...base.bosses[0], name: bossCandidate.name };
|
||||
|
||||
return {
|
||||
...base,
|
||||
@@ -97,11 +168,16 @@ export function createRandomizedBiteSizedStockadesDefinition(
|
||||
...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...",
|
||||
summary: "Every enemy slot is dealt a distinct cross-dungeon creature, followed by an independently randomized boss.",
|
||||
loadingMessage: "Choosing a location and shuffling every creature slot...",
|
||||
},
|
||||
areas: base.areas.map((area) => area.id === "bite-sized-boss-room"
|
||||
? { ...area, name: `${bossCandidate.name}'s Cell` }
|
||||
: area),
|
||||
entities,
|
||||
staticSpawns: randomizedSpawns,
|
||||
bosses: [boss],
|
||||
objectiveOrder: [boss],
|
||||
encounters: (base.encounters ?? []).map((encounter) => ({
|
||||
...encounter,
|
||||
id: `${RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID}:boss-progression`,
|
||||
@@ -109,10 +185,12 @@ export function createRandomizedBiteSizedStockadesDefinition(
|
||||
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.",
|
||||
reviewedBehavior: "Every corridor slot receives a distinct seeded creature and the final boss uses an independent seeded roll.",
|
||||
adaptations: [
|
||||
...encounter.source.adaptations,
|
||||
"Each ordinary spawn slot independently selects a placed Stockades mob archetype from the immutable review catalog.",
|
||||
"The trash shuffle deals without replacement from the complete model-backed cross-dungeon mob catalog, so one run cannot repeat a mob identity.",
|
||||
"The final boss is rolled independently from the complete model-backed cross-dungeon boss catalog while retaining the location's stable completion objective.",
|
||||
"Randomized combat multipliers are normalized before party-level scaling so source-dungeon tuning cannot overwhelm a short leveling run.",
|
||||
],
|
||||
} : encounter.source,
|
||||
})),
|
||||
|
||||
@@ -3,20 +3,32 @@ 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";
|
||||
DUNGEON_REGISTRY,
|
||||
RANDOMIZED_BITE_SIZED_CREATURE_CATALOG,
|
||||
dungeonDefinitionById,
|
||||
} from "./dungeonRegistry";
|
||||
import { activateDungeonSession } from "./dungeonSession";
|
||||
import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades";
|
||||
import { createRandomizedBiteSizedStockadesDefinition } from "./dungeons/randomizedBiteSizedStockades";
|
||||
|
||||
const source = DUNGEON_REGISTRY[STORMWIND_STOCKADE_DUNGEON_ID];
|
||||
const catalog = RANDOMIZED_BITE_SIZED_CREATURE_CATALOG;
|
||||
|
||||
function create(seed: string) {
|
||||
return createRandomizedBiteSizedStockadesDefinition(source, seed, catalog);
|
||||
}
|
||||
|
||||
function bossEntityServerEntry(definition: ReturnType<typeof create>): number | 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;
|
||||
}
|
||||
|
||||
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");
|
||||
const randomized = create("random-layout-a");
|
||||
|
||||
expect(randomized.id).toBe(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
|
||||
expect(randomized.navigationLinks).toEqual(source.navigationLinks);
|
||||
@@ -26,34 +38,85 @@ describe("Randomized Bite-Sized Stockades", () => {
|
||||
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("uses the complete model-backed cross-dungeon catalog regardless of installed packs", () => {
|
||||
const mobs = catalog.filter((candidate) => candidate.kind === "mob");
|
||||
const bosses = catalog.filter((candidate) => candidate.kind === "boss");
|
||||
|
||||
expect(mobs.length).toBeGreaterThan(500);
|
||||
expect(bosses.length).toBeGreaterThan(100);
|
||||
expect(new Set(catalog.map((candidate) => candidate.sourceDungeonId)).size).toBeGreaterThan(50);
|
||||
expect(catalog.some((candidate) => candidate.definition.visual.model?.url.startsWith("/assets/creatures/wailing-caverns/"))).toBe(true);
|
||||
expect(catalog.some((candidate) => candidate.definition.visual.model?.url.startsWith("/assets/game/creatures/shared/"))).toBe(true);
|
||||
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.every((candidate) => /^(wow|rom):(mob|boss):[1-9][0-9]*$/.test(candidate.key))).toBe(true);
|
||||
});
|
||||
|
||||
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");
|
||||
it("replaces every corridor spawn and deals distinct mobs without replacement", () => {
|
||||
const randomized = create("every-slot-is-random");
|
||||
const bossId = randomized.bosses[0].id;
|
||||
const trash = randomized.staticSpawns.filter((spawn) => spawn.id !== bossId);
|
||||
const selectedIds = trash.map((spawn) => spawn.entityId);
|
||||
const base = createBiteSizedStockadesDefinition(source, "every-slot-is-random:objective-slot");
|
||||
|
||||
expect(trash).toHaveLength(12);
|
||||
expect(new Set(selectedIds).size).toBe(trash.length);
|
||||
expect(selectedIds.every((entityId) => /^randomized:(wow|rom):mob:/.test(entityId))).toBe(true);
|
||||
for (const spawn of trash) {
|
||||
const entity = randomized.entities[spawn.entityId];
|
||||
const originalSpawn = base.staticSpawns.find((candidate) => candidate.id === spawn.id)!;
|
||||
const original = base.entities[originalSpawn.entityId];
|
||||
expect(entity.kind).toBe("mob");
|
||||
expect(entity.combat?.serverEntry).not.toBe(original.combat?.serverEntry);
|
||||
expect(entity.combat).toMatchObject({
|
||||
healthMultiplier: 1,
|
||||
damageMultiplier: 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("uses independent deterministic trash and boss rolls across run ids", () => {
|
||||
const first = create("shared-run");
|
||||
const second = create("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);
|
||||
const bossEntries = new Set(catalog.filter((candidate) => candidate.kind === "boss").map((candidate) => candidate.serverEntry));
|
||||
const observedBossEntries = new Set<number>();
|
||||
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 bossId = definition.bosses[0].id;
|
||||
observedTrashLineups.add(definition.staticSpawns
|
||||
.filter((spawn) => spawn.id !== bossId)
|
||||
.map((spawn) => spawn.entityId)
|
||||
.join("|"));
|
||||
}
|
||||
expect([...bosses].sort()).toEqual(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((candidate) => candidate.sourceSpawnId).sort());
|
||||
expect([...observedBossEntries].every((entry) => bossEntries.has(entry))).toBe(true);
|
||||
expect(observedBossEntries.size).toBeGreaterThan(100);
|
||||
expect(observedTrashLineups.size).toBeGreaterThan(1_900);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("keeps the randomized boss on the location's stable completion objective", () => {
|
||||
const randomized = create("random-boss-objective");
|
||||
const boss = randomized.bosses[0];
|
||||
const spawn = randomized.staticSpawns.find((candidate) => candidate.id === boss.id)!;
|
||||
const entity = randomized.entities[spawn.entityId];
|
||||
const base = createBiteSizedStockadesDefinition(source, "random-boss-objective:objective-slot");
|
||||
const originalSpawn = base.staticSpawns.find((candidate) => candidate.id === boss.id)!;
|
||||
const originalBoss = base.entities[originalSpawn.entityId];
|
||||
|
||||
expect(entity.kind).toBe("boss");
|
||||
expect(boss.name).toBe(entity.name);
|
||||
expect(catalog.some((candidate) => candidate.kind === "boss" && candidate.entityId === spawn.entityId)).toBe(true);
|
||||
expect(entity.combat?.serverEntry).not.toBe(originalBoss.combat?.serverEntry);
|
||||
expect(randomized.encounters?.[0].rules[0].trigger).toEqual({ kind: "entities-dead", entityIds: [boss.id] });
|
||||
expect(entity.combat).toMatchObject({ healthMultiplier: 1, damageMultiplier: 1 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user