309 lines
11 KiB
TypeScript
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();
|
|
});
|
|
});
|