Files
healer-man/src/scene/originalCombatEffectRuntime.test.ts
T
2026-09-01 16:31:26 -04:00

309 lines
11 KiB
TypeScript

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<string, unknown> {
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<string, unknown> {
return JSON.parse(JSON.stringify(graphFixture())) as Record<string, unknown>;
}
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<Record<string, unknown>>)[0].textureIndex = 2;
expect(() => parseOriginalCombatEffectGraph(badReference)).toThrow(/matching texture/);
const badSample = cloneFixture();
const samples = (badSample.particles as Array<Record<string, unknown>>)[0].samples as Record<string, unknown>;
(samples.emissionRate as Array<Record<string, unknown>>)[1].value = "150";
expect(() => parseOriginalCombatEffectGraph(badSample)).toThrow(/emissionRate\[1\]\.value/);
expect(isOriginalCombatEffectGraph(badSample)).toBe(false);
const badRuntime = cloneFixture();
(badRuntime.runtime as Record<string, unknown>).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();
});
});