Files
healer-man/src/game/runewakerNativeLighting.test.ts
T
2026-10-06 22:25:34 -04:00

141 lines
7.4 KiB
TypeScript

import { Euler, Quaternion, Vector3 } from "three";
import { describe, expect, it } from "vitest";
import type { DungeonTransform, Vector3Tuple } from "./dungeonTypes";
import { RUNEWAKER_NATIVE_LIGHTING_SOURCE } from "./generated/runewakerNativeLighting.generated";
import {
resolveRunewakerNativeLighting,
runewakerRuntimePositionToSource,
runewakerSourcePositionToRuntime,
} from "./runewakerNativeLighting";
const identityTransform: DungeonTransform = {
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: 1,
};
function definition(id: string, transform = identityTransform) {
return { id, transform };
}
function toTuple(vector: Vector3): Vector3Tuple {
return [vector.x, vector.y, vector.z];
}
describe("native Runewaker environment resolver", () => {
it("covers every registered RoM WDB and matches the source engine's entry trigger/settings result", () => {
const dungeons = RUNEWAKER_NATIVE_LIGHTING_SOURCE.dungeons;
expect(Object.keys(dungeons)).toHaveLength(30);
for (const [id, data] of Object.entries(dungeons)) {
const entry = data.authoredEntry;
expect(entry, `${id} should retain its audited source entry`).not.toBeNull();
const runtimePosition = runewakerSourcePositionToRuntime(
definition(id),
entry!.sourcePosition as Vector3Tuple,
);
expect(runtimePosition, `${id} source entry should map to runtime space`).not.toBeNull();
if (!runtimePosition) throw new Error(`Missing runtime lighting position for ${id}`);
const sourceRoundTrip = runewakerRuntimePositionToSource(definition(id), runtimePosition!);
sourceRoundTrip!.forEach((value, axis) => expect(value, `${id} axis ${axis}, runtime ${runtimePosition.join(",")}`).toBeCloseTo(entry!.sourcePosition[axis], 4));
const resolved = resolveRunewakerNativeLighting(definition(id), runtimePosition!);
const expectedTriggerId = entry!.selection.triggerZoneId;
const expectedSettingsId = entry!.selection.settingsZoneId;
if (expectedTriggerId == null || expectedSettingsId == null) {
expect(resolved, `${id} should retain its existing profile when no source zone resolves`).toBeNull();
} else {
expect(resolved, `${id} should resolve source lighting`).not.toBeNull();
expect(resolved!.triggerZone.id).toBe(expectedTriggerId);
expect(resolved!.settingsZone.id).toBe(expectedSettingsId);
expect(resolved!.timeGroup).toBe(entry!.selection.selectedTimeGroup);
expect(resolved!.weatherGroup).toBe(entry!.selection.selectedWeatherGroup);
expect(resolved!.sourceWdbSha256).toBe(data.source.wdbSha256);
}
}
});
it("uses the reviewed Origin path node's source zone instead of the raw Lua entrance", () => {
const data = RUNEWAKER_NATIVE_LIGHTING_SOURCE.dungeons["the-origin"];
const reviewed = data.reviewedRuntimeEntry;
expect(reviewed?.covered).toBe(true);
const runtimePosition = runewakerSourcePositionToRuntime(
definition("the-origin"),
reviewed!.sourcePosition as Vector3Tuple,
);
const resolved = resolveRunewakerNativeLighting(definition("the-origin"), runtimePosition!);
expect(resolved?.triggerZone.id).toBe(reviewed!.selection.triggerZoneId);
expect(resolved?.settingsZone.id).toBe(3);
});
it("inherits a child trigger's parent settings and applies native time/weather fallbacks", () => {
const data = RUNEWAKER_NATIVE_LIGHTING_SOURCE.dungeons["grafu-castle"];
const childTrigger = data.zoneTriggers.find((trigger) => trigger.zoneId === 3)!;
const runtimePosition = runewakerSourcePositionToRuntime(definition("grafu-castle"), childTrigger.obb.center as Vector3Tuple);
const resolved = resolveRunewakerNativeLighting(definition("grafu-castle"), runtimePosition!, {
timeGroup: 2,
weatherGroup: 7,
});
expect(resolved?.triggerZone.id).toBe(3);
expect(resolved?.settingsZone.id).toBe(1);
expect(resolved?.timeGroup).toBe(2);
expect(resolved?.weatherGroup).toBe(0);
});
it("preserves Rune's byte-normalized linear colors, three diffuse directionals, and distinct specular values", () => {
const data = RUNEWAKER_NATIVE_LIGHTING_SOURCE.dungeons["mystic-altar"];
const runtimePosition = runewakerSourcePositionToRuntime(
definition("mystic-altar"),
data.authoredEntry!.sourcePosition as Vector3Tuple,
);
const resolved = resolveRunewakerNativeLighting(definition("mystic-altar"), runtimePosition!, {
cameraFarDistance: 1000,
})!;
const settings = data.zones.find((zone) => zone.id === resolved.settingsZone.id)!.settings[0];
if (!settings) throw new Error("Missing source lighting settings fixture");
expect(resolved.ambient.colorLinear).toEqual(settings.ambient.rgba.slice(0, 3));
expect(resolved.directionalLights).toHaveLength(3);
expect(resolved.directionalLights.map((light) => light.intensity)).toEqual([1, 0.8, 0.8]);
expect(resolved.directionalLights[0].colorLinear).toEqual(settings.primary.rgba.slice(0, 3));
expect(resolved.directionalLights[1].colorLinear).toEqual(settings.secondary.rgba.slice(0, 3));
expect(resolved.specular.colorLinear).toEqual(settings.specular.rgba.slice(0, 3));
expect(resolved.fog.startFactor).toBeCloseTo(400 / 1600);
expect(resolved.fog.endFactor).toBeCloseTo(1300 / 1600);
expect(resolved.fog.startDistance).toBeCloseTo(250);
expect(resolved.fog.endDistance).toBeCloseTo(812.5);
});
it("transforms authored directions through the inverse primary placement and runtime dungeon rotation", () => {
const data = RUNEWAKER_NATIVE_LIGHTING_SOURCE.dungeons["mystic-altar"];
const transform: DungeonTransform = {
position: [8, -3, 4],
rotation: [0.2, -0.35, 0.5],
scale: 1.6,
};
const runtimePosition = runewakerSourcePositionToRuntime(
definition("mystic-altar", transform),
data.authoredEntry!.sourcePosition as Vector3Tuple,
);
const resolved = resolveRunewakerNativeLighting(definition("mystic-altar", transform), runtimePosition!)!;
const settings = data.zones.find((zone) => zone.id === resolved.settingsZone.id)!.settings[0];
if (!settings) throw new Error("Missing source lighting settings fixture");
const sourceDirection = new Vector3(...settings.nativeDirectionsLeftHandedYUp.primary);
const primaryRotation = new Quaternion(...data.source.primaryPlacement.rotation).normalize();
const primaryScale = new Vector3(...data.source.primaryPlacement.scale);
sourceDirection.applyQuaternion(primaryRotation.invert()).divide(primaryScale);
sourceDirection.z *= -1;
sourceDirection.normalize();
sourceDirection.applyQuaternion(new Quaternion().setFromEuler(new Euler(...transform.rotation, "XYZ")));
const actual = new Vector3(...resolved.directionalLights[0].rayDirection);
expect(actual.distanceTo(sourceDirection.normalize())).toBeLessThan(1e-6);
expect(new Vector3(...resolved.directionalLights[0].toLightDirection).distanceTo(actual.clone().negate())).toBeLessThan(1e-8);
});
it("returns null for unknown dungeons and uncovered source points", () => {
expect(resolveRunewakerNativeLighting(definition("not-a-rom-dungeon"), [0, 0, 0])).toBeNull();
const noCoverage = RUNEWAKER_NATIVE_LIGHTING_SOURCE.dungeons["grafu-castle"].authoredEntry!;
const runtimePosition = runewakerSourcePositionToRuntime(definition("grafu-castle"), noCoverage.sourcePosition as Vector3Tuple);
expect(resolveRunewakerNativeLighting(definition("grafu-castle"), runtimePosition!)).toBeNull();
});
});