Files
healer-man/src/game/mobPopulation.ts
T
2026-08-31 14:12:51 -04:00

801 lines
24 KiB
TypeScript

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<Record<string, PopulationEntityDefinition>>;
/** 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;
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;
}
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<string, StaticMobSpawnDefinition[]>();
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,
}));
});
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;