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> = 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>, 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>, prefix: string): string[] { return Object.keys(mobs).filter((id) => id.startsWith(prefix)); } function fangLordsDefeated(mobs: Readonly>): 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>, now?: number) => void; } export const useWailingEncounterStore = create((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, ]; }