Update Healer Man server

This commit is contained in:
phenom committed 2026-09-25 21:37:10 -04:00
1 parent 48a678dcc5
commit 9c5fb4c614
18 files changed
+397 -46

No files matched your search

+11 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { abilitiesForClass } from "./abilityCatalog";
import { channelAmountEffects } from "./combatChannelAmounts";
import { channelAmountEffects, channelPulseCount } from "./combatChannelAmounts";
describe("channel amount expansion", () => {
it("spreads a normal channel's full amount across its pulses", () => {
@@ -22,4 +22,14 @@ describe("channel amount expansion", () => {
: effect) };
expect(channelAmountEffects(conditional, 5)).toEqual([]);
});
it("deals Chain of Light's source hit on every pulse", () => {
const ability = abilitiesForClass("rom-priest").find((candidate) => candidate.name === "Chain of Light")!;
expect(ability.castMode).toBe("channel");
expect(ability.castTimeMs).toBe(4_000);
expect(channelPulseCount(ability)).toBe(4);
expect(channelAmountEffects(ability, 4)).toEqual([
{ effect: ability.effects.find((effect) => effect.kind === "damage"), multiplier: 1 },
]);
});
});
+7 -1
View File
@@ -2,6 +2,10 @@ import { triggeredAbilityBySpellId, type AbilityDefinition, type AbilityEffect }
type ChannelAmountEffect = Extract<AbilityEffect, { kind: "damage" | "dot" | "heal" | "hot" }>;
export function channelPulseCount(ability: Pick<AbilityDefinition, "name" | "dbcSpellId">): number {
return ability.name === "Divine Hymn" || ability.dbcSpellId === 491164 ? 4 : 5;
}
/** Direct channel amounts span the cast; periodic trigger payloads fire each pulse. */
export function channelAmountEffects(ability: AbilityDefinition, totalTicks: number): readonly {
effect: ChannelAmountEffect;
@@ -11,7 +15,9 @@ export function channelAmountEffects(ability: AbilityDefinition, totalTicks: num
const amounts: { effect: ChannelAmountEffect; multiplier: number }[] = [];
for (const effect of ability.effects) {
if (effect.kind === "damage" || effect.kind === "heal" || effect.kind === "dot" || effect.kind === "hot") {
amounts.push({ effect, multiplier: (effect.kind === "dot" || effect.kind === "hot" ? effect.ticks : 1) / ticks });
amounts.push({ effect, multiplier: ability.dbcSpellId === 491164 && effect.kind === "damage"
? 1
: (effect.kind === "dot" || effect.kind === "hot" ? effect.ticks : 1) / ticks });
} else if (effect.kind === "apply-aura") {
for (const proc of effect.aura.procs ?? []) {
const triggers = Array.isArray(proc.triggers) ? proc.triggers : [proc.triggers];
+4 -4
View File
@@ -139,7 +139,7 @@ import { rollBossLoot } from "./lootCatalog";
import { bossCardForSource } from "./bossCardCatalog";
import { collectBossCardDrop, emptyBossCardCollection, normalizeBossCardCollection, rollBossCards, type BossCardCollection, type BossCardDrop } from "./bossCards";
import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula";
import { channelAmountEffects } from "./combatChannelAmounts";
import { channelAmountEffects, channelPulseCount } from "./combatChannelAmounts";
import { normalizeInventory, type InventoryItem } from "./lootTypes";
import {
PLAYER_EQUIPMENT_OWNER_ID,
@@ -5166,7 +5166,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
const lastPrayerReady = ability.source === "runewaker" && ability.dbcSpellId !== 491648
&& ability.castTimeMs > 0 && hasRuneWakerState("rom-last-prayer");
&& ability.castMode !== "channel" && ability.castTimeMs > 0 && hasRuneWakerState("rom-last-prayer");
const ordinaryCastTimeMs = talentAdjustedCastTime(
ability,
modifiers,
@@ -5217,9 +5217,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|| (ability.source === "runewaker" && hasRuneWakerState("rom-mage-warlock-soul-stepping-cast")),
origin: [playerOrigin[0], playerOrigin[1], playerOrigin[2]],
groundPosition,
totalTicks: ability.castMode === "channel" ? (ability.name === "Divine Hymn" ? 4 : 5) : 0,
totalTicks: ability.castMode === "channel" ? channelPulseCount(ability) : 0,
ticksCompleted: 0,
nextTickAt: ability.castMode === "channel" ? now + castTimeMs / (ability.name === "Divine Hymn" ? 4 : 5) : 0,
nextTickAt: ability.castMode === "channel" ? now + castTimeMs / channelPulseCount(ability) : 0,
};
const channelCooldowns: Record<string, number> = { ...state.cooldowns };
if (activeCast.mode === "channel" && cooldownMs > 0) {
+2 -2
View File
@@ -65,7 +65,7 @@ import { imminentCooldownMechanic } from "./companionCooldownPolicy";
import { normalizeActorResourcePool } from "./combatActors";
import { enemyControlPreventsActions } from "./enemyAttacks";
import { advanceCombatResourcePool } from "./combatResources";
import { channelAmountEffects } from "./combatChannelAmounts";
import { channelAmountEffects, channelPulseCount } from "./combatChannelAmounts";
import { healingAbilityCanAidOthers, healingAbilityHasPendingHot, healingRecipientIds } from "./healingAbilityRules";
import {
combatHasLineOfSight,
@@ -1812,7 +1812,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null, status: "advancing", nextActionAt: now + 250 };
continue;
}
const totalTicks = castingAbility.name === "Divine Hymn" ? 4 : 5;
const totalTicks = channelPulseCount(castingAbility);
const interval = Math.max(1, cast.originalDurationMs / totalTicks);
let completed = cast.channelTicksCompleted ?? 0;
while (completed < totalTicks && cast.startedAt + (completed + 1) * interval <= now) {
+7 -6
View File
@@ -17,6 +17,7 @@ import {
import { overrideRomPriestKnightEffects, romPriestKnightAdditionalAbilities } from "./romPriestKnightOverrides";
import { overrideRomScoutRogueEffects } from "./romScoutRogueOverrides";
import { overrideRomMageWarlockEffects } from "./romMageWarlockOverrides";
import { romPriestDescription } from "./romPriestTooltips";
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
@@ -691,9 +692,9 @@ function rank(row: GeneratedRank, parent: GeneratedSkill): AbilityRankDefinition
rank: row.rank,
spellId: row.sourceSkillId,
level: row.level,
description: row.description,
castMode: row.castTimeMs > 0 ? "cast" : "instant",
castTimeMs: row.castTimeMs,
description: parent.classId === "rom-priest" ? romPriestDescription(row.sourceSkillId, row.description) : row.description,
castMode: row.sourceSkillId === 491164 ? "channel" : row.castTimeMs > 0 ? "cast" : "instant",
castTimeMs: row.sourceSkillId === 491164 ? 4_000 : row.castTimeMs,
cooldownMs: row.cooldownMs,
gcdMs: 1_500,
cost: rankCosts[0] ?? { resource: "mana", amount: 0 },
@@ -722,15 +723,15 @@ function ability(row: GeneratedSkill): AbilityDefinition {
unlockLevel: row.unlockLevel,
icon: row.icon,
sigil: row.name.split(/\s+/).map((part) => part[0]).join("").slice(0, 3).toUpperCase() || "RW",
description: row.description,
description: classId === "rom-priest" ? romPriestDescription(row.sourceSkillId, row.description) : row.description,
target: row.target,
range: { min: 0, max: row.range },
...(sourceTargeting(row.sourceSkillId)?.radius !== undefined
? { radius: sourceTargeting(row.sourceSkillId)!.radius }
: areaRadius(row) !== undefined ? { radius: areaRadius(row) } : {}),
...(sourceTargeting(row.sourceSkillId) ? { targeting: sourceTargeting(row.sourceSkillId) } : {}),
castMode: row.castTimeMs > 0 ? "cast" : "instant",
castTimeMs: row.castTimeMs,
castMode: row.sourceSkillId === 491164 ? "channel" : row.castTimeMs > 0 ? "cast" : "instant",
castTimeMs: row.sourceSkillId === 491164 ? 4_000 : row.castTimeMs,
cooldownMs: row.cooldownMs,
gcdMs: 1_500,
cost: abilityCosts[0] ?? { resource: "mana", amount: 0 },
@@ -37,6 +37,15 @@ function isMeaningfulEffect(effect: AbilityEffect): boolean {
}
describe("executable RuneWaker ability mappings", () => {
it("shows readable Priest / Knight spell descriptions without client string tokens", () => {
const priest = ROM_ALL_ABILITIES.filter((ability) => ability.classId === "rom-priest"
&& (!ability.secondaryClassId || ability.secondaryClassId === "rom-knight"));
for (const ability of priest) {
for (const description of [ability.description, ...(ability.ranks ?? []).map((rank) => rank.description)]) {
expect(description, ability.name).not.toMatch(/\([^)]*(?:Buff\d*|DMG\d*)[^)]*\)|\[SC_SKILLTIPS_[^\]]+\]|\[\d{6}\]/i);
}
}
});
it("maps Taunt, Shout, and Cleanse to their real combat mechanics", () => {
expect(named("Taunt").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "taunt" }),
+20
View File
@@ -109,6 +109,26 @@ describe("RuneWaker combat runtime", () => {
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true });
});
it("channels Chain of Light into four separate damage pulses", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", level: 30 });
useCombatStore.getState().registerMob("light-dummy", { name: "Light Dummy", maxHealth: 100_000 });
setMobPosition("light-dummy", [3, 0, 0]);
useCombatStore.getState().selectMob("light-dummy");
const initialHealth = useCombatStore.getState().mobs["light-dummy"]!.health;
expect(useCombatStore.getState().castAbility("rom-priest-primary-9", undefined, 1_000)).toMatchObject({ ok: true });
const cast = useCombatStore.getState().activeCast!;
expect(cast).toMatchObject({ mode: "channel", totalTicks: 4 });
expect(cast.completesAt).toBeGreaterThan(4_000);
expect(cast.completesAt).toBeLessThanOrEqual(5_000);
expect(useCombatStore.getState().mobs["light-dummy"]!.health).toBe(initialHealth);
useCombatStore.getState().tick(1, cast.nextTickAt);
const firstPulseHealth = useCombatStore.getState().mobs["light-dummy"]!.health;
expect(firstPulseHealth).toBeLessThan(initialHealth);
useCombatStore.getState().tick(3, cast.completesAt);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(useCombatStore.getState().mobs["light-dummy"]!.health).toBeLessThan(firstPulseHealth);
});
it("applies companion Holy Candle HP costs to its actor and party record", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 50 });
usePartyStore.getState().initializeParty("rom-self-drain", 50, useCombatStore.getState().health, "healer", 2, "rom");
+38
View File
@@ -0,0 +1,38 @@
/** Player-facing copy for the active Priest / Knight kit. Source string tokens
* such as Buff0-Time refer to client tables that are not present in the UI. */
const PRIEST_DESCRIPTIONS: Readonly<Record<number, string>> = {
490273: "Shields a friendly target from damage for 60 seconds and reduces physical and magical damage taken while the shield lasts.",
490261: "Restores health to a friendly target.",
490274: "Surrounds the target with icy fog, slowing it for a short time.",
491165: "Chills the target with Water damage every 2 seconds for 12 seconds.",
490297: "Bonds with a friendly target for 30 minutes, allowing it to revive once at the place where it falls.",
490290: "Increases a friendly target's maximum health for 30 minutes.",
490281: "Heals nearby party members. Casting it also applies Exhaustion, increasing the mana cost of group healing.",
491164: "Channels Light into the target for 4 seconds, dealing Light damage once per second.",
490491: "Increases a friendly target's physical attack power for 30 minutes. Does not stack with similar effects.",
490305: "Passive: increases Water resistance and Water spell damage.",
490295: "Passive: increases the healing done by your spells.",
490275: "Surrounds the target with icy fog, slowing it for a short time.",
490294: "Increases healing received by a friendly target for 60 seconds.",
490301: "Temporarily reduces your threat against enemies.",
490276: "Surrounds the target with icy fog, slowing it for a short time.",
490304: "Consumes some of your health each second to increase the healing you do.",
491176: "Revives a dead friendly target outside combat with reduced death debt.",
491177: "Revives a dead friendly target outside combat with greatly reduced death debt.",
491147: "Quickly restores health to a friendly target.",
490256: "Sends a torrent at the target, dealing Water damage.",
490269: "Restores a friendly target's health every 2 seconds for 20 seconds.",
490300: "Makes you immune to damage for 5 seconds.",
491166: "Increases the magical defense of party members for 15 minutes.",
490298: "Increases party members' Water resistance for a limited time.",
491366: "Increases your healing and reduces mana costs while lowering magical attack for 15 minutes.",
492639: "Protects a friendly target from Fear for 10 seconds.",
492934: "Sacrifices some of your health to heal a friendly party member.",
493044: "Increases your Wisdom for 20 seconds or until you cast Heal three times.",
499968: "Passive: Splendor Ripple makes your Devotion Halo heal its target for additional health.",
499957: "Passive: while Devotion Halo is active, increases your healing done.",
};
export function romPriestDescription(sourceSkillId: number, original: string): string {
return PRIEST_DESCRIPTIONS[sourceSkillId] ?? original;
}
+15
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { TimedCombatEffect } from "./combatStore";
import {
formatTimedEffectTimer,
groupTimedEffectsForDisplay,
timedEffectExpiresAt,
timedEffectRemainingMs,
} from "./timedEffectDisplay";
@@ -37,4 +38,18 @@ describe("timed effect display", () => {
expect(formatTimedEffectTimer(9_940)).toBe("9.9");
expect(formatTimedEffectTimer(-50)).toBe("0.0");
});
it("shows repeated poison applications as one icon with the live stack count", () => {
const first = effect({ id: "poison:1", sourceAbilityId: "rom-rogue-poison", nextTickAt: 4_000 });
const second = effect({ id: "poison:2", sourceAbilityId: "rom-rogue-poison", sourceActorId: "rogue-2", nextTickAt: 5_000 });
const third = effect({ id: "poison:3", sourceAbilityId: "rom-rogue-poison", sourceActorId: "rogue-3", nextTickAt: 6_000 });
expect(groupTimedEffectsForDisplay([first, second, third], 3_000)).toEqual([{
effect: third, stacks: 3, remainingMs: 18_000,
}]);
expect(groupTimedEffectsForDisplay([first, second, third], 20_000)).toEqual([{
effect: third, stacks: 1, remainingMs: 1_000,
}]);
expect(groupTimedEffectsForDisplay([first, effect({ id: "bleed", sourceAbilityId: "rom-rogue-bleed" })], 3_000))
.toHaveLength(2);
});
});
+30
View File
@@ -9,6 +9,36 @@ export function timedEffectRemainingMs(effect: TimedCombatEffect, now: number):
return Math.max(0, timedEffectExpiresAt(effect) - now);
}
export interface DisplayedTimedEffect {
readonly effect: TimedCombatEffect;
readonly stacks: number;
readonly remainingMs: number;
}
/** Keep every periodic application active in combat while showing one icon per spell and target. */
export function groupTimedEffectsForDisplay(
effects: readonly TimedCombatEffect[],
now: number,
): DisplayedTimedEffect[] {
const grouped = new Map<string, DisplayedTimedEffect>();
for (const effect of effects) {
const remainingMs = timedEffectRemainingMs(effect, now);
if (remainingMs <= 0) continue;
const key = [effect.targetId ?? "", effect.kind, effect.sourceAbilityId].join("\u0000");
const existing = grouped.get(key);
if (!existing) {
grouped.set(key, { effect, stacks: 1, remainingMs });
continue;
}
grouped.set(key, {
effect: remainingMs > existing.remainingMs ? effect : existing.effect,
stacks: existing.stacks + 1,
remainingMs: Math.max(existing.remainingMs, remainingMs),
});
}
return [...grouped.values()].sort((left, right) => left.remainingMs - right.remainingMs);
}
/** Compact MMO-style countdown: whole seconds, then tenths for the last 10 seconds. */
export function formatTimedEffectTimer(remainingMs: number): string {
const safeRemaining = Math.max(0, Number.isFinite(remainingMs) ? remainingMs : 0);
+60
View File
@@ -10,6 +10,9 @@ import {
} from "three";
import { useShellStore } from "../app/shellStore";
import { useCombatStore } from "../game/combatStore";
import { getMobPosition } from "../game/mobRuntimeRegistry";
import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
import { usePartyStore } from "../game/partyStore";
import {
combatEffectStyle,
preloadCombatEffectManifest,
@@ -51,6 +54,55 @@ const QUALITY_PARTICLE_COUNT: Readonly<Record<Exclude<CombatVfxQuality, "off">,
const COMBAT_EFFECT_DATASET_KEY = "combatEffectLoad";
/** The imported hand particles do not include Chain of Light's sustained target link. */
function ChainOfLightBeam({ actorId }: { readonly actorId?: string }) {
const beamRef = useRef<Group>(null);
const source = useMemo(() => new Vector3(), []);
const target = useMemo(() => new Vector3(), []);
const direction = useMemo(() => new Vector3(), []);
const up = useMemo(() => new Vector3(0, 1, 0), []);
useFrame(() => {
const beam = beamRef.current;
if (!beam) return;
const state = useCombatStore.getState();
const member = actorId ? usePartyStore.getState().members.find((entry) => entry.id === actorId) : null;
const cast = actorId ? member?.activeCast : state.activeCast;
const targetId = actorId ? member?.activeCastTargetId : state.activeCast?.targetId;
const origin = actorId ? getPartyRuntimePosition(actorId) : state.playerPosition;
const position = targetId ? getMobPosition(targetId) : null;
if (cast?.abilityId !== "rom-priest-primary-9" || !origin || !position) {
beam.visible = false;
return;
}
source.set(origin[0], origin[1] + 1.35, origin[2]);
target.set(position[0], position[1] + 1.15, position[2]);
direction.subVectors(target, source);
const length = direction.length();
if (length < 0.1) {
beam.visible = false;
return;
}
beam.visible = true;
beam.position.copy(source).add(target).multiplyScalar(0.5);
beam.quaternion.setFromUnitVectors(up, direction.multiplyScalar(1 / length));
beam.scale.set(1, length, 1);
});
return (
<group ref={beamRef} visible={false}>
<mesh>
<cylinderGeometry args={[0.14, 0.14, 1, 10]} />
<meshBasicMaterial color="#ffe57c" transparent opacity={0.28} depthWrite={false} blending={AdditiveBlending} />
</mesh>
<mesh>
<cylinderGeometry args={[0.055, 0.055, 1, 8]} />
<meshBasicMaterial color="#fff9d6" transparent opacity={0.85} depthWrite={false} blending={AdditiveBlending} />
</mesh>
</group>
);
}
function effectDurationMs(event: CombatPresentationEvent): number {
if (event.phase === "cast-start") return Math.max(280, Math.min(4_500, event.durationMs ?? 900));
if (event.phase === "tick") return 320;
@@ -250,6 +302,10 @@ const CombatEffectPrimitive = memo(function CombatEffectPrimitive({
export function CombatEffects({ active, safeGraphics = false }: CombatEffectsProps) {
const settings = useShellStore((state) => state.combatPresentationSettings);
const masterVolume = useCombatStore((state) => state.settings.masterVolume);
const chainOfLightChanneling = useCombatStore((state) => state.activeCast?.abilityId === "rom-priest-primary-9");
const partyChainCasterIds = usePartyStore((state) => state.members
.filter((member) => member.activeCast?.abilityId === "rom-priest-primary-9")
.map((member) => member.id).join("\u0000"));
const [effects, setEffects] = useState<readonly ActiveCombatEffect[]>([]);
const settingsRef = useRef(settings);
const activeRef = useRef(active);
@@ -362,6 +418,10 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
if (effectiveQuality === "off") return null;
return (
<group name="combat-effects">
{active && settings.vfxEnabled.player && chainOfLightChanneling ? <ChainOfLightBeam /> : null}
{active && settings.vfxEnabled.party && partyChainCasterIds
? partyChainCasterIds.split("\u0000").map((actorId) => <ChainOfLightBeam key={actorId} actorId={actorId} />)
: null}
{effects.map((effect) => (
effect.originalOnly && vfxMode !== "original" ? null :
vfxMode === "original" ? (
+2
View File
@@ -878,6 +878,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.timed-effect img { width: 100%; height: 100%; object-fit: cover; }
.timed-effect__fallback { color: #f1ba92; font-size: 12px; font-weight: 900; }
.timed-effect--hot .timed-effect__fallback { color: #9ae7ac; }
.timed-effect__stacks { position: absolute; right: 1px; top: 0; color: #fff4b8; font: 800 8px/8px var(--font-ui); text-shadow: 0 1px 2px #000, 0 0 3px #000; }
.timed-effect__timer {
position: absolute;
right: 1px;
@@ -894,6 +895,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.timed-effect-strip--party { min-height: 16px; margin-top: 1px; }
.timed-effect-strip--party .timed-effect { width: 16px; height: 16px; }
.timed-effect-strip--party .timed-effect__timer { font-size: 6px; line-height: 7px; }
.timed-effect-strip--party .timed-effect__stacks { font-size: 6px; line-height: 6px; }
.timed-effect-strip--target { justify-content: center; margin-top: 4px; }
.timed-effect-strip--target + small { display: block; margin-top: 1px; }
.timed-effect-strip--world { width: 144px; justify-content: center; filter: drop-shadow(0 2px 2px rgba(0,0,0,.85)); }
+11
View File
@@ -103,4 +103,15 @@ describe("aura strip visibility", () => {
expect(groupAurasForDisplay([first, second])).toEqual([second]);
});
it("counts separate applications of the same debuff while collapsing different aura components", () => {
const poison = activeAura(aura({ id: "poison", name: "Poison", icon: "/poison.png" }));
const otherSource = { ...poison, instanceId: "poison-2", sourceId: "rogue-2", appliedAt: 2_000 };
const thirdSource = { ...poison, instanceId: "poison-3", sourceId: "rogue-3", appliedAt: 3_000 };
expect(groupAurasForDisplay([poison, otherSource, thirdSource])).toEqual([
{ ...thirdSource, stacks: 3 },
]);
const secondaryComponent = { ...poison, definition: aura({ id: "poison-secondary", name: "Poison", icon: "/poison.png" }) };
expect(groupAurasForDisplay([poison, secondaryComponent])[0]?.stacks).toBe(1);
});
});
+10 -9
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { abilityById } from "../game/abilityCatalog";
import { PLAYER_AGGRO_ID } from "../game/aggro";
import { useCombatStore } from "../game/combatStore";
import { formatTimedEffectTimer, timedEffectRemainingMs } from "../game/timedEffectDisplay";
import { formatTimedEffectTimer, groupTimedEffectsForDisplay, timedEffectRemainingMs } from "../game/timedEffectDisplay";
import { AuraStrip } from "./AuraStrip";
import {
auraDisplaysInPlayerStatusPanel,
@@ -33,19 +33,18 @@ export function PlayerStatusPanel({ now }: { readonly now: number }) {
const activeAuras = groupAurasForDisplay(auras.filter((aura) => (
auraDisplaysInPlayerStatusPanel(aura, now)
)));
const activeEffects = effects.filter((effect) => (
const activeEffects = groupTimedEffectsForDisplay(effects.filter((effect) => (
PLAYER_EFFECT_TARGETS.includes(effect.targetId)
&& timedEffectRemainingMs(effect, now) > 0
));
)), now);
const shieldActive = shield > 0 && (shieldExpiresAt <= 0 || shieldExpiresAt > now);
const buffCount = activeAuras.filter((aura) => aura.definition.disposition === "buff").length
+ activeEffects.filter((effect) => effect.kind === "hot").length
+ activeEffects.filter(({ effect }) => effect.kind === "hot").length
+ (shieldActive ? 1 : 0);
const debuffCount = activeAuras.filter((aura) => aura.definition.disposition === "debuff").length
+ activeEffects.filter((effect) => effect.kind === "dot").length;
+ activeEffects.filter(({ effect }) => effect.kind === "dot").length;
const activeInspectionIds = new Set([
...activeAuras.map((aura) => aura.instanceId),
...activeEffects.map((effect) => effect.id),
...activeEffects.map(({ effect }) => effect.id),
...(shieldActive ? ["player-absorb-shield"] : []),
]);
const inspectedIsActive = inspected ? activeInspectionIds.has(inspected.id) : true;
@@ -79,8 +78,9 @@ export function PlayerStatusPanel({ now }: { readonly now: number }) {
: {}),
} satisfies StatusEffectInspection;
}
const effect = activeEffects.find((candidate) => candidate.id === inspected.id);
if (effect) {
const displayedEffect = activeEffects.find(({ effect }) => effect.id === inspected.id);
if (displayedEffect) {
const { effect, stacks } = displayedEffect;
const ability = abilityById(effect.sourceAbilityId);
return {
...inspected,
@@ -88,6 +88,7 @@ export function PlayerStatusPanel({ now }: { readonly now: number }) {
disposition: effect.kind === "hot" ? "buff" : "debuff",
description: timedEffectDescription(effect.kind, ability?.description),
remainingLabel: `${formatTimedEffectTimer(timedEffectRemainingMs(effect, now))} remaining`,
stacks,
} satisfies StatusEffectInspection;
}
return inspected;
+8 -13
View File
@@ -3,7 +3,7 @@ import { abilityById } from "../game/abilityCatalog";
import { useCombatStore, type TimedCombatEffect } from "../game/combatStore";
import {
formatTimedEffectTimer,
timedEffectRemainingMs,
groupTimedEffectsForDisplay,
} from "../game/timedEffectDisplay";
import {
timedEffectDescription,
@@ -41,20 +41,14 @@ function LiveTimedEffectStrip({
return () => window.clearInterval(timer);
}, []);
const visibleEffects = effects
.map((effect) => ({
effect,
ability: abilityById(effect.sourceAbilityId),
remainingMs: timedEffectRemainingMs(effect, now),
}))
.filter(({ remainingMs }) => remainingMs > 0)
.sort((left, right) => left.remainingMs - right.remainingMs);
const visibleEffects = groupTimedEffectsForDisplay(effects, now);
if (!visibleEffects.length) return null;
return (
<span className={`timed-effect-strip ${className}`.trim()} aria-label="Periodic effects">
{visibleEffects.map(({ effect, ability, remainingMs }) => {
{visibleEffects.map(({ effect, stacks, remainingMs }) => {
const ability = abilityById(effect.sourceAbilityId);
const label = ability?.name ?? effect.sourceAbilityId;
const timer = formatTimedEffectTimer(remainingMs);
const disposition = effect.kind === "hot" ? "buff" : "debuff";
@@ -64,16 +58,16 @@ function LiveTimedEffectStrip({
disposition,
description: timedEffectDescription(effect.kind, ability?.description),
remainingLabel: `${timer} remaining`,
stacks: 1,
stacks,
};
return (
<span
key={effect.id}
className={`timed-effect timed-effect--${effect.kind} ${onInspect ? "timed-effect--inspectable" : ""}`.trim()}
tabIndex={onInspect ? 0 : undefined}
aria-label={`${label}, ${effect.kind === "hot" ? "healing" : "damage"} over time, ${timer} remaining`}
aria-label={`${label}, ${effect.kind === "hot" ? "healing" : "damage"} over time${stacks > 1 ? `, ${stacks} stacks` : ""}, ${timer} remaining`}
aria-describedby={onInspect ? tooltipId : undefined}
title={onInspect ? undefined : `${label} · ${timer} remaining`}
title={onInspect ? undefined : `${label}${stacks > 1 ? ` · ${stacks} stacks` : ""} · ${timer} remaining`}
onPointerEnter={onInspect ? () => onInspect(inspection) : undefined}
onPointerLeave={onInspect ? (event) => {
if (event.currentTarget !== document.activeElement) onInspect(null);
@@ -84,6 +78,7 @@ function LiveTimedEffectStrip({
{ability?.icon
? <img src={ability.icon} alt="" draggable={false} />
: <span className="timed-effect__fallback" aria-hidden="true">{effect.kind === "hot" ? "+" : "!"}</span>}
{stacks > 1 ? <b className="timed-effect__stacks" aria-hidden="true">{stacks}</b> : null}
<b className="timed-effect__timer" aria-hidden="true">{timer}</b>
</span>
);
+12 -10
View File
@@ -30,24 +30,26 @@ function auraDisplayExpiry(aura: ActiveAura): number {
/** 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>();
const grouped = new Map<string, { representative: ActiveAura; stacksByDefinition: Map<string, number> }>();
for (const aura of auras) {
const key = auraDisplayGroupKey(aura);
const existing = grouped.get(key);
if (!existing) {
grouped.set(key, aura);
grouped.set(key, { representative: aura, stacksByDefinition: new Map([[aura.definition.id, aura.stacks]]) });
continue;
}
const representative = auraDisplayExpiry(aura) > auraDisplayExpiry(existing)
|| (auraDisplayExpiry(aura) === auraDisplayExpiry(existing) && aura.appliedAt > existing.appliedAt)
const representative = auraDisplayExpiry(aura) > auraDisplayExpiry(existing.representative)
|| (auraDisplayExpiry(aura) === auraDisplayExpiry(existing.representative) && aura.appliedAt > existing.representative.appliedAt)
? aura
: existing;
const stacks = Math.max(existing.stacks, aura.stacks);
grouped.set(key, representative.stacks === stacks
? representative
: { ...representative, stacks });
: existing.representative;
existing.stacksByDefinition.set(aura.definition.id,
(existing.stacksByDefinition.get(aura.definition.id) ?? 0) + aura.stacks);
existing.representative = representative;
}
return [...grouped.values()];
return [...grouped.values()].map(({ representative, stacksByDefinition }) => {
const stacks = Math.max(...stacksByDefinition.values());
return representative.stacks === stacks ? representative : { ...representative, stacks };
});
}
/** Permanent and one-minute-or-longer auras are persistent party-frame clutter. */