Release Healer Man 0.1.20

This commit is contained in:
phenom committed 2026-09-06 14:38:59 -04:00
1 parent d9bebb4e81
commit 699fc858c9
22 files changed
+551 -295

No files matched your search

+23 -1
View File
@@ -7,7 +7,8 @@ import {
auraDefinitionIcon,
auraDefinitionIsLongTerm,
auraDescription,
} from "./AuraStrip";
groupAurasForDisplay,
} from "./auraDisplay";
function aura(overrides: Partial<AuraDefinition> = {}): AuraDefinition {
return {
@@ -71,4 +72,25 @@ describe("aura strip visibility", () => {
absorbs: [{ id: "test-absorb", amount: 250 }],
}), 175))).toBe("Absorbs up to 175 incoming damage.");
});
it("shows multi-effect and repeatedly sourced spell auras as one icon", () => {
const first = activeAura(aura({
id: "frostbolt-slow",
name: "Frostbolt (DBC 116, Aura 33)",
icon: "/assets/ui/spells/frostbolt.png",
}));
const second = {
...activeAura(aura({
id: "frostbolt-secondary",
name: "Frostbolt (DBC 116, Aura 118)",
icon: "/assets/ui/spells/frostbolt.png",
})),
instanceId: "second-frostbolt-aura",
sourceId: "second-mage",
appliedAt: 2_000,
expiresAt: 14_000,
};
expect(groupAurasForDisplay([first, second])).toEqual([second]);
});
});
+11 -42
View File
@@ -1,11 +1,17 @@
import { useEffect, useState } from "react";
import type { ActiveAura, AuraDefinition, AuraDisposition } from "../game/combatAuras";
import type { AuraDisposition } from "../game/combatAuras";
import { useCombatStore } from "../game/combatStore";
import { formatTimedEffectTimer } from "../game/timedEffectDisplay";
import {
auraDefinitionDisplaysInStrip,
auraDefinitionIcon,
auraDefinitionIsLongTerm,
auraDescription,
auraDisplayName,
groupAurasForDisplay,
} from "./auraDisplay";
import type { StatusEffectInspectHandler, StatusEffectInspection } from "./statusEffectInspection";
export const LONG_TERM_AURA_DURATION_MS = 60_000;
interface AuraStripProps {
readonly targetId: string;
readonly targetIds?: readonly string[];
@@ -18,43 +24,6 @@ interface AuraStripProps {
readonly tooltipId?: string;
}
export function auraDefinitionDisplaysInStrip(definition: AuraDefinition): boolean {
return definition.hideFromAuraStrip !== true;
}
export function auraDefinitionIcon(definition: AuraDefinition): string | null {
return definition.icon?.trim() || null;
}
export function auraDisplayName(definition: AuraDefinition): string {
return definition.name.replace(/\s+\((?:DBC\s+\d+,\s*)?Aura\s+\d+\)$/i, "").trim();
}
/** Permanent and one-minute-or-longer auras are persistent party-frame clutter. */
export function auraDefinitionIsLongTerm(definition: AuraDefinition): boolean {
return definition.durationMs === null || definition.durationMs >= LONG_TERM_AURA_DURATION_MS;
}
export function auraDescription(aura: ActiveAura): string {
const authored = aura.definition.description?.trim();
if (authored) return authored;
const absorb = aura.absorbs.reduce((total, pool) => total + pool.remaining, 0);
if (absorb > 0) return `Absorbs up to ${Math.ceil(absorb)} incoming damage.`;
if (aura.definition.controlImmunities?.length) {
return `Grants immunity to ${aura.definition.controlImmunities.join(", ")}.`;
}
if (aura.definition.modifiers?.length) {
return aura.definition.disposition === "buff"
? "Improves combat attributes while active."
: "Reduces combat effectiveness while active.";
}
if (aura.definition.procs?.length) return "Can trigger an additional effect during combat.";
return aura.definition.disposition === "buff"
? "A beneficial effect is active."
: "A harmful effect is active.";
}
export function AuraStrip({
targetId,
targetIds,
@@ -69,14 +38,14 @@ export function AuraStrip({
const allAuras = useCombatStore((state) => state.auras);
const [now, setNow] = useState(() => Date.now());
const requestedTargetIds = targetIds ?? [targetId];
const auras = allAuras.filter((aura) => (
const auras = groupAurasForDisplay(allAuras.filter((aura) => (
requestedTargetIds.includes(aura.targetId)
&& dispositions.includes(aura.definition.disposition)
&& auraDefinitionDisplaysInStrip(aura.definition)
&& (!hideLongTerm || !auraDefinitionIsLongTerm(aura.definition))
&& (!showOnlyLongTerm || auraDefinitionIsLongTerm(aura.definition))
&& (aura.expiresAt === null || aura.expiresAt > now)
));
)));
useEffect(() => {
if (!auras.some((aura) => aura.expiresAt !== null)) return undefined;
+9 -11
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "../game/aggro";
import type { ActiveAura, AuraDefinition } from "../game/combatAuras";
import { auraDisplaysInPlayerStatusPanel } from "./PlayerStatusPanel";
import { auraDisplaysInPlayerStatusPanel } from "./auraDisplay";
function activeAura(
targetId: string,
@@ -31,18 +31,16 @@ function activeAura(
describe("top-right status visibility", () => {
const now = 20_000;
const partyTargets = new Set(["party-1"]);
it("shows every player aura and centralizes long-term party auras", () => {
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 15_000), now, partyTargets)).toBe(true);
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", 60_000), now, partyTargets)).toBe(true);
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", null), now, partyTargets)).toBe(true);
it("shows every active player aura without including party-member copies", () => {
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 15_000), now)).toBe(true);
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", 60_000), now)).toBe(false);
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", null), now)).toBe(false);
});
it("leaves short party effects on party frames and rejects unrelated, hidden, or expired auras", () => {
expect(auraDisplaysInPlayerStatusPanel(activeAura("party-1", 30_000), now, partyTargets)).toBe(false);
expect(auraDisplaysInPlayerStatusPanel(activeAura("stranger", 60_000), now, partyTargets)).toBe(false);
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 60_000, 70_000, true), now, partyTargets)).toBe(false);
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 5_000, 15_000), now, partyTargets)).toBe(false);
it("rejects unrelated, hidden, or expired auras", () => {
expect(auraDisplaysInPlayerStatusPanel(activeAura("stranger", 60_000), now)).toBe(false);
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 60_000, 70_000, true), now)).toBe(false);
expect(auraDisplaysInPlayerStatusPanel(activeAura(PLAYER_AGGRO_ID, 5_000, 15_000), now)).toBe(false);
});
});
+7 -51
View File
@@ -1,17 +1,15 @@
import { useEffect, useState } from "react";
import { abilityById } from "../game/abilityCatalog";
import { PLAYER_AGGRO_ID } from "../game/aggro";
import type { ActiveAura } from "../game/combatAuras";
import { useCombatStore } from "../game/combatStore";
import { usePartyStore } from "../game/partyStore";
import { formatTimedEffectTimer, timedEffectRemainingMs } from "../game/timedEffectDisplay";
import { AuraStrip } from "./AuraStrip";
import {
AuraStrip,
auraDefinitionDisplaysInStrip,
auraDefinitionIsLongTerm,
auraDisplaysInPlayerStatusPanel,
auraDescription,
auraDisplayName,
} from "./AuraStrip";
groupAurasForDisplay,
} from "./auraDisplay";
import { TimedEffectStrip } from "./TimedEffectStrip";
import {
timedEffectDescription,
@@ -25,34 +23,16 @@ function statusLabel(disposition: StatusEffectInspection["disposition"]): string
return disposition === "buff" ? "Buff" : "Debuff";
}
export function auraDisplaysInPlayerStatusPanel(
aura: ActiveAura,
now: number,
partyTargetIds: ReadonlySet<string>,
): boolean {
const belongsInPanel = aura.targetId === PLAYER_AGGRO_ID
|| (partyTargetIds.has(aura.targetId) && auraDefinitionIsLongTerm(aura.definition));
return belongsInPanel
&& auraDefinitionDisplaysInStrip(aura.definition)
&& (aura.expiresAt === null || aura.expiresAt > now);
}
export function PlayerStatusPanel({ now }: { readonly now: number }) {
const auras = useCombatStore((state) => state.auras);
const effects = useCombatStore((state) => state.effects);
const shield = useCombatStore((state) => state.shield);
const shieldExpiresAt = useCombatStore((state) => state.shieldExpiresAt);
const partyMembers = usePartyStore((state) => state.members);
const [inspected, setInspected] = useState<StatusEffectInspection | null>(null);
const partyAuraTargetIds = partyMembers.map((member) => member.id);
const partyAuraTargetIdSet = new Set(partyAuraTargetIds);
const partyAuraTargetLabels = Object.fromEntries(
partyMembers.map((member) => [member.id, member.name]),
);
const activeAuras = auras.filter((aura) => (
auraDisplaysInPlayerStatusPanel(aura, now, partyAuraTargetIdSet)
));
const activeAuras = groupAurasForDisplay(auras.filter((aura) => (
auraDisplaysInPlayerStatusPanel(aura, now)
)));
const activeEffects = effects.filter((effect) => (
PLAYER_EFFECT_TARGETS.includes(effect.targetId)
&& timedEffectRemainingMs(effect, now) > 0
@@ -152,18 +132,6 @@ export function PlayerStatusPanel({ now }: { readonly now: number }) {
onInspect={setInspected}
tooltipId={TOOLTIP_ID}
/>
{partyAuraTargetIds.length ? (
<AuraStrip
targetId={partyAuraTargetIds[0]}
targetIds={partyAuraTargetIds}
dispositions={["buff"]}
className="player-status-panel__strip"
showOnlyLongTerm
targetLabels={partyAuraTargetLabels}
onInspect={setInspected}
tooltipId={TOOLTIP_ID}
/>
) : null}
<TimedEffectStrip
targetId={null}
targetIds={PLAYER_EFFECT_TARGETS}
@@ -189,18 +157,6 @@ export function PlayerStatusPanel({ now }: { readonly now: number }) {
onInspect={setInspected}
tooltipId={TOOLTIP_ID}
/>
{partyAuraTargetIds.length ? (
<AuraStrip
targetId={partyAuraTargetIds[0]}
targetIds={partyAuraTargetIds}
dispositions={["debuff"]}
className="player-status-panel__strip"
showOnlyLongTerm
targetLabels={partyAuraTargetLabels}
onInspect={setInspected}
tooltipId={TOOLTIP_ID}
/>
) : null}
<TimedEffectStrip
targetId={null}
targetIds={PLAYER_EFFECT_TARGETS}
+85
View File
@@ -0,0 +1,85 @@
import { PLAYER_AGGRO_ID } from "../game/aggro";
import type { ActiveAura, AuraDefinition } from "../game/combatAuras";
export const LONG_TERM_AURA_DURATION_MS = 60_000;
export function auraDefinitionDisplaysInStrip(definition: AuraDefinition): boolean {
return definition.hideFromAuraStrip !== true;
}
export function auraDefinitionIcon(definition: AuraDefinition): string | null {
return definition.icon?.trim() || null;
}
export function auraDisplayName(definition: AuraDefinition): string {
return definition.name.replace(/\s+\((?:DBC\s+\d+,\s*)?Aura\s+\d+\)$/i, "").trim();
}
function auraDisplayGroupKey(aura: ActiveAura): string {
return [
aura.targetId,
aura.definition.disposition,
auraDisplayName(aura.definition).toLowerCase(),
auraDefinitionIcon(aura.definition) ?? "",
].join("\u0000");
}
function auraDisplayExpiry(aura: ActiveAura): number {
return aura.expiresAt ?? Number.POSITIVE_INFINITY;
}
/** Collapses a spell's multiple DBC aura effects and repeated sources into one HUD icon. */
export function groupAurasForDisplay(auras: readonly ActiveAura[]): ActiveAura[] {
const grouped = new Map<string, ActiveAura>();
for (const aura of auras) {
const key = auraDisplayGroupKey(aura);
const existing = grouped.get(key);
if (!existing) {
grouped.set(key, aura);
continue;
}
const representative = auraDisplayExpiry(aura) > auraDisplayExpiry(existing)
|| (auraDisplayExpiry(aura) === auraDisplayExpiry(existing) && aura.appliedAt > existing.appliedAt)
? aura
: existing;
const stacks = Math.max(existing.stacks, aura.stacks);
grouped.set(key, representative.stacks === stacks
? representative
: { ...representative, stacks });
}
return [...grouped.values()];
}
/** Permanent and one-minute-or-longer auras are persistent party-frame clutter. */
export function auraDefinitionIsLongTerm(definition: AuraDefinition): boolean {
return definition.durationMs === null || definition.durationMs >= LONG_TERM_AURA_DURATION_MS;
}
export function auraDescription(aura: ActiveAura): string {
const authored = aura.definition.description?.trim();
if (authored) return authored;
const absorb = aura.absorbs.reduce((total, pool) => total + pool.remaining, 0);
if (absorb > 0) return `Absorbs up to ${Math.ceil(absorb)} incoming damage.`;
if (aura.definition.controlImmunities?.length) {
return `Grants immunity to ${aura.definition.controlImmunities.join(", ")}.`;
}
if (aura.definition.modifiers?.length) {
return aura.definition.disposition === "buff"
? "Improves combat attributes while active."
: "Reduces combat effectiveness while active.";
}
if (aura.definition.procs?.length) return "Can trigger an additional effect during combat.";
return aura.definition.disposition === "buff"
? "A beneficial effect is active."
: "A harmful effect is active.";
}
export function auraDisplaysInPlayerStatusPanel(
aura: ActiveAura,
now: number,
): boolean {
return aura.targetId === PLAYER_AGGRO_ID
&& auraDefinitionDisplaysInStrip(aura.definition)
&& (aura.expiresAt === null || aura.expiresAt > now);
}