Update Healer Man server
This commit is contained in:
1 parent
48a678dcc5
commit
9c5fb4c614
18 files changed
+397
-46
No files matched your search
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user