import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { ORIGINAL_PARTICLE_LIMIT_PER_EMITTER, interpolateOriginalLifetime, interpolateOriginalScalar, isOriginalCombatEffectGraph, originalCombatAnchorActorId, originalAuraMatchesPresentationEvent, originalEmitterParticleBudget, parseOriginalCombatEffectGraph, selectCombatEffectRender, } from "./originalCombatEffectRuntime"; const shieldPresentationEvent = { id: 1, sessionId: 1, occurredAt: 0, phase: "aura-start" as const, actorGroup: "player" as const, sourceActorId: "player", targetActorId: "party-tank", abilityId: "wow335-priest-power-word-shield", sourceSpellId: 17, name: "Power Word: Shield", source: "wow335" as const, school: "holy" as const, delivery: "aura" as const, origin: [0, 0, 0] as const, }; const zeroTimeSamples = [{ timeMs: 0, value: 0 }]; const particleSamples = { emissionSpeed: zeroTimeSamples, speedVariation: zeroTimeSamples, verticalRange: zeroTimeSamples, horizontalRange: zeroTimeSamples, gravity: zeroTimeSamples, lifespan: [{ timeMs: 0, value: 1 }], emissionRate: [{ timeMs: 0, value: 0 }, { timeMs: 500, value: 150 }], emissionAreaLength: zeroTimeSamples, emissionAreaWidth: zeroTimeSamples, zSource: zeroTimeSamples, color: [ { timestamp: 0, normalizedTime: 0, value: [1, 0, 0] }, { timestamp: 32_767, normalizedTime: 1, value: [0, 0, 1] }, ], alpha: [ { timestamp: 0, normalizedTime: 0, value: 32_767 }, { timestamp: 32_767, normalizedTime: 1, value: 0 }, ], scale: [ { timestamp: 0, normalizedTime: 0, value: [1, 1] }, { timestamp: 32_767, normalizedTime: 1, value: [2, 2] }, ], headCell: [{ timestamp: 0, normalizedTime: 0, value: 3 }], tailCell: [], enabled: [{ timeMs: 0, value: 1 }], }; const trackMetadata = { emissionSpeed: 0, speedVariation: 0, verticalRange: 0, horizontalRange: 0, gravity: 0, lifespan: 0, emissionRate: 1, emissionAreaLength: 0, emissionAreaWidth: 0, zSource: 0, enabled: 0, }; function graphFixture(): Record { const particle = { id: 7, flags: 0, position: [0, 1, 2], parentBone: 0, textureIndex: 0, geometryModel: "", recursionModel: "", blendMode: 4, emitterType: 1, particleColorIndex: 0, particleType: 0, headOrTail: 0, textureTileRotation: 2, textureRows: 4, textureColumns: 8, lifespanVariation: 0, emissionRateVariation: 0, scaleVariation: [0, 0], tailLength: 0, interpolation: trackMetadata, globalSequence: Object.fromEntries(Object.keys(trackMetadata).map((key) => [key, -1])), samples: particleSamples, }; return { schemaVersion: 1, kind: "wow-m2-particle-effect", source: "wow335", sourceModel: "Spells/HolySmiteImpact.mdx", normalizedSourceModel: "Spells/HolySmiteImpact.m2", sourceSha256: "a".repeat(64), durationMs: 1_000, animations: [{ index: 0, id: 0, variationIndex: 0, durationMs: 1_000, moveSpeed: 0, flags: 0 }], textures: [ { type: 0, flags: 0, filename: "Spells\\HolySmite.blp", sourcePath: "Spells/HolySmite.BLP", sourceSha256: "b".repeat(64), url: "/assets/game/combat-effects/original/wow335/priest/textures/holy-smite.png", }, { type: 0, flags: 0, filename: "Spells\\GLOWSTAR.blp", sourcePath: "Spells/GLOWSTAR.BLP", }, ], particles: [particle, { ...particle, id: 8, textureIndex: 1 }], runtime: { renderer: "wow-m2-particles-v1", readyParticleIndices: [0], unsupportedRibbonCount: 1, unsupportedGeometryVertexCount: 12, }, }; } function cloneFixture(): Record { return JSON.parse(JSON.stringify(graphFixture())) as Record; } describe("original combat-effect graph contract", () => { it("anchors self-cast aura visuals to the moving player actor", () => { expect(originalCombatAnchorActorId({ phase: "aura-start", sourceActorId: "__player__", delivery: "aura", })).toBe("__player__"); expect(originalCombatAnchorActorId({ phase: "aura-start", sourceActorId: "__player__", targetActorId: "party-tank", delivery: "aura", })).toBe("party-tank"); expect(originalCombatAnchorActorId({ phase: "cast-start", sourceActorId: "__player__", targetActorId: "party-tank", delivery: "aura", })).toBe("__player__"); }); it("keeps an original aura visual attached to its active ranked WoW aura", () => { const activeShield = { targetId: "party-tank", definition: { id: "wow335:17:effect:0:aura:69", name: "Power Word: Shield (DBC 17, Aura 69)", }, }; expect(originalAuraMatchesPresentationEvent(activeShield, shieldPresentationEvent)).toBe(true); expect(originalAuraMatchesPresentationEvent( { ...activeShield, targetId: "other-party-member" }, shieldPresentationEvent, )).toBe(false); }); it("parses the shipped Holy Smite graph without contract drift", () => { const graphUrl = new URL( "../../public/assets/game/combat-effects/original/wow335/priest/graphs/holysmite-low-chest-5e638a90a1be.json", import.meta.url, ); const graph = parseOriginalCombatEffectGraph(JSON.parse(readFileSync(graphUrl, "utf8"))); expect(graph.normalizedSourceModel).toMatch(/\.m2$/i); expect(graph.runtime.renderer).toBe("wow-m2-particles-v1"); expect(graph.runtime.readyParticleIndices.length).toBeGreaterThan(0); }); it("parses the shipped Lesser Heal cast and impact graphs", () => { const graphNames = [ "holy-precast-med-hand-3001a24bb669.json", "lesserheal-base-a2863103c7d8.json", ]; const graphs = graphNames.map((name) => parseOriginalCombatEffectGraph(JSON.parse(readFileSync( new URL(`../../public/assets/game/combat-effects/original/wow335/priest/graphs/${name}`, import.meta.url), "utf8", )))); expect(graphs.map((graph) => graph.runtime.readyParticleIndices.length)).toEqual([1, 5]); expect(graphs.flatMap((graph) => graph.textures).filter((texture) => texture.url).length) .toBeGreaterThanOrEqual(4); }); it("strictly validates the packaged decoder output and derives renderer conveniences", () => { const graph = parseOriginalCombatEffectGraph(graphFixture()); const particle = graph.particles[0]; expect(graph).toMatchObject({ schemaVersion: 1, kind: "wow-m2-particle-effect", source: "wow335", normalizedSourceModel: "Spells/HolySmiteImpact.m2", runtime: { renderer: "wow-m2-particles-v1", readyParticleIndices: [0], }, }); expect(particle).toMatchObject({ textureIndex: 0, blend: "add", emitter: "plane", atlas: { rows: 4, columns: 8, rotation: 2 }, }); expect(particle.samples.emissionRate).toEqual([ { timeMs: 0, value: 0 }, { timeMs: 500, value: 150 }, ]); expect(graph.textures[1]).toEqual({ type: 0, flags: 0, filename: "Spells\\GLOWSTAR.blp", sourcePath: "Spells/GLOWSTAR.BLP", }); expect(isOriginalCombatEffectGraph(graphFixture())).toBe(true); }); it("rejects wrong schemas, unknown fields, invalid references, and malformed samples", () => { expect(() => parseOriginalCombatEffectGraph({ ...graphFixture(), source: "runewaker" })) .toThrow(/source/); expect(() => parseOriginalCombatEffectGraph({ ...graphFixture(), fallback: "generated" })) .toThrow(/not part of schema/); const badReference = cloneFixture(); (badReference.particles as Array>)[0].textureIndex = 2; expect(() => parseOriginalCombatEffectGraph(badReference)).toThrow(/matching texture/); const badSample = cloneFixture(); const samples = (badSample.particles as Array>)[0].samples as Record; (samples.emissionRate as Array>)[1].value = "150"; expect(() => parseOriginalCombatEffectGraph(badSample)).toThrow(/emissionRate\[1\]\.value/); expect(isOriginalCombatEffectGraph(badSample)).toBe(false); const badRuntime = cloneFixture(); (badRuntime.runtime as Record).readyParticleIndices = [1]; expect(() => parseOriginalCombatEffectGraph(badRuntime)).toThrow(/unpackaged texture/); }); it("linearly samples animated scalars and scalar or vector lifetime keys", () => { expect(interpolateOriginalScalar([ { timeMs: 100, value: 4 }, { timeMs: 300, value: 12 }, ], 200)).toBe(8); expect(interpolateOriginalScalar([{ timeMs: 100, value: 4 }], 1_000)).toBe(4); expect(interpolateOriginalScalar([], 100)).toBeNull(); expect(interpolateOriginalScalar([ { timeMs: 0, value: 0 }, { timeMs: 500, value: 10 }, ], 250, 0)).toBe(0); expect(interpolateOriginalLifetime([ { normalizedTime: 0, value: 1 }, { normalizedTime: 1, value: 3 }, ], 0.25)).toBe(1.5); expect(interpolateOriginalLifetime([ { normalizedTime: 0, value: [1, 2, 3] as const }, { normalizedTime: 1, value: [3, 4, 5] as const }, ], 0.5)).toEqual([2, 3, 4]); }); it("uses the peak animated emission rate for deterministic per-emitter budgets", () => { const particle = parseOriginalCombatEffectGraph(graphFixture()).particles[0]; expect(particle.samples.emissionRate[0].value).toBe(0); expect(originalEmitterParticleBudget(particle, "low")) .toBe(ORIGINAL_PARTICLE_LIMIT_PER_EMITTER.low); expect(originalEmitterParticleBudget(particle, "high")) .toBe(ORIGINAL_PARTICLE_LIMIT_PER_EMITTER.high); expect(originalEmitterParticleBudget(particle, "off")).toBe(0); expect(originalEmitterParticleBudget(particle, "high")) .toBe(originalEmitterParticleBudget(particle, "high")); const inactive = { samples: { ...particle.samples, emissionRate: [{ timeMs: 0, value: 0 }] }, }; expect(originalEmitterParticleBudget(inactive, "high")).toBe(0); }); it("never substitutes generated rendering when Original has no graph asset", () => { expect(selectCombatEffectRender("generated", undefined)).toEqual({ kind: "generated" }); expect(selectCombatEffectRender("original", [ {}, { effectGraphUrl: "/assets/game/combat-effects/original/wow335/priest/graphs/smite.json" }, ])).toEqual({ kind: "original", effectGraphUrl: "/assets/game/combat-effects/original/wow335/priest/graphs/smite.json", }); expect(selectCombatEffectRender("original", undefined)).toBeNull(); expect(selectCombatEffectRender("original", [{ effectGraphUrl: " " }])).toBeNull(); }); });