Update Healer Man server

This commit is contained in:
phenom committed 2026-09-20 18:08:43 -04:00
1 parent 3e30af4c98
commit 97ee0c13f9
25 files changed
+576 -179

No files matched your search

+135 -57
View File
@@ -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,
})),