forked from phenom/healer-man
172 lines
6.1 KiB
TypeScript
172 lines
6.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { CombatPresentationEvent } from "../game/combatPresentation";
|
|
import {
|
|
COMBAT_EFFECT_SYSTEM_LIMIT,
|
|
addOriginalInstantCastEffect,
|
|
addOriginalOnlyEffect,
|
|
advanceCombatEffectFrame,
|
|
originalInstantCastEvent,
|
|
originalInstantTerminalEvent,
|
|
updateCombatEffectQueue,
|
|
type ActiveCombatEffect,
|
|
} from "./combatEffectRuntime";
|
|
|
|
function event(
|
|
id: number,
|
|
patch: Partial<CombatPresentationEvent> = {},
|
|
): CombatPresentationEvent {
|
|
return {
|
|
id,
|
|
sessionId: 1,
|
|
occurredAt: id,
|
|
phase: "impact",
|
|
actorGroup: "enemy",
|
|
sourceActorId: `enemy-${id}`,
|
|
targetActorId: "player",
|
|
abilityId: "fixture",
|
|
name: "Fixture",
|
|
source: "fallback",
|
|
school: "shadow",
|
|
delivery: "projectile",
|
|
origin: [0, 0, 0],
|
|
targetPosition: [1, 0, 0],
|
|
...patch,
|
|
};
|
|
}
|
|
|
|
function enqueue(
|
|
events: readonly CombatPresentationEvent[],
|
|
quality: "off" | "low" | "high",
|
|
): readonly ActiveCombatEffect[] {
|
|
return events.reduce<readonly ActiveCombatEffect[]>(
|
|
(current, next) => updateCombatEffectQueue(current, next, quality),
|
|
[],
|
|
);
|
|
}
|
|
|
|
describe("combat-effect runtime budget", () => {
|
|
it("expires effects by wall time while bounding motion after a hitch", () => {
|
|
let frame = advanceCombatEffectFrame(0, 1 / 60, 420);
|
|
frame = advanceCombatEffectFrame(frame.elapsedMs, 1 / 60, 420);
|
|
frame = advanceCombatEffectFrame(frame.elapsedMs, 0.5, 420);
|
|
|
|
expect(frame.elapsedMs).toBeCloseTo(533.333, 2);
|
|
expect(frame.motionDeltaSeconds).toBe(0.05);
|
|
expect(frame.progress).toBe(1);
|
|
expect(frame.done).toBe(true);
|
|
expect(advanceCombatEffectFrame(frame.elapsedMs, Number.NaN, 420).elapsedMs)
|
|
.toBe(frame.elapsedMs);
|
|
});
|
|
|
|
it("bounds large bursts while retaining player feedback and the newest event", () => {
|
|
const player = event(1, {
|
|
actorGroup: "player",
|
|
sourceActorId: "player",
|
|
school: "holy",
|
|
});
|
|
const enemies = Array.from({ length: 512 }, (_, index) => event(index + 2, {
|
|
phase: "tick",
|
|
}));
|
|
|
|
const high = enqueue([player, ...enemies], "high");
|
|
const low = enqueue([player, ...enemies], "low");
|
|
|
|
expect(high).toHaveLength(COMBAT_EFFECT_SYSTEM_LIMIT.high);
|
|
expect(low).toHaveLength(COMBAT_EFFECT_SYSTEM_LIMIT.low);
|
|
expect(enqueue([player], "off")).toEqual([]);
|
|
expect(updateCombatEffectQueue(enqueue([player], "high"), enemies[0], "off"))
|
|
.toEqual([]);
|
|
expect(high.some(({ event: active }) => active.id === player.id)).toBe(true);
|
|
expect(high.at(-1)?.event.id).toBe(enemies.at(-1)?.id);
|
|
expect(new Set(high.map(({ key }) => key)).size).toBe(high.length);
|
|
});
|
|
|
|
it("keeps combat-significant enemy casts ahead of low-value friendly ticks", () => {
|
|
const partyTicks = Array.from({ length: COMBAT_EFFECT_SYSTEM_LIMIT.low }, (_, index) => event(index + 1, {
|
|
actorGroup: "party",
|
|
sourceActorId: `party-${index}`,
|
|
phase: "tick",
|
|
}));
|
|
const enemyCast = event(100, {
|
|
actorGroup: "enemy",
|
|
sourceActorId: "boss",
|
|
phase: "cast-start",
|
|
});
|
|
|
|
const effects = enqueue([...partyTicks, enemyCast], "low");
|
|
expect(effects).toHaveLength(COMBAT_EFFECT_SYSTEM_LIMIT.low);
|
|
expect(effects.some(({ event: active }) => active.id === enemyCast.id)).toBe(true);
|
|
expect(effects.filter(({ event: active }) => active.phase === "tick"))
|
|
.toHaveLength(COMBAT_EFFECT_SYSTEM_LIMIT.low - 1);
|
|
});
|
|
|
|
it("coalesces a single area burst but preserves projectile phases", () => {
|
|
const areaRelease = event(1, {
|
|
occurredAt: 100,
|
|
phase: "release",
|
|
delivery: "area",
|
|
sourceActorId: "caster",
|
|
});
|
|
const areaImpacts = Array.from({ length: 5 }, (_, index) => event(index + 2, {
|
|
occurredAt: 100,
|
|
phase: "impact",
|
|
delivery: "area",
|
|
sourceActorId: "caster",
|
|
targetActorId: `target-${index}`,
|
|
}));
|
|
const area = enqueue([areaRelease, ...areaImpacts], "high");
|
|
expect(area).toHaveLength(1);
|
|
expect(area[0].event.phase).toBe("impact");
|
|
|
|
const projectile = enqueue([
|
|
event(10, { occurredAt: 200, phase: "release", sourceActorId: "caster" }),
|
|
event(11, { occurredAt: 200, phase: "impact", sourceActorId: "caster" }),
|
|
], "high");
|
|
expect(projectile).toHaveLength(2);
|
|
});
|
|
|
|
it("lets an instant source cast overlap its release while completing timed casts", () => {
|
|
const release = event(2, {
|
|
phase: "release",
|
|
sourceActorId: "instant-caster",
|
|
instantCast: true,
|
|
});
|
|
const sourceCast = originalInstantCastEvent(release);
|
|
expect(sourceCast).toMatchObject({
|
|
id: -2,
|
|
phase: "cast-start",
|
|
sourceActorId: "instant-caster",
|
|
instantCast: true,
|
|
});
|
|
const instant = updateCombatEffectQueue(
|
|
addOriginalInstantCastEffect([], release, "high"),
|
|
release,
|
|
"high",
|
|
);
|
|
expect(instant.map(({ event: active }) => active.phase)).toEqual(["cast-start", "release"]);
|
|
expect(instant[0].originalOnly).toBe(true);
|
|
|
|
const timed = enqueue([
|
|
event(3, { phase: "cast-start", sourceActorId: "timed-caster" }),
|
|
event(4, { phase: "release", sourceActorId: "timed-caster" }),
|
|
], "high");
|
|
expect(timed.map(({ event: active }) => active.phase)).toEqual(["release"]);
|
|
expect(originalInstantCastEvent(timed[0].event)).toBeNull();
|
|
});
|
|
|
|
it("preserves source terminal semantics for instant impact and aura fallbacks", () => {
|
|
const impact = event(20, { phase: "impact", instantCast: true, delivery: "area" });
|
|
const auraFallback = originalInstantTerminalEvent(impact);
|
|
expect(auraFallback).toMatchObject({ id: -20, phase: "aura-start", instantCast: true });
|
|
|
|
const aura = event(21, { phase: "aura-start", instantCast: true, delivery: "aura" });
|
|
const impactFallback = originalInstantTerminalEvent(aura);
|
|
expect(impactFallback).toMatchObject({ id: -21, phase: "impact", instantCast: true });
|
|
expect(addOriginalOnlyEffect([], impactFallback!, "high")[0]).toMatchObject({
|
|
originalOnly: true,
|
|
event: { phase: "impact" },
|
|
});
|
|
expect(originalInstantTerminalEvent(event(22, { phase: "impact" }))).toBeNull();
|
|
});
|
|
});
|