import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OnlineRelayEvent, OnlineSessionSnapshot } from "../app/onlineSessionTypes"; import { damageTextClassName } from "../ui/FloatingDamageText"; import { PLAYER_AGGRO_ID } from "./aggro"; import { useCombatStore } from "./combatStore"; import { appendCombatEvent, type CombatEventInput } from "./combatEvents"; import { DAMAGE_TEXT_LIFETIME_MS, MAX_DAMAGE_TEXT_PER_TARGET, MAX_DAMAGE_TEXT_TOTAL, clearDamageText, connectFloatingCombatText, damageTextCategory, formatDamageText, ingestRelayDamageText, useDamageTextStore, withSuppressedReplayDamageText, } from "./floatingCombatText"; let disconnect: (() => void) | null = null; function damage(id: number, overrides: Partial = {}): void { appendCombatEvent([], { occurredAt: id * 100, kind: "damage", sourceActorId: PLAYER_AGGRO_ID, targetActorId: "target", abilityId: "priest-smite", rawAmount: 500, effectiveAmount: 18, overheal: 0, absorbed: 0, critical: false, ...overrides, }, id); } beforeEach(() => { vi.useFakeTimers(); useCombatStore.getState().initializeCharacter({ classId: "priest", level: 20 }); useCombatStore.getState().registerMob("target", { name: "Target", maxHealth: 1_000 }); disconnect = connectFloatingCombatText(); }); afterEach(() => { disconnect?.(); disconnect = null; clearDamageText(); vi.useRealTimers(); }); describe("floating damage text", () => { it("classifies own, ally, and incoming hits independently", () => { const mobs = new Set(["target"]); const party = new Set(["companion"]); const online = new Set(["online-player:local", "online-player:friend"]); expect(damageTextCategory(PLAYER_AGGRO_ID, "target", mobs, party, online, "online-player:local")).toBe("own"); expect(damageTextCategory("companion", "target", mobs, party, online, "online-player:local")).toBe("ally"); expect(damageTextCategory("online-player:friend", "target", mobs, party, online, "online-player:local")).toBe("ally"); expect(damageTextCategory("target", PLAYER_AGGRO_ID, mobs, party, online, "online-player:local")).toBe("incoming"); expect(damageTextCategory("target", "companion", mobs, party, online, "online-player:local")).toBe("incoming"); }); it("shows effective Smite damage, preserves crit, and expires after 1.5 seconds", () => { damage(1, { effectiveAmount: 17.6, critical: true }); expect(useDamageTextStore.getState().byTarget.target).toEqual([ expect.objectContaining({ amount: 18, critical: true, category: "own" }), ]); expect(formatDamageText(1_234.4)).toBe("1,234"); expect(damageTextClassName(useDamageTextStore.getState().byTarget.target[0]!)).toContain("is-critical"); vi.advanceTimersByTime(DAMAGE_TEXT_LIFETIME_MS); expect(useDamageTextStore.getState().byTarget.target).toBeUndefined(); }); it("honors each switch and removes a category immediately when disabled", () => { damage(1); expect(useDamageTextStore.getState().byTarget.target).toHaveLength(1); useCombatStore.getState().updateSettings({ showOwnDamageText: false, showIncomingDamageText: true }); expect(useDamageTextStore.getState().byTarget.target).toBeUndefined(); damage(2); expect(useDamageTextStore.getState().byTarget.target).toBeUndefined(); damage(3, { sourceActorId: "target", targetActorId: PLAYER_AGGRO_ID }); expect(useDamageTextStore.getState().byTarget[PLAYER_AGGRO_ID]).toEqual([ expect.objectContaining({ category: "incoming", amount: 18 }), ]); damage(4, { sourceActorId: "target", targetActorId: PLAYER_AGGRO_ID, effectiveAmount: 0 }); expect(useDamageTextStore.getState().byTarget[PLAYER_AGGRO_ID]).toHaveLength(1); }); it("bounds bursts per target and across the scene", () => { for (let id = 1; id <= 7; id += 1) damage(id); expect(useDamageTextStore.getState().byTarget.target).toHaveLength(MAX_DAMAGE_TEXT_PER_TARGET); const target = useCombatStore.getState().mobs.target; useCombatStore.setState({ mobs: Object.fromEntries(Array.from({ length: 90 }, (_, index) => [`mob-${index}`, { ...target, id: `mob-${index}` }])) }); for (let id = 0; id < 90; id += 1) damage(100 + id, { targetActorId: `mob-${id}` }); expect(Object.values(useDamageTextStore.getState().byTarget).flat()).toHaveLength(MAX_DAMAGE_TEXT_TOTAL); }); it("shows periodic and area hits on their own targets, including a killing hit", () => { const first = useCombatStore.getState().mobs.target; useCombatStore.setState({ mobs: { target: { ...first, dead: true, health: 0 }, second: { ...first, id: "second", health: 50 }, } }); damage(1, { targetActorId: "target", abilityId: "priest-shadow-word-pain", effectiveAmount: 9 }); damage(2, { targetActorId: "second", abilityId: "priest-holy-nova", effectiveAmount: 14 }); expect(useDamageTextStore.getState().byTarget.target?.[0]?.amount).toBe(9); expect(useDamageTextStore.getState().byTarget.second?.[0]?.amount).toBe(14); }); it("uses one relayed online crit and suppresses damage generated during replay", () => { useCombatStore.getState().updateSettings({ showAllyDamageText: true }); const snapshot = { localAccountId: "local", players: [{ accountId: "local" }, { accountId: "friend" }], } as unknown as OnlineSessionSnapshot; const relay = { id: 40, kind: "damage", sourceAccountId: "friend", sourceActorId: "online-player:friend", targetActorId: "target", effectiveAmount: 61, critical: true, } as OnlineRelayEvent; withSuppressedReplayDamageText(() => damage(40, { sourceActorId: "online-player:friend", effectiveAmount: 61 })); ingestRelayDamageText(relay, snapshot); ingestRelayDamageText(relay, snapshot); expect(useDamageTextStore.getState().byTarget.target).toEqual([ expect.objectContaining({ amount: 61, critical: true, category: "ally", key: "relay:40" }), ]); }); it("maps relayed damage to the local player's party frame with its original crit", () => { useCombatStore.getState().updateSettings({ showIncomingDamageText: true }); const snapshot = { localAccountId: "local", players: [{ accountId: "local" }, { accountId: "friend" }], } as unknown as OnlineSessionSnapshot; ingestRelayDamageText({ id: 41, kind: "damage", sourceAccountId: "friend", sourceActorId: "target", targetActorId: "online-player:local", effectiveAmount: 36, critical: true, } as OnlineRelayEvent, snapshot); expect(useDamageTextStore.getState().byTarget[PLAYER_AGGRO_ID]).toEqual([ expect.objectContaining({ amount: 36, critical: true, category: "incoming" }), ]); }); });