forked from phenom/healer-man
231 lines
7.2 KiB
TypeScript
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,
|
|
];
|
|
}
|
|
|