import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; import { COMBAT_MEDIA_MANIFEST_URL, combatMediaPackReady, fetchCombatMediaAsset, } from "./combatMediaPack"; export type CombatActorGroup = "player" | "party" | "enemy"; export type CombatVfxMode = "generated" | "original"; export type CombatVfxQuality = "off" | "low" | "high"; export type CombatPresentationPhase = | "cast-start" | "cast-cancel" | "release" | "impact" | "tick" | "aura-start" | "aura-end"; export type CombatPresentationDelivery = "melee" | "projectile" | "area" | "channel" | "aura"; export type CombatPresentationSchool = | "physical" | "arcane" | "fire" | "frost" | "nature" | "shadow" | "holy"; export type CombatPresentationSource = "wow335" | "ascension" | "runewaker" | "fallback"; export type CombatPresentationPosition = readonly [number, number, number]; export interface CombatPresentationSettings { readonly originalMediaEnabled: boolean; readonly vfxMode: CombatVfxMode; readonly vfxQuality: CombatVfxQuality; readonly vfxEnabled: Readonly>; readonly audioEnabled: Readonly>; readonly audioVolume: Readonly>; } const ACTOR_GROUPS: readonly CombatActorGroup[] = ["player", "party", "enemy"]; export const DEFAULT_COMBAT_PRESENTATION_SETTINGS: CombatPresentationSettings = Object.freeze({ originalMediaEnabled: true, vfxMode: "original", vfxQuality: "high", vfxEnabled: Object.freeze({ player: true, party: true, enemy: true }), audioEnabled: Object.freeze({ player: true, party: true, enemy: true }), audioVolume: Object.freeze({ player: 1, party: 1, enemy: 1 }), }); function finiteVolume(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : fallback; } function booleanGroups( value: unknown, fallback: Readonly>, ): Readonly> { const record = value && typeof value === "object" && !Array.isArray(value) ? value as Partial> : {}; return Object.freeze(Object.fromEntries(ACTOR_GROUPS.map((group) => [ group, typeof record[group] === "boolean" ? record[group] : fallback[group], ])) as Record); } function volumeGroups( value: unknown, fallback: Readonly>, ): Readonly> { const record = value && typeof value === "object" && !Array.isArray(value) ? value as Partial> : {}; return Object.freeze(Object.fromEntries(ACTOR_GROUPS.map((group) => [ group, finiteVolume(record[group], fallback[group]), ])) as Record); } export function normalizeCombatPresentationSettings( value: unknown, fallback: CombatPresentationSettings = DEFAULT_COMBAT_PRESENTATION_SETTINGS, ): CombatPresentationSettings { const record = value && typeof value === "object" && !Array.isArray(value) ? value as Partial> : {}; const originalMediaEnabled = typeof record.originalMediaEnabled === "boolean" ? record.originalMediaEnabled : fallback.originalMediaEnabled; const vfxMode = record.vfxMode === "generated" || record.vfxMode === "original" ? record.vfxMode : fallback.vfxMode; const vfxQuality = record.vfxQuality === "off" || record.vfxQuality === "low" || record.vfxQuality === "high" ? record.vfxQuality : fallback.vfxQuality; return Object.freeze({ originalMediaEnabled, vfxMode, vfxQuality, vfxEnabled: booleanGroups(record.vfxEnabled, fallback.vfxEnabled), audioEnabled: booleanGroups(record.audioEnabled, fallback.audioEnabled), audioVolume: volumeGroups(record.audioVolume, fallback.audioVolume), }); } export interface CombatPresentationEvent { readonly id: number; readonly sessionId: number; readonly occurredAt: number; readonly phase: CombatPresentationPhase; readonly actorGroup: CombatActorGroup; readonly sourceActorId: string; readonly targetActorId?: string; readonly abilityId: string; readonly sourceSpellId?: number; readonly name: string; readonly source: CombatPresentationSource; readonly school: CombatPresentationSchool; readonly delivery: CombatPresentationDelivery; readonly origin: CombatPresentationPosition; readonly targetPosition?: CombatPresentationPosition; readonly radius?: number; readonly durationMs?: number; } export type CombatPresentationEventInput = Omit; export type CombatPresentationMessage = | { readonly kind: "event"; readonly event: CombatPresentationEvent } | { readonly kind: "reset"; readonly sessionId: number }; type CombatPresentationListener = (message: CombatPresentationMessage) => void; const presentationListeners = new Set(); let presentationSequence = 0; let presentationSessionId = 1; export function subscribeCombatPresentation(listener: CombatPresentationListener): () => void { presentationListeners.add(listener); return () => presentationListeners.delete(listener); } export function emitCombatPresentation(input: CombatPresentationEventInput): CombatPresentationEvent { presentationSequence += 1; const event: CombatPresentationEvent = Object.freeze({ ...input, id: presentationSequence, sessionId: presentationSessionId, origin: [...input.origin] as CombatPresentationPosition, ...(input.targetPosition ? { targetPosition: [...input.targetPosition] as CombatPresentationPosition } : {}), }); for (const listener of presentationListeners) listener({ kind: "event", event }); return event; } export function resetCombatPresentationSession(): number { presentationSessionId += 1; for (const listener of presentationListeners) { listener({ kind: "reset", sessionId: presentationSessionId }); } return presentationSessionId; } export function combatPresentationSessionId(): number { return presentationSessionId; } export interface CombatEffectPhaseAsset { readonly model?: string; readonly attachment?: string; readonly scale?: number; /** Browser-ready graph generated from the original client asset. */ readonly effectGraphUrl?: string; readonly effectKind?: "wow-m2-particles" | "wow-m2-static-mesh" | "runewaker-particles"; readonly originalSourceSha256?: string; } export interface CombatEffectSoundAsset { readonly url?: string; readonly sourcePath?: string; readonly weight?: number; readonly volume?: number; readonly minimumDistance?: number; readonly maximumDistance?: number; } export interface CombatEffectManifestEntry { readonly key: string; readonly source: Exclude; readonly spellId: number; readonly visualIds?: readonly number[]; readonly phases?: Readonly>>; readonly sounds?: readonly CombatEffectSoundAsset[]; } interface GeneratedCombatEffectManifest { readonly schemaVersion: number; readonly entries: readonly CombatEffectManifestEntry[]; } const manifestByKey = new Map(); const manifestBySpellId = new Map(); let manifestRequest: Promise | null = null; function installCombatEffectManifest(manifest: GeneratedCombatEffectManifest): void { if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.entries)) return; for (const entry of manifest.entries) { manifestByKey.set(entry.key, entry); if (!manifestBySpellId.has(entry.spellId)) manifestBySpellId.set(entry.spellId, entry); } } export function preloadCombatEffectManifest(): Promise { if (manifestRequest) return manifestRequest; if (typeof fetch === "undefined" || !combatMediaPackReady()) return Promise.resolve(); manifestRequest = fetchCombatMediaAsset(COMBAT_MEDIA_MANIFEST_URL) .then((response) => response.ok ? response.json() : Promise.reject(new Error(String(response.status)))) .then((manifest) => installCombatEffectManifest(manifest as GeneratedCombatEffectManifest)) .catch(() => { manifestRequest = null; }); return manifestRequest; } export function effectiveCombatVfxMode(settings: CombatPresentationSettings): CombatVfxMode { return settings.originalMediaEnabled && combatMediaPackReady() ? settings.vfxMode : "generated"; } export function combatEffectManifestEntry( spellId: number | undefined, source?: CombatPresentationSource, ): CombatEffectManifestEntry | null { if (!spellId) return null; if (source && source !== "fallback") { const exact = manifestByKey.get(`${source}:${spellId}`); if (exact) return exact; } return manifestBySpellId.get(spellId) ?? null; } export function originalCombatPhaseAssets( entry: CombatEffectManifestEntry | null | undefined, phase: CombatPresentationPhase, ): readonly CombatEffectPhaseAsset[] { return (entry?.phases?.[phase] ?? []).filter((asset) => ( typeof asset.effectGraphUrl === "string" && asset.effectGraphUrl.startsWith("/assets/game/combat-effects/original/") && ( asset.effectKind === "wow-m2-particles" || asset.effectKind === "wow-m2-static-mesh" || asset.effectKind === "runewaker-particles" ) )); } export function combatOriginalPhaseAssets( event: Pick, ): readonly CombatEffectPhaseAsset[] { return originalCombatPhaseAssets( combatEffectManifestEntry(event.sourceSpellId, event.source), event.phase, ); } function abilityHasEffect(ability: AbilityDefinition, kind: AbilityEffect["kind"]): boolean { return ability.effects.some((effect) => effect.kind === kind); } export function presentationSourceForAbility(ability: AbilityDefinition): CombatPresentationSource { if (ability.source === "runewaker") return "runewaker"; if (ability.source?.startsWith("ascension") || ability.source?.startsWith("coa")) return "ascension"; return "wow335"; } export function presentationSchoolForAbility(ability: AbilityDefinition): CombatPresentationSchool { const name = `${ability.name} ${ability.description}`.toLowerCase(); if (/fire|flame|burn|inferno|pyro|molten/.test(name)) return "fire"; if (/frost|ice|rime|chill|snow/.test(name)) return "frost"; if (/shadow|void|curse|corrupt|death|plague|fel|blood/.test(name)) return "shadow"; if (/arcane|mana|time|chron|runic|astral/.test(name)) return "arcane"; if (/holy|light|smite|divine|sun|heal|renew|rejuven|salvation|blessing/.test(name)) return "holy"; if (/nature|earth|storm|lightning|thunder|wind|wrath|root|poison|venom/.test(name)) return "nature"; if (abilityHasEffect(ability, "heal") || abilityHasEffect(ability, "hot") || abilityHasEffect(ability, "resurrection")) { return ["druid", "shaman", "rom-druid", "rom-warden"].includes(ability.classId) ? "nature" : "holy"; } return "physical"; } export function presentationDeliveryForAbility(ability: AbilityDefinition): CombatPresentationDelivery { if (ability.castMode === "channel") return "channel"; if (ability.radius !== undefined && ability.radius > 0) return "area"; if (ability.target === "hostile") return ability.range.max <= 4.5 ? "melee" : "projectile"; if (abilityHasEffect(ability, "apply-aura") || abilityHasEffect(ability, "shield") || abilityHasEffect(ability, "hot")) { return "aura"; } return "projectile"; } export function presentationSourceForSpellId(spellId: number | undefined): CombatPresentationSource { if (!spellId) return "fallback"; const mapped = manifestBySpellId.get(spellId); if (mapped) return mapped.source; return spellId >= 490_000 && spellId < 600_000 ? "runewaker" : "wow335"; } export interface CombatEffectStyle { readonly primary: string; readonly secondary: string; readonly emissive: number; readonly authentic: boolean; readonly sourceModelNames: readonly string[]; } const SCHOOL_STYLE: Readonly>> = { physical: { primary: "#f3d18a", secondary: "#ffffff", emissive: 1.2 }, arcane: { primary: "#b774ff", secondary: "#76d8ff", emissive: 1.8 }, fire: { primary: "#ff6a20", secondary: "#ffd24a", emissive: 2.2 }, frost: { primary: "#72d8ff", secondary: "#e8fbff", emissive: 1.8 }, nature: { primary: "#64e06e", secondary: "#d4ff73", emissive: 1.7 }, shadow: { primary: "#8a4de8", secondary: "#ed5cff", emissive: 1.9 }, holy: { primary: "#ffe67a", secondary: "#fff7d0", emissive: 2 }, }; export function combatEffectStyle(event: CombatPresentationEvent): CombatEffectStyle { const entry = combatEffectManifestEntry(event.sourceSpellId, event.source); const sourceModelNames = Object.values(entry?.phases ?? {}) .flatMap((assets) => assets ?? []) .flatMap((asset) => asset.model ? [asset.model] : []); const inferred = sourceModelNames.join(" ").toLowerCase(); const school = /fire|flame|molten/.test(inferred) ? "fire" : /frost|ice|snow/.test(inferred) ? "frost" : /shadow|void|dark/.test(inferred) ? "shadow" : /holy|light|heal/.test(inferred) ? "holy" : /nature|earth|lightning|wind/.test(inferred) ? "nature" : event.school; return { ...SCHOOL_STYLE[school], authentic: Boolean(entry), sourceModelNames }; }