forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
6114a2295f
commit
69809f7228
593 files changed
+903643
-40774
No files matched your search
@@ -0,0 +1,471 @@
|
||||
import type { DungeonDefinition, DungeonTransform, Vector3Tuple } from "./dungeonTypes";
|
||||
import { RUNEWAKER_NATIVE_LIGHTING_SOURCE } from "./generated/runewakerNativeLighting.generated";
|
||||
|
||||
type Vector = readonly [number, number, number];
|
||||
type Quaternion = readonly [number, number, number, number];
|
||||
|
||||
interface NativeColor {
|
||||
readonly argb: string;
|
||||
/** Rune Engine's direct ARGB byte-to-float conversion; values are linear. */
|
||||
readonly rgba: readonly [number, number, number, number];
|
||||
}
|
||||
|
||||
interface NativeZoneSettings {
|
||||
readonly timeGroup: number;
|
||||
readonly weatherGroup: number;
|
||||
readonly primary: NativeColor;
|
||||
readonly secondary: NativeColor;
|
||||
readonly ambient: NativeColor;
|
||||
readonly specular: NativeColor;
|
||||
readonly fogColor: NativeColor;
|
||||
readonly fog: { readonly constant: number; readonly start: number; readonly end: number };
|
||||
readonly standardAnglesRadians: Vector;
|
||||
readonly specularAnglesRadians: Vector;
|
||||
readonly nativeDirectionsLeftHandedYUp: {
|
||||
readonly primary: Vector;
|
||||
readonly secondary0: Vector;
|
||||
readonly secondary1: Vector;
|
||||
readonly specular: Vector;
|
||||
};
|
||||
readonly weatherSystem: string;
|
||||
readonly skyGradientTexture: string;
|
||||
readonly customSkyBox: string;
|
||||
readonly cloudCover: number;
|
||||
readonly cloudSharpness: number;
|
||||
readonly cloudColors: readonly [NativeColor, NativeColor];
|
||||
}
|
||||
|
||||
interface NativeZone {
|
||||
readonly id: number;
|
||||
readonly parentId: number;
|
||||
readonly name: string;
|
||||
readonly zoneType: number;
|
||||
readonly settings: readonly NativeZoneSettings[];
|
||||
}
|
||||
|
||||
interface NativeZoneTrigger {
|
||||
readonly triggerType: number;
|
||||
readonly zoneId: number;
|
||||
readonly zoneType: number;
|
||||
readonly priority: number;
|
||||
readonly obb: {
|
||||
readonly center: Vector;
|
||||
readonly axes: readonly [Vector, Vector, Vector];
|
||||
readonly halfExtents: Vector;
|
||||
};
|
||||
}
|
||||
|
||||
interface NativeDungeonLightingData {
|
||||
readonly source: {
|
||||
readonly wdb: string;
|
||||
readonly wdbSha256: string;
|
||||
readonly coordinateSystem: string;
|
||||
readonly metersPerSourceUnit: number;
|
||||
readonly primaryPlacement: { readonly scale: Vector; readonly rotation: Quaternion; readonly translation: Vector };
|
||||
};
|
||||
readonly zones: readonly NativeZone[];
|
||||
readonly zoneTriggers: readonly NativeZoneTrigger[];
|
||||
}
|
||||
|
||||
const SOURCE = RUNEWAKER_NATIVE_LIGHTING_SOURCE as unknown as {
|
||||
readonly runtimeDefaults: { readonly timeGroup: number; readonly weatherGroup: number };
|
||||
readonly dungeons: Readonly<Record<string, NativeDungeonLightingData>>;
|
||||
};
|
||||
|
||||
export interface RunewakerLightingOptions {
|
||||
readonly timeGroup?: number;
|
||||
readonly weatherGroup?: number;
|
||||
/** Three.js world-space camera far distance, in runtime units/meters. */
|
||||
readonly cameraFarDistance?: number;
|
||||
}
|
||||
|
||||
export interface ResolvedRunewakerDirectionalLight {
|
||||
readonly role: "primary" | "secondary-0" | "secondary-1";
|
||||
readonly colorArgb: string;
|
||||
readonly colorLinear: Vector;
|
||||
readonly intensity: number;
|
||||
/** Adapted source `m_direction`, which points in the light-ray direction. */
|
||||
readonly rayDirection: Vector;
|
||||
/** Opposite of Rune's stored direction; Rune's SH code uses this convention. */
|
||||
readonly toLightDirection: Vector;
|
||||
}
|
||||
|
||||
export interface ResolvedRunewakerLighting {
|
||||
readonly dungeonId: string;
|
||||
readonly sourceWdb: string;
|
||||
readonly sourceWdbSha256: string;
|
||||
readonly sourceCoordinateSystem: string;
|
||||
readonly metersPerSourceUnit: number;
|
||||
readonly sourcePosition: Vector;
|
||||
readonly triggerZone: {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly priority: number;
|
||||
readonly verticalHitDistanceSourceUnits: number;
|
||||
};
|
||||
readonly settingsZone: { readonly id: number; readonly name: string };
|
||||
readonly timeGroup: number;
|
||||
readonly weatherGroup: number;
|
||||
readonly ambient: { readonly colorArgb: string; readonly colorLinear: Vector; readonly intensity: 1 };
|
||||
readonly directionalLights: readonly [
|
||||
ResolvedRunewakerDirectionalLight,
|
||||
ResolvedRunewakerDirectionalLight,
|
||||
ResolvedRunewakerDirectionalLight,
|
||||
];
|
||||
readonly specular: { readonly colorArgb: string; readonly colorLinear: Vector; readonly direction: Vector };
|
||||
readonly fog: {
|
||||
readonly colorArgb: string;
|
||||
readonly colorLinear: Vector;
|
||||
readonly constant: number;
|
||||
readonly startFactor: number;
|
||||
readonly endFactor: number;
|
||||
readonly startDistance?: number;
|
||||
readonly endDistance?: number;
|
||||
};
|
||||
readonly sky: {
|
||||
readonly weatherSystem: string;
|
||||
readonly skyGradientTexture: string;
|
||||
readonly customSkyBox: string;
|
||||
readonly cloudCover: number;
|
||||
readonly cloudSharpness: number;
|
||||
readonly cloudColorsLinear: readonly [Vector, Vector];
|
||||
};
|
||||
}
|
||||
|
||||
interface ZoneSelection {
|
||||
readonly trigger: NativeZoneTrigger;
|
||||
readonly boundaryDistance: number;
|
||||
readonly triggerZone: NativeZone;
|
||||
readonly settingsZone: NativeZone;
|
||||
readonly settings: NativeZoneSettings;
|
||||
readonly timeGroup: number;
|
||||
readonly weatherGroup: number;
|
||||
}
|
||||
|
||||
function add(a: Vector, b: Vector): Vector {
|
||||
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
||||
}
|
||||
|
||||
function subtract(a: Vector, b: Vector): Vector {
|
||||
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
}
|
||||
|
||||
function multiply(a: Vector, b: Vector): Vector {
|
||||
return [a[0] * b[0], a[1] * b[1], a[2] * b[2]];
|
||||
}
|
||||
|
||||
function divide(a: Vector, b: Vector): Vector | null {
|
||||
if (b.some((component) => Math.abs(component) < 1e-12)) return null;
|
||||
return [a[0] / b[0], a[1] / b[1], a[2] / b[2]];
|
||||
}
|
||||
|
||||
function dot(a: Vector, b: Vector): number {
|
||||
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
}
|
||||
|
||||
function normalize(value: Vector): Vector {
|
||||
const length = Math.hypot(value[0], value[1], value[2]);
|
||||
if (length < 1e-12) return [0, 0, 0];
|
||||
return [value[0] / length, value[1] / length, value[2] / length];
|
||||
}
|
||||
|
||||
function conjugate(q: Quaternion): Quaternion {
|
||||
return [-q[0], -q[1], -q[2], q[3]];
|
||||
}
|
||||
|
||||
function multiplyQuaternions(a: Quaternion, b: Quaternion): Quaternion {
|
||||
const [ax, ay, az, aw] = a;
|
||||
const [bx, by, bz, bw] = b;
|
||||
return [
|
||||
ax * bw + aw * bx + ay * bz - az * by,
|
||||
ay * bw + aw * by + az * bx - ax * bz,
|
||||
az * bw + aw * bz + ax * by - ay * bx,
|
||||
aw * bw - ax * bx - ay * by - az * bz,
|
||||
];
|
||||
}
|
||||
|
||||
function rotateByQuaternion(value: Vector, quaternion: Quaternion): Vector {
|
||||
const length = Math.hypot(quaternion[0], quaternion[1], quaternion[2], quaternion[3]);
|
||||
if (length < 1e-12) return value;
|
||||
const normalized: Quaternion = quaternion.map((component) => component / length) as unknown as Quaternion;
|
||||
const qv: Quaternion = [value[0], value[1], value[2], 0];
|
||||
const result = multiplyQuaternions(multiplyQuaternions(normalized, qv), conjugate(normalized));
|
||||
return [result[0], result[1], result[2]];
|
||||
}
|
||||
|
||||
function quaternionFromEulerXYZ(rotation: Vector3Tuple): Quaternion {
|
||||
const [x, y, z] = rotation;
|
||||
const c1 = Math.cos(x / 2);
|
||||
const c2 = Math.cos(y / 2);
|
||||
const c3 = Math.cos(z / 2);
|
||||
const s1 = Math.sin(x / 2);
|
||||
const s2 = Math.sin(y / 2);
|
||||
const s3 = Math.sin(z / 2);
|
||||
return [
|
||||
s1 * c2 * c3 + c1 * s2 * s3,
|
||||
c1 * s2 * c3 - s1 * c2 * s3,
|
||||
c1 * c2 * s3 + s1 * s2 * c3,
|
||||
c1 * c2 * c3 - s1 * s2 * s3,
|
||||
];
|
||||
}
|
||||
|
||||
function pointFromRuntimeWorldToDungeonLocal(transform: DungeonTransform, point: Vector): Vector | null {
|
||||
if (Math.abs(transform.scale) < 1e-12) return null;
|
||||
const inverseRotation = conjugate(quaternionFromEulerXYZ(transform.rotation));
|
||||
return divide(rotateByQuaternion(subtract(point, transform.position), inverseRotation), [transform.scale, transform.scale, transform.scale]);
|
||||
}
|
||||
|
||||
function pointFromDungeonLocalToSource(data: NativeDungeonLightingData, point: Vector): Vector | null {
|
||||
const sourceLocal = divide([point[0], point[1], -point[2]], [
|
||||
data.source.metersPerSourceUnit,
|
||||
data.source.metersPerSourceUnit,
|
||||
data.source.metersPerSourceUnit,
|
||||
]);
|
||||
if (!sourceLocal) return null;
|
||||
const scaled = multiply(sourceLocal, data.source.primaryPlacement.scale);
|
||||
return add(rotateByQuaternion(scaled, data.source.primaryPlacement.rotation), data.source.primaryPlacement.translation);
|
||||
}
|
||||
|
||||
function sourceDirectionToRuntime(data: NativeDungeonLightingData, transform: DungeonTransform, direction: Vector): Vector | null {
|
||||
const placementLocal = rotateByQuaternion(direction, conjugate(data.source.primaryPlacement.rotation));
|
||||
const unscaled = divide(placementLocal, data.source.primaryPlacement.scale);
|
||||
if (!unscaled) return null;
|
||||
const localThree: Vector = [unscaled[0], unscaled[1], -unscaled[2]];
|
||||
const runtimeDirection = rotateByQuaternion(
|
||||
[localThree[0] * transform.scale, localThree[1] * transform.scale, localThree[2] * transform.scale],
|
||||
quaternionFromEulerXYZ(transform.rotation),
|
||||
);
|
||||
return normalize(runtimeDirection);
|
||||
}
|
||||
|
||||
function segmentObbEntry(point: Vector, delta: Vector, trigger: NativeZoneTrigger): number | null {
|
||||
let entry = 0;
|
||||
let exit = 1;
|
||||
const relative = subtract(point, trigger.obb.center);
|
||||
for (let axisIndex = 0; axisIndex < 3; axisIndex += 1) {
|
||||
const axis = trigger.obb.axes[axisIndex];
|
||||
const originProjection = dot(relative, axis);
|
||||
const deltaProjection = dot(delta, axis);
|
||||
const extent = trigger.obb.halfExtents[axisIndex];
|
||||
if (Math.abs(deltaProjection) < 1e-12) {
|
||||
if (originProjection < -extent || originProjection > extent) return null;
|
||||
continue;
|
||||
}
|
||||
let t0 = (-extent - originProjection) / deltaProjection;
|
||||
let t1 = (extent - originProjection) / deltaProjection;
|
||||
if (t0 > t1) [t0, t1] = [t1, t0];
|
||||
entry = Math.max(entry, t0);
|
||||
exit = Math.min(exit, t1);
|
||||
if (entry > exit) return null;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function verticalTriggerDistance(point: Vector, trigger: NativeZoneTrigger): number | null {
|
||||
const down = segmentObbEntry(point, [0, -10000, 0], trigger);
|
||||
const up = segmentObbEntry(point, [0, 10000, 0], trigger);
|
||||
if (down == null) return up;
|
||||
if (up == null) return down;
|
||||
return Math.min(down, up);
|
||||
}
|
||||
|
||||
function isDescendant(zones: readonly NativeZone[], candidateId: number, ancestorId: number): boolean {
|
||||
const byId = new Map(zones.map((zone) => [zone.id, zone]));
|
||||
let zone = byId.get(candidateId);
|
||||
while (zone && zone.parentId !== -1) {
|
||||
if (zone.parentId === ancestorId) return true;
|
||||
zone = byId.get(zone.parentId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function selectSettings(zone: NativeZone, timeGroup: number, weatherGroup: number): { settings: NativeZoneSettings; timeGroup: number; weatherGroup: number } | null {
|
||||
const exact = zone.settings.find((entry) => entry.timeGroup === timeGroup && entry.weatherGroup === weatherGroup);
|
||||
if (exact) return { settings: exact, timeGroup: exact.timeGroup, weatherGroup: exact.weatherGroup };
|
||||
const sameTime = zone.settings.find((entry) => entry.timeGroup === timeGroup);
|
||||
if (sameTime) return { settings: sameTime, timeGroup: sameTime.timeGroup, weatherGroup: sameTime.weatherGroup };
|
||||
const any = zone.settings[0];
|
||||
return any ? { settings: any, timeGroup: any.timeGroup, weatherGroup: any.weatherGroup } : null;
|
||||
}
|
||||
|
||||
function selectZoneSettings(
|
||||
data: NativeDungeonLightingData,
|
||||
sourcePosition: Vector,
|
||||
requestedTimeGroup: number,
|
||||
requestedWeatherGroup: number,
|
||||
): ZoneSelection | null {
|
||||
let selectedTrigger: NativeZoneTrigger | null = null;
|
||||
let selectedDistance = Number.POSITIVE_INFINITY;
|
||||
// The native selector initializes its signed priority to 0xFFFFFFFF (-1).
|
||||
let selectedPriority = -1;
|
||||
for (const trigger of data.zoneTriggers) {
|
||||
const distance = verticalTriggerDistance(sourcePosition, trigger);
|
||||
if (distance == null) continue;
|
||||
const prefer = trigger.priority > selectedPriority
|
||||
|| (trigger.priority === selectedPriority && distance < selectedDistance)
|
||||
|| (trigger.priority === selectedPriority
|
||||
&& distance === selectedDistance
|
||||
&& selectedTrigger != null
|
||||
&& isDescendant(data.zones, trigger.zoneId, selectedTrigger.zoneId));
|
||||
if (prefer) {
|
||||
selectedTrigger = trigger;
|
||||
selectedDistance = distance;
|
||||
selectedPriority = trigger.priority;
|
||||
}
|
||||
}
|
||||
if (!selectedTrigger) return null;
|
||||
const triggerZone = data.zones.find((zone) => zone.id === selectedTrigger!.zoneId);
|
||||
if (!triggerZone) return null;
|
||||
let settingsZone: NativeZone | undefined = triggerZone;
|
||||
let chosen: ReturnType<typeof selectSettings> = null;
|
||||
while (settingsZone) {
|
||||
chosen = selectSettings(settingsZone, requestedTimeGroup, requestedWeatherGroup);
|
||||
if (chosen) break;
|
||||
settingsZone = data.zones.find((zone) => zone.id === settingsZone!.parentId);
|
||||
}
|
||||
if (!settingsZone || !chosen) return null;
|
||||
return {
|
||||
trigger: selectedTrigger,
|
||||
boundaryDistance: selectedDistance,
|
||||
triggerZone,
|
||||
settingsZone,
|
||||
settings: chosen.settings,
|
||||
timeGroup: chosen.timeGroup,
|
||||
weatherGroup: chosen.weatherGroup,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a Three.js world point back to the source WDB world coordinates. */
|
||||
export function runewakerRuntimePositionToSource(
|
||||
definition: Pick<DungeonDefinition, "id" | "transform">,
|
||||
runtimeWorldPosition: Vector3Tuple,
|
||||
): Vector | null {
|
||||
const data = SOURCE.dungeons[definition.id];
|
||||
if (!data) return null;
|
||||
const local = pointFromRuntimeWorldToDungeonLocal(definition.transform, runtimeWorldPosition);
|
||||
return local ? pointFromDungeonLocalToSource(data, local) : null;
|
||||
}
|
||||
|
||||
/** Convert a source WDB point to the current dungeon's Three.js world space. */
|
||||
export function runewakerSourcePositionToRuntime(
|
||||
definition: Pick<DungeonDefinition, "id" | "transform">,
|
||||
sourcePosition: Vector3Tuple,
|
||||
): Vector | null {
|
||||
const data = SOURCE.dungeons[definition.id];
|
||||
if (!data) return null;
|
||||
const sourceDelta = subtract(sourcePosition, data.source.primaryPlacement.translation);
|
||||
const inversePrimaryRotation = conjugate(data.source.primaryPlacement.rotation);
|
||||
const localSource = rotateByQuaternion(sourceDelta, inversePrimaryRotation);
|
||||
const scaledLocal = divide(localSource, data.source.primaryPlacement.scale);
|
||||
if (!scaledLocal) return null;
|
||||
const localThree: Vector = [scaledLocal[0] * data.source.metersPerSourceUnit, scaledLocal[1] * data.source.metersPerSourceUnit, -scaledLocal[2] * data.source.metersPerSourceUnit];
|
||||
return add(
|
||||
rotateByQuaternion(
|
||||
[localThree[0] * definition.transform.scale, localThree[1] * definition.transform.scale, localThree[2] * definition.transform.scale],
|
||||
quaternionFromEulerXYZ(definition.transform.rotation),
|
||||
),
|
||||
definition.transform.position,
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve source zone lighting at a runtime world position; null keeps the authored profile. */
|
||||
export function resolveRunewakerNativeLighting(
|
||||
definition: Pick<DungeonDefinition, "id" | "transform">,
|
||||
runtimeWorldPosition: Vector3Tuple,
|
||||
options: RunewakerLightingOptions = {},
|
||||
): ResolvedRunewakerLighting | null {
|
||||
const data = SOURCE.dungeons[definition.id];
|
||||
if (!data) return null;
|
||||
const sourcePosition = runewakerRuntimePositionToSource(definition, runtimeWorldPosition);
|
||||
if (!sourcePosition) return null;
|
||||
const selection = selectZoneSettings(
|
||||
data,
|
||||
sourcePosition,
|
||||
options.timeGroup ?? SOURCE.runtimeDefaults.timeGroup,
|
||||
options.weatherGroup ?? SOURCE.runtimeDefaults.weatherGroup,
|
||||
);
|
||||
if (!selection) return null;
|
||||
const settings = selection.settings;
|
||||
const rayDirection = (sourceDirection: Vector): Vector => sourceDirectionToRuntime(data, definition.transform, sourceDirection) ?? [0, 0, 0];
|
||||
const far = options.cameraFarDistance;
|
||||
const fogScale = settings.fog.end > 1 ? 1 / 1600 : 1;
|
||||
const startFactor = settings.fog.start * fogScale;
|
||||
const endFactor = settings.fog.end * fogScale;
|
||||
const triggerDistance = selection.boundaryDistance * 10000;
|
||||
|
||||
return {
|
||||
dungeonId: definition.id,
|
||||
sourceWdb: data.source.wdb,
|
||||
sourceWdbSha256: data.source.wdbSha256,
|
||||
sourceCoordinateSystem: data.source.coordinateSystem,
|
||||
metersPerSourceUnit: data.source.metersPerSourceUnit,
|
||||
sourcePosition,
|
||||
triggerZone: {
|
||||
id: selection.triggerZone.id,
|
||||
name: selection.triggerZone.name,
|
||||
priority: selection.trigger.priority,
|
||||
verticalHitDistanceSourceUnits: triggerDistance,
|
||||
},
|
||||
settingsZone: { id: selection.settingsZone.id, name: selection.settingsZone.name },
|
||||
timeGroup: selection.timeGroup,
|
||||
weatherGroup: selection.weatherGroup,
|
||||
ambient: { colorArgb: settings.ambient.argb, colorLinear: settings.ambient.rgba.slice(0, 3) as unknown as Vector, intensity: 1 },
|
||||
directionalLights: [
|
||||
{
|
||||
role: "primary",
|
||||
colorArgb: settings.primary.argb,
|
||||
colorLinear: settings.primary.rgba.slice(0, 3) as unknown as Vector,
|
||||
intensity: 1,
|
||||
rayDirection: rayDirection(settings.nativeDirectionsLeftHandedYUp.primary),
|
||||
toLightDirection: scaleVector(rayDirection(settings.nativeDirectionsLeftHandedYUp.primary), -1),
|
||||
},
|
||||
{
|
||||
role: "secondary-0",
|
||||
colorArgb: settings.secondary.argb,
|
||||
colorLinear: settings.secondary.rgba.slice(0, 3) as unknown as Vector,
|
||||
intensity: 0.8,
|
||||
rayDirection: rayDirection(settings.nativeDirectionsLeftHandedYUp.secondary0),
|
||||
toLightDirection: scaleVector(rayDirection(settings.nativeDirectionsLeftHandedYUp.secondary0), -1),
|
||||
},
|
||||
{
|
||||
role: "secondary-1",
|
||||
colorArgb: settings.secondary.argb,
|
||||
colorLinear: settings.secondary.rgba.slice(0, 3) as unknown as Vector,
|
||||
intensity: 0.8,
|
||||
rayDirection: rayDirection(settings.nativeDirectionsLeftHandedYUp.secondary1),
|
||||
toLightDirection: scaleVector(rayDirection(settings.nativeDirectionsLeftHandedYUp.secondary1), -1),
|
||||
},
|
||||
],
|
||||
specular: {
|
||||
colorArgb: settings.specular.argb,
|
||||
colorLinear: settings.specular.rgba.slice(0, 3) as unknown as Vector,
|
||||
direction: rayDirection(settings.nativeDirectionsLeftHandedYUp.specular),
|
||||
},
|
||||
fog: {
|
||||
colorArgb: settings.fogColor.argb,
|
||||
colorLinear: settings.fogColor.rgba.slice(0, 3) as unknown as Vector,
|
||||
constant: settings.fog.constant,
|
||||
startFactor,
|
||||
endFactor,
|
||||
...(far != null ? { startDistance: startFactor * far, endDistance: endFactor * far } : {}),
|
||||
},
|
||||
sky: {
|
||||
weatherSystem: settings.weatherSystem,
|
||||
skyGradientTexture: settings.skyGradientTexture,
|
||||
customSkyBox: settings.customSkyBox,
|
||||
cloudCover: settings.cloudCover,
|
||||
cloudSharpness: settings.cloudSharpness,
|
||||
cloudColorsLinear: [
|
||||
settings.cloudColors[0].rgba.slice(0, 3) as unknown as Vector,
|
||||
settings.cloudColors[1].rgba.slice(0, 3) as unknown as Vector,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function scaleVector(vector: Vector, scalar: number): Vector {
|
||||
return [vector[0] * scalar, vector[1] * scalar, vector[2] * scalar];
|
||||
}
|
||||
|
||||
/** Source map for generated-data integrity checks; consumers should use the resolver. */
|
||||
export const RUNEWAKER_NATIVE_LIGHTING_DUNGEON_IDS = Object.freeze(Object.keys(SOURCE.dungeons));
|
||||
Reference in new issue
Block a user