import type { EnemyCombatDefinition } from "./enemyAttacks"; export type MobVector3 = readonly [x: number, y: number, z: number]; export type MutableMobVector3 = [x: number, y: number, z: number]; export type MobArchetype = | "humanoid" | "raptor" | "crocolisk" | "ooze" | "plant" | "serpent" | "turtle" | "lizard" | "murloc" | "winged"; export interface MobModelStyle { readonly url: string; readonly rotationY: number; readonly groundOffset: number; readonly labelHeight: number; readonly markerRadius?: number; /** Static source models use the renderer's archetype gait instead of native clips. */ readonly animationMode?: "native" | "procedural"; } export interface MobVisualStyle { readonly primaryColor: string; readonly accentColor: string; readonly scale: number; readonly archetype: MobArchetype; readonly model?: MobModelStyle; } interface PopulationEntityBase { readonly id: string; readonly name: string; readonly combat?: EnemyCombatDefinition; /** Optional override; bosses normally derive lootability from the SQL catalog. */ readonly hasLoot?: boolean; readonly visual: MobVisualStyle; } export interface MobDefinition extends PopulationEntityBase { readonly kind: "mob"; } export interface BossDefinition extends PopulationEntityBase { readonly kind: "boss"; readonly title: string; } export type PopulationEntityDefinition = MobDefinition | BossDefinition; export type PopulationDefinitionMap = Readonly>; /** Formation values are lateral offset, vertical offset, and trailing distance. */ export type PackFormationOffset = readonly [lateral: number, vertical: number, trailing: number]; export interface MobPackMemberDefinition { readonly id: string; readonly entityId: string; readonly role: "leader" | "follower"; readonly formationOffset: PackFormationOffset; } export interface MobPackDefinition { readonly id: string; readonly name: string; readonly speed: number; readonly startDistance?: number; readonly pathId?: number; readonly formationSource: "database-solo" | "inferred-followers"; /** Trinity-style difficulty mask. Omitted legacy fixtures default to normal (1). */ readonly spawnMask?: number; /** Per-instance survival chance after source initialization scripts run. */ readonly spawnChance?: number; readonly waypoints: readonly MobVector3[]; readonly members: readonly MobPackMemberDefinition[]; } export interface StaticMobSpawnDefinition { readonly id: string; readonly entityId: string; readonly position: MobVector3; readonly yaw?: number; /** Trinity-style difficulty mask. Omitted legacy fixtures default to normal (1). */ readonly spawnMask?: number; /** Per-instance survival chance after source initialization scripts run. */ readonly spawnChance?: number; } export interface StaticMobSpawnCluster { readonly id: string; readonly points: readonly MobVector3[]; readonly spawns: readonly StaticMobSpawnDefinition[]; } export interface NormalizedMobPopulationPaths { readonly roamingPacks: readonly MobPackDefinition[]; readonly staticSpawns: readonly StaticMobSpawnDefinition[]; } /** * Partitions large imported populations into bounded activation cells. Actors * in distant cells are not mounted or registered with combat until approached. */ export function clusterStaticMobSpawns( spawns: readonly StaticMobSpawnDefinition[], horizontalSize = 72, verticalSize = 36, ): readonly StaticMobSpawnCluster[] { if (horizontalSize <= 0 || verticalSize <= 0) { throw new Error("Static population cluster dimensions must be positive."); } const clusters = new Map(); for (const spawn of spawns) { const cell = [ Math.floor(spawn.position[0] / horizontalSize), Math.floor(spawn.position[1] / verticalSize), Math.floor(spawn.position[2] / horizontalSize), ]; const id = cell.join(":"); const members = clusters.get(id) ?? []; members.push(spawn); clusters.set(id, members); } return Object.freeze([...clusters.entries()].map(([id, members]) => Object.freeze({ id, spawns: Object.freeze(members), points: Object.freeze(members.map((spawn) => spawn.position)), }))); } export function spawnMatchesDifficulty(spawnMask: number | undefined, difficultyMask: number): boolean { return ((spawnMask ?? 1) & difficultyMask) !== 0; } export interface LoopedPathSegment { readonly startIndex: number; readonly endIndex: number; readonly startDistance: number; readonly length: number; } export interface LoopedPath { readonly waypoints: readonly MobVector3[]; readonly segments: readonly LoopedPathSegment[]; readonly totalLength: number; } const EPSILON = 1e-6; export function hasUsableLoopedMobPath(waypoints: readonly MobVector3[]): boolean { if (waypoints.length < 2 || !waypoints.every((point) => point.every(Number.isFinite))) { return false; } const origin = waypoints[0]; return waypoints.some((point) => Math.hypot( point[0] - origin[0], point[1] - origin[1], point[2] - origin[2], ) > EPSILON); } /** * Imported databases occasionally label a stationary spawn as a patrol by * repeating one waypoint. Keep those actors, but mount them through the static * path so malformed content can never take down the scene. */ export function normalizeMobPopulationPaths( roamingPacks: readonly MobPackDefinition[], staticSpawns: readonly StaticMobSpawnDefinition[], ): NormalizedMobPopulationPaths { const invalidPacks = roamingPacks.filter((pack) => !hasUsableLoopedMobPath(pack.waypoints)); if (!invalidPacks.length) return { roamingPacks, staticSpawns }; const invalidIds = new Set(invalidPacks.map((pack) => pack.id)); const stationarySpawns = invalidPacks.flatMap((pack) => { const anchor = pack.waypoints.find((point) => point.every(Number.isFinite)); if (!anchor) return []; return pack.members.map((member): StaticMobSpawnDefinition => ({ id: `${pack.id}:${member.id}`, entityId: member.entityId, position: [ anchor[0] + member.formationOffset[0], anchor[1] + member.formationOffset[1], anchor[2] - member.formationOffset[2], ], spawnMask: pack.spawnMask, spawnChance: pack.spawnChance, })); }); return { roamingPacks: roamingPacks.filter((pack) => !invalidIds.has(pack.id)), staticSpawns: [...staticSpawns, ...stationarySpawns], }; } function assertFiniteWaypoint(point: MobVector3, index: number): void { if (!point.every(Number.isFinite)) { throw new Error(`Waypoint ${index} contains a non-finite coordinate.`); } } /** Precomputes immutable segment lengths once so frame sampling stays allocation-free. */ export function createLoopedPath(waypoints: readonly MobVector3[]): LoopedPath { if (waypoints.length < 2) { throw new Error("A looped mob path requires at least two waypoints."); } waypoints.forEach(assertFiniteWaypoint); const segments: LoopedPathSegment[] = []; let totalLength = 0; for (let startIndex = 0; startIndex < waypoints.length; startIndex += 1) { const endIndex = (startIndex + 1) % waypoints.length; const start = waypoints[startIndex]; const end = waypoints[endIndex]; const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2]); if (length <= EPSILON) continue; segments.push({ startIndex, endIndex, startDistance: totalLength, length }); totalLength += length; } if (totalLength <= EPSILON) { throw new Error("A looped mob path requires at least two distinct waypoints."); } return { waypoints, segments, totalLength }; } function wrapDistance(distance: number, totalLength: number): number { const wrapped = distance % totalLength; return wrapped < 0 ? wrapped + totalLength : wrapped; } /** * Samples a closed path into caller-owned tuples. Returns the sampled segment index. * Supplying the output tuples avoids transient Vector3 or array allocations in useFrame. */ export function sampleLoopedPath( path: LoopedPath, distance: number, outPosition: MutableMobVector3, outForward: MutableMobVector3, ): number { const loopDistance = wrapDistance(distance, path.totalLength); let segmentIndex = path.segments.length - 1; for (let index = 0; index < path.segments.length; index += 1) { const segment = path.segments[index]; if (loopDistance < segment.startDistance + segment.length) { segmentIndex = index; break; } } const segment = path.segments[segmentIndex]; const start = path.waypoints[segment.startIndex]; const end = path.waypoints[segment.endIndex]; const segmentDistance = loopDistance - segment.startDistance; const alpha = segmentDistance / segment.length; outPosition[0] = start[0] + (end[0] - start[0]) * alpha; outPosition[1] = start[1] + (end[1] - start[1]) * alpha; outPosition[2] = start[2] + (end[2] - start[2]) * alpha; outForward[0] = (end[0] - start[0]) / segment.length; outForward[1] = (end[1] - start[1]) / segment.length; outForward[2] = (end[2] - start[2]) / segment.length; return segmentIndex; } /** Samples a leader or follower, keeping trailing members on the path around corners. */ export function samplePackMember( path: LoopedPath, leaderDistance: number, formationOffset: PackFormationOffset, outPosition: MutableMobVector3, outForward: MutableMobVector3, ): void { const [lateral, vertical, trailing] = formationOffset; sampleLoopedPath(path, leaderDistance - trailing, outPosition, outForward); const horizontalLength = Math.hypot(outForward[0], outForward[2]); const rightX = horizontalLength > EPSILON ? outForward[2] / horizontalLength : 1; const rightZ = horizontalLength > EPSILON ? -outForward[0] / horizontalLength : 0; outPosition[0] += rightX * lateral; outPosition[1] += vertical; outPosition[2] += rightZ * lateral; } /** Samples every member into stable caller-owned buffers for render and proximity checks. */ export function samplePackFormation( path: LoopedPath, leaderDistance: number, members: readonly MobPackMemberDefinition[], outPositions: readonly MutableMobVector3[], outForwards: readonly MutableMobVector3[], ): void { if (outPositions.length < members.length || outForwards.length < members.length) { throw new Error("Pack formation output buffers must include every member."); } for (let index = 0; index < members.length; index += 1) { samplePackMember( path, leaderDistance, members[index].formationOffset, outPositions[index], outForwards[index], ); } } export const WAILING_CAVERNS_ENTITIES = { "deviate-guardian": { id: "deviate-guardian", kind: "mob", name: "Deviate Guardian", visual: { primaryColor: "#356f4f", accentColor: "#8fcf70", scale: 1, archetype: "raptor", model: { url: "/assets/creatures/wailing-caverns/deviate-guardian-animated.glb", rotationY: -Math.PI / 2, groundOffset: 0.025, labelHeight: 3.6, }, }, }, "druid-of-the-fang": { id: "druid-of-the-fang", kind: "mob", name: "Druid of the Fang", visual: { primaryColor: "#6b4f86", accentColor: "#b9d46a", scale: 1, archetype: "humanoid", model: { url: "/assets/creatures/wailing-caverns/druid-of-the-fang-animated.glb", rotationY: -Math.PI / 2, groundOffset: 0.015, labelHeight: 3.15, }, }, }, "deviate-ravager": { id: "deviate-ravager", kind: "mob", name: "Deviate Ravager", visual: { primaryColor: "#8a472f", accentColor: "#e0a45b", scale: 1.08, archetype: "raptor", model: { url: "/assets/creatures/wailing-caverns/deviate-ravager-animated.glb", rotationY: -Math.PI / 2, groundOffset: 0.02, labelHeight: 3.2, }, }, }, "deviate-crocolisk": { id: "deviate-crocolisk", kind: "mob", name: "Deviate Crocolisk", visual: { primaryColor: "#375f43", accentColor: "#8fb968", scale: 1.15, archetype: "crocolisk", model: { url: "/assets/creatures/wailing-caverns/deviate-crocolisk-animated.glb", rotationY: -Math.PI / 2, groundOffset: -0.005, labelHeight: 1.35, }, }, }, "evolving-ectoplasm": { id: "evolving-ectoplasm", kind: "mob", name: "Evolving Ectoplasm", visual: { primaryColor: "#467d70", accentColor: "#8fe2c2", scale: 0.92, archetype: "ooze", model: { url: "/assets/creatures/wailing-caverns/evolving-ectoplasm-animated.glb", rotationY: -Math.PI / 2, groundOffset: 0.607, labelHeight: 2.45, }, }, }, "deviate-shambler": { id: "deviate-shambler", kind: "mob", name: "Deviate Shambler", visual: { primaryColor: "#4b6331", accentColor: "#9ec96a", scale: 1.18, archetype: "plant", model: { url: "/assets/creatures/wailing-caverns/deviate-shambler-animated.glb", rotationY: -Math.PI / 2, groundOffset: 0.116, labelHeight: 3.8, }, }, }, "deviate-lasher": { id: "deviate-lasher", kind: "mob", name: "Deviate Lasher", visual: { primaryColor: "#705329", accentColor: "#d29a4b", scale: 0.78, archetype: "plant", model: { url: "/assets/creatures/wailing-caverns/deviate-lasher-animated.glb", rotationY: -Math.PI / 2, groundOffset: 0.019, labelHeight: 1.65, }, }, }, "deviate-faerie-dragon": { id: "deviate-faerie-dragon", kind: "boss", name: "Deviate Faerie Dragon", title: "Rare", visual: { primaryColor: "#5562a1", accentColor: "#d4a8ef", scale: 1.05, archetype: "winged", model: { url: "/assets/creatures/wailing-caverns/deviate-faerie-dragon-animated.glb", rotationY: -Math.PI / 2, groundOffset: 0.95, labelHeight: 5.25, markerRadius: 1.35, }, }, }, "disciple-of-naralex": { id: "disciple-of-naralex", kind: "mob", name: "Disciple of Naralex", visual: { primaryColor: "#526d3d", accentColor: "#b9d67a", scale: 1.05, archetype: "humanoid", }, }, "nightmare-ectoplasm": { id: "nightmare-ectoplasm", kind: "mob", name: "Nightmare Ectoplasm", visual: { primaryColor: "#422c62", accentColor: "#b879e5", scale: 1, archetype: "ooze", model: { url: "/assets/creatures/wailing-caverns/evolving-ectoplasm-animated.glb", rotationY: -Math.PI / 2, groundOffset: 0.607, labelHeight: 2.45, }, }, }, "deviate-viper": { id: "deviate-viper", kind: "mob", name: "Deviate Viper", visual: { primaryColor: "#365d39", accentColor: "#91c95c", scale: 0.78, archetype: "serpent", }, }, "deviate-adder": { id: "deviate-adder", kind: "mob", name: "Deviate Adder", visual: { primaryColor: "#56482c", accentColor: "#c9aa58", scale: 0.72, archetype: "serpent", }, }, "lady-anacondra": { id: "lady-anacondra", kind: "boss", name: "Lady Anacondra", title: "Fanglord", visual: { primaryColor: "#5b3f78", accentColor: "#d5ee72", scale: 1.42, archetype: "humanoid", model: { url: "/assets/creatures/wailing-caverns/lady-anacondra-animated.glb", rotationY: -Math.PI / 2, groundOffset: 0.015, labelHeight: 4.15, markerRadius: 1.55, }, }, }, kresh: { id: "kresh", kind: "boss", name: "Kresh", title: "Ancient Turtle", visual: { primaryColor: "#315c58", accentColor: "#6fd1ad", scale: 1.55, archetype: "turtle", model: { url: "/assets/creatures/wailing-caverns/kresh-animated.glb", rotationY: -Math.PI / 2, groundOffset: 0.016, labelHeight: 3.05, markerRadius: 2.45, }, }, }, "lord-cobrahn": { id: "lord-cobrahn", kind: "boss", name: "Lord Cobrahn", title: "Fanglord", visual: { primaryColor: "#465c30", accentColor: "#d1d86b", scale: 1.46, archetype: "humanoid", model: { url: "/assets/creatures/wailing-caverns/lord-cobrahn-animated.glb", rotationY: -Math.PI / 2, groundOffset: 0.015, labelHeight: 4.3, markerRadius: 1.6, }, }, }, "lord-pythas": { id: "lord-pythas", kind: "boss", name: "Lord Pythas", title: "Fanglord", visual: { primaryColor: "#355f64", accentColor: "#76d5bf", scale: 1.46, archetype: "humanoid", model: { url: "/assets/creatures/wailing-caverns/lord-pythas-animated.glb", rotationY: -Math.PI / 2, groundOffset: 0.015, labelHeight: 4.3, markerRadius: 1.6, }, }, }, skum: { id: "skum", kind: "boss", name: "Skum", title: "Thunder Lizard", visual: { primaryColor: "#745027", accentColor: "#f0bd52", scale: 1.58, archetype: "lizard", model: { url: "/assets/creatures/wailing-caverns/skum-animated.glb", rotationY: -Math.PI / 2, groundOffset: 0.013, labelHeight: 6.65, markerRadius: 4.2, }, }, }, "lord-serpentis": { id: "lord-serpentis", kind: "boss", name: "Lord Serpentis", title: "Fanglord", visual: { primaryColor: "#304d39", accentColor: "#9edb63", scale: 1.5, archetype: "humanoid", model: { url: "/assets/creatures/wailing-caverns/lord-serpentis-animated.glb", rotationY: -Math.PI / 2, groundOffset: 0.015, labelHeight: 4.4, markerRadius: 1.65, }, }, }, "verdan-the-everliving": { id: "verdan-the-everliving", kind: "boss", name: "Verdan the Everliving", title: "Ancient Guardian", visual: { primaryColor: "#315f35", accentColor: "#78e17f", scale: 1.75, archetype: "plant", model: { url: "/assets/creatures/wailing-caverns/verdan-the-everliving-animated.glb", rotationY: -Math.PI / 2, groundOffset: 0.523, labelHeight: 15.85, markerRadius: 5.3, }, }, }, "mutanus-the-devourer": { id: "mutanus-the-devourer", kind: "boss", name: "Mutanus the Devourer", title: "Nightmare Incarnate", visual: { primaryColor: "#285d55", accentColor: "#63e4c2", scale: 1.72, archetype: "murloc", model: { url: "/assets/creatures/wailing-caverns/mutanus-the-devourer-animated.glb", rotationY: -Math.PI / 2, groundOffset: 0.022, labelHeight: 6.15, markerRadius: 2.7, }, }, }, } as const satisfies PopulationDefinitionMap; export const WAILING_CAVERNS_ROAMING_PACKS: readonly MobPackDefinition[] = [ { id: "crocolisk-patrol", name: "Deviate Crocolisk Patrol", speed: 1.2, pathId: 261970, formationSource: "database-solo", waypoints: [ [143.996, -106.276, 68.703], [99.107, -106.211, 68.703], [61.185, -106.161, 72.827], [99.107, -106.211, 68.703], ], members: [ { id: "crocolisk-leader", entityId: "deviate-crocolisk", role: "leader", formationOffset: [0, 0, 0] }, ], }, { id: "ectoplasm-ravager-patrol", name: "Ectoplasm Ravager Pack", speed: 1.35, pathId: 262290, formationSource: "inferred-followers", waypoints: [ [34.646, -96.066, 211.374], [65.58, -93.561, 204.714], [81.57, -93.455, 223.608], [65.58, -93.561, 204.714], [34.646, -96.066, 211.374], [36.726, -93.306, 249.309], [30.924, -95.137, 227.555], ], members: [ { id: "ectoplasm-leader", entityId: "evolving-ectoplasm", role: "leader", formationOffset: [0, 0, 0] }, { id: "ectoplasm-ravager", entityId: "deviate-ravager", role: "follower", formationOffset: [-1.55, 0, 2.1] }, { id: "ectoplasm-guardian", entityId: "deviate-guardian", role: "follower", formationOffset: [1.55, 0, 2.7] }, ], }, { id: "fang-ravager-patrol", name: "Fang Ravager Pack", speed: 1.55, pathId: 262390, formationSource: "inferred-followers", waypoints: [ [50.911, -92.844, 263.772], [40.989, -92.442, 280.014], [62.635, -90.968, 288.42], [64.729, -92.844, 265.895], ], members: [ { id: "fang-leader", entityId: "druid-of-the-fang", role: "leader", formationOffset: [0, 0, 0] }, { id: "fang-ravager", entityId: "deviate-ravager", role: "follower", formationOffset: [-1.65, 0, 2.2] }, { id: "fang-adept", entityId: "druid-of-the-fang", role: "follower", formationOffset: [1.65, 0, 2.8] }, ], }, { id: "kresh-patrol", name: "Kresh's Pool Route", speed: 0.9, pathId: 273680, formationSource: "database-solo", waypoints: [ [64.408, -106.699, 319.084], [33.344, -106.417, 299.292], [3.641, -106.189, 229.268], [16.936, -106.162, 143.141], [55.788, -106.162, 93.616], [17.62, -106.162, 142.151], [3.641, -106.189, 229.268], [33.344, -106.417, 299.292], ], members: [ { id: "kresh-roaming", entityId: "kresh", role: "leader", formationOffset: [0, 0, 0] }, ], }, { id: "shambler-druid-patrol", name: "Shambler Druid Pack", speed: 1.25, pathId: 381160, formationSource: "inferred-followers", waypoints: [ [218.221, -82.34, 51.42], [198.702, -82.477, 69.771], [180.173, -79.124, 87.059], [164.926, -75.992, 82.552], [180.173, -79.124, 87.059], [198.702, -82.477, 69.771], ], members: [ { id: "shambler-leader", entityId: "deviate-shambler", role: "leader", formationOffset: [0, 0, 0] }, { id: "shambler-follower", entityId: "deviate-shambler", role: "follower", formationOffset: [-1.7, 0, 2.1] }, { id: "shambler-druid", entityId: "druid-of-the-fang", role: "follower", formationOffset: [1.7, 0, 2.8] }, ], }, { id: "lower-shambler-patrol", name: "Lower Cavern Shambler Pack", speed: 1.3, pathId: 859630, formationSource: "inferred-followers", waypoints: [ [-22.765, -75.547, -150.782], [1.649, -76.225, -136.536], [32.186, -72.013, -123.083], [1.649, -76.225, -136.536], ], members: [ { id: "lower-shambler-leader", entityId: "deviate-shambler", role: "leader", formationOffset: [0, 0, 0] }, { id: "lower-shambler-follower", entityId: "deviate-shambler", role: "follower", formationOffset: [0, 0, 2.2] }, { id: "lower-lasher", entityId: "deviate-lasher", role: "follower", formationOffset: [0, 0, 4.4] }, ], }, ] as const; export const WAILING_CAVERNS_BOSS_SPAWNS: readonly StaticMobSpawnDefinition[] = [ { id: "faerie-dragon-spawn", entityId: "deviate-faerie-dragon", position: [-0.008, -67.031, -55.934], yaw: 2.446 }, { id: "lady-anacondra-spawn", entityId: "lady-anacondra", position: [-15.34, -87.7, 297.18], yaw: 1.239 }, { id: "lord-cobrahn-spawn", entityId: "lord-cobrahn", position: [151.14, -72.63, 414.37], yaw: -1.292 }, { id: "lord-pythas-spawn", entityId: "lord-pythas", position: [-36.81, -79.5, -241.06], yaw: 1.693 }, { id: "skum-spawn", entityId: "skum", position: [285.58, -69.19, -312.97], yaw: 1.816 }, { id: "lord-serpentis-spawn", entityId: "lord-serpentis", position: [120.16, -28.58, -24.62], yaw: 1.26 }, { id: "verdan-spawn", entityId: "verdan-the-everliving", position: [81.86, -30.99, 32.26], yaw: 2.147 }, { id: "mutanus-spawn", entityId: "mutanus-the-devourer", position: [-151.27, -102.82, 252.26], yaw: 1.809 }, ] as const; /** * Small entrance encounter used as the population's visual smoke test. These * points were floor-snapped and line-of-sight checked against the shipped cave * collider; the original database has no authored formation for this group. */ export const WAILING_CAVERNS_ENTRANCE_SPAWNS: readonly StaticMobSpawnDefinition[] = [ { id: "entrance-ravager", entityId: "deviate-ravager", position: [153.45, -75.42, 126.12], yaw: 1.118 }, { id: "entrance-druid", entityId: "druid-of-the-fang", position: [150.9, -75.81, 126.77], yaw: 1.118 }, { id: "entrance-guardian", entityId: "deviate-guardian", position: [148.36, -76.37, 127.42], yaw: 1.118 }, ] as const; export const WAILING_CAVERNS_STATIC_SPAWNS: readonly StaticMobSpawnDefinition[] = [ ...WAILING_CAVERNS_ENTRANCE_SPAWNS, ...WAILING_CAVERNS_BOSS_SPAWNS, ] as const;