Update Healer Man server

This commit is contained in:
phenom committed 2026-09-26 17:51:56 -04:00
1 parent 9c5fb4c614
commit bd073fb00f
113 files changed
+4902 -273

No files matched your search

+1 -1
View File
@@ -185,7 +185,7 @@ export function CombatBridge() {
return;
}
const game = useGameStore.getState();
if (game.dungeonIntroActive) return;
if (game.dungeonIntroActive || game.raceStartBlocked) return;
const sharedOnline = onlineSessionIsActive();
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
advanceOneTick(delta, Date.now(), false);
+32
View File
@@ -0,0 +1,32 @@
import { useEffect } from "react";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
import { useDungeonRunStore } from "./dungeonRunStore";
import { useGameStore } from "./store";
/** Keeps match readiness and settlement separate from the local boss and loot lifecycle. */
export function DungeonRaceBridge() {
useEffect(() => {
let reportingRun: string | null = null;
const interval = window.setInterval(() => {
const race = useDungeonRaceStore.getState();
if (!race.inGame || !race.seed || !race.dungeonId) return;
const game = useGameStore.getState();
if (game.activeDungeonId !== race.dungeonId) return;
race.tick();
const ready = !game.dungeonIntroActive && ["ready", "proxy"].includes(game.assetStatus);
if (ready && !race.ready && race.status === "preparing") void race.readyUp();
const started = race.startAt !== null && Date.now() + race.serverOffset >= race.startAt;
if (game.raceStartBlocked && started && ready) useGameStore.setState({ raceStartBlocked: false });
if (game.raceStartBlocked && ["void", "idle"].includes(race.status))
useGameStore.setState({ raceStartBlocked: false });
const progress = useDungeonRunStore.getState().progress;
if (started && !["void", "idle"].includes(race.status) && progress?.id === race.seed && progress.finishedAt !== null
&& race.clearDurationMs === null && reportingRun !== race.seed) {
reportingRun = race.seed;
void race.reportClear(progress.killedBossIds).finally(() => { reportingRun = null; });
}
}, 100);
return () => window.clearInterval(interval);
}, []);
return null;
}
+5 -1
View File
@@ -8,6 +8,7 @@ import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import { useDungeonRunStore } from "./dungeonRunStore";
import type { DungeonRunMember } from "./dungeonRunTypes";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
function currentRunParty(): readonly DungeonRunMember[] {
const shell = useShellStore.getState();
@@ -36,6 +37,7 @@ export function DungeonRunBridge() {
const game = useGameStore.getState();
const shell = useShellStore.getState();
if (shell.phase !== "game" || game.gameMode !== "dungeon" || game.gmTestMode || game.dungeonIntroActive
|| game.raceStartBlocked
|| !["ready", "proxy"].includes(game.assetStatus)) return;
const run = useDungeonRunStore.getState();
if (run.progress?.dungeonId !== game.activeDungeonId) return;
@@ -46,7 +48,9 @@ export function DungeonRunBridge() {
useDungeonRunStore.setState({ progress: { ...run.progress, id: activity.id,
startedAt: game.dungeonIntroCompletedAt ?? activity.startedAt, party } });
}
run.start(game.dungeonIntroCompletedAt ?? Date.now(), party);
const race = useDungeonRaceStore.getState();
run.start(race.inGame && race.startAt ? race.startAt - race.serverOffset
: game.dungeonIntroCompletedAt ?? Date.now(), party);
run.advance(activeDungeonCompletedObjectiveIds(game.activeDungeonId),
Object.values(useCombatStore.getState().mobs).filter((mob) => mob.dead && !mob.despawned).map((mob) => mob.id),
Date.now(), party);
+10
View File
@@ -9,11 +9,13 @@ import { onlineGroupAiPartySize, useOnlineGroupStore } from "../app/onlineGroupS
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { useCompanionSpecReadinessStore } from "./companionSpecReadinessStore";
import { useCompanionPartyPreferences } from "./companionPartyPreferences";
import { usePartyBuffPolicyStore } from "./partyBuffPolicyStore";
import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "./dungeonContentSource";
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
export function PartyBridge() {
const character = useShellStore((state) => state.activeCharacter);
const session = useShellStore((state) => state.session);
const playerRole = useShellStore((state) => state.activeDungeonRole);
const gameMode = useGameStore((state) => state.gameMode);
const manastormPartySize = useManastormStore((state) => state.partySize);
@@ -26,8 +28,11 @@ export function PartyBridge() {
const readinessPolicy = useCompanionSpecReadinessStore((state) => state.config);
const readinessPolicyRevision = useCompanionSpecReadinessStore((state) => state.revision);
const deviceIncludeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs);
const partyBuffPolicy = usePartyBuffPolicyStore((state) => state.config);
const mountedResetRevision = useRef(resetRevision);
useEffect(() => { void usePartyBuffPolicyStore.getState().refresh(session); }, [session]);
useEffect(() => {
if (!character) return;
const combat = useCombatStore.getState();
@@ -97,6 +102,11 @@ export function PartyBridge() {
resetPartyRuntimeClock();
}, [resetRevision]);
useEffect(() => {
if (!character) return;
useCombatStore.getState().applyDefaultPartyBuffs();
}, [partyBuffPolicy]);
useEffect(() => useCombatStore.subscribe((state, previous) => {
if (state.selectedTargetId && state.selectedTargetId !== previous.selectedTargetId) {
usePartyStore.getState().clearSelection();
+40 -21
View File
@@ -8,6 +8,8 @@ import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
import { DEFAULT_PARTY_BUFF_POLICY } from "./partyBuffPolicy";
import { usePartyBuffPolicyStore } from "./partyBuffPolicyStore";
function registerTarget(id = "target", distance = 2): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
@@ -17,6 +19,7 @@ function registerTarget(id = "target", distance = 2): void {
describe("named aura combat runtime", () => {
beforeEach(() => {
usePartyBuffPolicyStore.setState({ config: DEFAULT_PARTY_BUFF_POLICY, draft: DEFAULT_PARTY_BUFF_POLICY });
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
usePartyStore.getState().initializeParty("aura-runtime", 80, 2_000);
@@ -61,7 +64,7 @@ describe("named aura combat runtime", () => {
expect(useCombatStore.getState()).toMatchObject({ health: baseHealth, maxHealth: baseMaxHealth });
});
it("grants learned long-term stat buffs to the full party on load", () => {
it("maintains only GM-selected buffs on the chosen party roles", () => {
useCombatStore.getState().initializeCharacter({
classId: "priest",
specializationId: "priest:holy",
@@ -75,31 +78,47 @@ describe("named aura combat runtime", () => {
const memberMaxHealth = new Map(usePartyStore.getState().members.map((member) => [member.id, member.maxHealth]));
expect(playerMaxHealth).toBe(407);
useCombatStore.getState().applyDefaultPartyBuffs(20_000);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.exclusiveGroup?.startsWith("gm-party-buff:"))).toBe(false);
usePartyBuffPolicyStore.setState({ config: { schemaVersion: 1, rules: [
{ abilityId: "wow335-priest-power-word-fortitude", scope: "tank" },
] } });
useCombatStore.getState().applyDefaultPartyBuffs(20_000);
const state = useCombatStore.getState();
const targetIds = [PLAYER_AGGRO_ID, ...usePartyStore.getState().members.map((member) => member.id)];
for (const targetId of targetIds) {
expect(state.auras).toContainEqual(expect.objectContaining({
targetId,
definition: expect.objectContaining({ exclusiveGroup: "default-party-buff:fortitude" }),
}));
}
expect(state.maxHealth).toBe(437);
const tank = usePartyStore.getState().members.find((member) => member.role === "tank");
expect(tank).toBeDefined();
expect(state.auras.filter((aura) => aura.definition.exclusiveGroup === "gm-party-buff:fortitude")
.map((aura) => aura.targetId)).toEqual([tank!.id]);
expect(state.maxHealth).toBe(playerMaxHealth);
for (const member of usePartyStore.getState().members) {
expect(member.maxHealth).toBeGreaterThan(memberMaxHealth.get(member.id)!);
expect(member.maxHealth > memberMaxHealth.get(member.id)!).toBe(member.role === "tank");
}
const buffedPlayerMaxHealth = state.maxHealth;
expect(state.auras.filter((aura) => (
aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.exclusiveGroup === "default-party-buff:fortitude"
))).toHaveLength(1);
expect(state.castAbility("wow335-priest-power-word-fortitude", undefined, 21_000).ok).toBe(true);
expect(useCombatStore.getState().maxHealth).toBe(buffedPlayerMaxHealth);
expect(useCombatStore.getState().auras.filter((aura) => (
aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.exclusiveGroup === "default-party-buff:fortitude"
))).toHaveLength(1);
usePartyBuffPolicyStore.setState({ config: { schemaVersion: 1, rules: [
{ abilityId: "wow335-priest-power-word-fortitude", scope: "party" },
] } });
useCombatStore.getState().applyDefaultPartyBuffs(21_000);
const targetIds = [PLAYER_AGGRO_ID, ...usePartyStore.getState().members.map((member) => member.id)];
expect(useCombatStore.getState().auras.filter((aura) => aura.definition.exclusiveGroup === "gm-party-buff:fortitude")
.map((aura) => aura.targetId).sort()).toEqual(targetIds.sort());
expect(useCombatStore.getState().maxHealth).toBe(437);
expect(state.castAbility("wow335-priest-power-word-fortitude", undefined, 21_500).ok).toBe(true);
expect(useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.exclusiveGroup === "gm-party-buff:fortitude")).toMatchObject([{ expiresAt: null }]);
usePartyBuffPolicyStore.setState({ config: { schemaVersion: 1, rules: [
{ abilityId: "wow335-priest-power-word-fortitude", scope: "healer" },
] } });
useCombatStore.getState().applyDefaultPartyBuffs(21_750);
const healerTargets = useCombatStore.getState().auras.filter((aura) => aura.definition.exclusiveGroup === "gm-party-buff:fortitude")
.map((aura) => aura.targetId);
expect(healerTargets).toEqual([
...(useCombatStore.getState().gearRole === "healer" ? [PLAYER_AGGRO_ID] : []),
...usePartyStore.getState().members.filter((member) => member.role === "healer").map((member) => member.id),
]);
usePartyBuffPolicyStore.setState({ config: DEFAULT_PARTY_BUFF_POLICY });
useCombatStore.getState().applyDefaultPartyBuffs(22_000);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.exclusiveGroup?.startsWith("gm-party-buff:"))).toBe(false);
expect(useCombatStore.getState().maxHealth).toBe(playerMaxHealth);
});
it("enters the dungeon with spec maintenance and permanent pets already prepared", () => {
+56 -16
View File
@@ -1,4 +1,6 @@
import { create } from "zustand";
import { permanentBuffEffects } from "./partyBuffPolicy";
import { usePartyBuffPolicyStore } from "./partyBuffPolicyStore";
import { combatNow } from "./combatClock";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { useGameStore } from "./store";
@@ -2166,7 +2168,7 @@ function auraDefinitionForAbility(
description: definition.description ?? ability.description,
icon: definition.icon ?? ability.icon,
durationMs,
...(defaultPartyBuffFamily ? {
...(defaultPartyBuffFamily && !definition.exclusiveGroup ? {
uniqueBySource: false,
exclusiveGroup: `default-party-buff:${defaultPartyBuffFamily}`,
} : {}),
@@ -4699,19 +4701,50 @@ export const useCombatStore = create<CombatState>((set, get) => ({
},
applyDefaultPartyBuffs: (now = combatNow()) => {
get().synchronizeActors();
const state = get();
const partyMembers = usePartyStore.getState().members;
set((state) => {
const auras = state.auras.filter((aura) => !aura.definition.exclusiveGroup?.startsWith("gm-party-buff:"));
if (auras.length === state.auras.length) return state;
const maxHealth = auraAdjustedPlayerMaxHealth(state.classId, state.level, state.talentModifiers,
state.gearStats, state.raceId, state.secondaryClassId, auras);
const health = state.health <= 0 ? 0 : clamp(state.health + maxHealth - state.maxHealth, 1, maxHealth);
const actors = { ...state.actors };
if (actors[PLAYER_AURA_ENTITY_ID]) actors[PLAYER_AURA_ENTITY_ID] = {
...actors[PLAYER_AURA_ENTITY_ID], health, maxHealth,
};
for (const member of partyMembers) {
const actor = actors[member.id];
if (!actor) continue;
const base = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId);
const delta = auraMaximumHealthBonus(base, auras, member.id)
- auraMaximumHealthBonus(base, state.auras, member.id);
const memberMaxHealth = Math.max(1, actor.maxHealth + delta);
actors[member.id] = {
...actor,
maxHealth: memberMaxHealth,
health: actor.health <= 0 ? 0 : clamp(actor.health + delta, 1, memberMaxHealth),
};
}
return { auras, health, maxHealth, actors };
});
get().synchronizeActors();
const state = get();
const rules = usePartyBuffPolicyStore.getState().config.rules;
const includePlayer = useDungeonSimulationStore.getState().sessionMode === "play";
const sources = [
...(includePlayer ? [{
id: PLAYER_AURA_ENTITY_ID,
classId: state.classId,
role: state.gearRole,
level: state.level,
abilities: state.abilities,
}] : []),
...partyMembers.filter((member) => member.health > 0).map((member) => ({
id: member.id,
classId: member.classId,
role: member.role,
level: member.level,
abilities: abilitiesForClass(member.classId),
abilities: abilitiesForCharacter(member.classId, member.secondaryClassId),
})),
];
const selected = new Map<string, {
@@ -4719,34 +4752,33 @@ export const useCombatStore = create<CombatState>((set, get) => ({
ability: AbilityDefinition;
effects: readonly Extract<AbilityEffect, { kind: "apply-aura" }>[];
potency: number;
scope: "party" | "tank" | "healer";
}>();
for (const source of sources) {
for (const catalogAbility of source.abilities) {
const family = DEFAULT_PARTY_BUFF_FAMILY.get(catalogAbility.name.toLowerCase());
if (!family || catalogAbility.passive || !isAbilityUnlocked(catalogAbility, source.level)) continue;
for (const rule of rules) {
for (const source of sources) {
const catalogAbility = source.abilities.find((ability) => ability.id === rule.abilityId);
if (!catalogAbility || !isAbilityUnlocked(catalogAbility, source.level)) continue;
const ability = abilityAtLevel(catalogAbility, source.level);
const effects = ability.effects.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => (
effect.kind === "apply-aura" && effect.aura.disposition === "buff"
));
const effects = permanentBuffEffects(ability);
if (!effects.length) continue;
const family = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()) ?? ability.id;
const potency = defaultPartyBuffPotency(effects);
if ((selected.get(family)?.potency ?? Number.NEGATIVE_INFINITY) >= potency) continue;
selected.set(family, { sourceId: source.id, ability, effects, potency });
selected.set(family, { sourceId: source.id, ability, effects, potency, scope: rule.scope });
}
}
const recipients = [
...(includePlayer ? [PLAYER_AURA_ENTITY_ID] : []),
...partyMembers.filter((member) => member.health > 0).map((member) => member.id),
];
for (const [family, buff] of selected) {
const recipients = sources.filter((source) => buff.scope === "party" || source.role === buff.scope)
.map((source) => source.id);
for (const recipientId of recipients) {
for (const effect of buff.effects) {
get().applyActorAura(buff.sourceId, recipientId, buff.ability, {
...effect,
aura: {
...effect.aura,
durationMs: null,
uniqueBySource: false,
exclusiveGroup: `default-party-buff:${family}`,
exclusiveGroup: `gm-party-buff:${family}`,
},
}, now);
}
@@ -6040,6 +6072,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: [targetId ?? PLAYER_AURA_ENTITY_ID];
for (const recipientId of recipients.filter((id) => !effect.excludeCaster || id !== PLAYER_AURA_ENTITY_ID)) {
const previousAuras = work.auras;
const family = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()) ?? ability.id;
if (previousAuras.some((aura) => aura.targetId === recipientId
&& aura.definition.exclusiveGroup === `gm-party-buff:${family}`)) continue;
if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue;
const auraResult = applyCombatAura(previousAuras, {
definition: auraDefinition,
@@ -7396,6 +7431,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const state = get();
if (!state.actors[sourceActorId] || (!state.actors[targetActorId] && !state.mobs[targetActorId])) return false;
if (ability.name === "Power Word: Shield" && hasWeakenedSoul(state.auras, targetActorId, now)) return false;
const family = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()) ?? ability.id;
const gmGroup = `gm-party-buff:${family}`;
if (effect.aura.exclusiveGroup !== gmGroup && state.auras.some((aura) => (
aura.targetId === targetActorId && aura.definition.exclusiveGroup === gmGroup
))) return true;
const durationMs = effect.aura.durationMs;
const definition = auraDefinitionForAbility(effect.aura, ability, durationMs);
const applied = applyCombatAura(state.auras, {
+32
View File
@@ -0,0 +1,32 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
import { dungeonArtworkContains, dungeonArtworkFloorFor, dungeonArtworkHeading, dungeonArtworkMapFor, dungeonArtworkPoint } from "./dungeonArtworkMap";
describe("RuneWaker dungeon maps", () => {
it("packages a checksummed, position-aware map with every RuneWaker dungeon", () => {
const dungeons = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion === "runewaker");
expect(dungeons).toHaveLength(30);
for (const dungeon of dungeons) {
const map = dungeonArtworkMapFor(dungeon);
expect(map, dungeon.id).not.toBeNull();
expect(map?.kind, dungeon.id).toBe("runewaker");
if (!map) continue;
const floor = dungeonArtworkFloorFor(map, dungeon.entrance.footPosition);
const image = readFileSync(`public${floor.image}`);
expect(image.toString("ascii", 1, 4), dungeon.id).toBe("PNG");
expect([image.readUInt32BE(16), image.readUInt32BE(20)]).toEqual([1002, 668]);
expect(createHash("sha256").update(image).digest("hex")).toBe(floor.checksum);
expect(dungeonArtworkContains(map, floor, dungeon.entrance.footPosition), dungeon.id).toBe(true);
expect(dungeonArtworkPoint(map, floor, dungeon.entrance.footPosition).every(Number.isFinite)).toBe(true);
expect(Math.hypot(...dungeonArtworkHeading(map, floor, dungeon.entrance.footPosition, 0.7))).toBeCloseTo(23, 5);
}
});
it("rejects stale maps when a dungeon environment changes", () => {
const dungeon = requireDungeonDefinition("forsaken-abbey");
expect(dungeonArtworkMapFor({ ...dungeon, provenance: { ...dungeon.provenance, coordinateTransform: "changed" } })).toBeNull();
expect(dungeonArtworkMapFor({ ...dungeon, assets: { ...dungeon.assets, visual: [] } })).toBeNull();
});
});
+78
View File
@@ -0,0 +1,78 @@
import runewakerCatalog from "./generated/runewakerDungeonMaps.generated.json";
import type { DungeonDefinition, Vector3Tuple } from "./dungeonTypes";
import {
wowDungeonMapFor,
wowMapFloorFor,
wowMapPoint,
type WowDungeonMap,
type WowMapFloor,
} from "./wowDungeonMap";
export interface RunewakerDungeonMap {
readonly mapId: number;
readonly coordinateTransform: string;
readonly visualChecksum: string;
readonly kind: "client" | "navigation";
readonly image: string;
readonly checksum: string;
readonly sourceHash: string;
/** Two affine rows converting runtime XYZ directly to image pixels. */
readonly runtimeToMap: readonly number[];
readonly width: number;
readonly height: number;
}
export interface ArtworkFloor {
readonly level: number;
readonly name: string;
readonly image: string;
readonly checksum: string;
}
export type DungeonArtworkMap =
| { readonly kind: "wow"; readonly source: WowDungeonMap; readonly floors: readonly WowMapFloor[]; readonly width: 1002; readonly height: 668 }
| { readonly kind: "runewaker"; readonly source: RunewakerDungeonMap; readonly floors: readonly ArtworkFloor[]; readonly width: number; readonly height: number };
const runewakerMaps = runewakerCatalog.dungeons as unknown as Readonly<Record<string, RunewakerDungeonMap>>;
export function dungeonArtworkMapFor(dungeon: DungeonDefinition): DungeonArtworkMap | null {
const wow = wowDungeonMapFor(dungeon);
if (wow) return { kind: "wow", source: wow, floors: wow.floors, width: 1002, height: 668 };
const source = runewakerMaps[dungeon.id];
if (source?.mapId !== dungeon.mapId
|| source.coordinateTransform !== dungeon.provenance.coordinateTransform
|| source.visualChecksum !== dungeon.assets.visual[0]?.checksum
|| source.runtimeToMap.length !== 8) return null;
return {
kind: "runewaker",
source,
floors: [{ level: 0, name: source.kind === "client" ? "Original dungeon map" : "Dungeon floorplan",
image: source.image, checksum: source.checksum }],
width: source.width,
height: source.height,
};
}
export function dungeonArtworkFloorFor(map: DungeonArtworkMap, position: Vector3Tuple, level?: number | null): ArtworkFloor {
return map.kind === "wow" ? wowMapFloorFor(map.source, position, level) : map.floors[0];
}
export function dungeonArtworkPoint(map: DungeonArtworkMap, floor: ArtworkFloor, position: Vector3Tuple): readonly [number, number] {
if (map.kind === "wow") return wowMapPoint(map.source, floor as WowMapFloor, position);
const matrix = map.source.runtimeToMap;
return [matrix[0] * position[0] + matrix[1] * position[1] + matrix[2] * position[2] + matrix[3],
matrix[4] * position[0] + matrix[5] * position[1] + matrix[6] * position[2] + matrix[7]];
}
export function dungeonArtworkContains(map: DungeonArtworkMap, floor: ArtworkFloor, position: Vector3Tuple): boolean {
const [x, y] = dungeonArtworkPoint(map, floor, position);
return Number.isFinite(x) && Number.isFinite(y) && x >= 0 && x <= map.width && y >= 0 && y <= map.height;
}
export function dungeonArtworkHeading(map: DungeonArtworkMap, floor: ArtworkFloor, position: Vector3Tuple, yaw: number): readonly [number, number] {
const [x, y] = dungeonArtworkPoint(map, floor, position);
const [targetX, targetY] = dungeonArtworkPoint(map, floor,
[position[0] + Math.sin(yaw), position[1], position[2] + Math.cos(yaw)]);
const length = Math.hypot(targetX - x, targetY - y) || 1;
return [(targetX - x) / length * 23, (targetY - y) / length * 23];
}
+22 -8
View File
@@ -13,10 +13,11 @@ import { DUNGEON_MANIFEST } from "./dungeonManifest";
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated";
import { compareDungeonReleaseOrder } from "./dungeonReleaseStage";
import availability from "./generated/dungeonAvailability.json";
import queuePolicyCatalog from "./generated/dungeonLeaderboardCatalog.generated.json";
describe("dungeon catalog", () => {
it("lists the complete campaign plus both bite-sized Stockades variants", () => {
expect(DUNGEON_CATALOG).toHaveLength(86);
it("lists the complete campaign and bite-sized variants", () => {
expect(DUNGEON_CATALOG).toHaveLength(89);
expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "stormwind-stockade")).toMatchObject({
id: "stormwind-stockade",
releaseStage: "beta",
@@ -51,13 +52,14 @@ describe("dungeon catalog", () => {
expect.objectContaining({ id: "forsaken-abbey" }),
);
expect(dungeonsByCategory("rom")).toHaveLength(30);
expect(dungeonsByCategory("bite-sized").map((dungeon) => dungeon.id))
.toEqual(["bite-sized-stormwind-stockade"]);
expect(dungeonsByCategory("bite-sized").map((dungeon) => dungeon.id).sort())
.toEqual(["bite-sized-deadmines-ship", "bite-sized-stormwind-stockade",
"bite-sized-utgarde-pinnacle-skadi", "bite-sized-wailing-caverns"]);
});
it("selects another available category dungeon and falls back to the sole bite-sized entry", () => {
it("selects another available category dungeon", () => {
expect(randomDungeonByCategory("bite-sized", "bite-sized-stormwind-stockade", () => 0)?.id)
.toBe("bite-sized-stormwind-stockade");
.not.toBe("bite-sized-stormwind-stockade");
expect(randomDungeonByCategory("wow", "stormwind-stockade", () => 0)?.id)
.not.toBe("stormwind-stockade");
});
@@ -87,6 +89,11 @@ describe("dungeon catalog", () => {
expect(dungeonById("missing-dungeon")).toBeNull();
});
it("keeps every queue candidate in the server policy validation catalog", () => {
const policyIds = new Set(queuePolicyCatalog.dungeons.map((dungeon) => dungeon.id));
expect(DUNGEON_CATALOG.every((dungeon) => policyIds.has(dungeon.id))).toBe(true);
});
it("provides centralized display copy for every entry", () => {
for (const dungeon of DUNGEON_CATALOG) {
expect(dungeon.location.trim()).not.toBe("");
@@ -101,8 +108,12 @@ describe("dungeon catalog", () => {
"randomized-bite-sized-stormwind-stockade",
"stormwind-stockade",
"bite-sized-stormwind-stockade",
"bite-sized-deadmines-ship",
"bite-sized-utgarde-pinnacle-skadi",
"bite-sized-wailing-caverns",
]);
expect(DUNGEON_CATALOG.filter((dungeon) => !dungeon.id.includes("stormwind-stockade"))
expect(DUNGEON_CATALOG.filter((dungeon) => !dungeon.id.includes("stormwind-stockade")
&& !dungeon.id.startsWith("bite-sized-"))
.every((dungeon) => dungeon.releaseStage === "alpha"))
.toBe(true);
for (const category of DUNGEON_CATEGORIES) {
@@ -123,6 +134,9 @@ describe("dungeon catalog", () => {
it("shares the complete dungeon identity list used to validate reports on the server", () => {
expect(DUNGEON_CATALOG.map((dungeon) => dungeon.id).sort())
.toEqual(["bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade", "forsaken-abbey", ...Object.keys(availability.enabled)].sort());
.toEqual(["bite-sized-deadmines-ship", "bite-sized-stormwind-stockade",
"bite-sized-utgarde-pinnacle-skadi", "bite-sized-wailing-caverns",
"randomized-bite-sized-stormwind-stockade", "forsaken-abbey",
...Object.keys(availability.enabled)].sort());
});
});
+2 -4
View File
@@ -2,9 +2,8 @@ import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonR
import { contentCategoryById, type ContentCategoryId } from "../app/contentCategories";
import { compareDungeonReleaseOrder, dungeonReleaseStage, type DungeonReleaseStage } from "./dungeonReleaseStage";
import {
BITE_SIZED_STOCKADES_DUNGEON_ID,
isBiteSizedDungeonId,
isRandomizedBiteSizedDungeonId,
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
} from "./dungeonContentSource";
const SOURCE_DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[];
@@ -43,8 +42,7 @@ export interface DungeonCatalogEntry {
export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze(
DUNGEON_DEFINITIONS.map<DungeonCatalogEntry>((definition) => ({
id: definition.id as DungeonId,
category: definition.id === BITE_SIZED_STOCKADES_DUNGEON_ID
|| definition.id === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID
category: isBiteSizedDungeonId(definition.id)
? "bite-sized"
: definition.expansion === "runewaker" ? "rom" : "wow",
title: definition.title,
+18
View File
@@ -1,8 +1,23 @@
export const STORMWIND_STOCKADE_DUNGEON_ID = "stormwind-stockade";
export const BITE_SIZED_STOCKADES_DUNGEON_ID = "bite-sized-stormwind-stockade";
export const RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID = "randomized-bite-sized-stormwind-stockade";
export const BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID = "bite-sized-wailing-caverns";
export const BITE_SIZED_DEADMINES_DUNGEON_ID = "bite-sized-deadmines-ship";
export const BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID = "bite-sized-utgarde-pinnacle-skadi";
export const RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID = "activity:randomized-bite-sized";
const BITE_SIZED_DUNGEON_IDS: ReadonlySet<string> = new Set([
BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
BITE_SIZED_DEADMINES_DUNGEON_ID,
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
]);
export function isBiteSizedDungeonId(dungeonId: string): boolean {
return BITE_SIZED_DUNGEON_IDS.has(dungeonId);
}
/** Randomized Bite-Sized locations share this id namespace as the mode expands. */
export function isRandomizedBiteSizedDungeonId(dungeonId: string): boolean {
return dungeonId.startsWith("randomized-bite-sized-");
@@ -11,6 +26,9 @@ export function isRandomizedBiteSizedDungeonId(dungeonId: string): boolean {
const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.freeze({
[BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
[RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
[BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID]: "wailing-caverns",
[BITE_SIZED_DEADMINES_DUNGEON_ID]: "deadmines",
[BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID]: "utgarde-pinnacle",
});
export function isDerivedStockadesDungeonId(dungeonId: string): boolean {
+1 -1
View File
@@ -35,7 +35,7 @@ describe("dungeon leaderboard catalog", () => {
})).sort((a, b) => a.id.localeCompare(b.id)),
classes: CLASSES.map((entry) => ({ id: entry.id, name: entry.name })),
};
expect(catalog.dungeons).toHaveLength(86);
expect(catalog.dungeons).toHaveLength(89);
expect(catalog.dungeons.filter((dungeon) => dungeon.bosses.length > 0 && !dungeon.finalBossId)).toEqual([]);
const path = "src/game/generated/dungeonLeaderboardCatalog.generated.json";
if (process.env.WRITE_DUNGEON_LEADERBOARD_CATALOG === "1") writeFileSync(path, `${JSON.stringify(catalog, null, 2)}\n`);
+40
View File
@@ -0,0 +1,40 @@
import type { DungeonCatalogEntry } from "./dungeonCatalog";
export type DungeonQueueMode = "regular" | "biteSized" | "randomizedBiteSized";
export interface DungeonQueuePolicy {
readonly schemaVersion: 1;
readonly disabled: Readonly<Record<DungeonQueueMode, readonly string[]>>;
}
export const DEFAULT_DUNGEON_QUEUE_POLICY: DungeonQueuePolicy = {
schemaVersion: 1,
disabled: { regular: [], biteSized: [], randomizedBiteSized: [] },
};
export function queueModeForDungeon(dungeon: DungeonCatalogEntry): DungeonQueueMode {
if (dungeon.category !== "bite-sized") return "regular";
return dungeon.id.startsWith("randomized-bite-sized-") ? "randomizedBiteSized" : "biteSized";
}
export function dungeonEnabledForQueue(policy: DungeonQueuePolicy, dungeon: DungeonCatalogEntry): boolean {
return !policy.disabled[queueModeForDungeon(dungeon)].includes(dungeon.id);
}
export function normalizeDungeonQueuePolicy(value: unknown): DungeonQueuePolicy {
if (!value || typeof value !== "object" || !("schemaVersion" in value) || value.schemaVersion !== 1
|| !("disabled" in value) || !value.disabled || typeof value.disabled !== "object") {
throw new Error("Invalid dungeon queue policy.");
}
const disabled = value.disabled as Record<string, unknown>;
for (const mode of ["regular", "biteSized", "randomizedBiteSized"] as const) {
if (!Array.isArray(disabled[mode]) || !disabled[mode].every((id: unknown) => typeof id === "string")) {
throw new Error("Invalid dungeon queue policy.");
}
}
return { schemaVersion: 1, disabled: {
regular: [...disabled.regular as string[]],
biteSized: [...disabled.biteSized as string[]],
randomizedBiteSized: [...disabled.randomizedBiteSized as string[]],
} };
}
+32
View File
@@ -0,0 +1,32 @@
{
"baselineMs": 240000,
"milestones": [
{ "at": 0.18, "progress": 20, "label": "First pack cleared" },
{ "at": 0.44, "progress": 48, "label": "Midpoint reached" },
{ "at": 0.72, "progress": 77, "label": "Final approach" },
{ "at": 0.9, "progress": 93, "label": "Boss engaged" },
{ "at": 1, "progress": 100, "label": "Boss defeated" }
],
"tiers": [
{ "id": "novice", "label": "Novice", "multiplier": 1.35 },
{ "id": "regular", "label": "Regular", "multiplier": 1.1 },
{ "id": "veteran", "label": "Veteran", "multiplier": 0.9 },
{ "id": "elite", "label": "Elite", "multiplier": 0.75 }
],
"teams": {
"wow": [
{ "name": "Darin", "classId": "warrior", "role": "tank" },
{ "name": "Mara", "classId": "priest", "role": "healer" },
{ "name": "Ilyra", "classId": "mage", "role": "damage" },
{ "name": "Rowan", "classId": "hunter", "role": "damage" },
{ "name": "Voss", "classId": "rogue", "role": "damage" }
],
"rom": [
{ "name": "Aldric", "classId": "rom-warrior", "role": "tank" },
{ "name": "Liora", "classId": "rom-priest", "role": "healer" },
{ "name": "Kael", "classId": "rom-scout", "role": "damage" },
{ "name": "Morgha", "classId": "rom-rogue", "role": "damage" },
{ "name": "Elyra", "classId": "rom-mage", "role": "damage" }
]
}
}
+17
View File
@@ -28,6 +28,11 @@ import {
dungeonContentSourceId,
} from "./dungeonContentSource";
import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades";
import {
createBiteSizedDeadminesDefinition,
createBiteSizedUtgardePinnacleDefinition,
createBiteSizedWailingCavernsDefinition,
} from "./dungeons/biteSizedWoWDungeons";
import {
createRandomizedBiteSizedStockadesDefinition,
randomizedBiteSizedCreatureCatalog,
@@ -203,6 +208,12 @@ const adaptedSourceEntries = sourceDungeonRegistryEntries.map(
const adaptedSourceRegistry = Object.fromEntries(adaptedSourceEntries) as Readonly<Record<string, DungeonDefinition>>;
const stockadesDefinition = adaptedSourceRegistry[STORMWIND_STOCKADE_DUNGEON_ID];
if (!stockadesDefinition) throw new Error("Stormwind Stockade is required for its bite-sized variant.");
const wailingCavernsDefinition = adaptedSourceRegistry["wailing-caverns"];
const deadminesDefinition = adaptedSourceRegistry["deadmines"];
const utgardePinnacleDefinition = adaptedSourceRegistry["utgarde-pinnacle"];
if (!wailingCavernsDefinition || !deadminesDefinition || !utgardePinnacleDefinition) {
throw new Error("Wailing Caverns, The Deadmines, and Utgarde Pinnacle are required for their bite-sized variants.");
}
export const RANDOMIZED_BITE_SIZED_CREATURE_CATALOG = Object.freeze(
randomizedBiteSizedCreatureCatalog(adaptedSourceEntries.map(([, definition]) => definition)),
@@ -217,10 +228,16 @@ export function randomizedBiteSizedDefinitionForSeed(seed: string): DungeonDefin
}
const biteSizedStockadesTemplate = createBiteSizedStockadesDefinition(stockadesDefinition, "catalog-template");
const randomizedBiteSizedStockadesTemplate = randomizedBiteSizedDefinitionForSeed("catalog-template");
const biteSizedWailingCaverns = createBiteSizedWailingCavernsDefinition(wailingCavernsDefinition);
const biteSizedDeadmines = createBiteSizedDeadminesDefinition(deadminesDefinition);
const biteSizedUtgardePinnacle = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition);
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
...adaptedSourceEntries,
[biteSizedStockadesTemplate.id, biteSizedStockadesTemplate],
[randomizedBiteSizedStockadesTemplate.id, randomizedBiteSizedStockadesTemplate],
[biteSizedWailingCaverns.id, biteSizedWailingCaverns],
[biteSizedDeadmines.id, biteSizedDeadmines],
[biteSizedUtgardePinnacle.id, biteSizedUtgardePinnacle],
];
export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = Object.freeze(
+3 -2
View File
@@ -1,3 +1,5 @@
import { isBiteSizedDungeonId } from "./dungeonContentSource";
export const DUNGEON_RELEASE_STAGES = ["full-release", "beta", "alpha"] as const;
export type DungeonReleaseStage = (typeof DUNGEON_RELEASE_STAGES)[number];
@@ -9,8 +11,7 @@ export const DUNGEON_RELEASE_STAGE_LABELS: Readonly<Record<DungeonReleaseStage,
export function dungeonReleaseStage(id: string): DungeonReleaseStage {
return id === "stormwind-stockade"
|| id === "bite-sized-stormwind-stockade"
|| id === "randomized-bite-sized-stormwind-stockade"
|| isBiteSizedDungeonId(id)
? "beta"
: "alpha";
}
+485
View File
@@ -0,0 +1,485 @@
import type {
DungeonBossObjective,
DungeonDefinition,
DungeonEncounterSpawnGroupDefinition,
Vector3Tuple,
} from "../dungeonTypes";
import type {
DungeonEncounterAction,
DungeonEncounterDefinition,
DungeonEncounterRule,
} from "../dungeonEncounterTypes";
import type {
MobPackDefinition,
PopulationEntityDefinition,
StaticMobSpawnDefinition,
} from "../mobPopulation";
import type { DungeonWorldObjectDefinition } from "../dungeonWorldObjects";
import {
BITE_SIZED_DEADMINES_DUNGEON_ID,
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
} from "../dungeonContentSource";
import {
WAILING_NARALEX_ENCOUNTER,
WAILING_NARALEX_SPAWN_GROUPS,
WAILING_NARALEX_START_POSITION,
WAILING_NARALEX_ESCORT_WAYPOINTS,
} from "../wailingCavernsEncounter";
const WAILING_ROUTE_WIDTH = 36;
const DEADMINES_ROUTE_WIDTH = 34;
const SKADI_ROUTE_WIDTH = 32;
function normalizedName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function distanceToSegment(point: Vector3Tuple, start: Vector3Tuple, end: Vector3Tuple): number {
const direction = end.map((value, axis) => value - start[axis]) as unknown as Vector3Tuple;
const offset = point.map((value, axis) => value - start[axis]) as unknown as Vector3Tuple;
const lengthSquared = direction.reduce((total, value) => total + value * value, 0);
const projection = lengthSquared === 0
? 0
: Math.max(0, Math.min(1, offset.reduce((total, value, axis) => total + value * direction[axis], 0) / lengthSquared));
return Math.hypot(...point.map((value, axis) => value - start[axis] - direction[axis] * projection));
}
function distanceToRoute(point: Vector3Tuple, route: readonly Vector3Tuple[]): number {
if (route.length < 2) return Number.POSITIVE_INFINITY;
return Math.min(...route.slice(1).map((end, index) => distanceToSegment(point, route[index], end)));
}
function routeSpawns(
source: DungeonDefinition,
route: readonly Vector3Tuple[],
width: number,
): readonly StaticMobSpawnDefinition[] {
return source.staticSpawns.filter((spawn) => (
source.entities[spawn.entityId]?.kind !== "boss"
&& distanceToRoute(spawn.position, route) <= width
));
}
function routeRoamingPacks(
source: DungeonDefinition,
route: readonly Vector3Tuple[],
width: number,
): readonly MobPackDefinition[] {
return source.roamingPacks.flatMap((pack) => {
const members = pack.members.filter((member) => source.entities[member.entityId]?.kind !== "boss");
return members.length > 0 && pack.waypoints.every((point) => distanceToRoute(point, route) <= width * 1.7)
? [{ ...pack, members }]
: [];
});
}
function entitiesFor(
source: DungeonDefinition,
staticSpawns: readonly StaticMobSpawnDefinition[],
roamingPacks: readonly MobPackDefinition[],
groups: readonly DungeonEncounterSpawnGroupDefinition[],
additionalEntityIds: readonly string[] = [],
): Readonly<Record<string, PopulationEntityDefinition>> {
const usedIds = new Set([
...staticSpawns.map((spawn) => spawn.entityId),
...roamingPacks.flatMap((pack) => pack.members.map((member) => member.entityId)),
...groups.flatMap((group) => group.spawns.map((spawn) => spawn.entityId)),
...additionalEntityIds,
]);
const entities: Record<string, PopulationEntityDefinition> = {};
for (const entityId of usedIds) {
const entity = source.entities[entityId];
if (!entity) throw new Error(`Bite-sized route references missing entity: ${entityId}`);
entities[entityId] = entity;
}
return entities;
}
function bossByName(source: DungeonDefinition, name: string): {
readonly objective: DungeonBossObjective;
readonly spawn: StaticMobSpawnDefinition;
readonly entity: PopulationEntityDefinition;
} {
const normalized = normalizedName(name);
const objective = source.objectiveOrder.find((candidate) => normalizedName(candidate.name) === normalized);
const spawn = objective && source.staticSpawns.find((candidate) => candidate.id === objective.id);
const entity = spawn && source.entities[spawn.entityId];
if (!objective || !spawn || !entity || entity.kind !== "boss") {
throw new Error(`${source.id} is missing its ${name} boss source.`);
}
return { objective, spawn, entity };
}
function routeArea(id: string, name: string, center: Vector3Tuple, radius = 36) {
return { id, name, center, radius };
}
function runCompletionActions(prefix: string, objectiveId: string): readonly DungeonEncounterAction[] {
return [
{ id: `${prefix}-complete-objective`, kind: "complete-objective", objectiveId, persistence: "run" },
{ id: `${prefix}-checkpoint`, kind: "set-checkpoint", checkpointId: objectiveId, persistence: "checkpoint" },
{ id: `${prefix}-mark-dungeon-complete`, kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: `${prefix}-complete-phase`, kind: "set-phase", phase: "complete", persistence: "run" },
];
}
function appendPrototypeWarning(source: DungeonDefinition, note: string): DungeonDefinition["validation"] {
return {
...source.validation,
warnings: [...source.validation.warnings, note],
};
}
export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinition): DungeonDefinition {
if (source.id !== "wailing-caverns") throw new Error("The Wailing Caverns slice requires its source dungeon.");
const sourceFinalBoss = bossByName(source, "Mutanus the Devourer");
const route = [WAILING_NARALEX_START_POSITION, ...WAILING_NARALEX_ESCORT_WAYPOINTS];
const staticSpawns = routeSpawns(source, route, WAILING_ROUTE_WIDTH);
const roamingPacks = routeRoamingPacks(source, route, WAILING_ROUTE_WIDTH);
const mutanusSpawn: StaticMobSpawnDefinition = {
...sourceFinalBoss.spawn,
position: sourceFinalBoss.objective.position,
};
const mutanusGroupId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:mutanus`;
const mutanusGroup: DungeonEncounterSpawnGroupDefinition = {
id: mutanusGroupId,
spawns: [mutanusSpawn],
};
const encounterId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:naralex-escort`;
const encounter: DungeonEncounterDefinition = {
...WAILING_NARALEX_ENCOUNTER,
id: encounterId,
dungeonId: BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
initialPhase: "ready",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "ready", checkpointPhase: "ready" },
source: {
...WAILING_NARALEX_ENCOUNTER.source,
reviewedBehavior: "A standalone run begins at the source escort start, follows the Naralex waypoint route, defends the Disciple through the source waves, and finishes with Mutanus.",
adaptations: [
...WAILING_NARALEX_ENCOUNTER.source.adaptations,
"The four Fanglords are skipped; the bite-sized run begins at the actual Naralex escort start.",
"Mutanus is activated by the encounter runtime as the final event spawn in this derived definition.",
],
},
rules: WAILING_NARALEX_ENCOUNTER.rules
.filter((rule) => rule.id !== "fanglords-defeated" && rule.id !== "restore-ready-after-checkpoint")
.map((rule) => {
if (rule.id === "start-escort") {
return {
...rule,
trigger: { kind: "signal", signalId: `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:naralex-start` },
};
}
if (rule.id === "nightmare-wave-cleared") {
return {
...rule,
actions: [
...rule.actions,
{ id: "activate-mutanus", kind: "activate-spawn-group", spawnGroupId: mutanusGroupId },
],
};
}
return rule;
}),
};
const encounterGroups = [
...WAILING_NARALEX_SPAWN_GROUPS,
mutanusGroup,
];
const finalObjective: DungeonBossObjective = {
...sourceFinalBoss.objective,
eventOnly: true,
};
return {
...source,
id: BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
title: "Wailing Caverns: Naralex’s Rescue",
lfgDungeonIds: [],
entrance: {
...source.entrance,
name: "Disciple of Naralex’s Camp",
footPosition: WAILING_NARALEX_START_POSITION,
forward: [-0.433, 0, 0.902],
yaw: -0.448,
},
presentation: {
...source.presentation,
location: "Wailing Caverns · Naralex’s Route",
theme: "Bite-Sized Dungeon",
summary: "Follow the winding escort route, defend Naralex’s Disciple through the awakening waves, and defeat Mutanus.",
loadingMessage: "Opening the path to the Dreamer’s Rock...",
unchartedAreaName: "Naralex’s Route",
},
areas: [
routeArea("entrance", "Disciple of Naralex’s Camp", WAILING_NARALEX_START_POSITION, 26),
routeArea("naralex-route", "The Winding Caverns", WAILING_NARALEX_ESCORT_WAYPOINTS[12], 64),
routeArea("mutanus", "Mutanus’s Chamber", sourceFinalBoss.objective.position, 42),
],
entities: entitiesFor(source, staticSpawns, roamingPacks, encounterGroups),
staticSpawns,
roamingPacks,
bosses: [finalObjective],
objectiveOrder: [finalObjective],
finalBossDurability: "standard",
encounters: [encounter],
encounterSpawnGroups: encounterGroups,
serverGameObjects: source.serverGameObjects,
validation: appendPrototypeWarning(
source,
"Naralex’s route corridor and bite-sized objective list are Healer Man-authored; the source cave environment, escort waypoints, wave rosters, and Mutanus combat data are shared with Wailing Caverns.",
),
};
}
export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): DungeonDefinition {
if (source.id !== "deadmines") throw new Error("The Deadmines slice requires its source dungeon.");
const captain = bossByName(source, "Captain Greenskin");
const finalBoss = bossByName(source, "Edwin VanCleef");
const route: readonly Vector3Tuple[] = [
[-785, 17, -28],
[-797.2147, 20.4973, -22.8489],
[-807, 30, -42],
captain.objective.position,
finalBoss.objective.position,
];
const routeTrash = routeSpawns(source, route, DEADMINES_ROUTE_WIDTH);
const roamingPacks = routeRoamingPacks(source, route, DEADMINES_ROUTE_WIDTH);
const vanCleefSpawn: StaticMobSpawnDefinition = {
...finalBoss.spawn,
position: finalBoss.objective.position,
};
const vanCleefGroupId = `${BITE_SIZED_DEADMINES_DUNGEON_ID}:van-cleef`;
const vanCleefGroup: DungeonEncounterSpawnGroupDefinition = {
id: vanCleefGroupId,
spawns: [vanCleefSpawn],
};
const encounterId = `${BITE_SIZED_DEADMINES_DUNGEON_ID}:ship-progression`;
const encounter: DungeonEncounterDefinition = {
id: encounterId,
dungeonId: BITE_SIZED_DEADMINES_DUNGEON_ID,
initialPhase: "greenskin",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "greenskin", checkpointPhase: "greenskin" },
source: {
fidelity: "healer-man-authored",
scope: "boss-progression",
files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/generated/dungeonCampaignCatalog.compressed.ts"],
reviewedBehavior: "Start at the ship ramp, clear the Defias crew, defeat Captain Greenskin, then confront Edwin VanCleef in his cabin.",
adaptations: [
"The mine, foundry, Cookie branch, and other Deadmines bosses are omitted from this run.",
"VanCleef is held in an encounter spawn group until Greenskin is defeated.",
"Source boss IDs are retained for objective records and loot lookup.",
],
remainingGaps: [
"The ship route boundary and the optional Cookie branch need live traversal review; this first pass filters population by a spatial corridor.",
"Original boss-specific scripted actions beyond their imported combat profiles are not included in this two-boss slice.",
],
},
rules: [
{
id: "ship-greenskin-defeated",
trigger: { kind: "entities-dead", entityIds: [captain.objective.id] },
guards: [{ kind: "phase", phases: ["greenskin"] }],
actions: [
{ id: "ship-complete-greenskin", kind: "complete-objective", objectiveId: captain.objective.id, persistence: "run" },
{ id: "ship-checkpoint-greenskin", kind: "set-checkpoint", checkpointId: captain.objective.id, persistence: "checkpoint" },
{ id: "ship-activate-vancleef", kind: "activate-spawn-group", spawnGroupId: vanCleefGroupId },
{ id: "ship-enter-vancleef-phase", kind: "set-phase", phase: "van-cleef" },
],
},
{
id: "ship-vancleef-defeated",
trigger: { kind: "entities-dead", entityIds: [vanCleefSpawn.id] },
guards: [{ kind: "phase", phases: ["van-cleef"] }],
actions: runCompletionActions("ship-complete-vancleef", finalBoss.objective.id),
},
],
};
const encounterGroups = [vanCleefGroup];
const objectives: readonly DungeonBossObjective[] = [
captain.objective,
{ ...finalBoss.objective, eventOnly: true },
];
const startPosition: Vector3Tuple = [-785, 17, -28];
return {
...source,
id: BITE_SIZED_DEADMINES_DUNGEON_ID,
title: "The Deadmines: Take the Ship",
lfgDungeonIds: [],
entrance: {
...source.entrance,
name: "Mr. Smite’s Ship Ramp",
footPosition: startPosition,
forward: [-0.5, 0, -0.866],
yaw: -2.618,
},
presentation: {
...source.presentation,
location: "The Deadmines · The Defias Ship",
theme: "Bite-Sized Dungeon",
summary: "Board the Defias ship, push through the crew, defeat Captain Greenskin, and finish VanCleef in the cabin.",
loadingMessage: "Boarding the Defias ship...",
unchartedAreaName: "Defias Ship",
},
areas: [
routeArea("entrance", "Mr. Smite’s Ship Ramp", startPosition, 25),
routeArea("captain-greenskin", "Greenskin’s Deck", captain.objective.position, 32),
routeArea("edwin-vancleef", "VanCleef’s Cabin", finalBoss.objective.position, 28),
],
entities: entitiesFor(source, routeTrash, roamingPacks, encounterGroups, [captain.spawn.entityId]),
staticSpawns: [...routeTrash, captain.spawn],
roamingPacks,
bosses: objectives,
objectiveOrder: objectives,
finalBossDurability: "standard",
encounters: [encounter],
encounterSpawnGroups: encounterGroups,
serverGameObjects: source.serverGameObjects.filter((object) => (
distanceToRoute(object.position, route) <= DEADMINES_ROUTE_WIDTH * 1.7
)),
validation: appendPrototypeWarning(
source,
"The Deadmines ship route and two-boss progression are Healer Man-authored; source ship geometry, crew, Greenskin, and VanCleef assets are shared with The Deadmines.",
),
};
}
const SKADI_GROUP_IDS = new Set([
"utgarde-pinnacle:skadi-first-wave",
"utgarde-pinnacle:skadi-reinforcement-one",
"utgarde-pinnacle:skadi-reinforcement-two",
"utgarde-pinnacle:skadi-ground",
]);
const SKADI_RULE_IDS = new Set([
"utgarde-pinnacle-start-skadi",
"utgarde-pinnacle-first-wave-cleared",
"utgarde-pinnacle-fire-harpoon-one",
"utgarde-pinnacle-reinforcement-one-cleared",
"utgarde-pinnacle-fire-harpoon-two",
"utgarde-pinnacle-reinforcement-two-cleared",
"utgarde-pinnacle-fire-harpoon-three",
"utgarde-pinnacle-skadi-defeated",
]);
function skadiSliceRule(rule: DungeonEncounterRule, objectiveId: string): DungeonEncounterRule {
if (rule.id !== "utgarde-pinnacle-skadi-defeated") return rule;
return {
...rule,
trigger: { kind: "entities-dead", entityIds: [objectiveId], timeoutMs: 300_000 },
actions: [
...rule.actions.filter((action) => (
action.kind === "deactivate-spawn-group"
|| action.kind === "complete-objective"
|| action.kind === "set-checkpoint"
)),
{ id: "utgarde-pinnacle-skadi-slice-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: "utgarde-pinnacle-skadi-slice-finish", kind: "set-phase", phase: "complete", persistence: "run" },
],
};
}
export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefinition): DungeonDefinition {
if (source.id !== "utgarde-pinnacle") throw new Error("The Skadi slice requires Utgarde Pinnacle.");
const skadi = source.objectiveOrder.find((objective) => normalizedName(objective.name) === "skaditheruthless");
const sourceEncounter = source.encounters?.find((encounter) => (
encounter.rules.some((rule) => rule.id === "utgarde-pinnacle-start-skadi")
));
const sourceSkadiGroup = source.encounterSpawnGroups?.find((group) => group.id === "utgarde-pinnacle:skadi-ground");
const skadiStart = source.serverGameObjects.find((object) => object.id === "utgarde-pinnacle:skadi-start");
const harpoons = [1, 2, 3].map((index) => source.serverGameObjects.find((object) => (
object.id === `utgarde-pinnacle:harpoon-${index}`
)));
if (!skadi || !sourceEncounter || !sourceSkadiGroup || !skadiStart || harpoons.some((object) => !object)) {
throw new Error("Utgarde Pinnacle is missing the reviewed Skadi gauntlet content.");
}
const route: readonly Vector3Tuple[] = [skadiStart.position, skadi.position];
const staticSpawns = routeSpawns(source, route, SKADI_ROUTE_WIDTH);
const roamingPacks = routeRoamingPacks(source, route, SKADI_ROUTE_WIDTH);
const skadiSpawn: StaticMobSpawnDefinition = {
...sourceSkadiGroup.spawns[0],
id: skadi.id,
position: skadi.position,
};
const skadiGroup: DungeonEncounterSpawnGroupDefinition = {
...sourceSkadiGroup,
spawns: [skadiSpawn],
};
const groups = source.encounterSpawnGroups!.filter((group) => SKADI_GROUP_IDS.has(group.id))
.map((group) => group.id === skadiGroup.id ? skadiGroup : group);
if (!groups.some((group) => group.id === skadiGroup.id)) groups.push(skadiGroup);
const encounter: DungeonEncounterDefinition = {
...sourceEncounter,
id: `${BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID}:skadi-gauntlet`,
dungeonId: BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
initialPhase: "skadi-ready",
initialState: {
flags: { "dungeon-complete": false },
counters: { "harpoon-hits": 0 },
worldObjectStates: {
[skadiStart.id]: "ready",
...Object.fromEntries(harpoons.map((object) => [object!.id, "unavailable"])),
},
},
resetPolicy: { bossWipePhase: "skadi-ready", checkpointPhase: "skadi-ready" },
source: {
...sourceEncounter.source!,
reviewedBehavior: "Begin at Skadi’s gauntlet, clear the source first wave, fire three harpoons through the bounded reinforcement packs, then defeat Skadi.",
adaptations: [
...sourceEncounter.source!.adaptations,
"The Svala and Gortok stages are skipped; the source Skadi event begins ready at its gauntlet trigger.",
"Skadi’s event actor uses the source boss objective ID so party targeting, loot, and completion stay aligned.",
],
},
rules: sourceEncounter.rules.filter((rule) => SKADI_RULE_IDS.has(rule.id))
.map((rule) => skadiSliceRule(rule, skadi.id)),
};
const objective: DungeonBossObjective = { ...skadi, eventOnly: true };
const routeNames = new Map<string, DungeonWorldObjectDefinition>([
[skadiStart.id, skadiStart],
...harpoons.map((object) => [object!.id, object!] as const),
]);
return {
...source,
id: BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
title: "Utgarde Pinnacle: Skadi’s Gauntlet",
lfgDungeonIds: [],
entrance: {
...source.entrance,
name: "Eagle’s Eye Gauntlet",
footPosition: skadiStart.position,
forward: [-0.167, 0, -0.986],
yaw: -2.974,
},
presentation: {
...source.presentation,
location: "Utgarde Pinnacle · Eagle’s Eye",
theme: "Bite-Sized Dungeon",
summary: "Enter Skadi’s gauntlet, push through the moving waves, fire the harpoons, and finish the fight on the ground.",
loadingMessage: "Preparing Skadi’s gauntlet...",
unchartedAreaName: "Eagle’s Eye",
},
areas: [
routeArea("entrance", "Eagle’s Eye Gauntlet", skadiStart.position, 30),
routeArea("skadi", "Skadi’s Arena", skadi.position, 34),
],
entities: entitiesFor(source, staticSpawns, roamingPacks, groups),
staticSpawns,
roamingPacks,
bosses: [objective],
objectiveOrder: [objective],
finalBossDurability: "standard",
encounters: [encounter],
encounterSpawnGroups: groups,
serverGameObjects: [...routeNames.values()],
validation: appendPrototypeWarning(
source,
"The standalone Skadi route is Healer Man-authored; source gauntlet waves, harpoon controls, Skadi combat, and the Utgarde Pinnacle environment are reused. Grauf’s flying vehicle phase remains adapted to ground-based harpoon interactions.",
),
};
}
@@ -216,6 +216,33 @@
],
"defaultDifficultyId": "normal"
},
{
"id": "bite-sized-deadmines-ship",
"title": "The Deadmines: Take the Ship",
"finalBossId": "creature-79336",
"bosses": [
{
"id": "creature-79333",
"name": "Captain Greenskin"
},
{
"id": "creature-79336",
"name": "Edwin VanCleef"
}
],
"difficulties": [
{
"id": "normal",
"name": "normal",
"bossIds": [
"creature-79333",
"creature-79336"
],
"finalBossId": "creature-79336"
}
],
"defaultDifficultyId": "normal"
},
{
"id": "bite-sized-stormwind-stockade",
"title": "Stormwind Stockade: Cellblock Rush",
@@ -265,6 +292,58 @@
],
"defaultDifficultyId": "normal"
},
{
"id": "bite-sized-utgarde-pinnacle-skadi",
"title": "Utgarde Pinnacle: Skadi’s Gauntlet",
"finalBossId": "creature-126103",
"bosses": [
{
"id": "creature-126103",
"name": "Skadi the Ruthless"
}
],
"difficulties": [
{
"id": "normal",
"name": "normal",
"bossIds": [
"creature-126103"
],
"finalBossId": "creature-126103"
},
{
"id": "heroic",
"name": "heroic",
"bossIds": [
"creature-126103"
],
"finalBossId": "creature-126103"
}
],
"defaultDifficultyId": "normal"
},
{
"id": "bite-sized-wailing-caverns",
"title": "Wailing Caverns: Naralex’s Rescue",
"finalBossId": "mutanus-spawn",
"bosses": [
{
"id": "mutanus-spawn",
"name": "Mutanus the Devourer"
}
],
"difficulties": [
{
"id": "normal",
"name": "normal",
"bossIds": [
"mutanus-spawn"
],
"finalBossId": "mutanus-spawn"
}
],
"defaultDifficultyId": "normal"
},
{
"id": "black-morass",
"title": "The Black Morass",
@@ -0,0 +1,635 @@
{
"schemaVersion": 1,
"dungeons": {
"aeternal-circle": {
"mapId": 144,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "de1ba06b7e3a9237fcff75aff2b00093287975e755cc7e98e77de1b7af6a9c14",
"kind": "client",
"image": "/assets/game/dungeons/aeternal-circle/maps/runewaker-map.png",
"checksum": "18e3f483734f5322a36b1e682480534b51875d1b99f8384d5d1fd45d7bc2c22f",
"sourceHash": "a555d52a8669d2aa5990ee55a71f476e063592d7140e6e934ac433d02273050d",
"runtimeToMap": [
0.94305882,
0.0,
0.0,
496.56034639,
0.0,
0.0,
0.94305882,
408.53672013
],
"width": 1002,
"height": 668
},
"barren-caves": {
"mapId": 110,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "e6dc49e6aa6fd20cee1b2afa43324a714f8f8284a6becef8dc514509b432f9bb",
"kind": "client",
"image": "/assets/game/dungeons/barren-caves/maps/runewaker-map.png",
"checksum": "0695bbba846094cd54a01edede7eae8c9ecd6c071ba6cc6fd2125c592fb82d00",
"sourceHash": "2ae51435420b81ae9cf423bff9bd2a8251d00d399a641c51c4403c8060a5423e",
"runtimeToMap": [
2.31008646,
0.0,
0.0,
318.25748741,
0.0,
0.0,
2.31008646,
320.74642415
],
"width": 1002,
"height": 668
},
"cave-water-dragon": {
"mapId": 118,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "dd1887f88d6ee9102da0e34ea2b4087742e37a2b8ce779c722bc124effe1faca",
"kind": "client",
"image": "/assets/game/dungeons/cave-water-dragon/maps/runewaker-map.png",
"checksum": "587745b4a040cefc1eb3e585825861d85ddaa6522dff60d5d8175a5895e8d2a2",
"sourceHash": "20526f42f0a71c1e038633e7a6f6ee767120782966d3f576ff249079e5c08bb3",
"runtimeToMap": [
1.01725888,
0.0,
0.0,
372.47804085,
0.0,
0.0,
1.01725888,
590.52902634
],
"width": 1002,
"height": 668
},
"demon-stronghold-125": {
"mapId": 125,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "e2106115dcc216999d2e37d26eeb52844ce4f41b44e19594699f37a54d7f19b9",
"kind": "navigation",
"image": "/assets/game/dungeons/demon-stronghold-125/maps/runewaker-map.png",
"checksum": "4816bface105c5bfccb579f1adbd602576c5e68691b0b594e0c3fb0ad93225ed",
"sourceHash": "a19dbc7ddd5e84ffedac76889a8673d8238c8a2240d7c7427886be5088b9863f",
"runtimeToMap": [
0.75675676,
0.0,
0.0,
448.97233788,
0.0,
0.0,
-0.75675676,
202.75896062
],
"width": 1002,
"height": 668
},
"demon-stronghold": {
"mapId": 122,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "664181dc790b9e68cdc7451dbd0b1170a033a71ca19375743b6e66ca25889723",
"kind": "navigation",
"image": "/assets/game/dungeons/demon-stronghold/maps/runewaker-map.png",
"checksum": "ce2e98b267e6b54559bd43c33f6d0c378e2c4e4721f7292610abc7e962bce72a",
"sourceHash": "0d10681eec4a2a1648b4d2699371672fea4736e5bc38457ea360916a32f1ba39",
"runtimeToMap": [
0.64494899,
0.0,
0.0,
456.65921583,
0.0,
0.0,
-0.64494899,
265.58661492
],
"width": 1002,
"height": 668
},
"dungeon-of-dalanis": {
"mapId": 127,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "45cad819bc348dec8926f7de237c625e9d18697b4200804e0440dd16b8976959",
"kind": "client",
"image": "/assets/game/dungeons/dungeon-of-dalanis/maps/runewaker-map.png",
"checksum": "674203d27fa37fbb3e8e01642995b1c1e5e31526cf05533ff8c904b354ffd632",
"sourceHash": "c74e6acccad66e905a746816d1c979b6e8ef766c18ceecd966bfc84196872e96",
"runtimeToMap": [
1.86418605,
0.0,
0.0,
134.43856543,
0.0,
0.0,
1.86418605,
169.54665566
],
"width": 1002,
"height": 668
},
"forsaken-abbey": {
"mapId": 102,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "b534f66c693ac57f560e8a7aeeff59ee955b8de4bf97dd8f0aa368e10d43e79f",
"kind": "client",
"image": "/assets/game/dungeons/forsaken-abbey/maps/runewaker-map.png",
"checksum": "004fb7c759eacdbb81bcc66586f33578c332511e33cc0daf53bdcf1cc418b15d",
"sourceHash": "fd5e150607472b0dc718900aa5cebbf8ed7da32860003e3416f11536bc08efd5",
"runtimeToMap": [
1.84417178,
0.0,
0.0,
143.07589407,
0.0,
0.0,
1.84417178,
309.91862641
],
"width": 1002,
"height": 668
},
"grafu-castle": {
"mapId": 137,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "b775778f457d639e3f21982aaa6bd3ee1b0eba951c711d1ba319b0588e24a38f",
"kind": "navigation",
"image": "/assets/game/dungeons/grafu-castle/maps/runewaker-map.png",
"checksum": "5db7bfe6de91a60321f1d46a42d3d7540570dcd0944125a1dbe6434d21068b1d",
"sourceHash": "f3925c7005706d5a11dfd9fc6e41c4f8af6993d22b4c84c62419346b16c4556e",
"runtimeToMap": [
1.44970412,
0.0,
0.0,
521.18524298,
0.0,
0.0,
-1.44970412,
278.88786182
],
"width": 1002,
"height": 668
},
"hall-of-survivors": {
"mapId": 117,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "96b91b24462b8428e8808295dcea29d26de9be8431ea6667dde92cdded4ddf2a",
"kind": "client",
"image": "/assets/game/dungeons/hall-of-survivors/maps/runewaker-map.png",
"checksum": "672593945d1f7938590d11f4612a49c0bd82e3e2adec04ca165677101e8e00ce",
"sourceHash": "2dcb8c2b86319314403da3643ffc9aeecc00e3a01e807bc928b5e9ce0960bd86",
"runtimeToMap": [
0.87702407,
0.0,
0.0,
470.48853677,
0.0,
0.0,
0.87702407,
327.67835904
],
"width": 1002,
"height": 668
},
"hoto": {
"mapId": 115,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "beff3a180c55166d5ebd7e3610e65b59f8104edfd7596aa22ac3a49c578fcdc0",
"kind": "navigation",
"image": "/assets/game/dungeons/hoto/maps/runewaker-map.png",
"checksum": "7acb38dc1a3c3a557e8b3a8e661221f7d16d1517d5ab287f3651332156b24963",
"sourceHash": "a2bde2e4b8ed0d0bac8c4669a05192ff0a35ae9c64df0c0a29fe3735b65cbf4b",
"runtimeToMap": [
1.75313054,
0.0,
0.0,
534.5109662,
0.0,
0.0,
-1.75313054,
310.51824005
],
"width": 1002,
"height": 668
},
"ice-dwarf-kingdom": {
"mapId": 114,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "41cef8ce1bf6dbf7df0c4e2797a325e4d50a3870d435d46684600894d4aa92e7",
"kind": "navigation",
"image": "/assets/game/dungeons/ice-dwarf-kingdom/maps/runewaker-map.png",
"checksum": "c1f48800e8e29b3a137664bc6bcdcd46e806784490daa416349ef930e6bb57f9",
"sourceHash": "05c1a2a9541b5a463b7032b47e509f66bb4db6466b279d3612b7f6add9cd0c46",
"runtimeToMap": [
1.27853874,
0.0,
0.0,
529.59821325,
0.0,
0.0,
-1.27853874,
220.50323225
],
"width": 1002,
"height": 668
},
"kal-turok-hive": {
"mapId": 105,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "75c0c596218cec07003e2def1d74c8e1f5ca3010ed370db558a1786b003f4f1c",
"kind": "client",
"image": "/assets/game/dungeons/kal-turok-hive/maps/runewaker-map.png",
"checksum": "09e9b2ae4d6fac6c96dc12d7bf1da3cc276031450f7810a6f8bef855e6373487",
"sourceHash": "b03787597089a676b1f81144f3e9602fc760fcf7e207269d2820d450cd69b3d3",
"runtimeToMap": [
3.48521739,
0.0,
0.0,
490.60879608,
0.0,
0.0,
3.48521739,
509.76018772
],
"width": 1002,
"height": 668
},
"kalins-shrine": {
"mapId": 107,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "023c43a56253e97fa0526185c61f76e0e9fa8398b7a35771728566c331cd22a9",
"kind": "navigation",
"image": "/assets/game/dungeons/kalins-shrine/maps/runewaker-map.png",
"checksum": "3660de8c80eb07f0612c24586f51149ff03a14ae82b43abf9a11db60bcf59275",
"sourceHash": "6b77496f7b46890d401f955f9254ae37d25d9a1287a3bc9e787e9a254f9d9071",
"runtimeToMap": [
0.8824853,
0.0,
0.0,
632.14257291,
0.0,
0.0,
-0.8824853,
59.90035597
],
"width": 1002,
"height": 668
},
"kawaks-tomb": {
"mapId": 134,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "3f1755780540c54a1ba9b528e646ebd39b12ab127e41fa2d497fd7749d202018",
"kind": "client",
"image": "/assets/game/dungeons/kawaks-tomb/maps/runewaker-map.png",
"checksum": "6fedf7501b2560b06bc8bd8d474bec5530231318ef6c0a1aa19cd2cb61766843",
"sourceHash": "dd8f5e3e95a3df505e891a6477d40f4f011311ff6fd6bde9ef6d703a29a66ac9",
"runtimeToMap": [
0.91090909,
0.0,
0.0,
514.89942508,
0.0,
0.0,
0.91090909,
608.00390825
],
"width": 1002,
"height": 668
},
"kulech-bones-nest": {
"mapId": 147,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "b06b42b3c7f19abf9d645eec6be78f868cf6966e0b472e2ca4a1c36d3cf4d2d1",
"kind": "client",
"image": "/assets/game/dungeons/kulech-bones-nest/maps/runewaker-map.png",
"checksum": "b3246989b267d600bc6d6185697ecf02771928779d31bd4594920bb8b3a76760",
"sourceHash": "066ee37932c543e6715243fcef3446f70a8eb9aa2a90758d7346262e46823df5",
"runtimeToMap": [
1.59363817,
0.0,
0.0,
508.5092438,
0.0,
0.0,
1.59363817,
342.87627637
],
"width": 1002,
"height": 668
},
"lair-demon-dragon": {
"mapId": 119,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "b371465e96ef978915c9a515846ecf9dccb98607fd549617a1c71f45468fa5a7",
"kind": "navigation",
"image": "/assets/game/dungeons/lair-demon-dragon/maps/runewaker-map.png",
"checksum": "cf2a67781897d772cb1178aa77539ad4700622699dc99954c5abdafc6fedd562",
"sourceHash": "3bb62814056d133d0f5942f6b5fb7c4c4f4178ea9df311f0363298beb0c6a8f9",
"runtimeToMap": [
1.55186061,
0.0,
0.0,
558.97494417,
0.0,
0.0,
-1.55186061,
292.85074742
],
"width": 1002,
"height": 668
},
"mystic-altar": {
"mapId": 104,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "c37f27643598c8fe6401828be2950bc08f299dfe491df37736fda4662c70a96e",
"kind": "navigation",
"image": "/assets/game/dungeons/mystic-altar/maps/runewaker-map.png",
"checksum": "245f4414aa6daa7251cdeae277800a1d03566fd9ee8aea7e08c62205bc4d191b",
"sourceHash": "3dbf8ce85b83f0d515b6925a784a36191f4b9e760b56205a8b20bb927e389ed1",
"runtimeToMap": [
0.62499999,
0.0,
0.0,
627.42226927,
0.0,
0.0,
-0.62499999,
339.38813581
],
"width": 1002,
"height": 668
},
"paspers-shrine": {
"mapId": 106,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "bcf6443e8adb8b7f7dd81f4a97b79aac1c99756a83289bbada31853d2d9adb7c",
"kind": "navigation",
"image": "/assets/game/dungeons/paspers-shrine/maps/runewaker-map.png",
"checksum": "d353dd3e3e7bbea7985fa24efe167bbf7eb88f71890ebdf27a5d56c10cbba7b7",
"sourceHash": "4ebba3fb97a99df49c7301dbfcdb8154634cf4e56a85c5ed587c7515221d0a10",
"runtimeToMap": [
1.19512195,
0.0,
0.0,
462.10446651,
0.0,
0.0,
-1.19512195,
248.31707243
],
"width": 1002,
"height": 668
},
"raksha-temple": {
"mapId": 130,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "1fad5149acfce870be822f5355369f15dd76fba280bbeda31ff7f371c442082f",
"kind": "client",
"image": "/assets/game/dungeons/raksha-temple/maps/runewaker-map.png",
"checksum": "5f46c29a4fcd7b3a537d72996a785b41db8d3dd7b52485292ea8135b2effba68",
"sourceHash": "7bc925e7f463099c596ffceebfc26969b1f8549952da81688f18da8b80070f7d",
"runtimeToMap": [
0.49975062,
0.0,
0.0,
592.25597645,
0.0,
0.0,
0.49975062,
354.44431688
],
"width": 1002,
"height": 668
},
"sand-vortex": {
"mapId": 121,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "48be6befbd24870a948a5a0f49f22098b44a5a3502c4749e292c42bde1df7baa",
"kind": "navigation",
"image": "/assets/game/dungeons/sand-vortex/maps/runewaker-map.png",
"checksum": "5cf410dbfab40f4337dad936ebf4db9a7bbe161a0e8d46674b678b8e3a4aa0e7",
"sourceHash": "8dab93dfda9145a6d10c1526281bee6d531e80426300c704b348ab562c7843ef",
"runtimeToMap": [
6.87719298,
0.0,
0.0,
508.32039709,
0.0,
0.0,
-6.87719298,
151.2326885
],
"width": 1002,
"height": 668
},
"sardo-castle": {
"mapId": 139,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "191396625e301a41afab38e168848a198a9be5918a071dadda841683a7c6261e",
"kind": "client",
"image": "/assets/game/dungeons/sardo-castle/maps/runewaker-map.png",
"checksum": "b3f418fd1e8a539e475a2823387791b8ae87cc7c2ec56d8590beb12c63d398f0",
"sourceHash": "f435dae1726d80a9903dc278bd1933a4f6a9afe59ca213846366c63e3a8c41af",
"runtimeToMap": [
1.52106262,
0.0,
0.0,
493.75955465,
0.0,
0.0,
1.52106262,
322.77658515
],
"width": 1002,
"height": 668
},
"the-origin": {
"mapId": 116,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "1f96c2b57a24470a003834a8c91cb0ceda5b01824d5bfa31904afd247c404e88",
"kind": "navigation",
"image": "/assets/game/dungeons/the-origin/maps/runewaker-map.png",
"checksum": "42b4240eae2118a030bd91145218f4be28c2a5a72b9cdbbbbcf5e1249fc35bbc",
"sourceHash": "70c0a78073d7c3cd7a95e0dc3531c25c0de9815376001f9b80cfbb59a72596cd",
"runtimeToMap": [
0.63947793,
0.0,
0.0,
554.13766371,
0.0,
0.0,
-0.63947793,
351.65470791
],
"width": 1002,
"height": 668
},
"tomb-seven-heroes": {
"mapId": 142,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "09250a1fbabf8a03f922c9a3950aee3daa1edc54215cd03ddc9bf5c96e1429bd",
"kind": "navigation",
"image": "/assets/game/dungeons/tomb-seven-heroes/maps/runewaker-map.png",
"checksum": "81260997140c71fc7c903e70325143eb4c113cafdf4dbe5fef9c7f44f3c5aaad",
"sourceHash": "617cac4b74b580ca214c241a9ffa9574aa19d1cd59efac71e4f9c4899709df51",
"runtimeToMap": [
1.23192956,
0.0,
0.0,
461.29778231,
0.0,
0.0,
-1.23192956,
281.72970787
],
"width": 1002,
"height": 668
},
"treasure-trove": {
"mapId": 108,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "c56d429187fd2ffbe20e11d5e7bbfd2128a779c9251fa2ab4b12286caf0de0b6",
"kind": "navigation",
"image": "/assets/game/dungeons/treasure-trove/maps/runewaker-map.png",
"checksum": "3d0a053648aa902ac2d6677f281590aaf783ab3f8116751b6ce70d410d1939b9",
"sourceHash": "73a70fcf482f7d13c9140901d8dd66c5b339a300f0aece69b00e863b83652657",
"runtimeToMap": [
0.74686104,
0.0,
0.0,
520.62262966,
0.0,
0.0,
-0.74686104,
296.69564946
],
"width": 1002,
"height": 668
},
"warnorken-arena": {
"mapId": 129,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "ee81c9e7c94d624977143297ca6c070c40799fdda1c51b92044c70dff53987ea",
"kind": "client",
"image": "/assets/game/dungeons/warnorken-arena/maps/runewaker-map.png",
"checksum": "74506c606c6fc4b2f5fe08b2147de60f8258932e3ca3d473b0299983e9ed0111",
"sourceHash": "6756f969314429818c12aedcf74ae399d1b8505a3c9ea65fb0b04b8014876265",
"runtimeToMap": [
1.13115715,
0.0,
-1.26195978,
515.13353291,
1.26195978,
0.0,
1.13115715,
335.10954034
],
"width": 1002,
"height": 668
},
"ystra-labyrinth-boss1": {
"mapId": 109,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "5e35eeccf5e207477bfb3a38dee75d53a5c5467433a90270b79a9ba8619a6260",
"kind": "client",
"image": "/assets/game/dungeons/ystra-labyrinth-boss1/maps/runewaker-map.png",
"checksum": "bee49409ca78c29ef428f841f7dfbbe70e7a1ea85e61a248209f269d6d54d661",
"sourceHash": "ce8efa65bf8d1e28557f36270551a6089567c6e76f743d30a501d83c1d44c374",
"runtimeToMap": [
4.175,
0.0,
0.0,
499.74712348,
0.0,
0.0,
4.175,
339.02184443
],
"width": 1002,
"height": 668
},
"ystra-labyrinth-boss2": {
"mapId": 111,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "be717e3d6e6301ff60762552283386d73da04240e6a7d1e36933e83e02d0250a",
"kind": "client",
"image": "/assets/game/dungeons/ystra-labyrinth-boss2/maps/runewaker-map.png",
"checksum": "18210f5f16aa2ed7c0f5d2356cb3a8bb4382bf5043fe396f103fbb38464b0e97",
"sourceHash": "02c2e7792242aedaa15075d24a1e6e571533f5e57e38cf94e810c2db35977022",
"runtimeToMap": [
4.175,
0.0,
0.0,
499.74712348,
0.0,
0.0,
4.175,
339.02184443
],
"width": 1002,
"height": 668
},
"ystra-labyrinth-boss3": {
"mapId": 112,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "be717e3d6e6301ff60762552283386d73da04240e6a7d1e36933e83e02d0250a",
"kind": "client",
"image": "/assets/game/dungeons/ystra-labyrinth-boss3/maps/runewaker-map.png",
"checksum": "0397004fbf5d52a9f887d350dc9d52f0974228fc9bd8b28664d1848a5c6d8a63",
"sourceHash": "f4552058b24988dfe546bee975f815f007872cd2ed09838fbda4f014da609328",
"runtimeToMap": [
4.175,
0.0,
0.0,
499.74712348,
0.0,
0.0,
4.175,
339.02184443
],
"width": 1002,
"height": 668
},
"zurhidon-stronghold-124": {
"mapId": 124,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "58be5be365b14e42772a4560c5c1d10fceb8aa8abb2c1771cc21ba2b2e02872a",
"kind": "navigation",
"image": "/assets/game/dungeons/zurhidon-stronghold-124/maps/runewaker-map.png",
"checksum": "e9fe448187cf87f46317b36967bc6eeb97f7b56be41a495aca2616c0d4b9cbee",
"sourceHash": "e57509105e15e67b1e4f3075c1c98f082552001a566e01a81fc1c8be9af41f29",
"runtimeToMap": [
0.86457869,
0.0,
0.0,
412.42758975,
0.0,
0.0,
-0.86457869,
55.43724674
],
"width": 1002,
"height": 668
},
"zurhidon-stronghold": {
"mapId": 120,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "58be5be365b14e42772a4560c5c1d10fceb8aa8abb2c1771cc21ba2b2e02872a",
"kind": "navigation",
"image": "/assets/game/dungeons/zurhidon-stronghold/maps/runewaker-map.png",
"checksum": "e9fe448187cf87f46317b36967bc6eeb97f7b56be41a495aca2616c0d4b9cbee",
"sourceHash": "e57509105e15e67b1e4f3075c1c98f082552001a566e01a81fc1c8be9af41f29",
"runtimeToMap": [
0.86457869,
0.0,
0.0,
412.42758975,
0.0,
0.0,
-0.86457869,
55.43724674
],
"width": 1002,
"height": 668
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { preloadAbilityCatalogsForClasses, sourceAbilitiesForClass } from "./abilityCatalog";
import { permanentBuffEffects } from "./partyBuffPolicy";
describe("permanent party buff catalog", () => {
it("offers learned active stat buffs without treating passive or hostile auras as GM candidates", async () => {
await preloadAbilityCatalogsForClasses(["priest"]);
const priest = sourceAbilitiesForClass("priest");
const fortitude = priest.find((ability) => ability.id === "wow335-priest-power-word-fortitude");
expect(fortitude).toBeDefined();
expect(permanentBuffEffects(fortitude!)).not.toHaveLength(0);
expect(priest.filter((ability) => ability.passive || ability.target === "hostile")
.every((ability) => permanentBuffEffects(ability).length === 0)).toBe(true);
});
});
+33
View File
@@ -0,0 +1,33 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
export type PartyBuffScope = "party" | "tank" | "healer";
export interface PartyBuffRule { readonly abilityId: string; readonly scope: PartyBuffScope }
export interface PartyBuffPolicy { readonly schemaVersion: 1; readonly rules: readonly PartyBuffRule[] }
export const DEFAULT_PARTY_BUFF_POLICY: PartyBuffPolicy = { schemaVersion: 1, rules: [] };
export function normalizePartyBuffPolicy(value: unknown): PartyBuffPolicy {
if (!value || typeof value !== "object") return DEFAULT_PARTY_BUFF_POLICY;
const policy = value as Partial<PartyBuffPolicy>;
if (policy.schemaVersion !== 1 || !Array.isArray(policy.rules)) return DEFAULT_PARTY_BUFF_POLICY;
const seen = new Set<string>();
return {
schemaVersion: 1,
rules: policy.rules.flatMap((rule) => {
if (!rule || typeof rule.abilityId !== "string" || !/^[a-z0-9][a-z0-9:._-]{0,127}$/i.test(rule.abilityId)
|| !["party", "tank", "healer"].includes(rule.scope) || seen.has(rule.abilityId)) return [];
seen.add(rule.abilityId);
return [{ abilityId: rule.abilityId, scope: rule.scope }];
}).slice(0, 500),
};
}
export function permanentBuffEffects(ability: AbilityDefinition): readonly Extract<AbilityEffect, { kind: "apply-aura" }>[] {
if (ability.passive || ability.target === "hostile" || ability.talentNodeId || ability.talentEntryId !== undefined) return [];
if (ability.effects.some((effect) => effect.kind !== "apply-aura" && effect.kind !== "unsupported")) return [];
return ability.effects.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => (
effect.kind === "apply-aura" && effect.aura.disposition === "buff"
&& !effect.aura.procs?.length && !effect.aura.controlImmunities?.length
&& Boolean(effect.aura.modifiers?.length)
));
}
+81
View File
@@ -0,0 +1,81 @@
import { create } from "zustand";
import type { PlayerSession } from "../app/types";
import { requestOnlineJson } from "../app/onlineAccountClient";
import { DEFAULT_PARTY_BUFF_POLICY, normalizePartyBuffPolicy, type PartyBuffPolicy } from "./partyBuffPolicy";
interface PublishedPolicy {
readonly revision: number;
readonly updatedAt: number | null;
readonly updatedBy?: string | null;
readonly config: PartyBuffPolicy;
}
interface PolicyState extends PublishedPolicy {
readonly draft: PartyBuffPolicy;
readonly loading: boolean;
readonly saving: boolean;
readonly dirty: boolean;
readonly error: string | null;
refresh: (session?: PlayerSession | null) => Promise<void>;
replaceDraft: (draft: PartyBuffPolicy) => void;
resetDraft: () => void;
publish: (session: PlayerSession | null) => Promise<boolean>;
}
const CACHE_KEY = "healer-man.party-buff-policy.v1";
function cached(): PublishedPolicy {
try {
const value = JSON.parse(window.localStorage.getItem(CACHE_KEY) ?? "null") as PublishedPolicy;
if (!Number.isInteger(value.revision)) throw new Error("missing");
return { ...value, config: normalizePartyBuffPolicy(value.config) };
} catch {
return { revision: 0, updatedAt: null, config: DEFAULT_PARTY_BUFF_POLICY };
}
}
function cache(payload: PublishedPolicy): void {
try { window.localStorage.setItem(CACHE_KEY, JSON.stringify(payload)); } catch { /* optional cache */ }
}
const initial = cached();
export const usePartyBuffPolicyStore = create<PolicyState>((set, get) => ({
...initial,
draft: initial.config,
loading: false,
saving: false,
dirty: false,
error: null,
refresh: async (session = null) => {
set({ loading: true, error: null });
try {
const gm = Boolean(session?.accessToken && session.roles?.includes("gm"));
const payload = await requestOnlineJson<PublishedPolicy>(
gm ? "/api/gm/party-buff-policy" : "/api/party-buff-policy", {}, gm ? session?.accessToken : undefined,
);
const normalized = { ...payload, config: normalizePartyBuffPolicy(payload.config) };
cache(normalized);
set({ ...normalized, draft: normalized.config, loading: false, dirty: false });
} catch (error) {
set({ loading: false, error: error instanceof Error ? error.message : "Could not load party buff policy." });
}
},
replaceDraft: (draft) => set({ draft: normalizePartyBuffPolicy(draft), dirty: true, error: null }),
resetDraft: () => set((state) => ({ draft: state.config, dirty: false, error: null })),
publish: async (session) => {
if (!session?.accessToken || !session.roles?.includes("gm")) {
set({ error: "Game Master access is required." });
return false;
}
set({ saving: true, error: null });
try {
const payload = await requestOnlineJson<PublishedPolicy>("/api/gm/party-buff-policy", {
method: "PUT", body: JSON.stringify({ expectedRevision: get().revision, config: get().draft }),
}, session.accessToken);
const normalized = { ...payload, config: normalizePartyBuffPolicy(payload.config) };
cache(normalized);
set({ ...normalized, draft: normalized.config, saving: false, dirty: false });
return true;
} catch (error) {
set({ saving: false, error: error instanceof Error ? error.message : "Could not publish party buff policy." });
return false;
}
},
}));
+3
View File
@@ -38,6 +38,7 @@ export interface GameState {
sessionRevision: number;
dungeonIntroActive: boolean;
dungeonIntroCompletedAt: number | null;
raceStartBlocked: boolean;
overlay: GameOverlay;
paused: boolean;
mapOpen: boolean;
@@ -108,6 +109,7 @@ export const useGameStore = create<GameState>((set) => ({
sessionRevision: 0,
dungeonIntroActive: false,
dungeonIntroCompletedAt: null,
raceStartBlocked: false,
overlay: null,
paused: false,
mapOpen: false,
@@ -148,6 +150,7 @@ export const useGameStore = create<GameState>((set) => ({
sessionRevision: state.sessionRevision + 1,
dungeonIntroActive: options?.showDungeonIntro ?? false,
dungeonIntroCompletedAt: null,
raceStartBlocked: false,
overlay: null,
paused: false,
mapOpen: false,
+11 -7
View File
@@ -48,6 +48,10 @@ interface WailingReferenceShape {
}
const REFERENCE = (referenceData as unknown as WailingReferenceShape).wailingCaverns;
export const WAILING_NARALEX_START_POSITION = REFERENCE.disciple.position;
export const WAILING_NARALEX_ESCORT_WAYPOINTS = Object.freeze(
REFERENCE.disciple.escortWaypoints.map((waypoint) => waypoint.position),
);
const FANG_LORD_RUNTIME_IDS = Object.freeze([
"lady-anacondra-spawn",
"lord-cobrahn-spawn",
@@ -343,34 +347,34 @@ export function wailingDisciplePosition(
phaseStartedAt: number,
now = Date.now(),
): MobVector3 {
const waypoints = REFERENCE.disciple.escortWaypoints;
const waypoints = WAILING_NARALEX_ESCORT_WAYPOINTS;
if (!waypoints.length || phase === "locked" || phase === "ready") {
return REFERENCE.disciple.position;
return WAILING_NARALEX_START_POSITION;
}
let start = 0;
let end = 4;
let duration = 8_000;
if (phase === "ravager-wave") return waypoints[4]?.position ?? REFERENCE.disciple.position;
if (phase === "ravager-wave") return waypoints[4] ?? WAILING_NARALEX_START_POSITION;
if (phase === "escort-serpents") {
start = 5;
end = 11;
duration = 12_000;
} else if (phase === "serpent-wave") {
return waypoints[11]?.position ?? REFERENCE.disciple.position;
return waypoints[11] ?? WAILING_NARALEX_START_POSITION;
} else if (phase === "cleansing") {
start = 12;
end = waypoints.length - 1;
duration = 32_500;
} else {
return waypoints.at(-1)?.position ?? REFERENCE.disciple.position;
return waypoints.at(-1) ?? WAILING_NARALEX_START_POSITION;
}
const progress = Math.max(0, Math.min(1, (now - phaseStartedAt) / duration));
const scaled = start + (end - start) * progress;
const lower = Math.floor(scaled);
const upper = Math.min(end, lower + 1);
const alpha = scaled - lower;
const left = waypoints[lower]?.position ?? REFERENCE.disciple.position;
const right = waypoints[upper]?.position ?? left;
const left = waypoints[lower] ?? WAILING_NARALEX_START_POSITION;
const right = waypoints[upper] ?? left;
return [
left[0] + (right[0] - left[0]) * alpha,
left[1] + (right[1] - left[1]) * alpha,