Files
healer-man/src/game/wailingCavernsEncounter.ts
T
2026-08-14 15:56:39 -04:00

231 lines
7.2 KiB
TypeScript

import { create } from "zustand";
import referenceData from "./generated/azerothCoreReference.json";
import type { MobCombatState } from "./combatStore";
import type { MobVector3, StaticMobSpawnDefinition } from "./mobPopulation";
export type WailingEncounterPhase =
| "locked"
| "ready"
| "escort-ravagers"
| "ravager-wave"
| "escort-serpents"
| "serpent-wave"
| "cleansing"
| "nightmare-wave"
| "mutanus"
| "complete";
interface WailingReferenceShape {
readonly wailingCaverns: {
readonly disciple: {
readonly position: MobVector3;
readonly escortWaypoints: readonly {
readonly point: number;
readonly position: MobVector3;
}[];
readonly summonGroups: readonly {
readonly group: number;
readonly entry: number;
readonly position: MobVector3;
readonly orientation: number;
}[];
};
};
}
const REFERENCE = (referenceData as unknown as WailingReferenceShape).wailingCaverns;
const FANG_LORD_RUNTIME_IDS = Object.freeze([
"lady-anacondra-spawn",
"lord-cobrahn-spawn",
"lord-pythas-spawn",
"lord-serpentis-spawn",
]);
const RAVAGER_RUNTIME_IDS = Object.freeze([
"naralex-event:ravager:1",
"naralex-event:ravager:2",
]);
const SERPENT_RUNTIME_PREFIX = "naralex-event:serpent:";
const NIGHTMARE_RUNTIME_PREFIX = "naralex-event:nightmare:";
const ENTRY_ENTITY: Readonly<Record<number, string>> = Object.freeze({
3636: "deviate-ravager",
5048: "deviate-adder",
5755: "deviate-viper",
5763: "nightmare-ectoplasm",
});
const RAVAGER_SPAWNS: readonly StaticMobSpawnDefinition[] = Object.freeze([
{
id: RAVAGER_RUNTIME_IDS[0],
entityId: "deviate-ravager",
position: [73.57, -93.66, 214.39],
yaw: -2.34,
spawnMask: 1,
},
{
id: RAVAGER_RUNTIME_IDS[1],
entityId: "deviate-ravager",
position: [70.85, -93.49, 211.6],
yaw: -2.34,
spawnMask: 1,
},
]);
function summonGroupSpawns(
group: number,
prefix: string,
): readonly StaticMobSpawnDefinition[] {
return Object.freeze(REFERENCE.disciple.summonGroups
.filter((summon) => summon.group === group)
.map((summon, index) => ({
id: `${prefix}${index + 1}`,
entityId: ENTRY_ENTITY[summon.entry] ?? "nightmare-ectoplasm",
position: summon.position,
yaw: summon.orientation,
spawnMask: 1,
})));
}
const SERPENT_SPAWNS = summonGroupSpawns(1, SERPENT_RUNTIME_PREFIX);
const NIGHTMARE_SPAWNS = summonGroupSpawns(2, NIGHTMARE_RUNTIME_PREFIX);
function allDeadOrTimedOut(
mobs: Readonly<Record<string, MobCombatState>>,
ids: readonly string[],
now: number,
phaseStartedAt: number,
timeoutMs: number,
): boolean {
const registered = ids.filter((id) => mobs[id]);
return (
registered.length === ids.length && registered.every((id) => mobs[id].dead)
) || now - phaseStartedAt >= timeoutMs;
}
function idsWithPrefix(mobs: Readonly<Record<string, MobCombatState>>, prefix: string): string[] {
return Object.keys(mobs).filter((id) => id.startsWith(prefix));
}
function fangLordsDefeated(mobs: Readonly<Record<string, MobCombatState>>): boolean {
return FANG_LORD_RUNTIME_IDS.every((id) => mobs[id]?.dead);
}
export interface WailingEncounterState {
readonly phase: WailingEncounterPhase;
readonly phaseStartedAt: number;
readonly revision: number;
reset: () => void;
start: (now?: number) => boolean;
advance: (mobs: Readonly<Record<string, MobCombatState>>, now?: number) => void;
}
export const useWailingEncounterStore = create<WailingEncounterState>((set, get) => ({
phase: "locked",
phaseStartedAt: 0,
revision: 0,
reset: () => set((state) => ({
phase: "locked",
phaseStartedAt: 0,
revision: state.revision + 1,
})),
start: (now = Date.now()) => {
if (get().phase !== "ready") return false;
set({ phase: "escort-ravagers", phaseStartedAt: now });
return true;
},
advance: (mobs, now = Date.now()) => set((state) => {
let phase = state.phase;
if (phase === "locked" && fangLordsDefeated(mobs)) phase = "ready";
else if (phase === "escort-ravagers" && now - state.phaseStartedAt >= 8_000) {
phase = "ravager-wave";
} else if (
phase === "ravager-wave"
&& allDeadOrTimedOut(mobs, RAVAGER_RUNTIME_IDS, now, state.phaseStartedAt, 60_000)
) {
phase = "escort-serpents";
} else if (phase === "escort-serpents" && now - state.phaseStartedAt >= 12_000) {
phase = "serpent-wave";
} else if (phase === "serpent-wave") {
const ids = idsWithPrefix(mobs, SERPENT_RUNTIME_PREFIX);
if (
(ids.length === SERPENT_SPAWNS.length && ids.every((id) => mobs[id].dead))
|| now - state.phaseStartedAt >= 60_000
) phase = "cleansing";
} else if (phase === "cleansing" && now - state.phaseStartedAt >= 32_500) {
phase = "nightmare-wave";
} else if (phase === "nightmare-wave") {
const ids = idsWithPrefix(mobs, NIGHTMARE_RUNTIME_PREFIX);
if (
(ids.length === NIGHTMARE_SPAWNS.length && ids.every((id) => mobs[id].dead))
|| now - state.phaseStartedAt >= 90_000
) phase = "mutanus";
} else if (phase === "mutanus" && mobs["mutanus-spawn"]?.dead) {
phase = "complete";
}
return phase === state.phase
? state
: { phase, phaseStartedAt: now, revision: state.revision + 1 };
}),
}));
export function wailingEventSpawns(
phase: WailingEncounterPhase,
): readonly StaticMobSpawnDefinition[] {
if (phase === "ravager-wave") return RAVAGER_SPAWNS;
if (phase === "serpent-wave") return SERPENT_SPAWNS;
if (phase === "nightmare-wave") return NIGHTMARE_SPAWNS;
return [];
}
export function wailingStaticSpawnsForPhase(
spawns: readonly StaticMobSpawnDefinition[],
phase: WailingEncounterPhase,
): readonly StaticMobSpawnDefinition[] {
const filtered = spawns.filter((spawn) => (
spawn.id !== "faerie-dragon-spawn"
&& (spawn.id !== "mutanus-spawn" || phase === "mutanus" || phase === "complete")
));
return [...filtered, ...wailingEventSpawns(phase)];
}
export function wailingDisciplePosition(
phase: WailingEncounterPhase,
phaseStartedAt: number,
now = Date.now(),
): MobVector3 {
const waypoints = REFERENCE.disciple.escortWaypoints;
if (!waypoints.length || phase === "locked" || phase === "ready") {
return REFERENCE.disciple.position;
}
let start = 0;
let end = 4;
let duration = 8_000;
if (phase === "ravager-wave") return waypoints[4]?.position ?? REFERENCE.disciple.position;
if (phase === "escort-serpents") {
start = 5;
end = 11;
duration = 12_000;
} else if (phase === "serpent-wave") {
return waypoints[11]?.position ?? REFERENCE.disciple.position;
} else if (phase === "cleansing") {
start = 12;
end = waypoints.length - 1;
duration = 32_500;
} else {
return waypoints.at(-1)?.position ?? REFERENCE.disciple.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;
return [
left[0] + (right[0] - left[0]) * alpha,
left[1] + (right[1] - left[1]) * alpha,
left[2] + (right[2] - left[2]) * alpha,
];
}