forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
9c5fb4c614
commit
bd073fb00f
113 files changed
+4902
-273
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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[]],
|
||||
} };
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user