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);