Update Healer Man server

This commit is contained in:
phenom committed 2026-10-04 13:13:35 -04:00
1 parent 39458d5de6
commit 6cc1b9217f
203 files changed
+133929 -31271

No files matched your search

+1
View File
@@ -559,6 +559,7 @@ function LoadedCharacterModel({
fallbackScale={fallbackEquipmentScale}
models={resolved.equipmentModels}
transforms={equipmentAttachmentTransforms}
onReady={preview ? onReady : undefined}
/>
</>
);
+1 -1
View File
@@ -74,7 +74,7 @@ const selected: CharacterAppearance = {
describe("resolveAvatarAppearance", () => {
it("composes body layers in WoW replacement-texture order", () => {
const resolved = resolveAvatarAppearance(manifest, entry, selected, "priest");
expect(resolved.bodyLayers.map((layer) => layer.url)).toEqual([
expect(resolved.bodyLayers.slice(0, 9).map((layer) => layer.url)).toEqual([
"skin-1",
"pelvis-1",
"torso-1",
+4 -1
View File
@@ -10,6 +10,7 @@ import {
resolveEquipmentAppearance,
type EquipmentModelVisual,
} from "./equipmentAppearance";
import { resolveRunewakerEquipment } from "./runewakerEquipment";
export interface AvatarTextureLayer {
url: string;
@@ -59,7 +60,9 @@ export function resolveAvatarAppearance(
const featureTextures = tripletAt(feature?.byColor ?? [], hairColorIndex);
const regions = manifest.textureLayout.regions;
const layers: AvatarTextureLayer[] = [];
const equipped = resolveEquipmentAppearance(equipment, entry.gender);
const equipped = entry.rendererStrategy?.kind === "runewaker-modular" && classId
? resolveRunewakerEquipment(classId, equipment)
: resolveEquipmentAppearance(equipment, entry.gender, classId);
if (skin?.base) addLayer(layers, skin.base, regions.base);
addLayer(layers, skin?.underwear?.[0] ?? "", regions.pelvisUpper);
+30
View File
@@ -13,6 +13,36 @@ function item(itemId: number): InventoryItem {
}
describe("resolveEquipmentAppearance", () => {
it("gives roster previews and unmapped heirlooms visible clothing without changing inventory", () => {
const heirloom = { itemId: 9_000_001, inventorySlot: "chest", armorType: "cloth" } as InventoryItem;
for (const items of [[], [heirloom]]) {
const resolved = resolveEquipmentAppearance(items, "female", "priest");
expect(resolved.bodyLayers.some((layer) => layer.url.includes("torso-upper-female"))).toBe(true);
expect(resolved.bodyLayers.some((layer) => layer.url.includes("foot.png"))).toBe(true);
expect(resolved.models.map((model) => model.attachment)).toEqual(["shoulderLeft", "shoulderRight"]);
expect(resolved.geosets.Trousers).toBe(2);
}
expect(heirloom.itemId).toBe(9_000_001);
});
it("keeps real equipped appearances ahead of the class armor defaults", () => {
const resolved = resolveEquipmentAppearance([
{ itemId: 6473, inventorySlot: "chest", armorType: "leather" } as InventoryItem,
], "male", "priest");
expect(resolved.bodyLayers.some((layer) => layer.url.includes("6473-armor-of-the-fang"))).toBe(true);
expect(resolved.bodyLayers.some((layer) => layer.url.includes("6465-robe"))).toBe(false);
expect(resolved.geosets.Trousers).toBe(1);
});
it("distinguishes cloth, leather, and heavy starter chest appearances and their caches", () => {
const appearances = ["priest", "rogue", "warrior"] as const;
const resolved = appearances.map((classId) => resolveEquipmentAppearance([], "male", classId));
expect(resolved[0].bodyLayers.some((layer) => layer.url.includes("6465-robe"))).toBe(true);
expect(resolved[1].bodyLayers.some((layer) => layer.url.includes("6473-armor"))).toBe(true);
expect(resolved[2].bodyLayers.some((layer) => layer.url.includes("6627-mutant"))).toBe(true);
expect(new Set(resolved.map((appearance) => appearance.cacheKey)).size).toBe(3);
});
it("maps equipped loot to its exact client-native model attachment", () => {
const resolved = resolveEquipmentAppearance([
item(6472),
+26 -4
View File
@@ -1,4 +1,4 @@
import type { GenderId } from "../app/characterCatalog";
import { classById, type ClassId, type GenderId } from "../app/characterCatalog";
import type { CharacterWeaponStance } from "../game/combatAnimation";
import type { InventoryItem } from "../game/lootTypes";
import type { TextureRegion } from "./characterManifest";
@@ -36,6 +36,8 @@ export interface EquipmentModelVisual {
readonly attachment: EquipmentAttachmentName;
readonly fallbackKind?: "bow" | "dagger" | "mace" | "shield" | "staff" | "sword";
readonly weaponStance?: CharacterWeaponStance;
/** Native RoM props already use the authored RoM socket frame. */
readonly sourceFrame?: "runewaker";
}
interface GenderedTextureLayer {
@@ -318,6 +320,7 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
export function resolveEquipmentAppearance(
items: readonly InventoryItem[],
gender: GenderId,
classId?: ClassId,
): ResolvedEquipmentAppearance {
const layers: EquipmentTextureLayer[] = [];
const models: EquipmentModelVisual[] = [];
@@ -325,8 +328,14 @@ export function resolveEquipmentAppearance(
const geosets: Record<string, number> = { Cloak: 0 };
let cloakUrl = "";
const equippedVisuals = items.flatMap((item) => {
const visual = EQUIPMENT_VISUALS[item.itemId];
const starterArmor = classId ? classById(classId).armor : "cloth";
const starterSlots = ["chest", "legs", "feet", "hands", "shoulder", "back"];
const representedSlots = new Set(items.map((item) => item.inventorySlot));
const starterItems = classId ? starterSlots.filter((slot) => !representedSlots.has(slot))
.map((slot) => ({ itemId: 0, inventorySlot: slot, armorType: starterArmor })) : [];
const equippedVisuals = [...starterItems, ...items].flatMap((item) => {
const visual = EQUIPMENT_VISUALS[item.itemId]
?? (classId ? representativeArmorVisual(item.inventorySlot, item.armorType ?? starterArmor) : undefined);
return visual ? [{ item, visual }] : [];
});
@@ -347,7 +356,7 @@ export function resolveEquipmentAppearance(
}
return {
cacheKey: items.map((item) => item.itemId).join(","),
cacheKey: [classId ?? "", ...items.map((item) => `${item.itemId}:${item.inventorySlot}:${item.armorType}`)].join(","),
bodyLayers: layers,
cloakUrl,
geosets,
@@ -355,6 +364,19 @@ export function resolveEquipmentAppearance(
};
}
/** Visible starter/heirloom armor, superseded by exact packaged item visuals. */
function representativeArmorVisual(slot: string, armor: string): EquipmentVisualDefinition | undefined {
switch (slot) {
case "chest": return EQUIPMENT_VISUALS[armor === "cloth" ? 6465 : armor === "leather" ? 6473 : 6627];
case "legs": return EQUIPMENT_VISUALS[10410];
case "feet": return EQUIPMENT_VISUALS[10411];
case "hands": return EQUIPMENT_VISUALS[5970];
case "shoulder": return EQUIPMENT_VISUALS[armor === "cloth" ? 6461 : 5404];
case "back": return EQUIPMENT_VISUALS[6632];
default: return undefined;
}
}
export function equipmentModelUrls(): readonly string[] {
return Object.values(EQUIPMENT_VISUALS)
.flatMap((visual) => visual.models?.map((itemModel) => itemModel.url) ?? []);
+15 -2
View File
@@ -115,16 +115,24 @@ function AttachedEquipmentModel({
entry,
itemModel,
transform,
onReady,
}: {
readonly entry: AvatarManifestEntry;
readonly itemModel: EquipmentModelVisual;
readonly transform: AvatarAttachmentTransform;
readonly onReady?: () => void;
}): ReactNode {
const gltf = useGLTF(resolveContentUrl(itemModel.url));
const scene = useMemo(
() => cloneEquipmentScene(gltf.scene, `${itemModel.itemId}-${itemModel.attachment}`),
[gltf.scene, itemModel.attachment, itemModel.itemId],
);
useEffect(() => {
if (!onReady) return;
// Include the ready pose after its initial blend, not the rig's bind-pose bounds.
const timer = window.setTimeout(onReady, 500);
return () => window.clearTimeout(timer);
}, [onReady, scene]);
useEffect(() => () => {
const materials = new Set<Material>();
scene.traverse((node) => {
@@ -136,7 +144,7 @@ function AttachedEquipmentModel({
materials.forEach((material) => material.dispose());
}, [scene]);
const socketRotation = itemModel.fallbackKind && itemModel.weaponStance
const socketRotation = itemModel.sourceFrame !== "runewaker" && itemModel.fallbackKind && itemModel.weaponStance
? avatarWeaponSocketRotation(entry, itemModel.fallbackKind, itemModel.weaponStance)
: NO_ROTATION;
@@ -192,11 +200,13 @@ function ResolvedEquipmentAttachment({
fallbackScale,
itemModel,
transform,
onReady,
}: {
readonly entry: AvatarManifestEntry;
readonly fallbackScale: number;
readonly itemModel: EquipmentModelVisual;
readonly transform: AvatarAttachmentTransform;
readonly onReady?: () => void;
}): ReactNode {
const fallback = (
<EquipmentModelFallback
@@ -212,7 +222,7 @@ function ResolvedEquipmentAttachment({
resetKey={`${itemModel.itemId}:${itemModel.attachment}:${itemModel.url}`}
>
<Suspense fallback={fallback}>
<AttachedEquipmentModel entry={entry} itemModel={itemModel} transform={transform} />
<AttachedEquipmentModel entry={entry} itemModel={itemModel} transform={transform} onReady={onReady} />
</Suspense>
</AvatarErrorBoundary>
);
@@ -223,11 +233,13 @@ export function EquipmentAttachments({
fallbackScale,
models,
transforms,
onReady,
}: {
readonly entry: AvatarManifestEntry;
readonly fallbackScale: number;
readonly models: readonly EquipmentModelVisual[];
readonly transforms: EquipmentAttachmentTransforms;
readonly onReady?: () => void;
}): ReactNode {
return models.map((itemModel) => {
const transform = transforms[itemModel.attachment];
@@ -238,6 +250,7 @@ export function EquipmentAttachments({
fallbackScale={fallbackScale}
itemModel={itemModel}
transform={transform}
onReady={onReady}
/>
) : null;
});
+42
View File
@@ -0,0 +1,42 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { CLASSES } from "../app/characterCatalog";
import type { InventoryItem } from "../game/lootTypes";
import { resolveRunewakerEquipment } from "./runewakerEquipment";
describe("original RoM equipment", () => {
it("equips every RoM class with source-client props in its native socket frame", () => {
for (const characterClass of CLASSES.filter((entry) => entry.id.startsWith("rom-"))) {
const resolved = resolveRunewakerEquipment(characterClass.id, []);
expect(resolved.models.length).toBeGreaterThan(0);
expect(resolved.bodyLayers).toEqual([]);
for (const model of resolved.models) {
expect(model.sourceFrame).toBe("runewaker");
expect(model.url).toContain("/equipment/runewaker-v1/");
expect(existsSync(resolve("public", model.url.slice(1)))).toBe(true);
}
}
});
it("shows the equipped weapon type and avoids applying WoW armor to native RoM UVs", () => {
const resolved = resolveRunewakerEquipment("rom-priest", [
{ itemId: 123, inventorySlot: "main-hand", weaponType: "mace" } as InventoryItem,
{ itemId: 6465, inventorySlot: "chest", armorType: "cloth" } as InventoryItem,
]);
expect(resolved.models).toEqual([expect.objectContaining({
itemId: 123, url: expect.stringContaining("starter-mace.glb"), attachment: "handRight",
})]);
expect(resolved.bodyLayers).toEqual([]);
});
it("preserves original ROS provenance for all six packaged weapon kinds", () => {
const imported = JSON.parse(readFileSync("public/assets/equipment/runewaker-v1/import-manifest.json", "utf8"));
expect(imported.assets).toHaveLength(6);
expect(imported.proceduralFallbacks).toEqual([]);
for (const asset of imported.assets) {
expect(asset.sourceModel).toMatch(/^model\/item\/weapon\/.+\.ros$/);
expect(asset.sourceModelSha256).toMatch(/^[a-f0-9]{64}$/);
}
});
});
+46
View File
@@ -0,0 +1,46 @@
import type { ClassId } from "../app/characterCatalog";
import type { InventoryItem } from "../game/lootTypes";
import type { EquipmentModelVisual, ResolvedEquipmentAppearance } from "./equipmentAppearance";
import { starterWeaponLoadout, type StarterWeaponKind } from "./starterWeapons";
export const RUNEWAKER_STARTER_WEAPON_ROOT = "/assets/equipment/runewaker-v1";
/** Original client props; the character GLB contains the native worn paperdoll. */
export function resolveRunewakerEquipment(
classId: ClassId,
equipment: readonly InventoryItem[],
): ResolvedEquipmentAppearance {
const loadout = starterWeaponLoadout(classId);
const models: EquipmentModelVisual[] = loadout.items.map((starter, index) => {
const equipped = equipment.find((item) => starter.kind === "shield"
? item.armorType === "shield"
: starter.kind === "bow"
? item.weaponType === "bow" || item.weaponType === "crossbow"
: starter.hand === "left"
? item.inventorySlot === "off-hand" && Boolean(item.weaponType)
: ["main-hand", "one-hand", "two-hand"].includes(item.inventorySlot));
const weaponType = equipped?.weaponType;
const kind: StarterWeaponKind = starter.kind === "shield" ? "shield"
: weaponType === "staff" ? "staff"
: weaponType === "dagger" ? "dagger"
: weaponType === "mace" || weaponType === "hammer" ? "mace"
: weaponType === "sword" || weaponType === "axe" ? "sword"
: starter.kind;
return {
itemId: equipped?.itemId ?? -(index + 1),
name: `RoM starter ${kind}`,
url: `${RUNEWAKER_STARTER_WEAPON_ROOT}/starter-${kind}.glb`,
attachment: kind === "shield" ? "shield" : starter.hand === "left" ? "handLeft" : "handRight",
fallbackKind: kind,
weaponStance: kind === "staff" ? "two-handed-left" : kind === "bow" ? "bow" : loadout.stance,
sourceFrame: "runewaker",
};
});
return {
cacheKey: `${classId}:${models.map((model) => `${model.itemId}:${model.url}`).join(",")}`,
bodyLayers: [],
cloakUrl: "",
geosets: {},
models,
};
}
+6
View File
@@ -121,6 +121,12 @@ describe("browser content packs", () => {
totalBytes: firstBytes.byteLength,
error: "",
});
const entriesSpy = vi.spyOn(Object, "entries");
for (let index = 0; index < 100; index += 1) {
expect(contentPackIsUsable("characters:test")).toBe(true);
}
expect(entriesSpy).not.toHaveBeenCalled();
entriesSpy.mockRestore();
const active = storage.stores.get(BROWSER_CONTENT_CACHE_NAME);
expect(await (await active?.match(browserContentObjectPath(manifest.files["/assets/characters/test.glb"])))?.text()).toBe("first-model");
const originalObject = browserContentObjectPath(manifest.files["/assets/characters/test.glb"]);
+9 -1
View File
@@ -78,6 +78,7 @@ let initialization: Promise<void> | null = null;
let installedStorageValidation: Promise<void> | null = null;
let installedStorageValidationComplete = Object.keys(installedState.packs).length === 0;
const validatedInstalledFiles = new Set<string>();
const usablePackCache = new Map<string, boolean>();
function safeLocalStorage(): Storage | null {
try { return typeof window === "undefined" ? null : window.localStorage; } catch { return null; }
@@ -148,6 +149,9 @@ function persistInstalledState(): void {
}
function publish(patch: Partial<Omit<ContentManagerSnapshot, "revision">>): void {
// Every receipt/storage/manifest change publishes before notifying consumers.
// Cache both usable and unavailable packs until the next content revision.
usablePackCache.clear();
snapshot = { ...snapshot, ...patch, revision: snapshot.revision + 1 };
listeners.forEach((listener) => listener());
}
@@ -364,7 +368,11 @@ function contentPackIsUsableInternal(packId: string, visiting: ReadonlySet<strin
}
export function contentPackIsUsable(packId: string): boolean {
return contentPackIsUsableInternal(packId, new Set());
const cached = usablePackCache.get(packId);
if (cached !== undefined) return cached;
const usable = contentPackIsUsableInternal(packId, new Set());
usablePackCache.set(packId, usable);
return usable;
}
/** Vite serves the complete source asset tree in development; shipped builds require an installed pack. */
+9
View File
@@ -48,6 +48,15 @@ describe("content manifest", () => {
}).map(([logicalPath]) => logicalPath)).toEqual(["/assets/b.glb"]);
});
it("reuses the pack index and replaces it when a new catalog file map arrives", () => {
const first = filesForContentPack(manifest, "dungeon:test");
expect(filesForContentPack(manifest, "dungeon:test")).toBe(first);
const updated = { ...manifest, files: { "/assets/b.glb": manifest.files["/assets/b.glb"] } };
expect(filesForContentPack(updated, "dungeon:test").map(([url]) => url)).toEqual(["/assets/b.glb"]);
expect(filesForContentPack(manifest, "dungeon:test")).toBe(first);
expect(filesForContentPack(updated, "missing")).toEqual([]);
});
it("resolves a seeded run's logical models to distinct owning packs", () => {
const withSecondPack: ContentManifest = {
...manifest,
+21 -3
View File
@@ -118,13 +118,31 @@ export function appVersionMeetsMinimum(current: string, minimum: string): boolea
return true;
}
type ContentPackFiles = readonly (readonly [string, ContentManifestFile])[];
const packFileIndexes = new WeakMap<ContentManifest["files"], ReadonlyMap<string, ContentPackFiles>>();
const EMPTY_PACK_FILES: ContentPackFiles = [];
/** Catalog file maps are immutable; index once instead of scanning during gameplay. */
export function filesForContentPack(
manifest: ContentManifest,
packId: string,
): readonly (readonly [string, ContentManifestFile])[] {
return Object.entries(manifest.files)
.filter(([, entry]) => entry.packId === packId)
.sort(([left], [right]) => left.localeCompare(right));
let index = packFileIndexes.get(manifest.files);
if (!index) {
const groups = new Map<string, [string, ContentManifestFile][]>();
for (const [logicalPath, entry] of Object.entries(manifest.files)) {
let files = groups.get(entry.packId);
if (!files) {
files = [];
groups.set(entry.packId, files);
}
files.push([logicalPath, entry]);
}
for (const files of groups.values()) files.sort(([left], [right]) => left.localeCompare(right));
index = groups;
packFileIndexes.set(manifest.files, index);
}
return index.get(packId) ?? EMPTY_PACK_FILES;
}
export interface ContentFileIdentity {
+8
View File
@@ -127,6 +127,12 @@ export interface AbilityRange {
}
export interface AbilityAmountScaling {
/** Raw RuneWaker component identity; native formulas never infer this from a WoW coefficient. */
readonly romNativeSource?: {
readonly skillId: number;
readonly componentId: number;
readonly kind: "damage" | "heal" | "dot" | "hot";
};
/**
* The client DBC's deterministic amount before its random die roll. Imported
* spells use this instead of treating the spell-power coefficient as the
@@ -353,6 +359,8 @@ export interface AbilityDefinition {
readonly ai?: AbilityAiHints;
readonly passive?: boolean;
readonly source?: "core" | "wow335-progression" | "ascension-progression" | "coa-starter" | "coa-progression" | "coa-tree" | "runewaker";
/** Source combat delivery metadata independent of a native skill's damage school. */
readonly sourceAttackKind?: "melee" | "ranged" | "spell";
readonly romSkillGroup?: RomSkillGroup;
/** The original client level before Healer Man's starter-kit remapping. */
readonly sourceUnlockLevel?: number;
+45 -6
View File
@@ -15,7 +15,13 @@ import type { PartyState } from "../partyStore";
import { usePartyStore } from "../partyStore";
import type { PartyRole } from "../partyRoles";
import type { BenchmarkScenarioDefinition } from "./benchmarkTypes";
import { COMBAT_ROTATION_VERSION, evaluateCombatRotation } from "../combatRotation";
import { nativeRotationAbilityAllowed, nativeRotationProfileFor } from "../romFiveRotation";
import {
COMBAT_ROTATION_VERSION,
evaluateCombatRotation,
nativeEarlyPriestHealReserveBlocksAbility,
type RotationContext,
} from "../combatRotation";
export const BENCHMARK_POLICY_ID = `rotation-v4:${COMBAT_ROTATION_VERSION}`;
@@ -193,19 +199,37 @@ export class BenchmarkController {
) return { made: false, cast: false, resourceBlocked: false, invalidFailures: 0 };
const targets = friendlyTargets(combat, party);
const availableAbilities = combat.abilities
const learnedAbilities = combat.abilities
.map((ability) => learnedAbility(ability, combat))
.filter((ability): ability is AbilityDefinition => ability !== null)
.filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability));
.filter((ability): ability is AbilityDefinition => ability !== null);
const availableAbilities = learnedAbilities
.filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability))
.filter((ability) => combat.statRuleset !== "rom" || (
(combat.cooldowns[ability.id] ?? 0) <= now
&& (ability.target !== "hostile" || (() => {
const position = combat.selectedTargetId ? getMobPosition(combat.selectedTargetId) : null;
if (!position) return false;
const distance = Math.hypot(...position.map((value, index) => value - combat.playerPosition[index]));
return distance >= ability.range.min && distance <= ability.range.max;
})())
));
const candidates = availableAbilities
.map((ability) => scoreAbility(ability, role, scenario, combat, targets))
.filter((candidate): candidate is ScoredAbility => candidate !== null)
.sort((left, right) => right.score - left.score || left.ability.id.localeCompare(right.ability.id));
const hostileTarget = combat.selectedTargetId ? combat.mobs[combat.selectedTargetId] : null;
const sharedDecision = evaluateCombatRotation({
const rotationContext: RotationContext = {
now,
canUseAbility: combat.statRuleset === "rom" ? (ability) =>
resolvedAbilityCosts(ability, combat.talentModifiers, combat).every((cost) =>
cost.amount <= (cost.resource === "health" ? combat.health : combat.resourcePools[cost.resource] ?? 0))
: undefined,
manaCostForAbility: (ability) => resolvedAbilityCosts(ability, combat.talentModifiers, combat)
.filter((cost) => cost.resource === "mana")
.reduce((total, cost) => total + cost.amount, 0),
classId: combat.classId,
secondaryClassId: combat.secondaryClassId,
statRuleset: combat.statRuleset,
specializationId: combat.specializationId,
role,
level: combat.level,
@@ -219,6 +243,7 @@ export class BenchmarkController {
comboTargetId: combat.comboTargetId,
runes: combat.deathKnightRunes,
abilities: availableAbilities,
learnedAbilities,
knownDamagingAbilityNames: new Set(availableAbilities.filter((ability) => {
const categories = aiHintsForAbility(ability).categories;
return categories.includes("damage") || categories.includes("execute");
@@ -248,6 +273,12 @@ export class BenchmarkController {
&& effect.aura.id === aura.definition.id))
.map((ability) => ability.id)),
]),
maintainedPeriodicEffects: combat.effects.map((effect) => ({
abilityId: effect.sourceAbilityId,
sourceActorId: effect.sourceActorId,
targetId: effect.targetId ?? PLAYER_AGGRO_ID,
remainingMs: Math.max(0, effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs - now),
})),
activeAuraExclusiveGroups: new Set(combat.auras
.filter((aura) => aura.sourceId === PLAYER_AGGRO_ID && (aura.expiresAt === null || aura.expiresAt > now))
.flatMap((aura) => aura.definition.exclusiveGroup ? [aura.definition.exclusiveGroup] : [])),
@@ -260,7 +291,15 @@ export class BenchmarkController {
return source?.effects.some((effect) => effect.kind === "pet"
&& ["summon", "call", "resurrect", "tame"].includes(effect.action)) ?? false;
}),
});
};
const sharedDecision = evaluateCombatRotation(rotationContext);
for (let index = candidates.length - 1; index >= 0; index -= 1) {
const ability = candidates[index].ability;
if (nativeEarlyPriestHealReserveBlocksAbility(ability, rotationContext)
|| (nativeRotationProfileFor(rotationContext) && !nativeRotationAbilityAllowed(ability, rotationContext))) {
candidates.splice(index, 1);
}
}
if (sharedDecision) {
const friendlyTarget = sharedDecision.targetId
? targets.find((target) => target.id === sharedDecision.targetId) ?? null
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import {
COA_FIRST_PARTY_MEMBERS,
coaFirstPartyBuildCatalog,
coaFirstPartySimulationConfig,
createCoaFirstPartyCompanions,
loadCoaFirstPartyBuilds,
} from "./coaFirstParty";
import { READY_COMPANION_SPECIALIZATION_IDS, readinessStatusFor } from "../companionSpecReadiness";
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
describe("CoA first-party diagnostic composition", () => {
it("defines the selected five as one tank, one healer, and three DPS seats", () => {
expect(COA_FIRST_PARTY_MEMBERS.map(({ classId, specializationId, specialization, role }) => (
[classId, specializationId, specialization, role]
))).toEqual([
["guardian", "guardian:vanguard", "Vanguard", "tank"],
["witch-doctor", "witch-doctor:brewing", "Brewing", "healer"],
["barbarian", "barbarian:brutality", "Brutality", "melee"],
["ranger", "ranger:archery", "Archery", "ranged"],
["pyromancer", "pyromancer:incineration", "Incineration", "caster"],
]);
expect(COA_FIRST_PARTY_MEMBERS.filter((member) => member.partyRole === "tank")).toHaveLength(1);
expect(COA_FIRST_PARTY_MEMBERS.filter((member) => member.partyRole === "healer")).toHaveLength(1);
expect(COA_FIRST_PARTY_MEMBERS.filter((member) => member.partyRole === "damage")).toHaveLength(3);
});
it("resolves only the five explicit build identities and preserves the Ready baseline", () => {
const catalog = coaFirstPartyBuildCatalog(60);
expect(catalog.map((build) => [build.classId, build.specializationId, build.orientation, build.role, build.source]))
.toEqual(COA_FIRST_PARTY_MEMBERS.map((member) => [
member.classId,
member.specializationId,
member.specialization,
member.partyRole,
"coa",
]));
expect(new Set(catalog.map((build) => build.id)).size).toBe(5);
expect(READY_COMPANION_SPECIALIZATION_IDS).toEqual([
"warrior:protection",
"priest:discipline",
"hunter:marksmanship",
"mage:fire",
"rogue:assassination",
]);
expect(COA_FIRST_PARTY_MEMBERS.map((member) => readinessStatusFor(member.specializationId)))
.toEqual(["needs-testing", "needs-testing", "needs-testing", "needs-testing", "needs-testing"]);
});
it("loads legal talent allocations for the exact five", async () => {
const builds = await loadCoaFirstPartyBuilds(20);
expect(builds.map((build) => [build.classId, build.orientation, build.role, build.source]))
.toEqual(COA_FIRST_PARTY_MEMBERS.map((member) => [
member.classId,
member.specialization,
member.partyRole,
"coa",
]));
for (const build of builds) {
expect(runtimeNormalizeTalentRanks(build.classId, build.level, build.talentRanks))
.toEqual(build.talentRanks);
}
}, 60_000);
it("keeps the same four configured companion builds for every active player seat", async () => {
const builds = await loadCoaFirstPartyBuilds(20);
for (let activeSlot = 0; activeSlot < 5; activeSlot += 1) {
const companions = createCoaFirstPartyCompanions(builds, activeSlot, "coa-first-party-test");
expect(companions).toHaveLength(4);
for (const [slot, build] of builds.entries()) {
if (slot === activeSlot) continue;
const companion = companions.find((candidate) => candidate.id.includes(`-${slot}-`));
expect(companion).toBeDefined();
expect(companion).toMatchObject({
classId: build.classId,
specialization: build.orientation,
specializationId: COA_FIRST_PARTY_MEMBERS[slot].specializationId,
role: build.role,
talentRanks: build.talentRanks,
});
}
}
}, 60_000);
it("rejects supplied builds with non-normalized talent rank entries", async () => {
const builds = await loadCoaFirstPartyBuilds(20);
for (const rank of [-1, 0, Number.NaN, Number.POSITIVE_INFINITY, 0.5]) {
const invalid = builds.map((build, slot) => slot === 0 ? {
...build,
talentRanks: { ...build.talentRanks, "invalid-node": rank },
} : build);
expect(() => createCoaFirstPartyCompanions(invalid, 1, "invalid-talents"))
.toThrow(/illegal or non-normalized talent ranks/);
}
}, 60_000);
it("builds a deterministic Balance Lab request with explicit bosses and a zero-based seat", () => {
const config = coaFirstPartySimulationConfig(60, ["deadmines-van-cleef", "shadowfang-archmage-arugal"]);
const catalog = coaFirstPartyBuildCatalog(60);
expect(config).toMatchObject({
schemaVersion: 1,
source: "coa",
level: 60,
durationSeconds: 30,
seeds: [1],
partyBuildIds: catalog.map((build) => build.id),
activeSlot: 1,
bossCategory: "wow",
bossKeys: ["deadmines-van-cleef", "shadowfang-archmage-arugal"],
});
expect(coaFirstPartySimulationConfig(60, ["boss"], 4).activeSlot).toBe(4);
});
it("rejects missing bosses, invalid levels, duplicate bosses, and invalid active seats", () => {
expect(() => coaFirstPartySimulationConfig(60, [])).toThrow(/explicit boss key/);
expect(() => coaFirstPartySimulationConfig(0, ["boss"])).toThrow(/level must be an integer/);
expect(() => coaFirstPartySimulationConfig(81, ["boss"])).toThrow(/level must be an integer/);
expect(() => coaFirstPartySimulationConfig(60, ["boss", "boss"])).toThrow(/must be unique/);
expect(() => coaFirstPartySimulationConfig(60, ["boss"], -1)).toThrow(/activeSlot/);
expect(() => coaFirstPartySimulationConfig(60, ["boss"], 5)).toThrow(/activeSlot/);
});
});
+198
View File
@@ -0,0 +1,198 @@
import { classById, type CoaClassId } from "../../app/characterCatalog";
import {
COA_FIRST_PARTY_CLASS_IDS,
COA_FIRST_PARTY_MEMBERS,
type CoaFirstPartyMemberDefinition,
} from "../coaFirstPartyRoster";
import { createConfiguredPartyMember, type PartyMember } from "../partyStore";
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
import {
loadSelectableBenchmarkBuilds,
selectableBenchmarkBuildCatalog,
type SelectableBenchmarkBuildCatalogEntry,
} from "./benchmarkBuilds";
import type { BenchmarkBuildDefinition } from "./benchmarkTypes";
export { COA_FIRST_PARTY_MEMBERS } from "../coaFirstPartyRoster";
function checkedLevel(level: number): number {
if (!Number.isInteger(level) || level < 1 || level > 80) {
throw new RangeError("CoA first-party level must be an integer from 1 through 80.");
}
return level;
}
function checkedActiveSlot(activeSlot: number): number {
if (!Number.isInteger(activeSlot) || activeSlot < 0 || activeSlot >= COA_FIRST_PARTY_MEMBERS.length) {
throw new RangeError("CoA first-party activeSlot must be an integer from 0 through 4.");
}
return activeSlot;
}
function sameTalentRanks(
left: Readonly<Record<string, number>>,
right: Readonly<Record<string, number>>,
): boolean {
const normalize = (ranks: Readonly<Record<string, number>>) => Object.entries(ranks)
.sort(([leftId], [rightId]) => leftId.localeCompare(rightId));
return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right));
}
function expectedMemberForBuild(
build: Pick<BenchmarkBuildDefinition, "classId" | "orientation" | "role" | "source" | "level" | "id" | "talentRanks">,
index: number,
): CoaFirstPartyMemberDefinition {
const expected = COA_FIRST_PARTY_MEMBERS[index];
if (!expected) throw new Error(`Unexpected CoA first-party slot ${index + 1}.`);
if (build.source !== "coa"
|| build.classId !== expected.classId
|| build.orientation !== expected.specialization
|| build.role !== expected.partyRole) {
throw new Error(
`CoA first-party slot ${index + 1} must be ${expected.classId}:${expected.specializationId} (${expected.partyRole}).`,
);
}
if (!build.id) throw new Error(`CoA first-party slot ${index + 1} has no build ID.`);
return expected;
}
function validateBuilds(
builds: readonly BenchmarkBuildDefinition[],
): number {
if (builds.length !== COA_FIRST_PARTY_MEMBERS.length) {
throw new Error("A CoA first-party diagnostic requires exactly five builds in tank/healer/melee/ranged/caster order.");
}
const ids = new Set<string>();
let level: number | null = null;
let canonicalIds: ReadonlyMap<string, string> | null = null;
for (const [index, build] of builds.entries()) {
expectedMemberForBuild(build, index);
if (ids.has(build.id)) throw new Error(`Duplicate CoA first-party build ID: ${build.id}.`);
ids.add(build.id);
if (level === null) level = checkedLevel(build.level);
else if (build.level !== level) throw new Error("CoA first-party builds must use one shared level.");
}
canonicalIds = new Map(coaFirstPartyBuildCatalog(level!).map((entry) => [entry.specializationId ?? "", entry.id]));
for (const build of builds) {
const specialization = COA_FIRST_PARTY_MEMBERS.find((member) => (
member.classId === build.classId && member.specialization === build.orientation
));
if (!specialization || canonicalIds.get(specialization.specializationId) !== build.id) {
throw new Error(`CoA first-party build ${build.id} does not match the canonical selector for its class and specialization.`);
}
const normalized = selectableTalentRanks(specialization.classId, build.level, build.talentRanks);
if (!sameTalentRanks(build.talentRanks, normalized)) {
throw new Error(`CoA first-party build ${build.id} has illegal or non-normalized talent ranks.`);
}
}
return level!;
}
function selectableTalentRanks(
classId: CoaClassId,
level: number,
ranks: Readonly<Record<string, number>>,
): Readonly<Record<string, number>> {
// Keep the legality check in the same runtime allocator used by the build loader.
return runtimeNormalizeTalentRanks(classId, level, ranks);
}
function exactCatalogMember(
catalog: readonly SelectableBenchmarkBuildCatalogEntry[],
expected: CoaFirstPartyMemberDefinition,
): SelectableBenchmarkBuildCatalogEntry {
const matches = catalog.filter((build) => (
build.source === "coa"
&& build.classId === expected.classId
&& build.specializationId === expected.specializationId
&& build.orientation === expected.specialization
&& build.role === expected.partyRole
));
if (matches.length !== 1) {
throw new Error(
`Expected exactly one selectable CoA build for ${expected.classId}:${expected.specializationId}; found ${matches.length}.`,
);
}
return matches[0];
}
/** Resolves the five exact selectors without loading catalogs or substituting builds. */
export function coaFirstPartyBuildCatalog(level = 60): readonly SelectableBenchmarkBuildCatalogEntry[] {
const safeLevel = checkedLevel(level);
const catalog = selectableBenchmarkBuildCatalog(safeLevel, COA_FIRST_PARTY_CLASS_IDS);
return Object.freeze(COA_FIRST_PARTY_MEMBERS.map((member) => exactCatalogMember(catalog, member)));
}
/** Loads legal talent allocations for the five exact classes and specifications. */
export async function loadCoaFirstPartyBuilds(level = 60): Promise<readonly BenchmarkBuildDefinition[]> {
const safeLevel = checkedLevel(level);
const catalog = await loadSelectableBenchmarkBuilds(safeLevel, COA_FIRST_PARTY_CLASS_IDS);
const builds = COA_FIRST_PARTY_MEMBERS.map((member) => {
const matches = catalog.filter((build) => (
build.source === "coa"
&& build.classId === member.classId
&& build.orientation === member.specialization
&& build.role === member.partyRole
));
if (matches.length !== 1) {
throw new Error(
`Expected exactly one loaded CoA build for ${member.classId}:${member.specializationId}; found ${matches.length}.`,
);
}
return matches[0];
});
validateBuilds(builds);
return Object.freeze(builds);
}
/** Creates the four exact companion actors around whichever party seat is active. */
export function createCoaFirstPartyCompanions(
builds: readonly BenchmarkBuildDefinition[],
activeSlot: number,
seed: string,
): readonly PartyMember[] {
const active = checkedActiveSlot(activeSlot);
validateBuilds(builds);
return Object.freeze(builds.flatMap((build, slot) => {
if (slot === active) return [];
const expected = expectedMemberForBuild(build, slot);
return [createConfiguredPartyMember({
name: build.characterName ?? `${classById(expected.classId).name} ${expected.specialization}`,
classId: expected.classId,
raceId: build.raceId,
secondaryClassId: build.secondaryClassId,
specialization: expected.specialization,
specializationId: expected.specializationId,
talentTreeId: expected.specializationId,
talentRanks: build.talentRanks,
role: expected.partyRole,
}, build.level, slot, seed)];
}));
}
/** Produces the existing Balance Lab request shape with a mandatory boss allowlist. */
export function coaFirstPartySimulationConfig(
level: number,
bossKeys: readonly string[],
activeSlot = 1,
) {
const safeLevel = checkedLevel(level);
const safeActiveSlot = checkedActiveSlot(activeSlot);
if (!bossKeys.length || bossKeys.some((key) => typeof key !== "string" || key.trim().length === 0)) {
throw new Error("CoA first-party simulation requires at least one non-empty explicit boss key.");
}
if (new Set(bossKeys).size !== bossKeys.length) {
throw new Error("CoA first-party simulation boss keys must be unique.");
}
return Object.freeze({
schemaVersion: 1 as const,
source: "coa" as const,
level: safeLevel,
durationSeconds: 30,
seeds: Object.freeze([1]),
partyBuildIds: Object.freeze(coaFirstPartyBuildCatalog(safeLevel).map((build) => build.id)),
activeSlot: safeActiveSlot,
bossCategory: "wow" as const,
bossKeys: Object.freeze([...bossKeys]),
});
}
+1
View File
@@ -113,6 +113,7 @@ function describeEffect(effect: EnemyAbilityEffect | undefined, attack: EnemyAtt
return `${titleCase(attack.school)} damage to ${target} (${Number(multiplier.toFixed(2))}× boss power)`;
}
if (effect.kind === "heal") return `Heals ${target} for ${effect.multiplier}× boss power`;
if (effect.kind === "dot") return `${titleCase(attack.school)} damage to ${target} every ${seconds(effect.intervalMs)} for ${effect.ticks} ticks; dispellable`;
if (effect.kind === "leech") {
return `Drains ${target} for ${effect.damageMultiplier}× boss power and heals the caster for ${effect.healMultiplier}× boss power`;
}
+14 -7
View File
@@ -21,6 +21,9 @@ const UNSOURCED_BASE_RANK_SPELL_IDS = new Set([
502889,
560469,
]);
// These newly exposed Talent actions retain source-operation markers until
// their bespoke copy/specific cooldown-reset behavior is implemented.
const UNMODELED_TALENT_ACTION_SPELL_IDS = new Set([681007, 704420]);
const abilities = Object.values(COA_ABILITIES_BY_CLASS).flat();
it("keeps Drakkari Barrier friendly and its retaliation in the damage-taken proc", () => {
@@ -79,9 +82,9 @@ describe("Ascension Conquest ability catalog", () => {
it("covers every class, source family, and playable rank", () => {
expect(Object.keys(COA_ABILITIES_BY_CLASS).sort()).toEqual([...COA_CLASS_IDS].sort());
expect(Object.keys(COA_CLASS_RESOURCES).sort()).toEqual([...COA_CLASS_IDS].sort());
expect(abilities).toHaveLength(1_276);
expect(ranks).toHaveLength(3_395);
expect(new Set(ranks.map(({ rank }) => rank.spellId)).size).toBe(3_348);
expect(abilities).toHaveLength(1_400);
expect(ranks).toHaveLength(3_519);
expect(new Set(ranks.map(({ rank }) => rank.spellId)).size).toBe(3_464);
const runtimeSpellIds = new Set(ranks.map(({ rank }) => rank.spellId));
for (const chain of COA_PROGRESSION_CHAINS) {
@@ -100,7 +103,7 @@ describe("Ascension Conquest ability catalog", () => {
}
});
it("gives every rank finite data and every sourced rank an executable effect", () => {
it("gives every rank finite data and preserves explicit unsourced or unmodeled operations", () => {
expect(new Set(abilities.map((ability) => ability.id)).size).toBe(abilities.length);
for (const ability of abilities) {
expect(ability.classId, ability.id).toBeTruthy();
@@ -122,9 +125,13 @@ describe("Ascension Conquest ability catalog", () => {
expect(rank.cost.amount, label).toBeGreaterThanOrEqual(0);
expect(rank.effects.length, label).toBeGreaterThan(0);
expect(rank.effects.some((effect) => effect.kind === "scripted" || effect.kind === "unsupported"), label).toBe(false);
if (!ability.passive && !UNSOURCED_BASE_RANK_SPELL_IDS.has(rank.spellId)) {
if (!ability.passive && !UNSOURCED_BASE_RANK_SPELL_IDS.has(rank.spellId)
&& !UNMODELED_TALENT_ACTION_SPELL_IDS.has(rank.spellId)) {
expect(rank.effects.some((effect) => effect.kind !== "utility" || effect.action !== "class-script"), label).toBe(true);
}
if (UNMODELED_TALENT_ACTION_SPELL_IDS.has(rank.spellId)) {
expect(rank.effects.every((effect) => effect.kind === "utility" && effect.action === "class-script"), label).toBe(true);
}
}
}
});
@@ -235,7 +242,7 @@ describe("Ascension Conquest ability catalog", () => {
it("ships executable hidden payloads for proc-triggered DBC spell IDs", () => {
// Geode Barrage now uses its ranked parent payload instead of hidden placeholder 803138.
expect(Object.keys(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)).toHaveLength(578);
expect(Object.keys(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)).toHaveLength(670);
expect(COA_TRIGGERED_ABILITIES_BY_SPELL_ID[803138]).toBeUndefined();
expect(COA_TRIGGERED_ABILITIES_BY_SPELL_ID[800951]).toMatchObject({
name: "Battle Rhythm",
@@ -243,7 +250,7 @@ describe("Ascension Conquest ability catalog", () => {
});
expect(Object.values(COA_TRIGGERED_ABILITIES_BY_SPELL_ID).filter((ability) => (
ability.effects.some((effect) => effect.kind !== "utility")
))).toHaveLength(536);
))).toHaveLength(621);
for (const [spellId, ability] of Object.entries(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)) {
expect(ability.dbcSpellId, spellId).toBe(Number(spellId));
expect(ability.effects.length, spellId).toBeGreaterThan(0);
+9 -2
View File
@@ -1,4 +1,5 @@
import type { ClassId, CoaClassId } from "../app/characterCatalog";
import { COA_FIRST_PARTY_CLASS_IDS } from "./coaFirstPartyRoster";
import type {
AbilityAmountScaling,
AbilityAuraControl,
@@ -158,7 +159,7 @@ export const COA_CLASS_RESOURCES: Readonly<Record<CoaClassId, ClassResourceProfi
guardian: { type: "energy", name: "Energy", color: "#d8c43f", maximum: 100, initial: 100, regenerationPerSecond: 10 },
templar: { type: "energy", name: "Energy / Holy Runes", color: "#00d989", maximum: 100, initial: 100, regenerationPerSecond: 10 },
bloodmage: { type: "rage", name: "Sanguine Rage", color: "#b74439", maximum: 100, initial: 0, regenerationPerSecond: 5 },
ranger: { type: "energy", name: "Focus", color: "#d8c43f", maximum: 100, initial: 100, regenerationPerSecond: 10 },
ranger: { type: "focus", name: "Focus", color: "#d8c43f", maximum: 100, initial: 100, regenerationPerSecond: 10 },
chronomancer: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
necromancer: { type: "mana", name: "Mana / Runic Power", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
pyromancer: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
@@ -1341,9 +1342,12 @@ function progressionAbility(chain: CoaProgressionChain): AbilityDefinition | nul
}
function treeAbility(entry: CoaTreeEntry): AbilityDefinition | null {
if (entry.entryType !== "Ability") return null;
const classId = coaClassIdFromDbcId(entry.classId);
if (!classId) return null;
// The five-class milestone also needs actions purchased with Talent Essence.
// Entry type describes the purchase, not whether the learned spell is active.
const talentAction = entry.entryType === "Talent";
if (talentAction && !COA_FIRST_PARTY_CLASS_IDS.includes(classId)) return null;
const resource = COA_CLASS_RESOURCES[classId].type;
const ranks = Array.from({ length: Math.max(1, entry.maxPoints) }, (_, index) => {
const rank = coaEntryRank(entry, index + 1);
@@ -1366,6 +1370,9 @@ function treeAbility(entry: CoaTreeEntry): AbilityDefinition | null {
|| /\b(?:passive|specialization)\b/i.test(firstSpell?.rank ?? dbc?.rankText ?? "")
|| /^Deprecate(?:d)?\.?$/i.test((first.description || entry.description).trim())
;
// Passive Talent nodes retain their existing modifier path. Missing spell
// evidence must not turn a node into an invented action.
if (talentAction && (passive || !dbc)) return null;
return {
id: `coa-tree-${entry.id}`,
classId,
+150
View File
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it } from "vitest";
import { COA_ABILITIES_BY_CLASS } from "./coaAbilityCatalog";
import { coaTreeEntryById } from "./coaLiveCatalog";
import {
COA_BARBARIAN_DECAPITATE_HEALTH_THRESHOLD,
COA_BARBARIAN_DECAPITATE_TALENT_ENTRY_ID,
coaBarbarianDecapitateTargetAllowed,
isCoaBarbarianDecapitate,
} from "./coaBarbarianExecuteRuntime";
import { useCombatStore } from "./combatStore";
import { evaluateCombatRotation, type RotationContext } from "./combatRotation";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
updatePartyRuntimeTargetVisibility,
} from "./partyRuntimeRegistry";
import { partyAbilityTargetIsValid } from "./partyRuntime";
import { createConfiguredPartyMember } from "./partyStore";
import { resourceProfileForClass } from "./abilityCatalog";
const TARGET_ID = "coa-decapitate-target";
const TALENT_RANKS = Object.freeze({ [`coa-entry-${COA_BARBARIAN_DECAPITATE_TALENT_ENTRY_ID}`]: 1 });
function decapitate() {
const ability = COA_ABILITIES_BY_CLASS.barbarian.find((candidate) => (
candidate.talentEntryId === COA_BARBARIAN_DECAPITATE_TALENT_ENTRY_ID
));
if (!ability) throw new Error("The source-backed CoA Barbarian Decapitate action is missing.");
return ability;
}
function setTargetHealth(health: number): void {
useCombatStore.setState((state) => ({
mobs: {
...state.mobs,
[TARGET_ID]: { ...state.mobs[TARGET_ID]!, health, dead: health <= 0 },
},
}));
}
function rotationContext(targetHealth: number): RotationContext {
const ability = decapitate();
const resource = resourceProfileForClass("barbarian").type;
return {
now: 1_000,
classId: "barbarian",
specializationId: "barbarian:brutality",
role: "damage",
level: 60,
health: 1_000,
maxHealth: 1_000,
resources: { [resource]: 10_000 },
maximumResources: { [resource]: 10_000 },
primaryResource: resource,
comboPoints: 0,
comboTargetId: null,
runes: [],
abilities: [ability],
hostileTarget: { id: TARGET_ID, health: targetHealth, maxHealth: 1_000 },
hostileCount: 1,
};
}
describe("CoA Barbarian Decapitate execute window", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({
classId: "barbarian",
specializationId: "barbarian:brutality",
role: "damage",
level: 60,
talentRanks: TALENT_RANKS,
});
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
useCombatStore.getState().registerMob(TARGET_ID, {
name: "Decapitate target",
maxHealth: 1_000,
xpReward: 0,
});
setMobPosition(TARGET_ID, [2, 0, 0]);
useCombatStore.getState().selectMob(TARGET_ID);
});
it("pins the execute threshold to the source-backed tree description and exact action identity", () => {
const source = coaTreeEntryById(COA_BARBARIAN_DECAPITATE_TALENT_ENTRY_ID);
expect(source?.rankDescriptions?.[0]).toMatchObject({ spellId: 804414 });
expect(source?.description).toContain("Only usable on enemies below 35% health.");
expect(COA_BARBARIAN_DECAPITATE_HEALTH_THRESHOLD).toBe(0.35);
const ability = decapitate();
expect(isCoaBarbarianDecapitate(ability)).toBe(true);
expect(coaBarbarianDecapitateTargetAllowed(ability, { health: 349, maxHealth: 1_000 })).toBe(true);
expect(coaBarbarianDecapitateTargetAllowed(ability, { health: 350, maxHealth: 1_000 })).toBe(false);
expect(coaBarbarianDecapitateTargetAllowed(ability, { health: 351, maxHealth: 1_000 })).toBe(false);
for (const target of [undefined, null, { health: 349 }, { health: 349, maxHealth: 0 },
{ health: 0, maxHealth: 1_000 }, { health: 349, maxHealth: 1_000, dead: true }]) {
expect(coaBarbarianDecapitateTargetAllowed(ability, target)).toBe(false);
}
expect(isCoaBarbarianDecapitate({ classId: "guardian", talentEntryId: 5744 })).toBe(false);
expect(isCoaBarbarianDecapitate({ classId: "barbarian", talentEntryId: 1234 })).toBe(false);
expect(coaBarbarianDecapitateTargetAllowed({ classId: "barbarian", talentEntryId: 1234 }, undefined)).toBe(true);
expect(coaBarbarianDecapitateTargetAllowed({ classId: "guardian", talentEntryId: 5744 }, undefined)).toBe(true);
});
it("rejects the player's boundary cast without spending resource or cooldown, then allows it below 35%", () => {
const ability = decapitate();
expect(useCombatStore.getState().talentRanks[`coa-entry-${ability.talentEntryId}`]).toBe(1);
setTargetHealth(350);
const before = useCombatStore.getState();
const rejected = useCombatStore.getState().castAbility(ability.id, { now: 1_000, targetActorId: TARGET_ID });
const after = useCombatStore.getState();
expect(rejected).toMatchObject({ ok: false, reason: "condition-not-met" });
expect(after.resourcePools).toEqual(before.resourcePools);
expect(after.cooldowns).toEqual(before.cooldowns);
expect(after.globalCooldownEndsAt).toBe(before.globalCooldownEndsAt);
setTargetHealth(349);
expect(useCombatStore.getState().castAbility(ability.id, { now: 2_000, targetActorId: TARGET_ID }))
.toMatchObject({ ok: true, abilityId: ability.id });
});
it("applies the same target boundary to a Barbarian companion", () => {
const ability = decapitate();
const companion = createConfiguredPartyMember({
name: "Brutality companion",
classId: "barbarian",
specialization: "Brutality",
specializationId: "barbarian:brutality",
talentTreeId: "barbarian:brutality",
talentRanks: TALENT_RANKS,
role: "damage",
}, 60, 0, "coa-decapitate-companion");
registerPartyRuntimePosition(companion.id, [0, 0, 0]);
updatePartyRuntimeTargetVisibility(companion.id, TARGET_ID, true);
setTargetHealth(350);
expect(partyAbilityTargetIsValid(companion, ability, TARGET_ID, undefined, 1_000)).toBe(false);
setTargetHealth(349);
expect(partyAbilityTargetIsValid(companion, ability, TARGET_ID, undefined, 1_000)).toBe(true);
});
it("keeps Decapitate out of the companion rotation above and at the boundary", () => {
const belowThreshold = evaluateCombatRotation(rotationContext(349));
expect(belowThreshold?.ability.id).toBe(decapitate().id);
expect(evaluateCombatRotation(rotationContext(350))?.ability.id).not.toBe(decapitate().id);
expect(evaluateCombatRotation(rotationContext(500))?.ability.id).not.toBe(decapitate().id);
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { AbilityDefinition } from "./abilityCatalog";
/** CoA tree node 5744 / source spell 804414: Decapitate is usable below 35% health. */
export const COA_BARBARIAN_DECAPITATE_TALENT_ENTRY_ID = 5744;
export const COA_BARBARIAN_DECAPITATE_HEALTH_THRESHOLD = 0.35;
type CoaBarbarianDecapitateIdentity = Pick<AbilityDefinition, "classId" | "talentEntryId">;
export interface CoaBarbarianExecuteTargetHealth {
readonly health?: number;
readonly maxHealth?: number;
readonly dead?: boolean;
}
export function isCoaBarbarianDecapitate(
ability: CoaBarbarianDecapitateIdentity | null | undefined,
): boolean {
return ability?.classId === "barbarian"
&& ability.talentEntryId === COA_BARBARIAN_DECAPITATE_TALENT_ENTRY_ID;
}
/** Other actions pass through; Decapitate requires a living, valid target strictly below 35%. */
export function coaBarbarianDecapitateTargetAllowed(
ability: CoaBarbarianDecapitateIdentity | null | undefined,
target: CoaBarbarianExecuteTargetHealth | null | undefined,
): boolean {
if (!isCoaBarbarianDecapitate(ability)) return true;
if (!target || target.dead === true
|| !Number.isFinite(target.health) || !Number.isFinite(target.maxHealth)
|| target.health! <= 0 || target.maxHealth! <= 0) return false;
return target.health! / target.maxHealth! < COA_BARBARIAN_DECAPITATE_HEALTH_THRESHOLD;
}
@@ -0,0 +1,100 @@
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import { abilitiesForClass } from "./abilityCatalog";
import { useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
updatePartyRuntimeTargetVisibility,
} from "./partyRuntimeRegistry";
import {
createCoaFirstPartyCompanions,
loadCoaFirstPartyBuilds,
} from "./benchmark/coaFirstParty";
import type { BenchmarkBuildDefinition } from "./benchmark/benchmarkTypes";
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
describe("first CoA party base Potion Toss delivery", () => {
let builds: readonly BenchmarkBuildDefinition[];
beforeAll(async () => { builds = await loadCoaFirstPartyBuilds(60); });
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
});
function initializeSeat(slot: number) {
const build = builds[slot];
useCombatStore.getState().initializeCharacter({
classId: build.classId,
specializationId: slot === 1 ? "witch-doctor:brewing" : "guardian:vanguard",
role: build.role,
level: build.level,
talentRanks: build.talentRanks,
});
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("coa-toss", 60, useCombatStore.getState().health, build.role);
const companions = createCoaFirstPartyCompanions(builds, slot, "coa-toss");
usePartyStore.setState({ members: companions });
for (const member of companions) registerPartyRuntimePosition(member.id, [1, 0, 0]);
useCombatStore.getState().synchronizeActors();
return companions;
}
it("lets a Brewing player heal the selected Vanguard companion with the learned Talent action", () => {
const companions = initializeSeat(1);
const tank = companions.find((member) => member.role === "tank")!;
const toss = abilitiesForClass("witch-doctor").find((ability) => ability.talentEntryId === 12646)!;
expect(useCombatStore.getState().talentRanks["coa-entry-12646"]).toBe(1);
usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === tank.id
? { ...member, health: Math.round(member.maxHealth / 2) } : member) }));
usePartyStore.getState().selectMember(tank.id);
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
const before = usePartyStore.getState().members.find((member) => member.id === tank.id)!.health;
const mana = useCombatStore.getState().resourcePools.mana;
const events: StructuredCombatEvent[] = [];
const stop = subscribeCombatEvents((event) => { if (event.kind === "healing") events.push(event); });
expect(useCombatStore.getState().castAbility(toss.id, undefined, 1_000)).toMatchObject({ ok: true });
if (toss.castTimeMs > 0) useCombatStore.getState().tick(0.1, 1_000 + toss.castTimeMs);
stop();
expect(events).toEqual(expect.arrayContaining([expect.objectContaining({
abilityId: toss.id, targetActorId: tank.id, effectiveAmount: expect.any(Number),
})]));
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)!.health).toBeGreaterThan(before);
expect(useCombatStore.getState().resourcePools.mana).toBeLessThan(mana!);
});
it("lets the Brewing companion deliver the same base heal to the injured Vanguard player", () => {
initializeSeat(0);
const toss = abilitiesForClass("witch-doctor").find((ability) => ability.talentEntryId === 12646)!;
const before = Math.round(useCombatStore.getState().maxHealth / 2);
useCombatStore.setState({ health: before });
usePartyStore.setState((state) => ({ members: state.members.map((member) => member.role === "healer"
? { ...member, cooldowns: Object.fromEntries(abilitiesForClass("witch-doctor")
.filter((ability) => ability.id !== toss.id).map((ability) => [ability.id, 60_000])) }
: { ...member, nextActionAt: 60_000 }) }));
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
const healer = usePartyStore.getState().members.find((member) => member.role === "healer")!;
expect(healer.talentRanks["coa-entry-12646"]).toBe(1);
useCombatStore.getState().registerMob("coa-toss-target", { name: "Target", maxHealth: 100_000, xpReward: 0 });
setMobPosition("coa-toss-target", [2, 0, 0]);
useCombatStore.getState().damageMob("coa-toss-target", 1, 100);
for (const member of usePartyStore.getState().members) {
updatePartyRuntimeTargetVisibility(member.id, "coa-toss-target", true);
}
useCombatStore.getState().synchronizeActors();
const events: StructuredCombatEvent[] = [];
const stop = subscribeCombatEvents((event) => { if (event.kind === "healing") events.push(event); });
advanceDungeonPartyCombat(1_000);
if (toss.castTimeMs > 0) advanceDungeonPartyCombat(1_000 + toss.castTimeMs);
stop();
expect(events).toEqual(expect.arrayContaining([expect.objectContaining({
abilityId: toss.id, sourceActorId: healer.id, targetActorId: "__player__",
})]));
expect(useCombatStore.getState().health).toBeGreaterThan(before);
expect(usePartyStore.getState().members.find((member) => member.id === healer.id)!.resource)
.toBeLessThan(healer.resource);
});
});
+56
View File
@@ -0,0 +1,56 @@
import type { CoaClassId } from "../app/characterCatalog";
import type { PartyRole } from "./partyRoles";
export type CoaFirstPartySeatRole = "tank" | "healer" | "melee" | "ranged" | "caster";
export interface CoaFirstPartyMemberDefinition {
readonly classId: CoaClassId;
readonly specializationId: string;
readonly specialization: string;
/** Seat label; melee, ranged, and caster all use the shared damage role in combat. */
readonly role: CoaFirstPartySeatRole;
readonly partyRole: PartyRole;
}
/** Fixed targeted diagnostic roster. This does not affect readiness or party pools. */
export const COA_FIRST_PARTY_MEMBERS: readonly CoaFirstPartyMemberDefinition[] = Object.freeze([
Object.freeze({
classId: "guardian",
specializationId: "guardian:vanguard",
specialization: "Vanguard",
role: "tank",
partyRole: "tank",
}),
Object.freeze({
classId: "witch-doctor",
specializationId: "witch-doctor:brewing",
specialization: "Brewing",
role: "healer",
partyRole: "healer",
}),
Object.freeze({
classId: "barbarian",
specializationId: "barbarian:brutality",
specialization: "Brutality",
role: "melee",
partyRole: "damage",
}),
Object.freeze({
classId: "ranger",
specializationId: "ranger:archery",
specialization: "Archery",
role: "ranged",
partyRole: "damage",
}),
Object.freeze({
classId: "pyromancer",
specializationId: "pyromancer:incineration",
specialization: "Incineration",
role: "caster",
partyRole: "damage",
}),
]);
export const COA_FIRST_PARTY_CLASS_IDS: readonly CoaClassId[] = Object.freeze([
...new Set(COA_FIRST_PARTY_MEMBERS.map((member) => member.classId)),
]);
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { learnedAbilityForBuild } from "./abilityCatalog";
import { COA_ABILITIES_BY_CLASS } from "./coaAbilityCatalog";
import { coaTreeEntryById } from "./coaLiveCatalog";
import type { CoaClassId } from "../app/characterCatalog";
const ACTIONS: readonly [CoaClassId, number, number, string][] = [
["guardian", 31169, 803129, "Heavy Blow"],
["witch-doctor", 12646, 801661, "Potion Toss"],
["witch-doctor", 7128, 802710, "Splash Potion"],
["barbarian", 5744, 804414, "Decapitate"],
["ranger", 31178, 500075, "Precision Shot"],
["ranger", 29660, 801191, "Hunting Shot"],
["pyromancer", 6985, 800791, "Ignite"],
];
describe("first CoA party Talent Essence actions", () => {
it.each(ACTIONS)("exposes %s node %i as a learned action", (classId, nodeId, spellId, name) => {
expect(coaTreeEntryById(nodeId)?.entryType).toBe("Talent");
const action = COA_ABILITIES_BY_CLASS[classId].find((ability) => ability.talentEntryId === nodeId);
expect(action).toMatchObject({ name, dbcSpellId: spellId, passive: false, source: "coa-tree" });
expect(learnedAbilityForBuild(action!, 60, {})).toBeNull();
expect(learnedAbilityForBuild(action!, 60, { [`coa-entry-${nodeId}`]: 1 }))
.toMatchObject({ name, dbcSpellId: spellId });
});
it("retains Potion Toss's sourced friendly heal instead of making it a damage action", () => {
const toss = COA_ABILITIES_BY_CLASS["witch-doctor"].find((ability) => ability.talentEntryId === 12646)!;
expect(toss.target).toBe("friendly");
expect(toss.effects.some((effect) => effect.kind === "heal")).toBe(true);
expect(toss.effects.some((effect) => effect.kind === "damage")).toBe(false);
});
it("keeps passive Talent nodes on their existing modifier path", () => {
expect(COA_ABILITIES_BY_CLASS.barbarian.some((ability) => ability.talentEntryId === 6744)).toBe(false);
expect(COA_ABILITIES_BY_CLASS["witch-doctor"].some((ability) => ability.talentEntryId === 30884)).toBe(false);
});
it("leaves the other CoA classes' Talent action adoption for later milestones", () => {
for (const [classId, abilities] of Object.entries(COA_ABILITIES_BY_CLASS)) {
if (["guardian", "witch-doctor", "barbarian", "ranger", "pyromancer"].includes(classId)) continue;
expect(abilities.some((ability) => ability.talentEntryId !== undefined
&& coaTreeEntryById(ability.talentEntryId)?.entryType === "Talent"), classId).toBe(false);
}
});
});
+96
View File
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it } from "vitest";
import { companionActorFromMember } from "./combatActorAuthority";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { commitPartyAbilityUse, partyAbilityCosts } from "./partyAbilityAi";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { abilitiesForClass, resourceProfileForClass } from "./abilityCatalog";
import { useCombatStore } from "./combatStore";
describe("Ranger Focus resource runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
});
it("spends and restores the player's displayed primary Focus pool", () => {
const profile = resourceProfileForClass("ranger");
expect(profile).toMatchObject({ type: "focus", name: "Focus", maximum: 100, initial: 100, regenerationPerSecond: 10 });
useCombatStore.getState().initializeCharacter({ classId: "ranger", raceId: "human", level: 20 });
const initial = useCombatStore.getState();
expect(initial.resourceName).toBe("Focus");
expect(initial.resource).toBe(initial.resourcePools.focus);
expect(initial.maxResource).toBe(initial.maxResourcePools.focus);
const powerShot = abilitiesForClass("ranger").find((ability) => ability.dbcSpellId === 501698)!;
expect(powerShot.name).toBe("Power Shot");
expect(powerShot.cost.resource).toBe("focus");
useCombatStore.getState().registerMob("ranger-focus-target", {
name: "Ranger Focus Target",
level: 20,
maxHealth: 50_000,
});
setMobPosition("ranger-focus-target", [8, 0, 0]);
useCombatStore.getState().selectMob("ranger-focus-target");
expect(useCombatStore.getState().castAbility(powerShot.id, undefined, 1_000)).toMatchObject({ ok: true });
const afterCast = useCombatStore.getState();
expect(afterCast.resource).toBeLessThan(initial.resource);
expect(afterCast.resourcePools.focus).toBe(afterCast.resource);
useCombatStore.getState().tick(1, 2_000);
const afterRecovery = useCombatStore.getState();
expect(afterRecovery.resource).toBeGreaterThan(afterCast.resource);
expect(afterRecovery.resourcePools.focus).toBe(afterRecovery.resource);
});
it("keeps an Archery companion's primary Focus pool aligned through party spending and recovery", () => {
useCombatStore.getState().initializeCharacter({ classId: "warrior", raceId: "human", level: 80 });
usePartyStore.getState().initializeParty(
"ranger-focus-party",
80,
useCombatStore.getState().health,
"damage",
1,
);
const ranger = createConfiguredPartyMember({
name: "Archery Ranger",
classId: "ranger",
specialization: "Archery",
specializationId: "ranger:archery",
talentTreeId: "coa-ranger-54",
talentRanks: {},
role: "damage",
}, 80, 0, "ranger-focus-party");
expect(ranger.resourceType).toBe("focus");
expect(ranger.resource).toBe(ranger.resourcePools.focus);
expect(ranger.maxResource).toBe(ranger.resourceMaximums?.focus);
const serratedShot = abilitiesForClass("ranger").find((ability) => ability.dbcSpellId === 500073)!;
expect(serratedShot.name).toBe("Serrated Shot");
expect(partyAbilityCosts(ranger, serratedShot, 1_000)).toContainEqual({ resource: "focus", amount: 25 });
const spent = commitPartyAbilityUse(ranger, serratedShot, 1_000);
expect(spent).not.toBeNull();
expect(spent!.resource).toBeLessThan(ranger.resource);
expect(spent!.resourcePools.focus).toBe(spent!.resource);
const actor = companionActorFromMember(spent!);
expect(actor.resources.focus).toMatchObject({
current: spent!.resource,
maximum: spent!.maxResource,
regenerationPerSecond: resourceProfileForClass("ranger").regenerationPerSecond,
});
usePartyStore.setState({ members: [spent!], command: "stop", activeMobId: null });
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
advanceDungeonPartyCombat(1_000);
advanceDungeonPartyCombat(2_000);
const recovered = usePartyStore.getState().members[0]!;
expect(recovered.resourcePools.focus).toBeGreaterThan(spent!.resourcePools.focus ?? 0);
expect(recovered.resource).toBe(recovered.resourcePools.focus);
});
});
+27 -10
View File
@@ -22,9 +22,17 @@ export function companionResourceMaximum(member: PartyMember, id: string): numbe
: 100;
}
function nativeCompanionSourceChanged(member: PartyMember, actor: CombatActorState): boolean {
if (member.statRuleset !== "rom" || !isRomClassId(member.classId)) return false;
return actor.classId !== member.classId
|| actor.statRuleset !== "rom"
|| actor.level !== member.level
|| Math.abs(actor.maxHealth - member.maxHealth) > 1e-6;
}
/**
* Creates the authoritative companion actor. Existing actor values win over
* the party-frame compatibility record once the actor has entered combat.
* Creates the authoritative companion actor. Existing actor values stay live
* unless a native RoM source update changes its derived health maximum.
*/
export function companionActorFromMember(
member: PartyMember,
@@ -32,9 +40,19 @@ export function companionActorFromMember(
existing?: CombatActorState | null,
): CombatActorState {
const profile = resourceProfileForClass(member.classId);
const actor = existing?.kind === "companion" ? existing : null;
const nativeSourceChanged = actor ? nativeCompanionSourceChanged(member, actor) : false;
const maxHealth = nativeSourceChanged ? Math.max(1, member.maxHealth) : actor?.maxHealth ?? member.maxHealth;
const health = nativeSourceChanged && actor
? actor.health <= 0
? 0
: actor.maxHealth > 0
? Math.max(0, Math.min(maxHealth, actor.health / actor.maxHealth * maxHealth))
: Math.max(0, Math.min(maxHealth, member.health))
: actor?.health ?? member.health;
let resources: CombatActorState["resources"];
if (existing?.kind === "companion" && member.statRuleset === "rom" && isRomClassId(member.classId)) {
const previousResources = existing.resources;
if (actor && member.statRuleset === "rom" && isRomClassId(member.classId)) {
const previousResources = actor.resources;
const nextResources: Record<string, ReturnType<typeof normalizeActorResourcePool>> = {};
const resourceIds = new Set([...Object.keys(previousResources), ...Object.keys(member.resourcePools)]);
for (const id of resourceIds) {
@@ -53,8 +71,8 @@ export function companionActorFromMember(
);
}
resources = nextResources;
} else if (existing?.kind === "companion") {
resources = existing.resources;
} else if (actor) {
resources = actor.resources;
} else {
resources = Object.fromEntries(Object.entries(member.resourcePools).map(([id, current]) => [id,
normalizeActorResourcePool(
@@ -66,7 +84,6 @@ export function companionActorFromMember(
),
]));
}
const actor = existing?.kind === "companion" ? existing : null;
return Object.freeze({
id: member.id,
threatActorId: member.id,
@@ -74,8 +91,8 @@ export function companionActorFromMember(
classId: member.classId,
statRuleset: member.statRuleset,
level: member.level,
health: actor?.health ?? member.health,
maxHealth: actor?.maxHealth ?? member.maxHealth,
health,
maxHealth,
absorb,
resources,
cooldowns: actor?.cooldowns ?? member.cooldowns,
@@ -87,7 +104,7 @@ export function companionActorFromMember(
comboTargetId: actor?.comboTargetId ?? member.comboTargetId,
runes: actor?.runes ?? member.runes,
abilityIds: actor?.abilityIds,
alive: (actor?.health ?? member.health) > 0,
alive: health > 0,
});
}
+62
View File
@@ -57,6 +57,68 @@ afterEach(() => {
});
describe("combat audio output policy", () => {
it("bounds decoded audio memory, refreshes recent cues, and preserves active playback", async () => {
vi.resetModules();
const audio = await import("./combatAudio");
const sources: { stop: ReturnType<typeof vi.fn>; buffer: unknown }[] = [];
const buffer = { duration: 1, length: 1024 * 1024, numberOfChannels: 2 };
vi.stubGlobal("window", { AudioContext: function () {
return {
currentTime: 0, state: "running", destination: {},
createGain: () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn(), value: 1 } }),
createBufferSource: () => {
const source = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, onended: null };
sources.push(source);
return source;
},
decodeAudioData: async () => buffer,
resume: async () => undefined,
};
} });
combatMedia.ready = true;
combatMedia.fetch.mockImplementation(async () => new Response(new Uint8Array([1])));
audio.configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
for (const cue of [1, 2, 3, 4, 1, 5]) await audio.playMasterAudioCue(`/cue-${cue}.wav`);
expect(combatMedia.fetch).toHaveBeenCalledTimes(5);
await audio.playMasterAudioCue("/cue-1.wav");
expect(combatMedia.fetch).toHaveBeenCalledTimes(5);
await audio.playMasterAudioCue("/cue-2.wav");
expect(combatMedia.fetch).toHaveBeenCalledTimes(6);
expect(sources[1].buffer).toBe(buffer);
expect(sources.every((source) => source.stop.mock.calls.length === 0)).toBe(true);
audio.resetCombatAudio();
});
it("bounds the number of cached cues, shares pending decodes, and retries failed audio", async () => {
vi.resetModules();
const audio = await import("./combatAudio");
vi.stubGlobal("window", { AudioContext: function () {
return {
currentTime: 0, state: "running", destination: {},
createGain: () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn(), value: 1 } }),
createBufferSource: () => ({ connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, onended: null }),
decodeAudioData: async () => ({ duration: 0.1, length: 100, numberOfChannels: 1 }),
resume: async () => undefined,
};
} });
combatMedia.ready = true;
combatMedia.fetch.mockImplementation(async () => new Response(new Uint8Array([1])));
audio.configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
const stops = await Promise.all([audio.playMasterAudioCue("/shared.wav"), audio.playMasterAudioCue("/shared.wav")]);
stops.forEach((stop) => stop?.());
expect(combatMedia.fetch).toHaveBeenCalledTimes(1);
for (let index = 0; index < 65; index += 1) (await audio.playMasterAudioCue(`/small-${index}.wav`))?.();
const calls = combatMedia.fetch.mock.calls.length;
(await audio.playMasterAudioCue("/small-64.wav"))?.();
expect(combatMedia.fetch).toHaveBeenCalledTimes(calls);
(await audio.playMasterAudioCue("/shared.wav"))?.();
expect(combatMedia.fetch).toHaveBeenCalledTimes(calls + 1);
combatMedia.fetch.mockResolvedValueOnce(new Response(null, { status: 404 }));
expect(await audio.playMasterAudioCue("/retry.wav")).toBeNull();
expect(await audio.playMasterAudioCue("/retry.wav")).toBeTypeOf("function");
audio.resetCombatAudio();
});
it("plays source cues only on their mapped phase, with instant-cast release support", () => {
const cast = { url: "/cast.mp3", phase: "cast-start" as const };
const impact = { url: "/impact.mp3", phase: "impact" as const };
+43 -6
View File
@@ -11,6 +11,8 @@ import {
} from "./combatMediaPack";
const MAX_COMBAT_VOICES = 32;
const MAX_CACHED_AUDIO_BUFFERS = 64;
const MAX_CACHED_AUDIO_BYTES = 32 * 1024 * 1024;
interface ActiveVoice {
readonly group: CombatActorGroup;
@@ -34,6 +36,23 @@ const activeVoices: ActiveVoice[] = [];
const activeCastLoops = new Map<string, ActiveCastLoop>();
const activeMasterVoices = new Set<() => void>();
const decodedBuffers = new Map<string, Promise<AudioBuffer | null>>();
const decodedBufferBytes = new Map<string, number>();
let cachedAudioBytes = 0;
function removeCachedAudio(url: string): void {
decodedBuffers.delete(url);
cachedAudioBytes -= decodedBufferBytes.get(url) ?? 0;
decodedBufferBytes.delete(url);
}
function trimCachedAudio(): void {
while (decodedBuffers.size > MAX_CACHED_AUDIO_BUFFERS || cachedAudioBytes > MAX_CACHED_AUDIO_BYTES) {
const oldest = decodedBuffers.keys().next().value;
if (oldest === undefined) break;
// Active sources retain their own buffer; eviction never stops playback.
removeCachedAudio(oldest);
}
}
export function masterAudioOutputEnabled(masterVolume: number): boolean {
return Number.isFinite(masterVolume) && masterVolume > 0;
@@ -222,13 +241,31 @@ function soundsForEvent(event: CombatPresentationEvent): readonly CombatEffectSo
async function decodeAudio(url: string): Promise<AudioBuffer | null> {
const audioContext = ensureContext();
if (!audioContext) return null;
if (!decodedBuffers.has(url)) {
decodedBuffers.set(url, fetchOptionalMediaAsset(url)
.then((response) => response.ok ? response.arrayBuffer() : Promise.reject(new Error(String(response.status))))
.then((data) => audioContext.decodeAudioData(data))
.catch(() => null));
const cached = decodedBuffers.get(url);
if (cached) {
decodedBuffers.delete(url);
decodedBuffers.set(url, cached);
return cached;
}
return decodedBuffers.get(url)!;
const request = fetchOptionalMediaAsset(url)
.then((response) => response.ok ? response.arrayBuffer() : Promise.reject(new Error(String(response.status))))
.then((data) => audioContext.decodeAudioData(data))
.then((buffer) => {
if (decodedBuffers.get(url) === request) {
const bytes = buffer.length * buffer.numberOfChannels * Float32Array.BYTES_PER_ELEMENT;
decodedBufferBytes.set(url, Number.isFinite(bytes) ? Math.max(0, bytes) : 0);
cachedAudioBytes += decodedBufferBytes.get(url)!;
trimCachedAudio();
}
return buffer;
})
.catch(() => {
if (decodedBuffers.get(url) === request) removeCachedAudio(url);
return null;
});
decodedBuffers.set(url, request);
trimCachedAudio();
return request;
}
export async function playMasterAudioCue(
+5
View File
@@ -32,6 +32,11 @@ export interface CombatDefenderStats {
}
export interface CombatResultRules {
/** Exact source packet identity and recipient ordinal for native RoM spells. */
readonly romNativeSource?: { readonly skillId: number; readonly componentId: number };
readonly romNativeTargetOrdinal?: number;
/** Native periodic power already includes source target defense at application. */
readonly targetMitigationSnapshotted?: boolean;
/** School or encounter immunity: the attack is reported as fully resisted. */
readonly immune?: boolean;
/** Ordinary abilities use the special-attack miss table. Auto attacks may use the dual-wield penalty. */
+10 -1
View File
@@ -177,7 +177,16 @@ describe("shared combat rotations", () => {
expect(learnedAbilityForBuild(signature, 80, {}), `${classId}:${name}`).toBeNull();
expect(learnedAbilityForBuild(signature, 80, { [signature.talentNodeId!]: 1 }), `${classId}:${name}`).not.toBeNull();
expect(signature.effects.length).toBeGreaterThan(0);
expect(signature.effects.some((effect) => !["unsupported", "scripted", "utility"].includes(effect.kind))).toBe(true);
if (classId === "rogue" && name === "Killing Spree") {
// Its DBC wrapper is a periodic-dummy target selector; the dedicated
// Rogue runtime test covers the script's actual two-weapon assaults.
expect(signature.effects).toEqual([
expect.objectContaining({ kind: "utility", action: "class-script", durationMs: 2_000 }),
]);
} else {
expect(signature.effects.some((effect) => !["unsupported", "scripted", "utility"].includes(effect.kind)))
.toBe(true);
}
expect(signature.ranks?.length, `${classId}:${name} ranks`).toBeGreaterThan(0);
expect(signature.ranks?.every((rank) => rank.cost.amount >= 0 && rank.cooldownMs >= 0 && rank.effects.length > 0)).toBe(true);
expect(signature.cooldownMs).toBeGreaterThanOrEqual(0);
+74 -5
View File
@@ -1,18 +1,21 @@
import type { ClassId } from "../app/characterCatalog";
import type { AbilityDefinition, ResourceType } from "./abilityCatalog";
import { coaBarbarianDecapitateTargetAllowed } from "./coaBarbarianExecuteRuntime";
import { aiHintsForAbility, canAbilityCastWhileMoving, runeCostForAbility } from "./abilityRules";
import type { DeathKnightRuneState } from "./combatActors";
import { channelAmountEffects } from "./combatChannelAmounts";
import { spendDeathKnightRunes } from "./combatResources";
import { healingAbilityCanAidOthers } from "./healingAbilityRules";
import type { PartyRole } from "./partyRoles";
import type { CharacterStatRuleset } from "./characterStatRuleset";
import { nativeRotationAbilityAllowed, nativeRotationProfileFor } from "./romFiveRotation";
import {
currentCompanionCooldownPolicy,
type CompanionCooldownMechanicRule,
type CompanionCooldownPolicy,
} from "./companionCooldownPolicy";
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v6-reactive-ready-five";
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v8-native-rom-healer-reserve";
export interface RotationProfile {
readonly id: string;
@@ -43,6 +46,7 @@ export interface RotationContext {
readonly now: number;
readonly classId: ClassId;
readonly secondaryClassId?: ClassId | null;
readonly statRuleset?: CharacterStatRuleset;
readonly specializationId?: string | null;
readonly specialization?: string | null;
readonly role: PartyRole;
@@ -59,6 +63,10 @@ export interface RotationContext {
/** Learned Reaping rank, used only to expose its otherwise-omitted Blood Strike trigger fallback. */
readonly deathKnightReapingRank?: number;
readonly abilities: readonly AbilityDefinition[];
/** Full learned kit before affordability/cooldown filtering; resource reservations need this list. */
readonly learnedAbilities?: readonly AbilityDefinition[];
/** Current effective Mana cost after actor-specific aura/build adjustments. */
readonly manaCostForAbility?: (ability: AbilityDefinition) => number;
/** Learned actions capable of hostile damage, including unavailable ones. */
readonly knownDamagingAbilityNames?: ReadonlySet<string>;
readonly hostileTarget: RotationHostileTarget | null;
@@ -75,6 +83,12 @@ export interface RotationContext {
readonly requiredControl?: boolean;
readonly moving?: boolean;
readonly maintainedAbilityIds?: ReadonlySet<string>;
readonly maintainedPeriodicEffects?: readonly {
readonly abilityId: string;
readonly sourceActorId: string;
readonly targetId: string;
readonly remainingMs: number;
}[];
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
readonly activeSummonAbilityIds?: ReadonlySet<string>;
readonly hasActivePet?: boolean;
@@ -294,7 +308,13 @@ function isCombatMaintenanceAura(ability: AbilityDefinition): boolean {
export function abilityHasRotationSemantics(ability: AbilityDefinition): boolean {
return ability.effects.some((effect) => {
if (effect.kind === "unsupported" || effect.kind === "scripted" || effect.kind === "utility") return false;
if (effect.kind === "unsupported" || effect.kind === "scripted") return false;
// Killing Spree's player-facing effect is intentionally a utility marker;
// the cast creates a timed aura whose source script supplies the assaults.
if (effect.kind === "utility") return ability.source === "wow335-progression"
&& ability.classId === "rogue"
&& ability.dbcSpellId === 51690
&& ability.name === "Killing Spree";
if (effect.kind !== "apply-aura") return true;
return Boolean(
effect.control
@@ -369,7 +389,10 @@ function scoreAbility(
const resource = context.resources[context.primaryResource] ?? 0;
const maximumResource = context.maximumResources[context.primaryResource] ?? 0;
const resourceFraction = maximumResource > 0 ? resource / maximumResource : 1;
const maintained = (context.maintainedAbilityIds?.has(ability.id) ?? false)
const nativePeriodic = nativeRotationProfileFor(context) !== null
&& context.maintainedPeriodicEffects !== undefined
&& ability.effects.some((effect) => effect.kind === "dot" || effect.kind === "hot");
const maintained = (!nativePeriodic && (context.maintainedAbilityIds?.has(ability.id) ?? false))
|| ability.effects.some((effect) => effect.kind === "apply-aura"
&& Boolean(effect.aura.exclusiveGroup
&& context.activeAuraExclusiveGroups?.has(effect.aura.exclusiveGroup)));
@@ -402,6 +425,7 @@ function scoreAbility(
knownDamagingAbilityNames.has(name)
)));
if (!coaBarbarianDecapitateTargetAllowed(ability, context.hostileTarget)) return null;
const targetHealthGate = TARGET_HEALTH_GATES.get(ability.name);
if (targetHealthGate !== undefined && targetHealth > targetHealthGate) return null;
if ((context.hostileCount ?? 1) < minimumHostileTargetsForAbility(ability.name)) return null;
@@ -543,10 +567,18 @@ function scoreAbility(
if (maintenanceBuff && healingCategory
&& (ability.target === "friendly" || ability.target === "any") && !lowest) return null;
if (actionableHealing && !damaging) {
const healingTargetHealth = ability.target === "self" ? selfHealth : lowestHealth;
const healingTargetHealth = ability.target === "self"
&& !(nativeRotationProfileFor(context) && healingAbilityCanAidOthers(ability))
? selfHealth : lowestHealth;
if ((ability.target === "friendly" || ability.target === "any") && !lowest) return null;
if (healingTargetHealth >= 0.9) return null;
}
if (nativeRotationProfileFor(context)?.role === "healer"
&& ability.name === "Urgent Heal" && lowest && lowestHealth <= 0.3) {
return { ability, score: 1_015_500, ruleId: "native-emergency-heal",
reason: "a critically injured ally needs the fastest available direct heal",
targetId: lowest.id };
}
// Pet stance and recall buttons require an explicit positioning/order intent;
// treating them as maintenance actions otherwise starves real attacks.
if (categories.has("pet") && !petSummon && !categories.has("summon") && !damaging
@@ -680,8 +712,43 @@ function scoreAbility(
return { ability, score, ruleId, reason, targetId: targetForAbility(ability, context, lowest) };
}
function rotationManaCost(ability: AbilityDefinition, context: RotationContext): number {
if (context.manaCostForAbility) return context.manaCostForAbility(ability);
return (ability.costs?.length ? ability.costs : [ability.cost])
.filter((cost) => cost.resource === "mana")
.reduce((total, cost) => total + Math.max(0, cost.amount), 0);
}
function nativeEarlyPriestHealManaReserve(context: RotationContext): number {
if (context.statRuleset !== "rom" || context.classId !== "rom-priest"
|| context.role !== "healer" || context.level >= 10) return 0;
return (context.learnedAbilities ?? context.abilities)
.filter((ability) => !ability.passive
&& (ability.target === "friendly" || ability.target === "any")
&& healingAbilityCanAidOthers(ability)
&& ability.effects.some((effect) => effect.kind === "heal"))
.map((ability) => rotationManaCost(ability, context))
.filter((cost) => Number.isFinite(cost) && cost > 0)
.reduce((minimum, cost) => Math.min(minimum, cost), Number.POSITIVE_INFINITY);
}
/** True when spending this hostile ability would consume the early RoM Priest's learned direct-heal reserve. */
export function nativeEarlyPriestHealReserveBlocksAbility(
ability: AbilityDefinition,
context: RotationContext,
): boolean {
const reserve = nativeEarlyPriestHealManaReserve(context);
if (!Number.isFinite(reserve) || reserve <= 0 || ability.target !== "hostile"
|| !aiHintsForAbility(ability).categories.some((category) => category === "damage" || category === "execute")) {
return false;
}
const manaCost = rotationManaCost(ability, context);
if (!Number.isFinite(manaCost) || manaCost <= 0) return false;
return (context.resources.mana ?? 0) - manaCost < reserve;
}
export function evaluateCombatRotation(context: RotationContext): RotationDecision | null {
const baseRotationProfile = rotationProfileFor(
const baseRotationProfile = nativeRotationProfileFor(context) ?? rotationProfileFor(
context.classId,
context.specializationId,
context.specialization,
@@ -726,6 +793,8 @@ export function evaluateCombatRotation(context: RotationContext): RotationDecisi
return !runeCost || spendDeathKnightRunes(context.runes, runeCost, context.now) !== null;
})
.filter((ability) => context.canUseAbility?.(ability) ?? true)
.filter((ability) => !nativeEarlyPriestHealReserveBlocksAbility(ability, context))
.filter((ability) => nativeRotationAbilityAllowed(ability, context))
.map((ability) => scoreAbility(ability, context, rotationProfile, lowest))
.filter((candidate): candidate is ScoredRotationAbility => candidate !== null)
.sort((left, right) => right.score - left.score
+942 -188
View File
File diff suppressed because it is too large. Load diff
+19 -2
View File
@@ -100,14 +100,31 @@ describe("dungeon boss durability and difficulty", () => {
it("gives each level-three Stockades party its source-calibrated health minimum", () => {
const easy = dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "wow");
expect(easy).toMatchObject({ level: 4, maxHealth: 1500 });
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 1, "normal", 1, "wow"))
.toMatchObject({ level: 2, maxHealth: 1800 });
expect(easy).toMatchObject({ level: 4, maxHealth: 2600 });
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 10, "wow").maxHealth)
.toBe(1500 * bossDifficultyMultipliers(10).health);
.toBe(2600 * bossDifficultyMultipliers(10).health);
// The dungeon catalog must not choose the companion power curve.
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "rom").maxHealth).toBeCloseTo(7074.8);
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "coa").maxHealth).toBe(10766);
});
it("retunes legacy-mode RoM boss floors without scaling native RoM health budgets", () => {
const levelEightLegacy = dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id,
8, "normal", 1, "rom", "wow");
expect(levelEightLegacy.maxHealth).toBeCloseTo(15_619.3 * 1.12, 6);
const levelEightNative = dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id,
8, "normal", 1, "rom", "rom");
// The separately calibrated level-one boss anchor interpolates smoothly to level 10.
expect(levelEightNative.maxHealth).toBeCloseTo(7_983.197777777779, 6);
expect(levelEightNative.nativeDamageBudgetScale).toBe(1);
const level80Native = dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id,
80, "normal", 1, "rom", "rom");
expect(level80Native.maxHealth).toBeCloseTo(1_312_656, 6);
expect(level80Native.nativeDamageBudgetScale).toBe(40);
});
for (const level of [1, 3, 8, 20, 40, 80]) {
it(`preserves WoW boss mechanic windows in Stockades at level ${level}`, () => {
for (const seed of ["wow-health-1", "wow-health-2", "wow-health-3"]) {
+42 -9
View File
@@ -7,12 +7,35 @@ import type { PopulationEntityDefinition } from "./mobPopulation";
import type { ContentCategoryId } from "../app/contentCategories";
import { isBiteSizedDungeonId } from "./dungeonContentSource";
import { resolveBossBalanceOverride } from "./bossBalanceOverrides";
import type { CharacterStatRuleset } from "./characterStatRuleset";
import { clampPlayerLevel } from "./progression";
// Simulator health budgets measured with the five native training builds and
// active player rotations. Keep the legacy RoM/WoW-stat curve independent.
const ROM_NATIVE_HEALTH_BANDS: readonly (readonly [number, number, number])[] = [
// character level, trash scale, boss scale
[1, 0.4, 0.2], [10, 1.0, 0.6], [30, 1.1, 0.85], [60, 1.5, 1.5],
[65, 1.8, 1.9], [66, 2.6, 2.8], [79, 4.5, 4.8], [80, 4.5, 4.8],
];
// Normal training-party point-damage budgets. Native HP/DEF grows faster than
// the shared creature budget; exact source formulas bypass this adjustment.
const ROM_NATIVE_DAMAGE_BANDS: readonly (readonly [number, number])[] = [
[1, 1], [10, 1], [30, 3], [60, 10], [65, 12], [66, 20], [79, 40], [80, 40],
];
// Legacy RoM-character parties still use their compatibility-stat output
// while generated RoM NPCs use native snapshots. Retune only the boss-health
// floor for that mixed mode; explicit native RoM health bands stay independent.
const ROM_LEGACY_BOSS_HEALTH_SCALE: readonly (readonly [level: number, scale: number])[] = [
[1, 1], [7, 1], [8, 1.12], [70, 1.12], [80, 2.3],
];
const WOW_BOSS_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [
// The starter anchors include enough room for authentic presence, imbue,
// disease, and shock rotations to retain the 25/45-second mechanic window.
[2, 1_050],
[4, 1_500],
// Calibrate the starter floors to the tested Ready five-member party while
// retaining the existing 25/45-second mechanic windows.
[2, 1_800],
[4, 2_600],
[9, 7_000],
[21, 11_000],
[41, 23_000],
@@ -74,7 +97,7 @@ function partyBossHealthFloor(level: number, characterSource: ContentCategoryId,
/** Campaign fights retain their full durability floor; testing rooms use a reduced floor. */
export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: PopulationEntityDefinition,
instanceId: string, characterLevel: number, difficultyId: string, bossDifficulty: number,
characterSource: ContentCategoryId = "wow") {
characterSource: ContentCategoryId = "wow", statRuleset: CharacterStatRuleset = "wow") {
const boss = entity.kind === "boss";
const testing = dungeon.id.startsWith("boss-testing:");
const level = scaledDungeonEnemyLevel(characterLevel, boss);
@@ -90,7 +113,10 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
const multipliers = boss ? bossDifficultyMultipliers(bossDifficulty) : { health: 1, damage: 1 };
const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
// Match durability to the character's game, including when crossing dungeon catalogs.
const partyHealthFloor = partyBossHealthFloor(level, characterSource, testing);
const legacyRomCompatibilityBossScale = boss && characterSource === "rom" && statRuleset === "wow"
? interpolatedFloor(clampPlayerLevel(characterLevel), ROM_LEGACY_BOSS_HEALTH_SCALE)
: 1;
const partyHealthFloor = partyBossHealthFloor(level, characterSource, testing) * legacyRomCompatibilityBossScale;
const biteSizedBoss = boss && !testing && isBiteSizedDungeonId(dungeon.id);
const biteSizedFloorScale = biteSizedBoss
? interpolatedFloor(characterLevel, BITE_SIZED_BOSS_HEALTH_BANDS.map(([levelAnchor, floor]) => [levelAnchor, floor]))
@@ -121,7 +147,14 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
const bossOverride = boss
? resolveBossBalanceOverride(dungeon.id, entity.id, level)
: { healthScale: 1, damageScale: 1 };
return { level, maxHealth: scaledHealth * multipliers.health * bossOverride.healthScale,
healthMultiplier: authoredHealth * multipliers.health * bossOverride.healthScale,
damageMultiplier: authoredDamage * multipliers.damage * testingDamageScale * bossOverride.damageScale };
const nativeHealthScale = characterSource === "rom" && statRuleset === "rom"
? interpolatedFloor(clampPlayerLevel(characterLevel), ROM_NATIVE_HEALTH_BANDS.map(
([anchor, trashScale, bossScale]) => [anchor, boss ? bossScale : trashScale]))
: 1;
const nativeDamageBudgetScale = characterSource === "rom" && statRuleset === "rom"
? interpolatedFloor(clampPlayerLevel(characterLevel), ROM_NATIVE_DAMAGE_BANDS) : 1;
return { level, maxHealth: scaledHealth * multipliers.health * bossOverride.healthScale * nativeHealthScale,
healthMultiplier: authoredHealth * multipliers.health * bossOverride.healthScale * nativeHealthScale,
damageMultiplier: authoredDamage * multipliers.damage * testingDamageScale * bossOverride.damageScale,
nativeDamageBudgetScale };
}
+61 -3
View File
@@ -1,3 +1,5 @@
import { enemyDamageTypeSourceForSpell, resolveEnemyDamageType, type EnemyDamageOrigin } from "./mobDamageTypes";
import { romNativeEnemyPeriodicDamageSources } from "./romNativePeriodicDamage";
import {
normalizeRunewakerDamageFormula,
type RunewakerDamageFormulaSource,
@@ -72,6 +74,8 @@ export interface EnemyAttackTelegraph {
export type EnemyAtomicAbilityEffect =
| { readonly kind: "damage"; readonly multiplier: number }
| { readonly kind: "dot"; readonly multiplier: number; readonly ticks: number; readonly intervalMs: number;
readonly sourceSkillId?: number; readonly componentId?: number }
| { readonly kind: "heal"; readonly multiplier: number }
| {
readonly kind: "leech";
@@ -162,6 +166,19 @@ export interface EnemyAttackDefinition {
readonly delivery: EnemyAttackDelivery;
readonly target: EnemyAttackTarget;
readonly school: EnemyDamageSchool;
/** Hit-table channel, independent from projectile/area presentation and damage school. */
readonly attackKind?: "melee" | "ranged" | "spell";
readonly damageClass?: number;
/** Original WoW school bits; composite schools retain the authored scalar policy. */
readonly schoolMask?: number;
/** Source RuneWaker HP calculation channel (0 magic, 1 physical). */
readonly runewakerEffectType?: number;
readonly runewakerComponentId?: number;
readonly damageOrigin?: EnemyDamageOrigin;
/** Runtime pulse of a previously applied periodic debuff. */
readonly periodicTick?: boolean;
readonly targetMitigationSnapshotted?: boolean;
readonly periodicNoKill?: boolean;
readonly animation: EnemyAttackAnimation;
/** Zero keeps the legacy instant-execution behavior. */
readonly castTimeMs?: number;
@@ -184,6 +201,8 @@ export interface EnemyAttackDefinition {
export interface EnemyCombatDefinition {
readonly serverEntry?: number;
/** RuneWaker NPC source identity; independent from AzerothCore creature entries. */
readonly runewakerNpcId?: number;
readonly level?: number;
readonly healthMultiplier?: number;
readonly damageMultiplier?: number;
@@ -226,6 +245,7 @@ function harmfulEnemyAttack(value: EnemyAttackDefinition): boolean {
if (value.effect.kind === "composite") {
return value.effect.effects.some((effect) => (
effect.kind === "damage"
|| effect.kind === "dot"
|| effect.kind === "leech"
|| effect.kind === "control"
|| effect.kind === "target-aura"
@@ -233,6 +253,7 @@ function harmfulEnemyAttack(value: EnemyAttackDefinition): boolean {
));
}
return value.effect.kind === "damage"
|| value.effect.kind === "dot"
|| value.effect.kind === "leech"
|| value.effect.kind === "control"
|| value.effect.kind === "target-aura"
@@ -310,6 +331,7 @@ function normalizeTelegraph(value: EnemyAttackTelegraph): EnemyAttackTelegraph {
export function normalizeEnemyAttack(
value: EnemyAttackDefinition,
fallback: EnemyAttackDefinition = DEFAULT_ENEMY_ATTACK,
runewakerNpcId?: number,
): EnemyAttackDefinition {
const cooldownMs = Math.round(finiteAtLeast(value.cooldownMs, fallback.cooldownMs, 1));
const telegraph = value.telegraph ?? inferEnemyAttackTelegraph(value);
@@ -321,6 +343,33 @@ export function normalizeEnemyAttack(
telegraph?.warningMs ?? 0,
);
const runewakerFormula = normalizeRunewakerDamageFormula(value.runewakerFormula);
const damageOrigin = value.damageOrigin ?? (runewakerFormula || value.id.startsWith("rw-") ? "runewaker" : "wow335");
const sourceType = value.spellId ? enemyDamageTypeSourceForSpell(damageOrigin, value.spellId) : undefined;
// Script/control placeholder spell IDs must not override authored damage packets.
const sourceHasDamage = sourceType && ("components" in sourceType
? sourceType.components.length > 0 : sourceType.damageEffects.length > 0);
const damageType = resolveEnemyDamageType({ ...value,
...(sourceHasDamage ? { origin: damageOrigin,
componentId: value.runewakerComponentId ?? runewakerFormula?.componentId } : {}),
});
const periodicSources = damageOrigin === "runewaker" && value.spellId
? romNativeEnemyPeriodicDamageSources(value.spellId, runewakerNpcId) : [];
const uniquePeriodicSources = periodicSources.filter((source, index, all) => all.findIndex((entry) =>
entry.sourceSkillId === source.sourceSkillId && entry.component.componentId === source.component.componentId) === index);
const periodicEffects: EnemyAtomicAbilityEffect[] = uniquePeriodicSources.map((source) => ({
kind: "dot", multiplier: value.damageMultiplier,
ticks: Math.max(1, Math.trunc(source.component.effectTimeSeconds / source.component.dotTimeSeconds)),
intervalMs: source.component.dotTimeSeconds * 1_000,
sourceSkillId: source.sourceSkillId, componentId: source.component.componentId,
}));
const baseEffect = value.effect ?? { kind: "damage" as const, multiplier: value.damageMultiplier };
const existingEffects = baseEffect.kind === "composite" ? baseEffect.effects : [baseEffect];
const directEffects = (runewakerFormula?.powerKind === "dot-only" ? [] : existingEffects)
.filter((effect) => !(effect.kind === "dot" && periodicEffects.some((periodic) => periodic.kind === "dot"
&& periodic.componentId === effect.componentId && periodic.sourceSkillId === effect.sourceSkillId)));
const resolvedEffect = periodicEffects.length
? { kind: "composite" as const, effects: [...directEffects, ...periodicEffects] }
: value.effect;
return Object.freeze({
id: value.id.trim() || fallback.id,
name: value.name.trim() || fallback.name,
@@ -329,7 +378,15 @@ export function normalizeEnemyAttack(
: {}),
delivery: value.delivery,
target: value.target,
school: value.school,
school: damageType.school ?? value.school,
damageOrigin,
...(damageType.attackKind ? { attackKind: damageType.attackKind } : {}),
...(damageType.damageClass !== undefined ? { damageClass: damageType.damageClass } : {}),
...(damageType.schoolMask !== undefined ? { schoolMask: damageType.schoolMask } : {}),
...(damageType.sourceDamageClass === "magical" ? { runewakerEffectType: 0 }
: damageType.sourceDamageClass === "physical" ? { runewakerEffectType: 1 }
: value.runewakerEffectType !== undefined ? { runewakerEffectType: value.runewakerEffectType } : {}),
...(value.runewakerComponentId ? { runewakerComponentId: value.runewakerComponentId } : {}),
animation: value.animation,
castTimeMs,
interruptible: value.interruptible ?? fallback.interruptible ?? true,
@@ -350,7 +407,7 @@ export function normalizeEnemyAttack(
? { radius: finiteAtLeast(value.radius, 0, 0) }
: {}),
...(telegraph ? { telegraph: normalizeTelegraph(telegraph) } : {}),
...(value.effect ? { effect: value.effect } : {}),
...(resolvedEffect ? { effect: resolvedEffect } : {}),
...(value.condition ? { condition: value.condition } : {}),
...(value.source ? { source: value.source } : {}),
});
@@ -359,9 +416,10 @@ export function normalizeEnemyAttack(
export function normalizeEnemyAttacks(
attacks: readonly EnemyAttackDefinition[] | undefined,
fallback: EnemyAttackDefinition = DEFAULT_ENEMY_ATTACK,
runewakerNpcId?: number,
): readonly EnemyAttackDefinition[] {
const source = attacks?.length ? attacks : [fallback];
const normalized = source.map((attack) => normalizeEnemyAttack(attack, fallback));
const normalized = source.map((attack) => normalizeEnemyAttack(attack, fallback, runewakerNpcId));
const seen = new Set<string>();
return Object.freeze(normalized.filter((attack) => {
if (seen.has(attack.id)) return false;
+167
View File
@@ -3,6 +3,7 @@ import { PLAYER_AGGRO_ID } from "./aggro";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
import referenceData from "./generated/azerothCoreReference.json";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
import {
normalizeEnemyAttack,
@@ -10,6 +11,20 @@ import {
type EnemyDamageSchool,
} from "./enemyAttacks";
interface ImportedWailingAbilities {
readonly wailingCaverns: {
readonly abilitiesByEntry: Readonly<Record<string, readonly EnemyAttackDefinition[]>>;
};
}
const IMPORTED_WAILING = referenceData as unknown as ImportedWailingAbilities;
const ANACONDRA_THORNS = IMPORTED_WAILING.wailingCaverns.abilitiesByEntry["3671"]
.find((attack) => attack.spellId === 8148)!;
const ANACONDRA_LIGHTNING_BOLT = IMPORTED_WAILING.wailingCaverns.abilitiesByEntry["3671"]
.find((attack) => attack.spellId === 9532)!;
const CROCOLISK_TENDON_RIP = IMPORTED_WAILING.wailingCaverns.abilitiesByEntry["5053"]
.find((attack) => attack.spellId === 3604)!;
function spell(
id: string,
school: EnemyDamageSchool,
@@ -45,6 +60,43 @@ function registerCaster(attacks: readonly EnemyAttackDefinition[], id = "caster"
useCombatStore.getState().damageMob(id, 1, 1_000);
}
function silenceMob(id: string, endsAt: number): void {
useCombatStore.setState((state) => {
const mob = state.mobs[id];
if (!mob) return state;
return {
mobs: {
...state.mobs,
[id]: {
...mob,
statuses: [...mob.statuses, {
kind: "silence",
sourceAbilityId: "vow-of-silence",
sourceActorId: PLAYER_AGGRO_ID,
endsAt,
}],
},
},
};
});
}
function setMobAttackReadyAt(id: string, attackId: string, readyAt: number): void {
useCombatStore.setState((state) => {
const mob = state.mobs[id];
if (!mob) return state;
return {
mobs: {
...state.mobs,
[id]: {
...mob,
abilityReadyAt: { ...mob.abilityReadyAt, [attackId]: readyAt },
},
},
};
});
}
describe("enemy casting and interrupts", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
@@ -129,6 +181,121 @@ describe("enemy casting and interrupts", () => {
expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull();
});
it("cancels a live imported Lightning Bolt cast when Silence lands", () => {
expect(ANACONDRA_LIGHTNING_BOLT).toMatchObject({
spellId: 9532,
school: "nature",
delivery: "projectile",
source: { entry: 3671, comment: "Lady Anacondra - In Combat - Cast Lightning Bolt" },
});
registerCaster([ANACONDRA_LIGHTNING_BOLT]);
useCombatStore.getState().advanceMobCombat(1_000);
expect(useCombatStore.getState().mobs.caster.activeCast).toMatchObject({
attackId: "spell-9532",
spellId: 9532,
school: "nature",
startedAt: 1_000,
completesAt: 2_500,
});
silenceMob("caster", 9_500);
useCombatStore.getState().advanceMobCombat(1_100);
expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull();
for (const now of [2_500, 3_500, 7_500, 9_499]) {
useCombatStore.getState().advanceMobCombat(now);
expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull();
}
useCombatStore.getState().advanceMobCombat(9_500);
expect(useCombatStore.getState().mobs.caster.activeCast).toMatchObject({
attackId: "spell-9532",
spellId: 9532,
startedAt: 9_500,
completesAt: 11_000,
});
});
it("blocks Anacondra's imported Nature Thorns aura for the full Silence window", () => {
expect(ANACONDRA_THORNS).toMatchObject({
spellId: 8148,
school: "nature",
delivery: "melee",
animation: "cast",
effect: { kind: "aura", aura: "thorns" },
});
expect(ANACONDRA_THORNS.attackKind).toBeUndefined();
expect(ANACONDRA_THORNS.damageClass).toBeUndefined();
registerCaster([ANACONDRA_THORNS]);
silenceMob("caster", 9_500);
const events: CombatPresentationEvent[] = [];
const unsubscribe = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.sourceActorId === "caster") {
events.push(message.event);
}
});
for (const now of [1_000, 2_500, 4_000, 7_500, 9_499]) {
useCombatStore.getState().advanceMobCombat(now);
expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull();
}
expect(events).toEqual([]);
useCombatStore.getState().advanceMobCombat(9_500);
expect(useCombatStore.getState().mobs.caster.activeCast).toMatchObject({
attackId: "spell-8148",
spellId: 8148,
school: "nature",
startedAt: 9_500,
completesAt: 11_000,
});
expect(events.map((event) => event.phase)).toEqual(["cast-start"]);
useCombatStore.getState().advanceMobCombat(11_000);
expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull();
expect(events.map((event) => event.phase)).toEqual(["cast-start", "release", "aura-start"]);
unsubscribe();
});
it("keeps physical melee and ranged attacks active under Silence but blocks physical spells", () => {
expect(CROCOLISK_TENDON_RIP).toMatchObject({ school: "physical", delivery: "melee" });
const physicalRanged = {
...spell("physical-ranged", "physical", 1_500),
attackKind: "ranged" as const,
};
const physicalSpell = {
...spell("physical-spell", "physical", 1_500),
damageClass: 1,
};
const physicalAttackKindSpell = {
...spell("physical-kind-spell", "physical", 1_500),
attackKind: "spell" as const,
};
registerCaster([CROCOLISK_TENDON_RIP], "physical-melee");
registerCaster([physicalRanged], "physical-ranged");
registerCaster([physicalSpell], "physical-spell");
registerCaster([physicalAttackKindSpell], "physical-kind-spell");
silenceMob("physical-melee", 9_500);
silenceMob("physical-ranged", 9_500);
silenceMob("physical-spell", 9_500);
silenceMob("physical-kind-spell", 9_500);
setMobAttackReadyAt("physical-melee", "spell-3604", 0);
useCombatStore.getState().advanceMobCombat(1_000);
expect(useCombatStore.getState().mobs["physical-melee"].activeCast).toMatchObject({
attackId: "spell-3604",
school: "physical",
});
expect(useCombatStore.getState().mobs["physical-ranged"].activeCast).toMatchObject({
attackId: "physical-ranged",
school: "physical",
});
expect(useCombatStore.getState().mobs["physical-spell"].activeCast).toBeNull();
expect(useCombatStore.getState().mobs["physical-kind-spell"].activeCast).toBeNull();
});
it("marks an instant source spell release without publishing a synthetic cast event", () => {
registerCaster([spell("shadow-bolt", "shadow", 0)]);
const events: CombatPresentationEvent[] = [];
@@ -0,0 +1,180 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
import {
subscribeCombatPresentation,
type CombatPresentationEvent,
} from "./combatPresentation";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { useCombatStore } from "./combatStore";
import { BARREN_CAVES_ENTITIES } from "./generated/barrenCavesPopulation.generated";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import type { EnemyAttackDefinition } from "./enemyAttacks";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
const warder = requireDungeonDefinition("blackrock-depths").entities["entry-9041"];
const wowFrostbolt = warder?.combat?.attacks.find((attack) => attack.spellId === 12_675);
const runeWakerSandStorm = BARREN_CAVES_ENTITIES["rw-barren-caves-101456"].combat.attacks
.find((attack) => "spellId" in attack && attack.spellId === 492_289);
interface EnemyHitResult {
readonly mobId: string;
readonly healthBefore: number;
readonly healthAfter: number;
readonly attack: EnemyAttackDefinition;
readonly presentation: readonly CombatPresentationEvent[];
readonly damage: readonly StructuredCombatEvent[];
}
function resolveRealEnemyAttack(
mobId: string,
attack: EnemyAttackDefinition,
): EnemyHitResult {
const presentation: CombatPresentationEvent[] = [];
const damage: StructuredCombatEvent[] = [];
const stopPresentation = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.sourceActorId === mobId) presentation.push(message.event);
});
const stopDamage = subscribeCombatEvents((event) => {
if (event.sourceActorId === mobId && event.targetActorId === PLAYER_AGGRO_ID) damage.push(event);
});
try {
useCombatStore.getState().registerMob(mobId, {
name: mobId,
level: 80,
maxHealth: 10_000,
xpReward: 0,
attacks: [{ ...attack, initialCooldownMs: [0, 0] }],
});
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().selectMob(mobId);
useCombatStore.getState().damageMob(mobId, 1, 1_000);
const healthBefore = useCombatStore.getState().health;
useCombatStore.getState().advanceMobCombat(1_000);
useCombatStore.getState().advanceMobCombat(3_000);
const healthAfter = useCombatStore.getState().health;
const normalizedAttack = useCombatStore.getState().mobs[mobId]!.attacks[0]!;
return { mobId, healthBefore, healthAfter, attack: normalizedAttack, presentation, damage };
} finally {
stopPresentation();
stopDamage();
}
}
describe("enemy damage type integration for actual imported mobs", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("enemy-damage-types", 80,
useCombatStore.getState().health, "damage", 2, "wow");
});
it("uses the imported WoW Frostbolt source and records the actual player hit", () => {
expect(wowFrostbolt).toBeDefined();
const result = resolveRealEnemyAttack("source-wow-warder", wowFrostbolt!);
expect(result.attack).toMatchObject({
spellId: 12_675,
damageOrigin: "wow335",
attackKind: "spell",
damageClass: 1,
schoolMask: 16,
school: "frost",
});
expect(result.healthAfter).toBeLessThan(result.healthBefore);
expect(result.damage.some((event) => event.abilityId === wowFrostbolt!.id
&& event.school === "frost" && event.effectiveAmount > 0)).toBe(true);
expect(result.presentation.some((event) => event.phase === "impact"
&& event.sourceSpellId === 12_675 && event.source === "wow335" && event.school === "frost")).toBe(true);
});
it("uses the imported RuneWaker physical component and records the actual player hit", () => {
expect(runeWakerSandStorm).toBeDefined();
const result = resolveRealEnemyAttack("source-runewaker-cave-bug", runeWakerSandStorm!);
expect(result.attack).toMatchObject({
spellId: 492_289,
damageOrigin: "runewaker",
runewakerEffectType: 1,
school: "physical",
runewakerFormula: { componentId: 502_503, calculationPath: "physical" },
});
expect(result.healthAfter).toBeLessThan(result.healthBefore);
expect(result.damage.some((event) => event.abilityId === runeWakerSandStorm!.id
&& event.school === "physical" && event.effectiveAmount > 0)).toBe(true);
expect(result.presentation.some((event) => event.phase === "impact"
&& event.sourceSpellId === 492_289 && event.source === "runewaker" && event.school === "physical"
&& event.delivery === "projectile")).toBe(true);
});
it("ticks an enemy periodic effect on player and party NPC, including expiry and dispel", () => {
const mobId = "periodic-effect-source";
const companion = usePartyStore.getState().members[0]!;
registerPartyRuntimePosition(companion.id, [1, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().registerMob(mobId, {
name: "Periodic Source", level: 80, maxHealth: 10_000, xpReward: 0,
attacks: [{
id: "test-frost-dot",
name: "Frosting Wound",
spellId: 12_675,
delivery: "area",
target: "nearby-party",
radius: 8,
school: "frost",
animation: "cast",
castTimeMs: 0,
range: 12,
cooldownMs: 8_000,
initialCooldownMs: [0, 0],
damageMultiplier: 0.2,
effect: { kind: "dot", multiplier: 0.2, ticks: 3, intervalMs: 1_000 },
}],
});
setMobPosition(mobId, [2, 0, 0]);
useCombatStore.getState().selectMob(mobId);
useCombatStore.getState().damageMob(mobId, 1, 1_000);
const playerHealthBefore = useCombatStore.getState().health;
const companionHealthBefore = usePartyStore.getState().members[0]!.health;
useCombatStore.getState().advanceMobCombat(1_000);
expect(useCombatStore.getState().health).toBe(playerHealthBefore);
expect(usePartyStore.getState().members[0]!.health).toBe(companionHealthBefore);
expect(useCombatStore.getState().enemyPeriodicEffects).toHaveLength(0);
expect(useCombatStore.getState().combatEvents.filter((event) => (
event.sourceActorId === mobId && event.kind === "damage"
))).toHaveLength(0);
useCombatStore.getState().advanceMobCombat(2_650);
expect(useCombatStore.getState().enemyPeriodicEffects).toHaveLength(2);
expect(useCombatStore.getState().health).toBe(playerHealthBefore);
expect(usePartyStore.getState().members[0]!.health).toBe(companionHealthBefore);
useCombatStore.getState().advanceMobCombat(3_649);
expect(useCombatStore.getState().combatEvents.filter((event) => (
event.sourceActorId === mobId && event.kind === "damage"
))).toHaveLength(0);
useCombatStore.getState().advanceMobCombat(3_650);
const firstPulses = useCombatStore.getState().combatEvents.filter((event) => (
event.sourceActorId === mobId && event.kind === "damage"
));
expect(firstPulses).toHaveLength(2);
expect(firstPulses.every((event) => event.school === "frost" && event.effectiveAmount > 0)).toBe(true);
expect(firstPulses.map((event) => event.targetActorId).sort()).toEqual([companion.id, "__player__"].sort());
expect(useCombatStore.getState().dispelActor("__player__", ["magic"])).toBe(1);
useCombatStore.getState().advanceMobCombat(4_650);
useCombatStore.getState().advanceMobCombat(5_650);
const finalPulses = useCombatStore.getState().combatEvents.filter((event) => (
event.sourceActorId === mobId && event.kind === "damage"
));
const playerPulses = finalPulses.filter((event) => event.targetActorId === "__player__");
const companionPulses = finalPulses.filter((event) => event.targetActorId === companion.id);
expect(playerPulses).toHaveLength(1);
expect(companionPulses).toHaveLength(3);
expect(companionPulses.map((event) => event.occurredAt)).toEqual([3_650, 4_650, 5_650]);
expect(useCombatStore.getState().enemyPeriodicEffects).toHaveLength(0);
});
});
File diff suppressed because it is too large. Load diff
@@ -50,6 +50,25 @@
"sha256": "b37850e91aa4172ac7c627a71ee8b2a82c5f6e7a587dcb6102e32a4fc0a85b7b",
"table": "instance_encounters"
},
{
"byteLength": 70706,
"columns": [
"ID",
"SchoolMask",
"DispelTypeMask",
"MechanicsMask",
"Effects",
"Auras",
"ImmuneAoE",
"ImmuneChain",
"Comment"
],
"file": "creature_immunities.sql",
"rows": 456,
"sha256": "aee81a1d8b99986f8498a2b4c51b64eaf4b9095a0d1a12bbf3705d9588a90a13",
"table": "creature_immunities",
"url": "https://raw.githubusercontent.com/azerothcore/azerothcore-wotlk/4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3/data/sql/base/db_world/creature_immunities.sql"
},
{
"byteLength": 5423056,
"columns": [
@@ -1 +1 @@
{"schemaVersion":1,"classId":"barbarian","abilities":[{"id":"coa-tree-30762","classId":"barbarian","dbcSpellId":500918,"name":"Ancestral Roar","unlockLevel":1,"icon":"/assets/ui/spells/_d3threateningshout.png","sigil":"AR","description":"Requires Enraged Roar with ancestral fervor, healing you for 100 + 50% AP.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22,"attackPowerCoefficient":0.5,"recipient":"caster"},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500918,"level":1,"description":"Requires Enraged Roar with ancestral fervor, healing you for 100 + 50% AP.","castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22,"attackPowerCoefficient":0.5,"recipient":"caster"},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}]}],"talentEntryId":30762},{"id":"coa-12-ancestral-spirit-47-705153","classId":"barbarian","dbcSpellId":705153,"name":"Ancestral Spirit","unlockLevel":1,"icon":"/assets/ui/spells/inv_misc_questionmark.png","sigil":"AS","description":"Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by -50%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705153,"level":1,"description":"Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by -50%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]},{"rank":2,"spellId":705154,"level":1,"description":"Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by -100%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705154:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705154:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]}]},{"id":"coa-tree-34279","classId":"barbarian","dbcSpellId":560456,"name":"Athleticism","unlockLevel":1,"icon":"/assets/ui/spells/5_run_border.png","sigil":"AT","description":"Increases your Energy regeneration by 25%.","target":"self","Line truncated
{"schemaVersion":1,"classId":"barbarian","abilities":[{"id":"coa-tree-29284","classId":"barbarian","dbcSpellId":805780,"name":"Ale of The God-King","unlockLevel":1,"icon":"/assets/ui/spells/aleofgodking.png","sigil":"AO","description":"Empower your Honored Ancestor's melee attacks to deal additional Frost damage scaling with your attack power. Lasts for 20 seconds or 10 attacks. This ability consumes Fill Level stacks and triggers a reduced cooldown based on stacks consumed.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":15000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:805780:effect:0:aura:42","name":"Ale of The God-King (Aura 42)","disposition":"buff","durationMs":20000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:805780:proc:0:805785","triggers":["hit"],"chance":1,"charges":10,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":805785}}}]},"sourceEffectType":6,"sourceAuraType":42,"miscValue":1,"triggerSpellId":805785}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":805780,"level":1,"description":"Empower your Honored Ancestor's melee attacks to deal additional Frost damage scaling with your attack power. Lasts for 20 seconds or 10 attacks. This ability consumes Fill Level stacks and triggers a reduced cooldown based on stacks consumed.","castMode":"instant","castTimeMs":0,"cooldownMs":15000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:805780:effect:0:aura:42","name":"Ale of The God-King (Aura 42)","disposition":"buff","durationMs":20000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:805780:proc:0:805785","triggers":["hit"],"chance":1,"charges":10,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":805785}}}]},"sourceEffectType":6,"sourceAuraType":42,"miscValue":1,"triggerSpellId":805785}]}],"talentEntryId":29284},{"id":"coa-tree-29799","classId":"barbarian","dbcSpellId":801782,"name":"Ancestral Combat","unlockLevel":1,"icon":"/assets/ui/spells/_d3calloftheancients.png","sigil":"AC","description":"Empower yourself, granting your Physical damage dealt a 25% chance to trigger an additional main hand attack as Frost damage on your target and grant a stack of Fill Level. Lasts until triggered 20 times.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":30000,"gcdMs":1000,"cost":{"resource":"energy","amount":60},"effects":[{"kind":"apply-aura","aura":{"id":"coa:801782:effect:0:aura:42","name":"Ancestral Combat (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:801782:proc:0:801783","triggers":["hit"],"chance":0.25,"charges":20,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":801783}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":1,"triggerSpellId":801783}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":801782,"level":1,"description":"Empower yourself, granting your Physical damage dealt a 25% chance to trigger an additional main hand attack as Frost damage on your target and grant a stack of Fill Level. Lasts until triggered 20 times.","castMode":"instant","castTimeMs":0,"cooldownMs":30000,"gcdMs":1000,"cost":{"resource":"energy","amount":60},"effects":[{"kind":"apply-aura","aura":{"id":"coa:801782:effect:0:aura:42","name":"Ancestral Combat (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:801782:proc:0:801783","triggers":["hit"],"chance":0.25,"charges":20,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":801783}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":1,"triggerSpellId":801783}]}],"talentEntryId":29799},{"id":"coa-tree-30762","classId":"barbarian","dbcSpellId":500918,"name":"Ancestral Roar","unlockLevel":1,"icon":"/assets/ui/spells/_d3threateningshout.png","sigil":"AR","description":"Requires Enraged Roar with ancestral fervor, healing you for 100 + 50% AP.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22,"attackPowerCoefficient":0.5,"recipient":"caster"},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500918,"level":1,"description":"Requires Enraged Roar with ancestral fervor, healing you for 100 + 50% AP.","castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount"Line truncated
@@ -1 +1 @@
{"schemaVersion":1,"classId":"bloodmage","abilities":[{"id":"coa-tree-29871","classId":"bloodmage","dbcSpellId":504285,"name":"Alacrity","unlockLevel":1,"icon":"/assets/ui/spells/5_run_border.png","sigil":"AL","description":"Increases all primary stats by 4%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504285:effect:0:aura:137","name":"Alacrity (Aura 137)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"all-primary","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":137,"miscValue":-1,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":504285,"level":1,"description":"Increases all primary stats by 4%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504285:effect:0:aura:137","name":"Alacrity (Aura 137)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"all-primary","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":137,"miscValue":-1,"triggerSpellId":0}]},{"rank":2,"spellId":504893,"level":1,"description":"Increases all primary stats by 8%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504893:effect:0:aura:137","name":"Alacrity (Aura 137)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"all-primary","operation":"percent","value":0.08}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":137,"miscValue":-1,"triggerSpellId":0}]}],"talentEntryId":29871},{"id":"coa-20-atheranns-wickedness-73-704642","classId":"bloodmage","dbcSpellId":704642,"name":"Atherann's Wickedness","unlockLevel":1,"icon":"/assets/ui/spells/spell_shadow_destructivesoul.png","sigil":"AS","description":"Increases your Shadow damage dealt by 4% and increases your hit chance by 3%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704642:effect:0:aura:79","name":"Atherann's Wickedness (Aura 79)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"dealt","school":"shadow","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":79,"miscValue":32,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:704642:effect:1:aura:333","name":"Atherann's Wickedness (Aura 333)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":333,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":704642,"level":1,"description":"Increases your Shadow damage dealt by 4% and increases your hit chance by 3%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704642:effect:0:aura:79","name":"Atherann's Wickedness (Aura 79)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"dealt","school":"shadow","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":79,"miscValue":32,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:704642:effect:1:aura:333","name":"Atherann's Wickedness (Aura 333)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":333,"miscValue":0,"triggerSpellId":0}]},{"rank":2,"spellId":704643,"level":1,"description":"Increases the damage of Atherann's Anguish by $s1%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704643:effect:0:aura:108","name":"Atherann's Wickedness (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}]}]},{"id":"coa-tree-8215","classId":"bloodmage","dbcSpellId":504627,"name":"Blood Bond","unlockLevel":1,"icon":"/assets/ui/spells/nhi_bloodsacrifice_border.png","sigil":"BB","description":"Bond yourself to an ally for 30 minutes, causing direct damage taken by the ally to generate 1 Rage and 3% base health and redirect -10% of their threat generated to you. Line truncated
{"schemaVersion":1,"classId":"bloodmage","abilities":[{"id":"coa-tree-29871","classId":"bloodmage","dbcSpellId":504285,"name":"Alacrity","unlockLevel":1,"icon":"/assets/ui/spells/5_run_border.png","sigil":"AL","description":"Increases all primary stats by 4%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504285:effect:0:aura:137","name":"Alacrity (Aura 137)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"all-primary","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":137,"miscValue":-1,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":504285,"level":1,"description":"Increases all primary stats by 4%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504285:effect:0:aura:137","name":"Alacrity (Aura 137)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"all-primary","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":137,"miscValue":-1,"triggerSpellId":0}]},{"rank":2,"spellId":504893,"level":1,"description":"Increases all primary stats by 8%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504893:effect:0:aura:137","name":"Alacrity (Aura 137)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"all-primary","operation":"percent","value":0.08}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":137,"miscValue":-1,"triggerSpellId":0}]}],"talentEntryId":29871},{"id":"coa-20-atheranns-wickedness-73-704642","classId":"bloodmage","dbcSpellId":704642,"name":"Atherann's Wickedness","unlockLevel":1,"icon":"/assets/ui/spells/spell_shadow_destructivesoul.png","sigil":"AS","description":"Increases your Shadow damage dealt by 4% and increases your hit chance by 3%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704642:effect:0:aura:79","name":"Atherann's Wickedness (Aura 79)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"dealt","school":"shadow","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":79,"miscValue":32,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:704642:effect:1:aura:333","name":"Atherann's Wickedness (Aura 333)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":333,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":704642,"level":1,"description":"Increases your Shadow damage dealt by 4% and increases your hit chance by 3%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704642:effect:0:aura:79","name":"Atherann's Wickedness (Aura 79)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"dealt","school":"shadow","operation":"percent","value":0.04}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":79,"miscValue":32,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:704642:effect:1:aura:333","name":"Atherann's Wickedness (Aura 333)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":333,"miscValue":0,"triggerSpellId":0}]},{"rank":2,"spellId":704643,"level":1,"description":"Increases the damage of Atherann's Anguish by $s1%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:704643:effect:0:aura:108","name":"Atherann's Wickedness (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.2}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}]}]},{"id":"coa-tree-8215","classId":"bloodmage","dbcSpellId":504627,"name":"Blood Bond","unlockLevel":1,"icon":"/assets/ui/spells/nhi_bloodsacrifice_border.png","sigil":"BB","description":"Bond yourself to an ally for 30 minutes, causing direct damage taken by the ally to generate 1 Rage and 3% base health and redirect -10% of their threat generated to you. Line truncated
@@ -1 +1 @@
{"schemaVersion":1,"classId":"guardian","abilities":[{"id":"coa-18-banner-boy-68-705358","classId":"guardian","dbcSpellId":705358,"name":"Banner Boy","unlockLevel":1,"icon":"/assets/ui/spells/achievement_bg_captureflag_wsg.png","sigil":"BB","description":"Reduces the Energy cost of Banners by $s1.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705358:effect:0:aura:107","name":"Banner Boy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":-0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:1:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:2:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":3,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705358,"level":1,"description":"Reduces the Energy cost of Banners by $s1.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705358:effect:0:aura:107","name":"Banner Boy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":-0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:1:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:2:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":3,"triggerSpellId":0}]}]},{"id":"coa-tree-11797","classId":"guardian","dbcSpellId":803683,"name":"Battle Drums","unlockLevel":1,"icon":"/assets/ui/spells/inv_archaeology_70_tauren_drum.png","sigil":"BD","description":"Place down 3 Battle Drums around you for 1 minute. Allies within 12 yards of the drums have their critical strike chance, haste, damage, and healing done increased by 10% and their damage dealt will trigger a Sound of War for their next 5 instances of damage or 30 seconds.","target":"hostile","range":{"min":0,"max":0},"radius":3,"castMode":"instant","castTimeMs":0,"cooldownMs":180000,"gcdMs":1500,"cost":{"resource":"energy","amount":50},"effects":[{"kind":"damage","coefficient":1},{"kind":"heal","coefficient":1},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":803683,"level":1,"description":"Place down 3 Battle Drums around you for 1 minute. Allies within 12 yards of the drums have their critical strike chance, haste, damage, and healing done increased by 10% and their damage dealt will trigger a Sound of War for their next 5 instances of damage or 30 seconds.","castMode":"instant","castTimeMs":0,"cooldownMs":180000,"gcdMs":1500,"cost":{"resource":"energy","amount":50},"effects":[{"kind":"damage","coefficient":1},{"kind":"heal","coefficient":1},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000},{"kind":"utility","action":"portal","effectType":50,"auraType":0,"miscValue":9000117,"triggerSpellId":0,"durationMs":60000}]}],"talentEntryId":11797},{"id":"guardian-battle-rush","classId":"guardian","dbcSpellId":802197,"name":"Battle Rush","unlockLevel":1,"icon":"/assets/ui/spells/warrior_talent_icon_skirmisher.png","sigil":"BR","description":"Charge an enemy, dealing ${$m2+0+$AP*0.09996} Physical damage and stunning them for $d.","target":"hostile","range":{"min":8,"max":25},"caLine truncated
{"schemaVersion":1,"classId":"guardian","abilities":[{"id":"coa-18-banner-boy-68-705358","classId":"guardian","dbcSpellId":705358,"name":"Banner Boy","unlockLevel":1,"icon":"/assets/ui/spells/achievement_bg_captureflag_wsg.png","sigil":"BB","description":"Reduces the Energy cost of Banners by $s1.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705358:effect:0:aura:107","name":"Banner Boy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":-0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:1:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:2:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":3,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705358,"level":1,"description":"Reduces the Energy cost of Banners by $s1.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705358:effect:0:aura:107","name":"Banner Boy (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":-0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:1:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705358:effect:2:aura:4","name":"Banner Boy (Aura 4)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","utilityTags":["coa-aura:4"]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":4,"miscValue":3,"triggerSpellId":0}]}]},{"id":"coa-tree-30434","classId":"guardian","dbcSpellId":802283,"name":"Bastion","unlockLevel":1,"icon":"/assets/ui/spells/shield_draenorcrafted_d_02_a_alliance.png","sigil":"BA","description":"Raise your shield and lead your allies within 12 yds, reducing their damage taken by -40% for 10 seconds.","target":"self","range":{"min":0,"max":0},"radius":12,"castMode":"instant","castTimeMs":0,"cooldownMs":300000,"gcdMs":0,"cost":{"resource":"energy","amount":40},"effects":[{"kind":"apply-aura","aura":{"id":"coa:802283:effect:0:aura:87","name":"Bastion (Aura 87)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":-0.4}]},"recipient":"caster","scope":"party","sourceEffectType":65,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":"trigger-spell","spellId":500256,"expanded":true},{"kind":"apply-aura","aura":{"id":"coa:500256:effect:1:aura:136","name":"Motivation (Aura 136)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"healing","direction":"done","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":136,"miscValue":127,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:500256:effect:2:aura:79","name":"Motivation (Aura 79)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"damage","direction":"dealt","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":79,"miscValue":127,"triggerSpellId":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":802283,"level":1,"description":"Raise your shield and lead your allies within 12 yds, reducing their damage taken by -40% for 10 seconds.","castMode":"instant","castTimeMs":0,"cooldownMs":300000,"gcdMs":0,"cost":{"resource":"energy","amount":40},"effects":[{"kind":"apply-aura","aura":{"id":"coa:802283:effect:0:aura:87","name":"Bastion (Aura 87)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"damage","direction":"taken","operation":"percent","value":-0.4}]},"recipient":"caster","scope":"party","sourceEffectType":65,"sourceAuraType":87,"miscValue":127,"triggerSpellId":0},{"kind":Line truncated
@@ -1 +1 @@
{"schemaVersion":1,"classId":"pyromancer","abilities":[{"id":"coa-tree-33843","classId":"pyromancer","dbcSpellId":300921,"name":"Al Dente","unlockLevel":1,"icon":"/assets/ui/spells/achievement_cooking_masteroftheoven.png","sigil":"AD","description":"Targets affected by Gaze of Ysera now take 15% increased Fire damage.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:300921:effect:0:aura:107","name":"Al Dente (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.15}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":23,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":300921,"level":1,"description":"Targets affected by Gaze of Ysera now take 15% increased Fire damage.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:300921:effect:0:aura:107","name":"Al Dente (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.15}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":23,"triggerSpellId":0}]}],"talentEntryId":33843},{"id":"coa-tree-33882","classId":"pyromancer","dbcSpellId":504397,"name":"Blessing of the Firelands","unlockLevel":1,"icon":"/assets/ui/spells/nhi_fire_soul_border.png","sigil":"BO","description":"Each stack of Heat now increases the damage dealt by Dragon Leap by 6%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504397:effect:0:aura:107","name":"Blessing of the Firelands (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.06}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":12,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:504397:effect:1:aura:107","name":"Blessing of the Firelands (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":23,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":504397,"level":1,"description":"Each stack of Heat now increases the damage dealt by Dragon Leap by 6%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:504397:effect:0:aura:107","name":"Blessing of the Firelands (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.06}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":12,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:504397:effect:1:aura:107","name":"Blessing of the Firelands (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":23,"triggerSpellId":0}]}],"talentEntryId":33882},{"id":"coa-tree-32499","classId":"pyromancer","dbcSpellId":300759,"name":"Bright Flames","unlockLevel":1,"icon":"/assets/ui/spells/spell_frost_fireresistancetotem.png","sigil":"BF","description":"Increases the amount absorbed by Inferno Barrier by 10% and the damage it deals by 15%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:300759:effect:0:aura:317","name":"Bright Flames (Aura 317)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":317,"miscValue":4,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:300759:effect:1:aura:108","name":"Bright Flames (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.15}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":300759,"level":1,"description":"Increases the amount absorbed by Inferno Barrier by 10% and the damage it deals by 15%.","caLine truncated
{"schemaVersion":1,"classId":"pyromancer","abilities":[{"id":"coa-tree-33843","classId":"pyromancer","dbcSpellId":300921,"name":"Al Dente","unlockLevel":1,"icon":"/assets/ui/spells/achievement_cooking_masteroftheoven.png","sigil":"AD","description":"Targets affected by Gaze of Ysera now take 15% increased Fire damage.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:300921:effect:0:aura:107","name":"Al Dente (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.15}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":23,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":300921,"level":1,"description":"Targets affected by Gaze of Ysera now take 15% increased Fire damage.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:300921:effect:0:aura:107","name":"Al Dente (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0.15}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":23,"triggerSpellId":0}]}],"talentEntryId":33843},{"id":"coa-tree-29556","classId":"pyromancer","dbcSpellId":802168,"name":"Aspect's Blessing","unlockLevel":1,"icon":"/assets/ui/spells/ability_deathwing_assualtaspects.png","sigil":"AS","description":"Your next Echo of Nozdormu or Flames of Neltharion is guaranteed to critically strike and triggers Aspect of Time, casting an additional time for 45% of the damage dealt. Lasts 15 seconds. Damage dealt in this way can trigger up to 3 additional times.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":0,"cost":{"resource":"mana","amount":15,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"apply-aura","aura":{"id":"coa:802168:effect:0:aura:107","name":"Aspect's Blessing (Aura 107)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":7,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:802168:effect:1:aura:107","name":"Aspect's Blessing (Aura 107)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":18,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:802168:effect:2:aura:354","name":"Aspect's Blessing (Aura 354)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":354,"miscValue":0,"triggerSpellId":503864}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":802168,"level":1,"description":"Your next Echo of Nozdormu or Flames of Neltharion is guaranteed to critically strike and triggers Aspect of Time, casting an additional time for 45% of the damage dealt. Lasts 15 seconds. Damage dealt in this way can trigger up to 3 additional times.","castMode":"instant","castTimeMs":0,"cooldownMs":60000,"gcdMs":0,"cost":{"resource":"mana","amount":15,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"apply-aura","aura":{"id":"coa:802168:effect:0:aura:107","name":"Aspect's Blessing (Aura 107)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":7,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:802168:effect:1:aura:107","name":"Aspect's Blessing (Aura 107)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":107,"miscValue":18,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:802168:effect:2:aura:354","name":"Aspect's Blessing (Aura 354)","disposition":"buff","durationMs":15000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":354,"miscValue":0,"triggerSpellId":503864}]}],"talentEntryId":29556},{"id":"coa-tree-33882","classId":"pyromancer","dbcSpellId":504397,"name":"Blessing of the Firelands","unlockLevel":1,"icoLine truncated
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"classId":"ranger","abilities":[{"id":"coa-21-agile-none-560796","classId":"ranger","dbcSpellId":560796,"name":"Agile","unlockLevel":1,"icon":"/assets/ui/spells/inv_sword_38.png","sigil":"AG","description":"Increases the damage of Wild Strike, Whipvine Arrow$?s500024[, Assault][], and Ravage by $s1%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:560796:effect:0:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:560796:effect:1:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":22,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":2,"spellId":560796,"level":1,"description":"Increases the damage of Wild Strike, Whipvine Arrow$?s500024[, Assault][], and Ravage by $s1%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:560796:effect:0:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:560796:effect:1:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":22,"triggerSpellId":0}]}]},{"id":"coa-21-archery-training-76-705036","classId":"ranger","dbcSpellId":705036,"name":"Archery Training","unlockLevel":1,"icon":"/assets/ui/spells/inv_bow_1h_draenorcrafted_d_01_a_horde.png","sigil":"AT","description":"Increases the damage of Skullpiercer by $s2% and your critical strike chance by $s1%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705036:effect:0:aura:290","name":"Archery Training (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705036:effect:1:aura:108","name":"Archery Training (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.03}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705036,"level":1,"description":"Increases the damage of Skullpiercer by $s2% and your critical strike chance by $s1%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705036:effect:0:aura:290","name":"Archery Training (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705036:effect:1:aura:108","name":"Archery Training (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.03}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0}]},{"rank":2,"spellId":705037,"level":1,"description":"Increases the damage of Skullpiercer by $s2% and your critical strike chance by $s1%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705037:effect:0:aura:290","name":"Archery Training (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705037:effect:1:aura:108","name":"Archery Training (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior"Line truncated
{"schemaVersion":1,"classId":"ranger","abilities":[{"id":"coa-tree-6946","classId":"ranger","dbcSpellId":520578,"name":"Adrenaline Rush","unlockLevel":1,"icon":"/assets/ui/spells/5_archerskill19_border.png","sigil":"AR","description":"Surge with vitality, dispeling all harmful effects from yourself and increasing your movement speed by 40% for 10 seconds. Usable while stealthed.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":90000,"gcdMs":0,"cost":{"resource":"focus","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:520578:effect:0:aura:31","name":"Adrenaline Rush (Aura 31)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"movement-speed","operation":"percent","value":0.4}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":31,"miscValue":0,"triggerSpellId":0},{"kind":"dispel","mode":"cleanse","relationship":"friendly","categories":["magic","curse","poison","disease","enrage"],"maxCount":100}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":520578,"level":1,"description":"Surge with vitality, dispeling all harmful effects from yourself and increasing your movement speed by 40% for 10 seconds. Usable while stealthed.","castMode":"instant","castTimeMs":0,"cooldownMs":90000,"gcdMs":0,"cost":{"resource":"focus","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:520578:effect:0:aura:31","name":"Adrenaline Rush (Aura 31)","disposition":"buff","durationMs":10000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"movement-speed","operation":"percent","value":0.4}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":31,"miscValue":0,"triggerSpellId":0},{"kind":"dispel","mode":"cleanse","relationship":"friendly","categories":["magic","curse","poison","disease","enrage"],"maxCount":100}]}],"talentEntryId":6946},{"id":"coa-21-agile-none-560796","classId":"ranger","dbcSpellId":560796,"name":"Agile","unlockLevel":1,"icon":"/assets/ui/spells/inv_sword_38.png","sigil":"AG","description":"Increases the damage of Wild Strike, Whipvine Arrow$?s500024[, Assault][], and Ravage by $s1%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:560796:effect:0:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:560796:effect:1:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":22,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":2,"spellId":560796,"level":1,"description":"Increases the damage of Wild Strike, Whipvine Arrow$?s500024[, Assault][], and Ravage by $s1%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:560796:effect:0:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:560796:effect:1:aura:108","name":"Agile (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":22,"triggerSpellId":0}]}]},{"id":"coa-21-archery-training-76-705036","classId":"ranger","dbcSpellId":705036,"name":"Archery Training","unlockLevel":1,"icon":"/assets/ui/spells/inv_bow_1h_draenorcrafted_d_01_a_horde.png","sigil":"AT","description":"Increases the damage of Skullpiercer by $s2% and your critical strike chance by $s1%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"rage","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705036:effect:0:aura:290","name":"Archery Training (Aura 290)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh"},"recipient":"caster","sourceEffectType":6,"sourceAuraType":290,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705036:effect:1:aura:108","name":"Archery Training (Aura 108)","Line truncated
@@ -1 +1 @@
{"schemaVersion":1,"classId":"witch-doctor","abilities":[{"id":"coa-tree-30147","classId":"witch-doctor","dbcSpellId":705921,"name":"Alchemical Enhancement","unlockLevel":1,"icon":"/assets/ui/spells/inv_alchemy_71_potion4.png","sigil":"AE","description":"Increases your haste by 5%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":705921,"level":1,"description":"Increases your haste by 5%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}]}],"talentEntryId":30147},{"id":"coa-tree-11323","classId":"witch-doctor","dbcSpellId":301297,"name":"Blatant Curse","unlockLevel":1,"icon":"/assets/ui/spells/spell_nature_removecurse.png","sigil":"BC","description":"Reduces the mana cost of your Jinxes by -25%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:301297:effect:0:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:301297:effect:1:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":301297,"level":1,"description":"Reduces the mana cost of your Jinxes by -25%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:301297:effect:0:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":8,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:301297:effect:1:aura:108","name":"Blatant Curse (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"curse","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.25}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0}]}],"talentEntryId":11323},{"id":"coa-tree-12048","classId":"witch-doctor","dbcSpellId":572870,"name":"Blessing of Hir'eek","unlockLevel":1,"icon":"/assets/ui/spells/trade_archaeology_trollbatidol.png","sigil":"BO","description":"Your Hexbreak now removes an additional curse effect.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"mana","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:572870:effect:0:aura:107","name":"Blessing of Hir'eek (Aura 107)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"Line truncated
{"schemaVersion":1,"classId":"witch-doctor","abilities":[{"id":"coa-tree-30147","classId":"witch-doctor","dbcSpellId":705921,"name":"Alchemical Enhancement","unlockLevel":1,"icon":"/assets/ui/spells/inv_alchemy_71_potion4.png","sigil":"AE","description":"Increases your haste by 5%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":705921,"level":1,"description":"Increases your haste by 5%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705921:effect:0:aura:216","name":"Alchemical Enhancement (Aura 216)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":216,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705921:effect:1:aura:192","name":"Alchemical Enhancement (Aura 192)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"haste","operation":"percent","value":0.05}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":192,"miscValue":127,"triggerSpellId":0}]}],"talentEntryId":30147},{"id":"coa-tree-6031","classId":"witch-doctor","dbcSpellId":500952,"name":"Amphibimorph","unlockLevel":1,"icon":"/assets/ui/spells/nhi_hypnotoad_border.png","sigil":"AM","description":"Transform enemies at target location into a frog, making them unable to attack or cast spells, and slowing their movement speed by -25% for 40 seconds (8 sec vs players). While transformed into a frog the enemy is considered a Beast. Damage caused may interrupt the effect.","target":"self","range":{"min":0,"max":0},"radius":8,"castMode":"cast","castTimeMs":1000,"cooldownMs":120000,"gcdMs":0,"cost":{"resource":"mana","amount":18,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"apply-aura","aura":{"id":"coa:500952:effect:0:aura:56","name":"Amphibimorph (Aura 56)","disposition":"buff","durationMs":40000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"control":{"kind":"confuse"},"sourceEffectType":6,"sourceAuraType":56,"miscValue":216377,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:500952:effect:1:aura:60","name":"Amphibimorph (Aura 60)","disposition":"buff","durationMs":40000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"control":{"kind":"silence"},"sourceEffectType":6,"sourceAuraType":60,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:500952:effect:2:aura:33","name":"Amphibimorph (Aura 33)","disposition":"buff","durationMs":40000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","modifiers":[{"kind":"stat","stat":"movement-speed","operation":"percent","value":-0.25}]},"sourceEffectType":6,"sourceAuraType":33,"miscValue":0,"triggerSpellId":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500952,"level":1,"description":"Transform enemies at target location into a frog, making them unable to attack or cast spells, and slowing their movement speed by -25% for 40 seconds (8 sec vs players). While transformed into a frog the enemy is considered a Beast. Damage caused may interrupt the effect.","castMode":"cast","castTimeMs":1000,"cooldownMs":120000,"gcdMs":0,"cost":{"resource":"mana","amount":18,"percentage":true,"percentageBasis":"base-mana"},"effects":[{"kind":"apply-aura","aura":{"id":"coa:500952:effect:0:aura:56","name":"Amphibimorph (Aura 56)","disposition":"buff","durationMs":40000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"control":{"kind":"confuse"},"sourceEffectType":6,"sourceAuraType":56,"miscValue":216377,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:500952:effect:1:aura:60","name":"Amphibimorph (Aura 60)","disposition":"buff","durationMs":40000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic"},"control":{"kind":"silence"},"sourceEffectType":6,"sourceAuraType":60,"miscValue":0,"triggerSpellId":0},{"kind":"apply-aurLine truncated
@@ -1 +1 @@
{"schemaVersion":1,"abilitiesByClass":{"barbarian":[{"id":"coa-tree-30762","classId":"barbarian","dbcSpellId":500918,"name":"Ancestral Roar","unlockLevel":1,"icon":"/assets/ui/spells/_d3threateningshout.png","sigil":"AR","description":"Requires Enraged Roar with ancestral fervor, healing you for 100 + 50% AP.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22,"attackPowerCoefficient":0.5,"recipient":"caster"},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500918,"level":1,"description":"Requires Enraged Roar with ancestral fervor, healing you for 100 + 50% AP.","castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22,"attackPowerCoefficient":0.5,"recipient":"caster"},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}]}],"talentEntryId":30762},{"id":"coa-12-ancestral-spirit-47-705153","classId":"barbarian","dbcSpellId":705153,"name":"Ancestral Spirit","unlockLevel":1,"icon":"/assets/ui/spells/inv_misc_questionmark.png","sigil":"AS","description":"Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by -50%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705153,"level":1,"description":"Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by -50%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]},{"rank":2,"spellId":705154,"level":1,"description":"Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by -100%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":0,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705154:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705154:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]}]},{"id":"coa-tree-34279","classId":"barbarian","dbcSpellId":560456,"name":"Athleticism","unlockLevel":1,"icon":"/assets/ui/spells/5_run_border.png","sigil":"AT","description":"Increases your Energy regeneration by 25%.","target":"self","raLine truncated
{"schemaVersion":1,"abilitiesByClass":{"barbarian":[{"id":"coa-tree-29284","classId":"barbarian","dbcSpellId":805780,"name":"Ale of The God-King","unlockLevel":1,"icon":"/assets/ui/spells/aleofgodking.png","sigil":"AO","description":"Empower your Honored Ancestor's melee attacks to deal additional Frost damage scaling with your attack power. Lasts for 20 seconds or 10 attacks. This ability consumes Fill Level stacks and triggers a reduced cooldown based on stacks consumed.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":15000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:805780:effect:0:aura:42","name":"Ale of The God-King (Aura 42)","disposition":"buff","durationMs":20000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:805780:proc:0:805785","triggers":["hit"],"chance":1,"charges":10,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":805785}}}]},"sourceEffectType":6,"sourceAuraType":42,"miscValue":1,"triggerSpellId":805785}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":805780,"level":1,"description":"Empower your Honored Ancestor's melee attacks to deal additional Frost damage scaling with your attack power. Lasts for 20 seconds or 10 attacks. This ability consumes Fill Level stacks and triggers a reduced cooldown based on stacks consumed.","castMode":"instant","castTimeMs":0,"cooldownMs":15000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:805780:effect:0:aura:42","name":"Ale of The God-King (Aura 42)","disposition":"buff","durationMs":20000,"maxStacks":1,"stackBehavior":"refresh","dispelCategory":"magic","procs":[{"id":"coa:805780:proc:0:805785","triggers":["hit"],"chance":1,"charges":10,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":805785}}}]},"sourceEffectType":6,"sourceAuraType":42,"miscValue":1,"triggerSpellId":805785}]}],"talentEntryId":29284},{"id":"coa-tree-29799","classId":"barbarian","dbcSpellId":801782,"name":"Ancestral Combat","unlockLevel":1,"icon":"/assets/ui/spells/_d3calloftheancients.png","sigil":"AC","description":"Empower yourself, granting your Physical damage dealt a 25% chance to trigger an additional main hand attack as Frost damage on your target and grant a stack of Fill Level. Lasts until triggered 20 times.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":30000,"gcdMs":1000,"cost":{"resource":"energy","amount":60},"effects":[{"kind":"apply-aura","aura":{"id":"coa:801782:effect:0:aura:42","name":"Ancestral Combat (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:801782:proc:0:801783","triggers":["hit"],"chance":0.25,"charges":20,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":801783}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":1,"triggerSpellId":801783}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":801782,"level":1,"description":"Empower yourself, granting your Physical damage dealt a 25% chance to trigger an additional main hand attack as Frost damage on your target and grant a stack of Fill Level. Lasts until triggered 20 times.","castMode":"instant","castTimeMs":0,"cooldownMs":30000,"gcdMs":1000,"cost":{"resource":"energy","amount":60},"effects":[{"kind":"apply-aura","aura":{"id":"coa:801782:effect:0:aura:42","name":"Ancestral Combat (Aura 42)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","procs":[{"id":"coa:801782:proc:0:801783","triggers":["hit"],"chance":0.25,"charges":20,"action":{"kind":"custom","id":"wow335-trigger-spell","data":{"spellId":801783}}}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":42,"miscValue":1,"triggerSpellId":801783}]}],"talentEntryId":29799},{"id":"coa-tree-30762","classId":"barbarian","dbcSpellId":500918,"name":"Ancestral Roar","unlockLevel":1,"icon":"/assets/ui/spells/_d3threateningshout.png","sigil":"AR","description":"Requires Enraged Roar with ancestral fervor, healing you for 100 + 50% AP.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22,"attackPowerCoefficient":0.5,"recipient":"caster"},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500918,"level":1,"description":"Requires Enraged Roar with ancestral fervor, healing you for 100 + 50% AP.","castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0Line truncated
@@ -1 +1 @@
{"schemaVersion":1,"source":{"spellDbcPath":"client-current/patch-T/DBFilesClient/Spell.dbc","sha256":"c4601cb80e2fb58dd10af3da82e2fdea653d4462714e30fdf48db6ba4a152794"},"spells":[{"id":1604,"name":"Dazed","rankText":"","description":"A concussive strike dismounts the target and reduces movement speed by $s1%.","categoryId":0,"dispelType":0,"mechanic":27,"attributes":[16,262144,0,0,1,0,0,0],"castTimeIndex":1,"castTimeMs":0,"recoveryTimeMs":0,"categoryRecoveryTimeMs":0,"interruptFlags":0,"auraInterruptFlags":0,"channelInterruptFlags":0,"procFlags":0,"procChance":101,"procCharges":0,"maxLevel":0,"baseLevel":0,"spellLevel":0,"durationIndex":35,"durationMs":4000,"powerType":0,"powerCost":0,"powerCostPerLevel":0,"powerCostPerSecond":0,"powerCostPerSecondPerLevel":0,"rangeIndex":2,"rangeMin":0,"rangeMax":5,"projectileSpeed":0,"stackAmount":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"equippedItemInventoryMask":0,"iconId":15,"powerCostPercentage":0,"startRecoveryCategory":0,"startRecoveryTimeMs":0,"maximumTargets":0,"damageClass":0,"preventionType":0,"schoolMask":1,"runeCostId":0,"powerDisplayId":0,"effects":[{"index":0,"effectType":6,"auraType":33,"basePoints":-50,"dieSides":1,"pointsPerLevel":0,"mechanic":27,"implicitTargetA":6,"implicitTargetB":0,"radiusIndex":0,"radius":0,"periodMs":0,"valueMultiplier":0,"chainTargets":0,"itemType":0,"miscValue":0,"miscValueB":0,"triggerSpellId":0,"pointsPerCombo":0,"coefficient":0},{"index":1,"effectType":108,"auraType":0,"basePoints":1,"dieSides":1,"pointsPerLevel":0,"mechanic":0,"implicitTargetA":6,"implicitTargetB":0,"radiusIndex":0,"radius":0,"periodMs":0,"valueMultiplier":0,"chainTargets":0,"itemType":0,"miscValue":21,"miscValueB":0,"triggerSpellId":0,"pointsPerCombo":0,"coefficient":1}]},{"id":32991,"name":"Nature Cast Visual","rankText":"Rank 1","description":"","categoryId":0,"dispelType":0,"mechanic":0,"attributes":[65920,0,0,0,0,0,0,0],"castTimeIndex":6,"castTimeMs":5000,"recoveryTimeMs":0,"categoryRecoveryTimeMs":0,"interruptFlags":15,"auraInterruptFlags":524290,"channelInterruptFlags":0,"procFlags":0,"procChance":101,"procCharges":0,"maxLevel":0,"baseLevel":1,"spellLevel":1,"durationIndex":0,"durationMs":0,"powerType":0,"powerCost":0,"powerCostPerLevel":0,"powerCostPerSecond":0,"powerCostPerSecondPerLevel":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"projectileSpeed":0,"stackAmount":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"equippedItemInventoryMask":0,"iconId":82,"powerCostPercentage":0,"startRecoveryCategory":133,"startRecoveryTimeMs":0,"maximumTargets":0,"damageClass":1,"preventionType":1,"schoolMask":8,"runeCostId":0,"powerDisplayId":0,"effects":[{"index":0,"effectType":3,"auraType":0,"basePoints":1,"dieSides":0,"pointsPerLevel":0,"mechanic":0,"implicitTargetA":1,"implicitTargetB":0,"radiusIndex":0,"radius":0,"periodMs":0,"valueMultiplier":0,"chainTargets":0,"itemType":0,"miscValue":0,"miscValueB":0,"triggerSpellId":0,"pointsPerCombo":0,"coefficient":0}]},{"id":35764,"name":"Astral Armor Visual","rankText":"","description":"","categoryId":0,"dispelType":0,"mechanic":0,"attributes":[262528,0,0,0,0,0,0,0],"castTimeIndex":1,"castTimeMs":0,"recoveryTimeMs":0,"categoryRecoveryTimeMs":0,"interruptFlags":0,"auraInterruptFlags":0,"channelInterruptFlags":0,"procFlags":0,"procChance":101,"procCharges":0,"maxLevel":0,"baseLevel":0,"spellLevel":0,"durationIndex":0,"durationMs":0,"powerType":0,"powerCost":0,"powerCostPerLevel":0,"powerCostPerSecond":0,"powerCostPerSecondPerLevel":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"projectileSpeed":0,"stackAmount":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"equippedItemInventoryMask":0,"iconId":1918,"powerCostPercentage":0,"startRecoveryCategory":0,"startRecoveryTimeMs":0,"maximumTargets":0,"damageClass":0,"preventionType":0,"schoolMask":64,"runeCostId":0,"powerDisplayId":0,"effects":[{"index":0,"effectType":3,"auraType":0,"basePoints":1,"dieSides":0,"pointsPerLevel":0,"mechanic":0,"implicitTargetA":1,"implicitTargetB":0,"radiusIndex":16,"radius":1,"periodMs":0,"valueMultiplier":0,"chainTargets":0,"itemType":0,"miscValue":0,"miscValueB":0,"triggerSpellId":0,"pointsPerCombo":0,"coefficient":0}]},{"id":48108,"name":"Hot Streak","rankText":"","description":"Your next Pyroblast is instant.","categoryId":0,"dispelType":0,"mechanic":0,"attributes":[0,0,0,0,0,0,64,0],"castTimeIndex":1,"castTimeMs":0,"recoveryTimeMs":0,"categoryRecoveryTimeMs":0,"interruptFlags":0,"auraInterruptFlags":0,"channelInterruptFlags":0,"procFlags":0,"procChance":100,"procCharges":1,"maxLevel":0,"baseLevel":0,"spellLevel":0,"durationIndex":1,"durationMs":10000,"powerType":0,"powerCost":0,"powerCostPerLevel":0,"powerCostPerSecond":0,"powerCostPerSecondPerLevel":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"projectileSpeed":0,"stackAmount":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"equippedItemInventoryMask":0,"iconId":2999,"powerCostPercentage":0,"startRecoveryCategory":0,"startRecoveryTimeMs":0,"maximumTargets":0,"damageLine truncated
{"schemaVersion":1,"source":{"spellDbcPath":"client-current/patch-T/DBFilesClient/Spell.dbc","sha256":"c4601cb80e2fb58dd10af3da82e2fdea653d4462714e30fdf48db6ba4a152794"},"spells":[{"id":1604,"name":"Dazed","rankText":"","description":"A concussive strike dismounts the target and reduces movement speed by $s1%.","categoryId":0,"dispelType":0,"mechanic":27,"attributes":[16,262144,0,0,1,0,0,0],"castTimeIndex":1,"castTimeMs":0,"recoveryTimeMs":0,"categoryRecoveryTimeMs":0,"interruptFlags":0,"auraInterruptFlags":0,"channelInterruptFlags":0,"procFlags":0,"procChance":101,"procCharges":0,"maxLevel":0,"baseLevel":0,"spellLevel":0,"durationIndex":35,"durationMs":4000,"powerType":0,"powerCost":0,"powerCostPerLevel":0,"powerCostPerSecond":0,"powerCostPerSecondPerLevel":0,"rangeIndex":2,"rangeMin":0,"rangeMax":5,"projectileSpeed":0,"stackAmount":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"equippedItemInventoryMask":0,"iconId":15,"powerCostPercentage":0,"startRecoveryCategory":0,"startRecoveryTimeMs":0,"maximumTargets":0,"damageClass":0,"preventionType":0,"schoolMask":1,"runeCostId":0,"powerDisplayId":0,"effects":[{"index":0,"effectType":6,"auraType":33,"basePoints":-50,"dieSides":1,"pointsPerLevel":0,"mechanic":27,"implicitTargetA":6,"implicitTargetB":0,"radiusIndex":0,"radius":0,"periodMs":0,"valueMultiplier":0,"chainTargets":0,"itemType":0,"miscValue":0,"miscValueB":0,"triggerSpellId":0,"pointsPerCombo":0,"coefficient":0},{"index":1,"effectType":108,"auraType":0,"basePoints":1,"dieSides":1,"pointsPerLevel":0,"mechanic":0,"implicitTargetA":6,"implicitTargetB":0,"radiusIndex":0,"radius":0,"periodMs":0,"valueMultiplier":0,"chainTargets":0,"itemType":0,"miscValue":21,"miscValueB":0,"triggerSpellId":0,"pointsPerCombo":0,"coefficient":1}]},{"id":32991,"name":"Nature Cast Visual","rankText":"Rank 1","description":"","categoryId":0,"dispelType":0,"mechanic":0,"attributes":[65920,0,0,0,0,0,0,0],"castTimeIndex":6,"castTimeMs":5000,"recoveryTimeMs":0,"categoryRecoveryTimeMs":0,"interruptFlags":15,"auraInterruptFlags":524290,"channelInterruptFlags":0,"procFlags":0,"procChance":101,"procCharges":0,"maxLevel":0,"baseLevel":1,"spellLevel":1,"durationIndex":0,"durationMs":0,"powerType":0,"powerCost":0,"powerCostPerLevel":0,"powerCostPerSecond":0,"powerCostPerSecondPerLevel":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"projectileSpeed":0,"stackAmount":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"equippedItemInventoryMask":0,"iconId":82,"powerCostPercentage":0,"startRecoveryCategory":133,"startRecoveryTimeMs":0,"maximumTargets":0,"damageClass":1,"preventionType":1,"schoolMask":8,"runeCostId":0,"powerDisplayId":0,"effects":[{"index":0,"effectType":3,"auraType":0,"basePoints":1,"dieSides":0,"pointsPerLevel":0,"mechanic":0,"implicitTargetA":1,"implicitTargetB":0,"radiusIndex":0,"radius":0,"periodMs":0,"valueMultiplier":0,"chainTargets":0,"itemType":0,"miscValue":0,"miscValueB":0,"triggerSpellId":0,"pointsPerCombo":0,"coefficient":0}]},{"id":35764,"name":"Astral Armor Visual","rankText":"","description":"","categoryId":0,"dispelType":0,"mechanic":0,"attributes":[262528,0,0,0,0,0,0,0],"castTimeIndex":1,"castTimeMs":0,"recoveryTimeMs":0,"categoryRecoveryTimeMs":0,"interruptFlags":0,"auraInterruptFlags":0,"channelInterruptFlags":0,"procFlags":0,"procChance":101,"procCharges":0,"maxLevel":0,"baseLevel":0,"spellLevel":0,"durationIndex":0,"durationMs":0,"powerType":0,"powerCost":0,"powerCostPerLevel":0,"powerCostPerSecond":0,"powerCostPerSecondPerLevel":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"projectileSpeed":0,"stackAmount":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"equippedItemInventoryMask":0,"iconId":1918,"powerCostPercentage":0,"startRecoveryCategory":0,"startRecoveryTimeMs":0,"maximumTargets":0,"damageClass":0,"preventionType":0,"schoolMask":64,"runeCostId":0,"powerDisplayId":0,"effects":[{"index":0,"effectType":3,"auraType":0,"basePoints":1,"dieSides":0,"pointsPerLevel":0,"mechanic":0,"implicitTargetA":1,"implicitTargetB":0,"radiusIndex":16,"radius":1,"periodMs":0,"valueMultiplier":0,"chainTargets":0,"itemType":0,"miscValue":0,"miscValueB":0,"triggerSpellId":0,"pointsPerCombo":0,"coefficient":0}]},{"id":48108,"name":"Hot Streak","rankText":"","description":"Your next Pyroblast is instant.","categoryId":0,"dispelType":0,"mechanic":0,"attributes":[0,0,0,0,0,0,64,0],"castTimeIndex":1,"castTimeMs":0,"recoveryTimeMs":0,"categoryRecoveryTimeMs":0,"interruptFlags":0,"auraInterruptFlags":0,"channelInterruptFlags":0,"procFlags":0,"procChance":100,"procCharges":1,"maxLevel":0,"baseLevel":0,"spellLevel":0,"durationIndex":1,"durationMs":10000,"powerType":0,"powerCost":0,"powerCostPerLevel":0,"powerCostPerSecond":0,"powerCostPerSecondPerLevel":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"projectileSpeed":0,"stackAmount":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"equippedItemInventoryMask":0,"iconId":2999,"powerCostPercentage":0,"startRecoveryCategory":0,"startRecoveryTimeMs":0,"maximumTargets":0,"damageLine truncated
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
{"schemaVersion":1,"source":{"npcObjectSha256":"b627b6eb269c30576a6558000a31abe2db472689ef9a60cc1c1e20207abd69f1","magicCollectSha256":"4aae19865cb07b30f99945aa7319a0096db244ff5e5b4b5d902fc6889474f501","magicObjectSha256":"85723d78da84f2f46761d29aa546262bfee941ec93ac05be97c492b58c9cae17","vocationTableSha256":"f06cdaf60c54415dcbea47e9fa0e77b7527035a2aec73d3289370dc39751e76c","raceTableSha256":"dc7153ff6959d1af3d51930d32b822ed6f8b6cea849ab928384cf70b5603835b","sexTableSha256":"47f974db03afbd9fb237f0e3caacb8a836e59bb068ce2ee81e20a1dc632b8f68","fixTableSha256":"aaf75496b90972c054bb734ee371c809e512b46ffb327f405d17842ed88effee","skillBaseTableSha256":"1d844e70a251b7197046644e2b1caf1e850c647ce98b811172ec4df993d5008f","jobSkillTableSha256":"0a8fe1b617e5821a3b38818059151999bbe2110cc500fdd471233bef6e16986f","npcRecordSize":2224,"npcRaceOffset":176,"npcSpellStartOffset":568,"npcSpellStride":28,"npcEquipmentOffsets":{"mainHand":412,"offHand":432},"npcMagicResistanceOffset":888,"npcFixedEquipmentOffsets":{"bow":1260,"ornament":1648},"npcClassFixTable":0,"playerFixTable":1,"playerSkillPolicy":"simulator-max-trained-by-source-tables","playerBindingCatalogs":["romNativeHolyHealBindings.generated.json","romNativePeriodicDamageBindings.generated.json"]},"sourceRules":{"npcClasses":{"0":{"sourceRecordId":301000,"growth":{"strength":{"levelOne":14,"flat":5,"percent":0.02},"stamina":{"levelOne":14,"flat":5,"percent":0.02},"intellect":{"levelOne":14,"flat":5,"percent":0.02},"wisdom":{"levelOne":14,"flat":5,"percent":0.02},"agility":{"levelOne":14,"flat":5,"percent":0.02},"health":{"levelOne":60,"flat":2,"percent":0.02},"mana":{"levelOne":20,"flat":2,"percent":0.02}},"coefficients":{"hpStrength":1,"hpStamina":5,"mpIntellect":1,"mpWisdom":5,"attackStrength":2,"attackIntellect":0,"attackAgility":0,"defenseStamina":2,"baseHealthRecovery":2,"baseManaRecovery":0,"healthRecoveryStamina":0.15,"manaRecoveryWisdom":0.3,"magicalAttackIntellect":2,"magicalDefenseWisdom":2},"damageRate":1},"1":{"sourceRecordId":301002,"growth":{"strength":{"levelOne":14,"flat":5,"percent":0.02},"stamina":{"levelOne":13,"flat":4.5,"percent":0.02},"intellect":{"levelOne":5,"flat":1.9,"percent":0.02},"wisdom":{"levelOne":14,"flat":4,"percent":0.02},"agility":{"levelOne":12,"flat":4.25,"percent":0.02},"health":{"levelOne":60,"flat":2,"percent":0.02},"mana":{"levelOne":20,"flat":2,"percent":0.02}},"coefficients":{"hpStrength":0.2,"hpStamina":5,"mpIntellect":1,"mpWisdom":5,"attackStrength":2,"attackIntellect":0,"attackAgility":0,"defenseStamina":2.3,"baseHealthRecovery":2,"baseManaRecovery":0,"healthRecoveryStamina":0.15,"manaRecoveryWisdom":0.3,"magicalAttackIntellect":2,"magicalDefenseWisdom":2},"damageRate":1},"2":{"sourceRecordId":301004,"growth":{"strength":{"levelOne":11,"flat":3.85,"percent":0.02},"stamina":{"levelOne":10,"flat":3.65,"percent":0.02},"intellect":{"levelOne":10,"flat":3.6,"percent":0.02},"wisdom":{"levelOne":14,"flat":4,"percent":0.02},"agility":{"levelOne":13,"flat":4.5,"percent":0.02},"health":{"levelOne":60,"flat":2,"percent":0.02},"mana":{"levelOne":20,"flat":2,"percent":0.02}},"coefficients":{"hpStrength":0.2,"hpStamina":5,"mpIntellect":1,"mpWisdom":5,"attackStrength":1,"attackIntellect":0,"attackAgility":1,"defenseStamina":1.8,"baseHealthRecovery":2,"baseManaRecovery":0,"healthRecoveryStamina":0.15,"manaRecoveryWisdom":0.3,"magicalAttackIntellect":2,"magicalDefenseWisdom":2},"damageRate":1},"3":{"sourceRecordId":301006,"growth":{"strength":{"levelOne":12,"flat":4.15,"percent":0.02},"stamina":{"levelOne":11,"flat":3.9,"percent":0.02},"intellect":{"levelOne":7,"flat":2.6,"percent":0.02},"wisdom":{"levelOne":14,"flat":4,"percent":0.02},"agility":{"levelOne":14,"flat":5,"percent":0.02},"health":{"levelOne":60,"flat":2,"percent":0.02},"mana":{"levelOne":20,"flat":2,"percent":0.02}},"coefficients":{"hpStrength":0.2,"hpStamina":5,"mpIntellect":1,"mpWisdom":5,"attackStrength":1.2,"attackIntellect":0,"attackAgility":1.3,"defenseStamina":1.8,"baseHealthRecovery":2,"baseManaRecovery":0,"healthRecoveryStamina":0.15,"manaRecoveryWisdom":0.3,"magicalAttackIntellect":2,"magicalDefenseWisdom":2},"damageRate":1},"4":{"sourceRecordId":301008,"growth":{"strength":{"levelOne":5,"flat":1.9,"percent":0.02},"stamina":{"levelOne":9,"flat":3.4,"percent":0.02},"intellect":{"levelOne":14,"flat":5,"percent":0.02},"wisdom":{"levelOne":14,"flat":5,"percent":0.02},"agility":{"levelOne":10,"flat":3.5,"percent":0.02},"health":{"levelOne":60,"flat":2,"percent":0.02},"mana":{"levelOne":20,"flat":2,"percent":0.02}},"coefficients":{"hpStrength":0.2,"hpStamina":5,"mpIntellect":1,"mpWisdom":5,"attackStrength":0.8,"attackIntellect":0.5,"attackAgility":0,"defenseStamina":1.5,"baseHealthRecovery":2,"baseManaRecovery":0,"healthRecoveryStamina":0.15,"manaRecoveryWisdom":0.3,"magicalAttackIntellect":2,"magicalDefenseWisdom":2},"damageRate":1},"5":{"sourceRecordId":301010,"growth":{"strength":{"levelOne":7,"flat":2.5,"percent":0.02},"stamina":{"levelOne":10,"flat":3.6,"percent":0Line truncated
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+6 -2
View File
@@ -281,11 +281,15 @@ function heirloomItem(
});
}
const stableNumber = [...`${profile.id}:${slot}`].reduce((hash, char) => (hash * 31 + char.charCodeAt(0)) >>> 0, 17);
// RoM has no heirloom items. The shared fallback supplies automatic training
// gear until equipment progression is introduced; retain its stable IDs and
// internal quality so this label change cannot alter saved builds or budgets.
const romTrainingGear = isRomClassId(context.classId);
const result: InventoryItem = Object.freeze({
instanceId: `heirloom:${context.ownerId}:${profile.id}:${slot}`,
itemId: 9_000_000 + stableNumber % 900_000,
itemKey: `heirloom-${profile.id.replace(/:/g, "-")}-${slot}`,
name: `${profile.displayName} Heirloom ${SLOT_NAMES[slot]}`,
name: `${profile.displayName} ${romTrainingGear ? "Training" : "Heirloom"} ${SLOT_NAMES[slot]}`,
quality: "heirloom",
inventorySlot,
armorType,
@@ -295,7 +299,7 @@ function heirloomItem(
stats,
quantity: 1,
sourceBossKey: "heirloom",
sourceBossName: "Heirloom Armory",
sourceBossName: romTrainingGear ? "Training Armory" : "Heirloom Armory",
acquiredAt: 0,
});
itemCache.set(cacheKey, result);
+219
View File
@@ -0,0 +1,219 @@
import { describe, expect, it } from "vitest";
import { normalizeEnemyAttack } from "./enemyAttacks";
import {
enemyDamageTypeComponentForSpell,
enemyDamageTypeSourceForSpell,
resolveEnemyDamageType,
wowSchoolsFromSchoolMask,
} from "./mobDamageTypes";
describe("enemy damage type source metadata", () => {
it("resolves a direct WoW spell from the imported WotLK DBC", () => {
expect(enemyDamageTypeSourceForSpell("wow335", 12_675)).toMatchObject({
spellId: 12_675,
name: "Frostbolt",
damageClass: 1,
schoolMask: 16,
schools: ["frost"],
school: "frost",
attackKind: "spell",
});
expect(resolveEnemyDamageType({
origin: "wow335",
spellId: 12_675,
school: "physical",
})).toMatchObject({
attackKind: "spell",
sourceDamageClass: "magic",
school: "frost",
schools: ["frost"],
sourceVerified: true,
});
});
it("keeps multi-school masks intact and uses authored school as the runtime fallback", () => {
expect(wowSchoolsFromSchoolMask(20)).toEqual({
schools: ["fire", "frost"],
unknownBits: 0,
});
expect(resolveEnemyDamageType({
origin: "wow335",
spellId: 12_675,
schoolMask: 20,
school: "shadow",
})).toMatchObject({
schoolMask: 20,
schools: ["fire", "frost"],
school: "shadow",
});
});
it("preserves authored school for missing or non-damaging utility spells", () => {
expect(enemyDamageTypeSourceForSpell("wow335", 48_258)).toBeUndefined();
expect(enemyDamageTypeSourceForSpell("wow335", 48_331)).toBeUndefined();
expect(resolveEnemyDamageType({
origin: "wow335",
spellId: 48_258,
school: "fire",
})).toEqual({
schools: [],
school: "fire",
sourceVerified: false,
});
expect(resolveEnemyDamageType({
origin: "wow335",
damageClass: 3,
schoolMask: 0,
school: "nature",
})).toMatchObject({
attackKind: "ranged",
sourceDamageClass: "ranged",
schools: [],
school: "nature",
});
});
it("keeps RuneWaker packet class, MagicType channel, and raw EffectType separate", () => {
expect(enemyDamageTypeSourceForSpell("runewaker", 12_675)).toBeUndefined();
const holySeal = enemyDamageTypeComponentForSpell(490_149, 500_142);
expect(holySeal).toMatchObject({
calculationType: 0,
effectType: 0,
magicType: 4,
sourceDamageClass: "physical",
attackKind: "melee",
sourceChannel: "light",
school: "holy",
});
expect(resolveEnemyDamageType({
origin: "runewaker",
spellId: 490_149,
componentId: 500_142,
attackKind: "spell",
school: "nature",
})).toMatchObject({
attackKind: "melee",
sourceDamageClass: "physical",
school: "holy",
schools: ["holy"],
sourceVerified: true,
});
const darkAttack = enemyDamageTypeComponentForSpell(498_602, 621_306);
expect(darkAttack).toMatchObject({
calculationType: 1,
effectType: 1,
magicType: 5,
sourceDamageClass: "magical",
attackKind: "spell",
sourceChannel: "darkness",
school: "shadow",
});
expect(resolveEnemyDamageType({
origin: "runewaker",
spellId: 498_602,
componentId: 621_306,
})).toMatchObject({
sourceDamageClass: "magical",
attackKind: "spell",
schools: ["shadow"],
school: "shadow",
sourceVerified: true,
});
const unknownCalculation = enemyDamageTypeComponentForSpell(490_045, 500_056);
expect(unknownCalculation).toMatchObject({
calculationType: 8,
effectType: 0,
magicType: 1,
sourceChannel: "water",
school: "frost",
});
expect(unknownCalculation?.sourceDamageClass).toBeUndefined();
expect(resolveEnemyDamageType({
origin: "runewaker",
spellId: 490_045,
componentId: 500_056,
attackKind: "melee",
})).toMatchObject({
attackKind: "melee",
school: "frost",
sourceVerified: true,
});
});
it("exports formula-specific metadata without conflating the hit gate, raw effect, or resistance channel", () => {
expect(enemyDamageTypeComponentForSpell(492_857, 503_019)).toMatchObject({
calculationType: 4,
directFormula: "fixed",
formulaMitigation: "magic-absorb",
effectType: 1,
magicType: -1,
});
expect(enemyDamageTypeComponentForSpell(492_438, 501_098)).toMatchObject({
calculationType: 8,
directFormula: "target-hp-percent",
formulaMitigation: "weapon-absorb",
effectType: 1,
magicType: 5,
sourceChannel: "darkness",
school: "shadow",
});
expect(enemyDamageTypeComponentForSpell(491_183, 501_278)).toMatchObject({
calculationType: 2,
directFormula: "throw",
formulaMitigation: "weapon-absorb",
sourceDamageClass: "physical",
attackKind: "ranged",
});
const healed = enemyDamageTypeComponentForSpell(492_438, 501_098)!;
expect(healed.effectType).toBe(1); // CalType 8 chooses physical absorption from this raw field.
expect(healed.magicType).toBe(5); // Resistance metadata remains Darkness.
expect(enemyDamageTypeSourceForSpell("runewaker", 492_438)).toMatchObject({
components: [expect.objectContaining({ componentId: 501_098 })],
});
});
it("normalizes an imported RuneWaker component over stale attack metadata", () => {
const normalized = normalizeEnemyAttack({
id: "rw-490149",
name: "Holy Seal",
spellId: 490_149,
runewakerComponentId: 500_142,
damageOrigin: "runewaker",
delivery: "melee",
target: "primary",
school: "nature",
attackKind: "spell",
animation: "attack",
range: 5,
cooldownMs: 6_500,
damageMultiplier: 1,
});
expect(normalized).toMatchObject({
attackKind: "melee",
runewakerEffectType: 1,
school: "holy",
runewakerComponentId: 500_142,
});
});
it("requires a component id when a RuneWaker spell mixes direct MagicType channels", () => {
const ambiguous = resolveEnemyDamageType({
origin: "runewaker",
spellId: 490_613,
school: "nature",
});
expect(ambiguous).toMatchObject({
school: "nature",
sourceVerified: false,
});
expect(resolveEnemyDamageType({
origin: "runewaker",
spellId: 490_613,
componentId: 501_063,
school: "nature",
})).toMatchObject({ sourceDamageClass: "magical", attackKind: "spell", school: "holy", sourceVerified: true });
});
});
+196
View File
@@ -0,0 +1,196 @@
import sourceCatalog from "./generated/enemyDamageTypeSources.generated.json";
export type EnemyDamageOrigin = "wow335" | "runewaker";
export type EnemyAttackKind = "melee" | "ranged" | "spell";
export type EnemyDamageSchool =
| "physical"
| "arcane"
| "fire"
| "frost"
| "nature"
| "shadow"
| "holy";
export interface WowEnemyDamageTypeSource {
readonly spellId: number;
readonly name: string;
readonly damageClass: number;
readonly schoolMask: number;
readonly schools: readonly EnemyDamageSchool[];
readonly school?: EnemyDamageSchool;
readonly attackKind?: EnemyAttackKind;
readonly damageEffects: readonly number[];
readonly source: string;
}
export interface RunewakerEnemyDamageTypeComponent {
readonly componentId: number;
/** MagicObject.Attack.CalType; direct packet class, independent of EffectType. */
readonly calculationType: number;
/** Raw MagicObject.EffectType, retained as source metadata. */
readonly effectType: number;
readonly magicType: number;
/** Formula-specific handling; not a packet class and not the MagicCollect hit gate. */
readonly directFormula?: "throw" | "fixed" | "target-hp-percent";
/** Absorption branch selected by the direct formula and raw component flags. */
readonly formulaMitigation?: "weapon-absorb" | "magic-absorb" | "bypassed";
readonly sourceDamageClass?: "physical" | "magical";
readonly attackKind?: EnemyAttackKind;
readonly sourceChannel?: "physical" | "earth" | "water" | "fire" | "wind" | "light" | "darkness";
/** Approximate mapping into HealerMan's seven-school resistance model. */
readonly school?: EnemyDamageSchool;
}
export interface RunewakerEnemyDamageTypeSource {
readonly spellId: number;
readonly name: string;
readonly components: readonly RunewakerEnemyDamageTypeComponent[];
readonly source: string;
}
export type EnemyDamageTypeSource = WowEnemyDamageTypeSource | RunewakerEnemyDamageTypeSource;
export interface ResolveEnemyDamageTypeInput {
readonly origin?: EnemyDamageOrigin;
readonly spellId?: number;
readonly componentId?: number;
/** Raw WoW Spell.dbc DmgClass when present. */
readonly damageClass?: number;
/** Runtime delivery classification, authored or normalized by the importer. */
readonly attackKind?: EnemyAttackKind;
/** Raw WoW Spell.dbc SchoolMask; bit values are preserved. */
readonly schoolMask?: number;
/** Authored runtime school used when source school data is absent or ambiguous. */
readonly school?: EnemyDamageSchool;
}
export interface ResolvedEnemyDamageType {
readonly attackKind?: EnemyAttackKind;
readonly damageClass?: number;
readonly sourceDamageClass?: "none" | "magic" | "melee" | "ranged" | "physical" | "magical";
readonly schoolMask?: number;
readonly schools: readonly EnemyDamageSchool[];
/** Present only when source evidence identifies exactly one runtime school. */
readonly school?: EnemyDamageSchool;
readonly unknownSchoolMaskBits?: number;
readonly sourceVerified: boolean;
}
type GeneratedCatalog = {
readonly wow335: Readonly<Record<string, WowEnemyDamageTypeSource>>;
readonly runewaker: Readonly<Record<string, RunewakerEnemyDamageTypeSource>>;
};
const catalog = sourceCatalog as GeneratedCatalog;
const WOW_SCHOOL_BITS: readonly (readonly [number, EnemyDamageSchool])[] = [
[1, "physical"],
[2, "holy"],
[4, "fire"],
[8, "nature"],
[16, "frost"],
[32, "shadow"],
[64, "arcane"],
];
const KNOWN_WOW_SCHOOL_BITS = WOW_SCHOOL_BITS.reduce((mask, [bit]) => mask | bit, 0);
export function enemyDamageTypeSourceForSpell(
origin: EnemyDamageOrigin,
spellId: number,
): EnemyDamageTypeSource | undefined {
if (!Number.isSafeInteger(spellId) || spellId <= 0) return undefined;
const key = String(spellId);
return origin === "wow335" ? catalog.wow335[key] : catalog.runewaker[key];
}
export function enemyDamageTypeComponentForSpell(
spellId: number,
componentId: number,
): RunewakerEnemyDamageTypeComponent | undefined {
const source = enemyDamageTypeSourceForSpell("runewaker", spellId);
if (!source || !("components" in source)) return undefined;
return source.components.find((component) => component.componentId === componentId);
}
/**
* Converts WotLK SchoolMask bits without imposing a strongest-school rule.
* Composite masks remain a list; callers should keep the authored single-school
* fallback until the damage resolver supports combined-school mitigation.
*/
export function wowSchoolsFromSchoolMask(mask: number): {
readonly schools: readonly EnemyDamageSchool[];
readonly unknownBits: number;
} {
if (!Number.isSafeInteger(mask) || mask < 0) return { schools: [], unknownBits: 0 };
return {
schools: WOW_SCHOOL_BITS.filter(([bit]) => (mask & bit) !== 0).map(([, school]) => school),
unknownBits: (mask & ~KNOWN_WOW_SCHOOL_BITS) >>> 0,
};
}
function sourceComponentForResolution(
source: RunewakerEnemyDamageTypeSource | undefined,
componentId: number | undefined,
): RunewakerEnemyDamageTypeComponent | undefined {
if (!source) return undefined;
if (componentId !== undefined) {
return source.components.find((component) => component.componentId === componentId);
}
if (source.components.length === 1) return source.components[0];
const first = source.components[0];
if (first && source.components.every((component) => (
component.sourceDamageClass === first.sourceDamageClass
&& component.attackKind === first.attackKind
&& component.school === first.school
&& component.sourceChannel === first.sourceChannel
))) return first;
return undefined;
}
function attackKindForDamageClass(value: number | undefined): EnemyAttackKind | undefined {
if (value === 1) return "spell";
if (value === 2) return "melee";
if (value === 3) return "ranged";
return undefined;
}
export function resolveEnemyDamageType(input: ResolveEnemyDamageTypeInput): ResolvedEnemyDamageType {
const source = input.origin && input.spellId !== undefined
? enemyDamageTypeSourceForSpell(input.origin, input.spellId)
: undefined;
const wowSource = source && "schoolMask" in source ? source : undefined;
const runewakerSource = source && "components" in source ? source : undefined;
const runewakerComponent = sourceComponentForResolution(runewakerSource, input.componentId);
const damageClass = input.damageClass ?? wowSource?.damageClass;
const schoolMask = input.schoolMask ?? wowSource?.schoolMask;
const maskResult = schoolMask === undefined
? { schools: [] as readonly EnemyDamageSchool[], unknownBits: 0 }
: wowSchoolsFromSchoolMask(schoolMask);
const exactWoWSchool = schoolMask !== undefined
&& maskResult.schools.length === 1
&& maskResult.unknownBits === 0
? maskResult.schools[0]
: undefined;
const sourceDamageClass = input.origin === "runewaker"
? runewakerComponent?.sourceDamageClass
: damageClass === 0 ? "none"
: damageClass === 1 ? "magic"
: damageClass === 2 ? "melee"
: damageClass === 3 ? "ranged" : undefined;
const attackKind = input.origin === "runewaker"
? runewakerComponent?.attackKind ?? input.attackKind
: input.attackKind ?? attackKindForDamageClass(damageClass);
const school = exactWoWSchool
?? runewakerComponent?.school
?? input.school;
return {
...(attackKind ? { attackKind } : {}),
...(damageClass !== undefined ? { damageClass } : {}),
...(sourceDamageClass ? { sourceDamageClass } : {}),
...(schoolMask !== undefined ? { schoolMask } : {}),
schools: maskResult.schools.length ? maskResult.schools : runewakerComponent?.school ? [runewakerComponent.school] : [],
...(school ? { school } : {}),
...(maskResult.unknownBits ? { unknownSchoolMaskBits: maskResult.unknownBits } : {}),
sourceVerified: Boolean(wowSource || runewakerComponent),
};
}
+17
View File
@@ -1,3 +1,5 @@
import type { RomNativeDerivedStats } from "./romNativeStatsTypes";
import type { RomNativeDamageTargetContext } from "./romNativeCombatAmount";
import {
abilitiesForCharacter,
learnedAbilityForBuild,
@@ -455,6 +457,7 @@ export function choosePartyRotationDecision(
specializationId: member.specializationId,
specialization: member.specialization,
role: member.role,
statRuleset: member.statRuleset,
level: member.level,
health: member.health,
maxHealth: member.maxHealth,
@@ -467,6 +470,10 @@ export function choosePartyRotationDecision(
runes: member.runes,
deathKnightReapingRank: reapingRank,
abilities: rotationAbilities,
learnedAbilities: learned,
manaCostForAbility: (ability) => partyAbilityCosts(member, ability, context.now)
.filter((cost) => cost.resource === "mana")
.reduce((total, cost) => total + cost.amount, 0),
knownDamagingAbilityNames: new Set(learned.filter((ability) => {
const categories = aiHintsForAbility(ability).categories;
return categories.includes("damage") || categories.includes("execute");
@@ -482,6 +489,12 @@ export function choosePartyRotationDecision(
moving: context.moving,
requiredControl: context.requiredControl,
maintainedAbilityIds: context.maintainedAbilityIds,
maintainedPeriodicEffects: useCombatStore.getState().effects.map((effect) => ({
abilityId: effect.sourceAbilityId,
sourceActorId: effect.sourceActorId,
targetId: effect.targetId ?? "__player__",
remainingMs: Math.max(0, effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs - context.now),
})),
activeAuraExclusiveGroups: context.activeAuraExclusiveGroups,
activeSummonAbilityIds: context.activeSummonAbilityIds,
hasActivePet: context.hasActivePet,
@@ -566,6 +579,8 @@ export function partyAbilityAmount(
effect: AbilityEffect,
poisonDoses = 0,
intellectOverride?: number,
effectiveNativeStats?: RomNativeDerivedStats,
nativeTargetContext?: RomNativeDamageTargetContext,
): number {
const sourceIntellect = intellectOverride ?? deriveCharacterStats(
member.classId,
@@ -618,6 +633,8 @@ export function partyAbilityAmount(
member.gender,
member.statRuleset ?? "wow",
member.secondaryClassId,
effectiveNativeStats,
nativeTargetContext,
) + (effect.kind === "damage" ? effect.bonusFlat ?? 0 : 0);
if (member.classId === "druid" && (effect.kind === "damage" || effect.kind === "dot")) {
return applyTalentModifierValue(
+97
View File
@@ -4,6 +4,7 @@ import { applyAura } from "./combatAuras";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
clearPartyRuntimeRegistry,
getPartyRuntimePosition,
registerPartyRuntimePosition,
updatePartyRuntimeAttackCorridor,
updatePartyRuntimeTargetVisibility,
@@ -1285,6 +1286,102 @@ describe("party combat runtime", () => {
expect(autoAttackTimes).toEqual([2_200, 3_400]);
});
it("runs party Rogue Killing Spree on its own timed window and frozen cast-time targets", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const seed = "killing-spree-party-runtime";
const spree = abilitiesForClass("rogue").find((candidate) => candidate.name === "Killing Spree");
if (!spree) throw new Error("Missing generated Rogue Killing Spree");
const spreeTalentNodeId = abilityTalentNodeId(spree);
if (!spreeTalentNodeId) throw new Error("Rogue Killing Spree has no generated talent node");
const rogue = createConfiguredPartyMember({
name: "Combat Rogue",
classId: "rogue",
specialization: "Combat",
specializationId: "rogue:combat",
talentTreeId: "rogue-combat",
talentRanks: { [spreeTalentNodeId]: 1 },
role: "damage",
}, 80, 0, seed);
usePartyStore.getState().initializeParty(seed, 80, useCombatStore.getState().health, "damage", 2);
usePartyStore.setState({
members: [{ ...rogue, nextActionAt: 0 }],
command: "attack",
activeMobId: "party-spree:a",
});
registerPartyRuntimePosition(rogue.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
registerEngagedMob("party-spree:a", 1_000_000);
registerEngagedMob("party-spree:b", 1_000_000);
useCombatStore.getState().selectMob("party-spree:a");
usePartyStore.setState({ activeMobId: "party-spree:a", command: "attack" });
setMobPosition("party-spree:b", [3, 0, 0]);
updatePartyRuntimeTargetVisibility(rogue.id, "party-spree:b", true);
updatePartyRuntimeTargetVisibility(rogue.id, "party-spree:a", true);
advanceDungeonPartyCombat(1_000);
const window = useCombatStore.getState().killingSpreeWindows.find((candidate) => candidate.ownerActorId === rogue.id);
expect(window).toMatchObject({
ownerActorId: rogue.id,
abilityId: spree.id,
role: "damage",
targetIds: ["party-spree:a", "party-spree:b"],
startedAt: 1_000,
nextAssaultAt: 1_000,
completedAssaults: 0,
});
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
sourceId: rogue.id,
targetId: rogue.id,
expiresAt: 3_001,
definition: expect.objectContaining({
utilityTags: ["wow335-rogue-killing-spree"],
modifiers: [{ kind: "damage", direction: "dealt", operation: "percent", value: 0.2 }],
}),
}));
expect(usePartyStore.getState().members[0]?.cooldowns[spree.id]).toBeGreaterThan(1_000);
registerEngagedMob("party-spree:c", 1_000_000);
setMobPosition("party-spree:c", [2, 0, 2]);
updatePartyRuntimeTargetVisibility(rogue.id, "party-spree:c", true);
useCombatStore.getState().advanceMobCombat(1_000);
const firstPulse = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.sourceActorId === rogue.id && event.abilityId === spree.id
));
expect(firstPulse.length).toBeGreaterThan(0);
expect(firstPulse.every((event) => event.targetActorId !== "party-spree:c")).toBe(true);
expect(getPartyRuntimePosition(rogue.id)).not.toEqual([0, 0, 0]);
for (const now of [1_499, 1_500, 2_000, 2_500, 3_000]) {
useCombatStore.getState().advanceMobCombat(now);
}
const strikes = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.sourceActorId === rogue.id && event.abilityId === spree.id
));
expect([...new Set(strikes.map((event) => event.occurredAt))]).toEqual([1_000, 1_500, 2_000, 2_500, 3_000]);
expect(strikes.every((event) => event.targetActorId !== "party-spree:c")).toBe(true);
expect(strikes.filter((event) => event.occurredAt === 3_000).map((event) => event.rawAmount))
.toEqual(window!.hits.map((hit) => Math.round(hit.damage * 1.2)));
expect(useCombatStore.getState().killingSpreeWindows).toHaveLength(0);
expect(useCombatStore.getState().auras.some((aura) => aura.sourceId === rogue.id
&& aura.targetId === rogue.id
&& aura.definition.utilityTags?.includes("wow335-rogue-killing-spree"))).toBe(false);
expect(useCombatStore.getState().beginKillingSpree(
rogue.id,
spree,
["party-spree:a"],
[],
"damage",
3_001,
)).toBe(true);
expect(useCombatStore.getState().killingSpreeWindows).toHaveLength(1);
usePartyStore.setState({ members: [] });
useCombatStore.getState().advanceMobCombat(3_001);
expect(useCombatStore.getState().killingSpreeWindows).toHaveLength(0);
expect(useCombatStore.getState().auras.some((aura) => aura.sourceId === rogue.id
&& aura.definition.utilityTags?.includes("wow335-rogue-killing-spree"))).toBe(false);
});
it("precasts Earth Shield on the tank while the Restoration Shaman is at full health", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const tank = createConfiguredPartyMember({
+198 -20
View File
@@ -1,3 +1,5 @@
import { nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount";
import { romNativePlayerHitProfile } from "./romNativeCombatResolution";
import {
abilitiesForClass,
abilitiesForCharacter,
@@ -53,6 +55,7 @@ import {
import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore";
import { baseClassFor, classById, isRomClassId } from "../app/characterCatalog";
import { useGameStore } from "./store";
import { coaBarbarianDecapitateTargetAllowed } from "./coaBarbarianExecuteRuntime";
import { useManastormStore } from "./manastormStore";
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
import {
@@ -73,14 +76,20 @@ import {
} from "./wow335WarriorUnbridledWrathRuntime";
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
import { PLAYER_AGGRO_ID, type ThreatSource } from "./aggro";
import type { Wow335KillingSpreeWeaponSnapshot } from "./wow335RogueKillingSpreeRuntime";
import {
applyAura as applyCombatAura,
resolveDamageValue as resolveAuraDamageValue,
resolveResourceValue as resolveAuraResourceValue,
resolveStatValue as resolveAuraStatValue,
type ActiveAura,
type DispelCategory,
} from "./combatAuras";
import { romPriestHolyBlessingDamage, romPriestHolyBlessingStats } from "./romPriestHolyBlessingRuntime";
import {
ROM_PRIEST_HOLY_BLESSING_TAG,
romPriestHolyBlessingDamage,
romPriestHolyBlessingStats,
} from "./romPriestHolyBlessingRuntime";
import { romKnightGodsVengeanceShouldResetShadowStep } from "./romKnightOverrides";
import {
romChampionHasForgeRankTwoAura,
@@ -157,6 +166,11 @@ import { imminentCooldownMechanic } from "./companionCooldownPolicy";
import { normalizeActorResourcePool, type DeathKnightRuneState } from "./combatActors";
import { enemyControlPreventsActions } from "./enemyAttacks";
import { advanceCombatResourcePool } from "./combatResources";
import {
advanceRomNativeRecoveryClock,
romNativePeriodicManaEffectDuring,
romNativeRecoveryPulseAmounts,
} from "./romNativeRecovery";
import { baseMissChancePercent } from "./combatResults";
import { wow335DeathKnightSuddenDoomTriggerForLandedHit } from "./wow335DeathKnightSuddenDoomRuntime";
import {
@@ -987,6 +1001,7 @@ function partyAbilityAmountWithHolyBlessing(
effect: AbilityEffect,
poisonDoses = 0,
now = Date.now(),
nativeTargetContext?: import("./romNativeCombatAmount").RomNativeDamageTargetContext,
): number {
const state = useCombatStore.getState();
const base = deriveCharacterStats(
@@ -1005,10 +1020,14 @@ function partyAbilityAmountWithHolyBlessing(
(value, stat) => resolveAuraStatValue(value, auras, stat),
base.intellect,
);
if (member.statRuleset === "rom") {
return partyAbilityAmount(member, ability, effect, poisonDoses, intellect,
auraAdjustedCharacterStats(base, state.auras, member.id, now).romNative, nativeTargetContext);
}
if (member.classId !== "rom-priest") {
return partyAbilityAmount(member, ability, effect, poisonDoses, intellect);
}
const bonus = member.statRuleset === "rom" ? 0 : romPriestHolyBlessingStats(base, auras).spellPowerBonus;
const bonus = romPriestHolyBlessingStats(base, auras).spellPowerBonus;
if (bonus <= 0) return partyAbilityAmount(member, ability, effect, poisonDoses, intellect);
return partyAbilityAmount({
...member,
@@ -1023,8 +1042,9 @@ function partyWarlockAbilityAmount(
poisonDoses = 0,
targetId?: string,
now = Date.now(),
nativeTargetContext?: import("./romNativeCombatAmount").RomNativeDamageTargetContext,
): number {
const amount = partyAbilityAmountWithHolyBlessing(member, ability, effect, poisonDoses, now);
const amount = partyAbilityAmountWithHolyBlessing(member, ability, effect, poisonDoses, now, nativeTargetContext);
if (member.classId === "warlock" && ability.name === "Conflagrate" && effect.kind === "damage") {
const conflagrateDot = targetId
? activePartyWarlockConflagrateDot(useCombatStore.getState().effects, member.id, targetId, now)
@@ -1187,6 +1207,7 @@ export function partyAttackDamage(
& Partial<Pick<PartyMember,
"id" | "classId" | "specialization" | "gearStats" | "raceId" | "gender" | "statRuleset" | "secondaryClassId"
>>,
now = Date.now(),
): number {
const coefficient = member.role === "tank" ? 0.85 : member.role === "healer" ? 0.55 : 1.25;
const gear = member.gearStats;
@@ -1209,8 +1230,10 @@ export function partyAttackDamage(
member.secondaryClassId,
member.gender ?? "male",
"rom",
), useCombatStore.getState().auras, member.id ?? "").romNative
), useCombatStore.getState().auras, member.id ?? "", now).romNative
: undefined;
const nativeAuto = kind !== "spell" ? nativeTrainingAutoAttackProfile(nativeStats, kind === "ranged" ? "ranged" : "main-hand") : undefined;
if (nativeAuto) return Math.max(1, Math.round(nativeAuto.damage));
const weapon = kind === "ranged" ? gear.rangedWeapon : kind === "melee" ? gear.meleeWeapon : null;
const equipmentPower = nativeStats
? kind === "spell"
@@ -1745,6 +1768,13 @@ function tankPriorityTargetId(
}
function memberSwingMs(member: PartyMember, now: number): number {
const native = member.statRuleset === "rom" ? auraAdjustedCharacterStats(deriveCharacterStats(
member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId, member.gender, "rom",
), useCombatStore.getState().auras, member.id, now).romNative : undefined;
const physicalAuto = !partyUsesRangedAttacks(member) || baseClassFor(member.classId) === "hunter";
const nativeAuto = physicalAuto
? nativeTrainingAutoAttackProfile(native, member.classId === "rom-scout" ? "ranged" : "main-hand")
: undefined;
const base = member.role === "tank"
? 1_800
: member.classId === "rogue"
@@ -1767,6 +1797,8 @@ function memberSwingMs(member: PartyMember, now: number): number {
aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now)
));
const effectiveHaste = resolveAuraStatValue(hasteRating, memberAuras, hasteStat);
if (nativeAuto) return Math.max(500, Math.round(hastedDuration(nativeAuto.intervalMs, effectiveHaste)
/ raceAffixHasteMultiplier(now)));
return Math.round(hastedDuration(
base,
effectiveHaste,
@@ -1850,8 +1882,13 @@ function partyRogueOffhandAttackProfile(
const auras = useCombatStore.getState().auras.filter((aura) => (
aura.targetId === member.id && (aura.expiresAt === null || aura.expiresAt > now)
));
const attackPower = partyShamanAttackPower(stats.attackPower, auras);
const nativeAuto = nativeTrainingAutoAttackProfile(auraAdjustedCharacterStats(stats, useCombatStore.getState().auras, member.id, now).romNative, "off-hand");
const meleeHastePercent = resolveAuraStatValue(stats.meleeHastePercent, auras, "melee-haste");
if (nativeAuto) return { damage: Math.max(1, Math.round(nativeAuto.damage
* (masteryActive ? 1 + ROM_ROGUE_OFF_HAND_MASTERY_DAMAGE_PERCENT / 100 : 1))),
intervalMs: Math.max(500, Math.round(hastedDuration(nativeAuto.intervalMs, meleeHastePercent)
/ raceAffixHasteMultiplier(now))) };
const attackPower = partyShamanAttackPower(stats.attackPower, auras);
const fullWeaponDamage = (weapon.damageMin + weapon.damageMax) / 2
+ attackPower / 14 * (weapon.speedMs / 1_000);
const masteryMultiplier = masteryActive
@@ -2314,7 +2351,7 @@ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: nu
? "rom-rogue-dual-wield-main-hand"
: forgeAutoAttackActive ? "rom-champion-forge-auto-attack" : "scent-of-blood-auto-attack"}`;
useCombatStore.getState().notifyOwnerHostileAction(member.id, targetId);
const ownerAttackDamage = partyAttackDamage(member);
const ownerAttackDamage = partyAttackDamage(member, now);
const result = useCombatStore.getState().damageMob(targetId, ownerAttackDamage, now,
{ actorId: member.id, role: member.role, abilityId }, "physical", needsAttackRoll ? {
attackKind: goForTheThroatRank > 0 ? "ranged" : "melee",
@@ -2450,6 +2487,7 @@ export function partyAbilityTargetIsValid(
&& aura.definition.utilityTags?.includes("rom-vanish")))
&& Object.values(combat.mobs).some((candidate) => candidate.engaged && !candidate.dead)) return false;
if (ability.name === "Kill Shot" && mob && mob.health / Math.max(1, mob.maxHealth) > 0.2) return false;
if (!coaBarbarianDecapitateTargetAllowed(ability, mob)) return false;
if (ability.name === "Power Word: Shield" && combat.auras.some((aura) => aura.targetId === targetId
&& aura.definition.id === "wow335:priest:weakened-soul")) return false;
if (ability.name === "Enraged Regeneration" && !combat.auras.some((aura) => aura.targetId === member.id
@@ -2596,6 +2634,19 @@ function partyHostileTargets(
return primaryPosition && combatHasLineOfSight(origin, primaryPosition) ? [primaryTargetId] : [];
}
function partyKillingSpreeTargetIds(primaryTargetId: string, origin: MobVector3): readonly string[] {
const combat = useCombatStore.getState();
return [...new Set([primaryTargetId, ...Object.keys(combat.mobs)])]
.filter((id) => {
const mob = combat.mobs[id];
const position = getMobPosition(id);
return Boolean(mob && mob.engaged && !mob.dead && mob.health > 0 && position
&& distanceSquared(origin, position) <= 10 ** 2
&& combatHasLineOfSight(origin, position));
})
.sort();
}
function partyHealingTargets(
ability: AbilityDefinition,
primaryTargetId: string,
@@ -3111,11 +3162,13 @@ function executePartyAbility(
&& ability.effects.some((effect) => ["damage", "dot", "finisher-damage"].includes(effect.kind))
&& combat.auras.some((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("next-offensive-critical"));
const spellSchool = member.classId === "shaman"
&& ability.source === "wow335-progression"
&& ["Lightning Bolt", "Chain Lightning"].includes(ability.name)
? abilityDamageSchool("shaman", ability)
: presentationSchoolForAbility(ability);
const spellSchool = ability.source === "runewaker"
? abilityDamageSchool(member.classId, ability)
: member.classId === "shaman"
&& ability.source === "wow335-progression"
&& ["Lightning Bolt", "Chain Lightning"].includes(ability.name)
? abilityDamageSchool("shaman", ability)
: presentationSchoolForAbility(ability);
const consumesShamanNaturesGuardianCrit = member.classId === "shaman"
&& (hostileTargets.length > 0 || friendlyTargets.length > 0)
&& ability.effects.some((effect) => effect.kind === "damage"
@@ -3357,7 +3410,7 @@ function executePartyAbility(
combat.applyTimedActorEffect(member.id, member.id, ability, effect, now);
continue;
}
for (const hostileTargetId of hostileTargets) {
for (const [targetOrdinal, hostileTargetId] of hostileTargets.entries()) {
if (darkEnergyStrikeGroup === "dark"
&& !romChampionDarkEnergyStrikeGroup0SurvivedTargets.has(hostileTargetId)) continue;
if (effect.kind === "dot") {
@@ -3391,6 +3444,7 @@ function executePartyAbility(
envenomDosesByTarget.get(hostileTargetId) ?? 0,
hostileTargetId,
now,
{ targetCount: hostileTargets.length, targetOrdinal },
);
const targetStatuses = useCombatStore.getState().mobs[hostileTargetId]?.statuses ?? [];
const mageTargetDamageAmount = member.classId === "mage"
@@ -3534,7 +3588,7 @@ function executePartyAbility(
&& combat.auras.some((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes(ROM_WARLOCK_PRIEST_SPREADING_PAIN_CRIT_TAG)
&& (aura.expiresAt === null || aura.expiresAt > now));
const partyUsesCombatRollContext = drumsOfWarShot || lightOfDayHit || deathKnightSuddenDoomTrigger
const partyUsesCombatRollContext = member.statRuleset === "rom" || drumsOfWarShot || lightOfDayHit || deathKnightSuddenDoomTrigger
|| deathKnightScentOfBloodMeleeHit
|| deathKnightBloodyVengeanceHit
|| deathKnightBloodGorgedHit
@@ -3559,7 +3613,8 @@ function executePartyAbility(
? member.talentRanks[WOW335_MAGE_MOLTEN_FURY_TALENT_ID] ?? 0
: 0;
const partyDamageTargetBeforeHit = useCombatStore.getState().mobs[hostileTargetId];
const damageSchool = effect.kind === "damage" && isRomChampionDarkEnergyStrike
const damageSchool = effect.kind === "damage"
&& (ability.source === "runewaker" || isRomChampionDarkEnergyStrike)
? effect.school ?? spellSchool
: spellSchool;
const partyRawDirectDamageBase = mageTargetDamageAmount
@@ -3601,6 +3656,27 @@ function executePartyAbility(
partyDamageTargetBeforeHit.health / partyDamageTargetBeforeHit.maxHealth,
)
: partyDirectDamageWithMoltenFury;
const partyDirectDamageWithRunewakerAuras = effect.kind === "damage"
&& ability.source === "runewaker"
&& !partyUsesCombatRollContext
? resolveAuraDamageValue(
partyDirectDamageWithMercilessCombat,
combat.auras.filter((aura) => aura.targetId === member.id
&& (aura.expiresAt === null || aura.expiresAt > now)
&& !aura.definition.utilityTags?.includes(ROM_PRIEST_HOLY_BLESSING_TAG)),
{
direction: "dealt",
school: damageSchool,
attackKind: effect.romNativeSource
&& romNativePlayerHitProfile(
effect.romNativeSource.skillId,
effect.romNativeSource.componentId,
)?.damageChannel === "physical"
? member.classId === "rom-scout" ? "ranged" : "melee"
: "spell",
},
)
: partyDirectDamageWithMercilessCombat;
const maelstromWeaponPvpDamageReductionPercent = partyDamageTargetBeforeHit?.characterDefender
&& damageSchool !== "physical"
? wow335ShamanMaelstromWeaponPvpDamageReductionPercent(
@@ -3611,7 +3687,7 @@ function executePartyAbility(
: 0;
const result = combat.damageMob(
hostileTargetId,
Math.round(partyDirectDamageWithMercilessCombat
Math.round(partyDirectDamageWithRunewakerAuras
* (1 - maelstromWeaponPvpDamageReductionPercent / 100)),
now,
{
@@ -3621,7 +3697,10 @@ function executePartyAbility(
},
damageSchool,
partyUsesCombatRollContext ? {
attackKind: drumsOfWarShot ? "ranged"
attackKind: member.statRuleset === "rom" && effect.kind === "damage" && effect.romNativeSource
? romNativePlayerHitProfile(effect.romNativeSource.skillId, effect.romNativeSource.componentId)?.damageChannel === "physical"
? member.classId === "rom-scout" ? "ranged" : "melee" : "spell"
: drumsOfWarShot ? "ranged"
: hunterGoForTheThroatAttackKind
?? (deathKnightScentOfBloodMeleeHit || deathKnightBloodGorgedHit
|| shamanWeaponMeleeHit || godsSwordDirectPhysicalHit || darkEnergyStrikePhysicalHit
@@ -3651,7 +3730,9 @@ function executePartyAbility(
canParry: false,
canBlock: false,
} }
: ability.name === "Shiv" ? { rules: { canCrit: false } } : {}),
: { rules: { ...(ability.name === "Shiv" ? { canCrit: false } : {}),
romNativeSource: effect.kind === "damage" ? effect.romNativeSource : undefined,
romNativeTargetOrdinal: targetOrdinal } }),
} : undefined,
);
if (darkEnergyStrikeGroup === "physical" && hostileTargetId === targetId
@@ -3985,6 +4066,36 @@ function executePartyAbility(
if (effect.kind === "finisher-damage") {
runtimeMember = { ...runtimeMember, comboPoints: 0, comboTargetId: null };
}
} else if (effect.kind === "utility"
&& effect.action === "class-script"
&& ability.name === "Killing Spree"
&& baseClassFor(member.classId) === "rogue") {
const targetIds = partyKillingSpreeTargetIds(targetId, origin);
const loadout = resolvedEquipmentLoadout(combat.equipment, combat.inventory, {
ownerId: member.id,
ownerKind: "party",
classId: member.classId,
secondaryClassId: member.secondaryClassId,
specializationId: member.specializationId,
role: member.role,
level: member.level,
});
const hands: Wow335KillingSpreeWeaponSnapshot[] = (["main-hand", "off-hand"] as const)
.flatMap((hand) => {
const item = loadout[hand];
const weapon = item?.stats.meleeWeapon ?? (hand === "main-hand" ? member.gearStats.meleeWeapon : null);
return weapon ? [{
hand,
...(item?.weaponType ? { weaponType: item.weaponType } : {}),
damageMin: weapon.damageMin,
damageMax: weapon.damageMax,
speedMs: weapon.speedMs,
}] : [];
});
if (targetIds.length > 0) {
useCombatStore.getState().beginKillingSpree(member.id, ability, targetIds, hands, member.role, now);
combat = useCombatStore.getState();
}
} else if (effect.kind === "utility"
&& effect.action === "class-script"
&& ability.name === "Pestilence"
@@ -4102,6 +4213,7 @@ function executePartyAbility(
* (riptideHot ? PARTY_SHAMAN_RIPTIDE_CHAIN_HEAL_MULTIPLIER : 1));
const result = useCombatStore.getState().healActor(friendlyTargetId, requested, {
sourceActorId: member.id,
romNativeSource: effect.romNativeSource,
abilityId: ability.id,
school: healingSchool,
criticalChance,
@@ -4868,10 +4980,65 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
partyOrigin,
);
if (!targetId && party.command !== "stop") targetId = activeRaceOrbId();
const nativePartyInCombat = Boolean(targetId) || Object.values(combat.mobs).some((mob) => (
!mob.dead && (mob.engaged || Object.values(mob.threatByActor).some((threat) => threat > 0))
));
members = members.map((member) => {
const profile = resourceProfileForClass(member.classId);
const memberAuras = combat.auras.filter((aura) => aura.targetId === member.id
&& (aura.expiresAt === null || aura.expiresAt > now));
const nativeRecoveryBaseStats = member.statRuleset === "rom" && isRomClassId(member.classId)
? deriveCharacterStats(
member.classId, member.level, member.gearStats, member.raceId, {},
member.secondaryClassId, member.gender, "rom",
)
: undefined;
const nativeRecoveryBase = nativeRecoveryBaseStats?.romNative;
const living = member.health > 0;
const recoveryClockAdvance = living
? advanceRomNativeRecoveryClock(
member.romNativeRecoveryClock,
member.statRuleset ?? "wow",
"party-npc",
deltaSeconds * 1_000,
)
: { clock: undefined, pulseOffsetsMs: [] as readonly number[] };
let nativeHealthAfterRecovery = member.health;
let nativeManaPulseAmount = 0;
if (living && nativeRecoveryBaseStats && nativeRecoveryBase) {
const intervalStartAt = now - deltaSeconds * 1_000;
for (const offsetMs of recoveryClockAdvance.pulseOffsetsMs) {
const pulseAt = intervalStartAt + offsetMs;
const pulseStats = auraAdjustedCharacterStats(
nativeRecoveryBaseStats,
combat.auras.filter((aura) => aura.expiresAt === null || aura.expiresAt > pulseAt),
member.id,
pulseAt,
).romNative;
if (!pulseStats) continue;
const amount = romNativeRecoveryPulseAmounts(pulseStats, "party-npc", nativePartyInCombat);
nativeHealthAfterRecovery += amount.health;
nativeManaPulseAmount += amount.mana;
}
}
nativeHealthAfterRecovery = Math.min(member.maxHealth, nativeHealthAfterRecovery);
const nativeManaMaximum = companionResourceMaximum(member, "mana");
const nativeManaPoolEnabled = Boolean(nativeRecoveryBase?.usesMana && nativeManaMaximum > 0);
const nativeManaPeriodicAmount = nativeManaPoolEnabled && living
? romNativePeriodicManaEffectDuring(
combat.auras.filter((aura) => aura.targetId === member.id),
now - deltaSeconds * 1_000,
now,
nativeManaMaximum,
)
: 0;
const nativeManaAfterRecovery = nativeManaPoolEnabled
? Math.max(0, Math.min(
nativeManaMaximum,
(member.resourcePools.mana ?? (member.resourceType === "mana" ? member.resource : 0))
+ (living ? nativeManaPulseAmount + nativeManaPeriodicAmount : 0),
))
: undefined;
const passiveRates: Partial<Record<ResourceType, number>> = {
mana: 4,
rage: 0,
@@ -4880,7 +5047,9 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
"runic-power": 0,
};
const resourcePools: Partial<Record<ResourceType, number>> = { ...member.resourcePools };
if (nativeManaAfterRecovery !== undefined) resourcePools.mana = nativeManaAfterRecovery;
for (const type of Object.keys(resourcePools) as ResourceType[]) {
if (type === "mana" && nativeManaAfterRecovery !== undefined) continue;
const manaRegeneration = member.gearStats.mp5 / 5 + member.gearStats.spirit * 0.02;
const baseRegeneration = type === member.resourceType
? (profile.type === "rage" || profile.type === "runic-power" ? 0 : profile.regenerationPerSecond + manaRegeneration)
@@ -4923,8 +5092,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
}
return {
...member,
health: nativeHealthAfterRecovery,
resource: resourcePools[member.resourceType] ?? member.resource,
resourcePools,
romNativeRecoveryClock: living ? recoveryClockAdvance.clock : undefined,
warriorAngerManagementRageRemainderMs,
cooldowns: Object.fromEntries(Object.entries(member.cooldowns).filter(([, readyAt]) => readyAt > now)),
schoolLockedUntil: Object.fromEntries(Object.entries(member.schoolLockedUntil).filter(([, readyAt]) => (readyAt ?? 0) > now)),
@@ -4939,6 +5110,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
members = members.map((member) => {
if (
member.health <= 0
|| member.statRuleset === "rom"
|| (
member.controlledUntil > now
&& enemyControlPreventsActions(member.controlMechanic)
@@ -5569,9 +5741,15 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
members = members.map((member) => {
const actor = actorsAfterActions[member.id];
const baseline = authoritativeMembers.find((candidate) => candidate.id === member.id);
return actor?.kind === "companion" && baseline && actor.health !== baseline.health
? { ...member, health: actor.health, maxHealth: actor.maxHealth }
: member;
const health = actor?.kind === "companion" && baseline && actor.health !== baseline.health
? actor.health
: member.health;
return {
...(actor?.kind === "companion" && baseline && actor.health !== baseline.health
? { ...member, health: actor.health, maxHealth: actor.maxHealth }
: member),
romNativeRecoveryClock: health <= 0 ? undefined : member.romNativeRecoveryClock,
};
});
const targetMapChanged = (
+14
View File
@@ -2,12 +2,14 @@ import { beforeEach, describe, expect, it } from "vitest";
import {
clearPartyRuntimeRegistry,
clearPartyRuntimeAttackCorridor,
consumePartyRuntimeCombatRelocation,
getPartyRuntimePosition,
partyRuntimeDistanceSquared,
partyRuntimeCenter,
partyRuntimeTargetVisible,
partyRuntimePositionAlongAttackCorridor,
registerPartyRuntimePosition,
requestPartyRuntimeCombatRelocation,
updatePartyRuntimeTargetVisibility,
updatePartyRuntimeAttackCorridor,
updatePartyRuntimePosition,
@@ -46,6 +48,18 @@ describe("party runtime position registry", () => {
expect(partyRuntimeIsRejoining("tank")).toBe(false);
});
it("consumes combat teleports once without creating a rejoin task", () => {
const origin: [number, number, number] = [1, 2, 3];
registerPartyRuntimePosition("rogue", [0, 0, 0]);
requestPartyRuntimeCombatRelocation("rogue", origin, 1.25);
origin[0] = 999;
expect(getPartyRuntimePosition("rogue")).toEqual([1, 2, 3]);
const relocation = consumePartyRuntimeCombatRelocation("rogue")!;
expect(relocation).toEqual({ position: [1, 2, 3], yaw: 1.25 });
expect(consumePartyRuntimeCombatRelocation("rogue")).toBeNull();
expect(partyRuntimeIsRejoining("rogue")).toBe(false);
});
it("tracks world actors without exposing mutable registry storage", () => {
const unregister = registerPartyRuntimePosition("tank", [1, 2, 3]);
updatePartyRuntimePosition("tank", 4, 5, 6);
+27
View File
@@ -4,9 +4,33 @@ export type MutablePartyWorldPosition = [x: number, y: number, z: number];
const positions = new Map<string, MutablePartyWorldPosition>();
const targetVisibility = new Map<string, { targetId: string | null; visible: boolean }>();
const rejoins = new Map<string, { position: PartyWorldPosition; pending: boolean }>();
const combatRelocations = new Map<string, { position: PartyWorldPosition; yaw: number }>();
/** One-shot movement authored by combat, without an attached return-to-player task. */
export function requestPartyRuntimeCombatRelocation(
id: string,
position: PartyWorldPosition,
yaw: number,
): void {
const destination: PartyWorldPosition = [finite(position[0]), finite(position[1]), finite(position[2])];
const facing = finite(yaw);
combatRelocations.set(id, { position: destination, yaw: facing });
updatePartyRuntimePosition(id, destination[0], destination[1], destination[2]);
}
/** Returns and clears a pending combat relocation so the scene applies it once. */
export function consumePartyRuntimeCombatRelocation(
id: string,
): { readonly position: PartyWorldPosition; readonly yaw: number } | null {
const relocation = combatRelocations.get(id);
if (!relocation) return null;
combatRelocations.delete(id);
return { position: [...relocation.position], yaw: relocation.yaw };
}
/** A one-shot scene relocation followed by an individual return-to-player task. */
export function beginPartyRuntimeRejoin(id: string, position: PartyWorldPosition): void {
combatRelocations.delete(id);
rejoins.set(id, { position: [...position], pending: true });
updatePartyRuntimePosition(id, position[0], position[1], position[2]);
targetVisibility.delete(id);
@@ -45,6 +69,7 @@ export function registerPartyRuntimePosition(
positions.delete(id);
targetVisibility.delete(id);
rejoins.delete(id);
combatRelocations.delete(id);
};
}
@@ -136,6 +161,7 @@ export function removePartyRuntimePosition(id: string): void {
positions.delete(id);
targetVisibility.delete(id);
rejoins.delete(id);
combatRelocations.delete(id);
}
export function getPartyRuntimePosition(id: string): PartyWorldPosition | null {
@@ -192,5 +218,6 @@ export function clearPartyRuntimeRegistry(): void {
positions.clear();
targetVisibility.clear();
rejoins.clear();
combatRelocations.clear();
attackCorridor = null;
}
+13 -3
View File
@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
APPEARANCE_KEYS,
ROM_CLASS_IDS,
classesForRace,
raceById,
supportedGenders,
@@ -18,6 +17,7 @@ import type { InventoryItem } from "./lootTypes";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { DEFAULT_COMPANION_SPEC_READINESS_POLICY } from "./companionSpecReadiness";
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
describe("dungeon party composition", () => {
beforeEach(() => usePartyStore.setState({
@@ -215,7 +215,7 @@ describe("dungeon party composition", () => {
expect(first.every((member) => member.level === 18 && member.health === member.maxHealth)).toBe(true);
});
it("syncs every RoM companion class through entry and level changes without WoW talents", () => {
it("generates only the active five RoM companion classes and preserves them on level changes", () => {
const seenClasses = new Set<string>();
for (const role of ["tank", "healer", "damage"] as const) {
for (let seed = 0; seed < 20; seed += 1) {
@@ -240,7 +240,7 @@ describe("dungeon party composition", () => {
}
}
}
expect([...seenClasses].sort()).toEqual([...ROM_CLASS_IDS].sort());
expect([...seenClasses].sort()).toEqual(ROM_FIVE_IDENTITIES.map((identity) => identity.classId).sort());
});
it("preserves fallen RoM companions and living health/resource ratios on level sync", () => {
@@ -260,6 +260,16 @@ describe("dungeon party composition", () => {
expect(syncedHealer.resourcePools.mana).toBe(syncedHealer.resource);
});
it("retains explicitly configured RoM companions outside the default active pool", () => {
const member = createConfiguredPartyMember({
name: "Explicit Warlock", classId: "rom-warlock", statRuleset: "rom",
role: "damage", specialization: "Primary", talentTreeId: "", raceId: "rom-dwarf",
}, 30, 0, "explicit-rom-companion");
expect(member.classId).toBe("rom-warlock");
expect(member.statRuleset).toBe("rom");
expect(member.maxHealth).toBeGreaterThan(0);
});
it("supports solo through five-person Manastorm party sizes without changing the dungeon default", () => {
expect(createDungeonParty("sizes", 18)).toHaveLength(4);
for (const size of [1, 2, 3, 4, 5] as const) {
+49 -26
View File
@@ -26,7 +26,9 @@ import {
damageAfterEquipmentMitigation,
deriveCharacterStats,
} from "./wotlkStats";
import { clampPlayerLevel } from "./progression";
import type { InventoryItem } from "./lootTypes";
import type { RomNativeRecoveryClock } from "./romNativeRecovery";
import {
runtimeDefaultPartyTalentProfile,
runtimePartyTalentRanksForLevel,
@@ -91,6 +93,7 @@ export interface PartyMember {
readonly appearance: CharacterAppearance;
readonly classId: ClassId;
readonly statRuleset?: CharacterStatRuleset;
readonly romNativeRecoveryClock?: RomNativeRecoveryClock;
readonly secondaryClassId?: ClassId | null;
readonly specialization: string;
readonly specializationId: string | null;
@@ -202,20 +205,15 @@ function wowTemplates(
}
const ROM_TANK_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
{ name: "Aldric", classId: "rom-warrior", statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
{ ...romFiveIdentity("rom-knight", "tank")!, statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
{ name: "Maerwen", classId: "rom-warden", statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
{ name: "Bronn", classId: "rom-champion", statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
]);
const ROM_HEALER_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
{ ...romFiveIdentity("rom-priest", "healer")!, statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
{ name: "Sylva", classId: "rom-druid", statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
]);
const ROM_DAMAGE_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
{ ...romFiveIdentity("rom-scout", "damage")!, statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
{ ...romFiveIdentity("rom-rogue", "damage")!, statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
{ ...romFiveIdentity("rom-mage", "damage")!, statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
{ name: "Morgha", classId: "rom-warlock", statRuleset: "rom", specialization: "Primary", talentTreeId: "" },
]);
export const TANK_SPECIALIZATIONS = Object.freeze(new Set(
@@ -264,7 +262,7 @@ function memberMaxHealth(
secondaryClassId?: ClassId | null,
gender: GenderId = "male",
): number {
const safeLevel = Math.max(1, Math.trunc(level));
const safeLevel = clampPlayerLevel(level);
if (statRuleset === "rom") {
return deriveCharacterStats(classId, safeLevel, gearStats, raceId, {}, secondaryClassId, gender, statRuleset).health;
}
@@ -285,9 +283,10 @@ function memberMaxResource(
secondaryClassId?: ClassId | null,
gender: GenderId = "male",
): number {
const safeLevel = clampPlayerLevel(level);
const profile = resourceProfileForClass(classId);
return profile.type === "mana"
? deriveCharacterStats(classId, level, gearStats, raceId, {}, secondaryClassId, gender, statRuleset).mana
? deriveCharacterStats(classId, safeLevel, gearStats, raceId, {}, secondaryClassId, gender, statRuleset).mana
: profile.maximum;
}
@@ -311,6 +310,19 @@ function initialSecondaryResource(type: ResourceType, maximum: number): number {
: maximum;
}
function rescaledMemberValue(
current: number,
currentMaximum: number,
nextMaximum: number,
preserveNativeCap: boolean,
minimum: number,
): number {
if (current <= 0) return 0;
const fraction = Math.max(0, Math.min(1, current / Math.max(1, currentMaximum)));
if (preserveNativeCap && fraction >= 1) return nextMaximum;
return Math.max(minimum, Math.min(nextMaximum, Math.round(fraction * nextMaximum)));
}
/** Keeps every resource used by the member's learned kit available to companion AI. */
function withMemberAbilityResourcePools(member: PartyMember): PartyMember {
const types = new Set<ResourceType>([member.resourceType]);
@@ -370,13 +382,13 @@ function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMe
);
const health = member.health <= 0
? 0
: Math.max(1, Math.round(member.health / Math.max(1, member.maxHealth) * maxHealth));
: rescaledMemberValue(member.health, member.maxHealth, maxHealth, member.statRuleset === "rom", 1);
const maxResource = memberMaxResource(
member.classId, member.level, member.raceId, gearStats, member.statRuleset, member.secondaryClassId, member.gender,
);
const resource = Math.max(0, Math.round(
member.resource / Math.max(1, member.maxResource) * maxResource,
));
const resource = rescaledMemberValue(
member.resource, member.maxResource, maxResource, member.statRuleset === "rom", 0,
);
return withMemberAbilityResourcePools({
...member,
resourceMaximums: undefined,
@@ -397,6 +409,7 @@ function createMember(
seed: string,
configuredTalentRanks?: TalentRanks,
): PartyMember {
const safeLevel = clampPlayerLevel(level);
// Appearance has its own keyed stream so adding customization fields later
// cannot reshuffle which class/spec templates make up this character's party.
const identityRandom = {
@@ -432,22 +445,28 @@ function createMember(
ownerId: id,
ownerKind: "party",
classId: template.classId,
secondaryClassId: level >= ROM_SECONDARY_UNLOCK_LEVEL ? template.secondaryClassId ?? null : null,
secondaryClassId: safeLevel >= ROM_SECONDARY_UNLOCK_LEVEL ? template.secondaryClassId ?? null : null,
specializationId,
role,
level,
level: safeLevel,
});
const secondaryClassId = level >= ROM_SECONDARY_UNLOCK_LEVEL ? template.secondaryClassId ?? null : null;
const secondaryClassId = safeLevel >= ROM_SECONDARY_UNLOCK_LEVEL ? template.secondaryClassId ?? null : null;
const maxHealth = memberMaxHealth(
level, role, gearStats, template.classId, race.id, statRuleset, secondaryClassId, gender,
safeLevel, role, gearStats, template.classId, race.id, statRuleset, secondaryClassId, gender,
);
const resourceProfile = resourceProfileForClass(template.classId);
const maxResource = memberMaxResource(
template.classId, level, race.id, gearStats, statRuleset, secondaryClassId, gender,
);
const resource = Math.round(
maxResource * (resourceProfile.maximum > 0 ? resourceProfile.initial / resourceProfile.maximum : 0),
template.classId, safeLevel, race.id, gearStats, statRuleset, secondaryClassId, gender,
);
const resource = statRuleset === "rom"
? resourceProfile.initial === resourceProfile.maximum && resourceProfile.maximum > 0
? maxResource
: Math.max(0, Math.min(maxResource, Math.round(
maxResource * (resourceProfile.maximum > 0 ? resourceProfile.initial / resourceProfile.maximum : 0),
)))
: Math.round(
maxResource * (resourceProfile.maximum > 0 ? resourceProfile.initial / resourceProfile.maximum : 0),
);
return withMemberAbilityResourcePools({
id,
name: template.name,
@@ -463,9 +482,9 @@ function createMember(
talentProfileId: template.talentProfileId ?? null,
talentRanks: configuredTalentRanks ?? (isRomClassId(template.classId)
? Object.freeze({})
: runtimePartyTalentRanksForLevel(template.classId, template.specialization, level, template.talentProfileId ?? undefined)),
: runtimePartyTalentRanksForLevel(template.classId, template.specialization, safeLevel, template.talentProfileId ?? undefined)),
role,
level: Math.max(1, Math.trunc(level)),
level: safeLevel,
health: maxHealth,
maxHealth,
resourceType: resourceProfile.type,
@@ -739,7 +758,7 @@ export const usePartyStore = create<PartyState>((set, get) => ({
}),
syncLevel: (level) => set((state) => {
const safeLevel = Math.max(1, Math.trunc(level));
const safeLevel = clampPlayerLevel(level);
if (state.members.every((member) => member.level === safeLevel)) return state;
return {
members: state.members.map((member) => {
@@ -761,7 +780,9 @@ export const usePartyStore = create<PartyState>((set, get) => ({
);
const health = member.health <= 0
? 0
: Math.max(1, Math.round(member.health / Math.max(1, member.maxHealth) * maxHealth));
: rescaledMemberValue(
member.health, member.maxHealth, maxHealth, member.statRuleset === "rom", 1,
);
const maxResource = memberMaxResource(
member.classId,
safeLevel,
@@ -771,9 +792,9 @@ export const usePartyStore = create<PartyState>((set, get) => ({
nextSecondaryClassId,
member.gender,
);
const resource = Math.max(0, Math.round(
member.resource / Math.max(1, member.maxResource) * maxResource,
));
const resource = rescaledMemberValue(
member.resource, member.maxResource, maxResource, member.statRuleset === "rom", 0,
);
return withMemberAbilityResourcePools({
...member,
resourceMaximums: undefined,
@@ -900,6 +921,7 @@ export const usePartyStore = create<PartyState>((set, get) => ({
return {
...member,
health: restored,
romNativeRecoveryClock: undefined,
status: "ready",
controlledUntil: 0,
controlMechanic: null,
@@ -928,6 +950,7 @@ export const usePartyStore = create<PartyState>((set, get) => ({
return {
...member,
health,
romNativeRecoveryClock: health <= 0 ? undefined : member.romNativeRecoveryClock,
status: health <= 0 ? "down" : member.status,
};
}),
+11
View File
@@ -427,6 +427,14 @@ function procTrigger(trigger: Extract<RomAbilitySemantic, { kind: "proc" }>["tri
function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic, index: number): AbilityEffect {
const componentKey = semantic.sourceComponentIds.join("-") || "tooltip";
const effectId = `${row.sourceSkillId}-${componentKey}-${index}`;
const nativeSource = ["damage", "heal", "dot", "hot"].includes(semantic.kind)
&& semantic.sourceComponentIds.length === 1
? { romNativeSource: {
skillId: row.sourceSkillId,
componentId: semantic.sourceComponentIds[0],
kind: semantic.kind as "damage" | "heal" | "dot" | "hot",
} }
: {};
if (semantic.kind === "damage" || semantic.kind === "heal") {
const rawPower = Math.max(0, semantic.power);
const rawFixed = Math.max(0, semantic.fixedValue);
@@ -443,6 +451,7 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
const pointsPerLevel = sourcePoints * Math.max(0, semantic.powerPerSkillLevel) / 100;
const basePoints = sourcePoints + pointsPerLevel;
const amount = {
...nativeSource,
coefficient,
...(coefficientPerLevel > 0 ? { coefficientPerLevel, sourceLevel: 1 } : {}),
...(basePoints > 0 ? {
@@ -469,6 +478,7 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
const percentHealthHot = semantic.kind === "hot" && semantic.resource === "health-percent";
return {
kind: semantic.kind,
...nativeSource,
coefficient: 0,
...(semantic.kind === "dot" && combatSchool(semantic.school)
? { school: combatSchool(semantic.school) }
@@ -1613,6 +1623,7 @@ function ability(row: GeneratedSkill): AbilityDefinition {
effects: currentCoaPassive ? [] : effects({ ...row, target, classId, secondaryClassId }),
passive: currentMageDruidGreenGuardian ? false : currentCoaPassiveMode || row.sourceSkillId === 490274 ? true : row.passive,
source: "runewaker",
...(row.sourceSkillId === 490148 ? { sourceAttackKind: "melee" as const } : {}),
sourceUnlockLevel: row.unlockLevel,
ranks: currentRanks,
romSkillGroup: currentMageDruidGreenGuardian ? "elite" : row.group,
+268
View File
@@ -0,0 +1,268 @@
import { afterAll, describe, expect, it } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { contentCategoryForClass } from "../app/characterCatalog";
import { useCombatStore } from "./combatStore";
import { subscribeCombatEvents } from "./combatEvents";
import { dungeonEnemyScaling } from "./dungeonBossScaling";
import { finalDungeonBoss } from "./dungeonRunStore";
import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { useGameStore } from "./store";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { abilitiesForCharacter } from "./abilityCatalog";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
const LEVELS = [1, 10, 30, 60, 79, 80] as const;
const RUN_LIMIT_MS = 120_000;
const dungeon = requireDungeonDefinition("barren-caves");
const trashSpawn = dungeon.staticSpawns.find((spawn) => dungeon.entities[spawn.entityId]?.kind === "mob")!;
const trashEntity = dungeon.entities[trashSpawn.entityId];
const anglo = finalDungeonBoss(dungeon)!;
const angloSpawn = dungeon.staticSpawns.find((spawn) => spawn.id === anglo.id)!;
const angloEntity = dungeon.entities[angloSpawn.entityId];
interface HealthSample {
readonly identity: string;
readonly classId: string;
readonly level: number;
readonly runLimitMs: number;
readonly target: "trash" | "Anglo";
readonly mobLevel: number;
readonly maxHealth: number;
readonly remainingHealth: number;
readonly mobDead: boolean;
readonly mobAttackRevision: number;
readonly playerHealth: number;
readonly defeatedCompanions: readonly string[];
readonly secondsFromFirstPartyHit: number | null;
readonly firstPartyHitAtMs: number | null;
readonly incomingDamage: number;
readonly healing: number;
readonly partyClassIds: readonly string[];
readonly partyRoles: readonly string[];
}
const samples: HealthSample[] = [];
afterAll(() => {
if (process.env.ROM_FIVE_NATIVE_DUNGEON_HEALTH_REPORT !== "1") return;
const directory = resolve("artifacts/rom-five-native-dungeon-health");
mkdirSync(directory, { recursive: true });
writeFileSync(resolve(directory, "report.json"), `${JSON.stringify({
diagnostic: "Native RoM identity cohort; companion damage is simulated, with player healing when the player is the Priest.",
levels: LEVELS,
runLimitMs: RUN_LIMIT_MS,
samples,
}, null, 2)}\n`);
});
function initializeNativeIdentityParty(activeIdentity: (typeof ROM_FIVE_IDENTITIES)[number], level: number, seed: string) {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useGameStore.getState().activateDungeon("barren-caves", "normal", { gameMode: "dungeon", bossDifficulty: 1 });
const secondaryClassId = level >= 10 ? activeIdentity.secondaryClassId : null;
const combat = useCombatStore.getState();
combat.initializeCharacter({
classId: activeIdentity.classId,
statRuleset: "rom",
raceId: activeIdentity.raceId,
secondaryClassId,
role: activeIdentity.role,
level,
});
combat.setPlayerPosition([-18, 0, 0]);
usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health,
activeIdentity.role, 5, "rom");
const companions = ROM_FIVE_IDENTITIES
.filter((identity) => identity.classId !== activeIdentity.classId)
.map((identity, index) => createConfiguredPartyMember({
name: identity.name,
classId: identity.classId,
statRuleset: "rom",
secondaryClassId: level >= 10 ? identity.secondaryClassId : null,
raceId: identity.raceId,
specialization: "Primary",
talentTreeId: "",
role: identity.role,
}, level, index, seed));
usePartyStore.setState({ members: companions });
combat.synchronizeActors();
return companions;
}
function measurePartyPull(activeIdentity: (typeof ROM_FIVE_IDENTITIES)[number], level: number,
target: "trash" | "Anglo"): HealthSample {
const entity = target === "Anglo" ? angloEntity : trashEntity;
const spawn = target === "Anglo" ? angloSpawn : trashSpawn;
const boss = entity.kind === "boss";
const runLimitMs = target === "Anglo" && level === 1 ? 240_000 : RUN_LIMIT_MS;
const seed = `rom-five-native:${activeIdentity.classId}:${level}:${target}`;
const companions = initializeNativeIdentityParty(activeIdentity, level, seed);
const scaling = dungeonEnemyScaling(dungeon, entity, spawn.id, level, "normal", 1,
contentCategoryForClass(activeIdentity.classId));
const combat = useCombatStore.getState();
combat.registerMob(spawn.id, {
name: entity.name,
serverEntry: entity.combat?.serverEntry,
boss,
...scaling,
attacks: entity.combat?.attacks,
xpReward: 0,
});
setMobPosition(spawn.id, [0, 0, 0]);
for (const member of companions) {
registerPartyRuntimePosition(member.id, [-partyCombatRangeBand(member).preferred, 0, 0]);
updatePartyRuntimeTargetVisibility(member.id, spawn.id, true);
}
// This opens the pull before event collection, matching the existing RoM durability diagnostic.
combat.damageMob(spawn.id, 1, 0);
let firstPartyHitAtMs: number | null = null;
let diedAtMs: number | null = null;
let incomingDamage = 0;
let healing = 0;
const stop = subscribeCombatEvents((event) => {
if (event.targetActorId === spawn.id && event.kind === "damage"
&& event.effectiveAmount > 0 && event.sourceActorId !== "player") {
firstPartyHitAtMs ??= event.occurredAt;
}
if (event.sourceActorId === spawn.id && event.kind === "damage") incomingDamage += event.effectiveAmount;
if (event.kind === "healing") healing += event.effectiveAmount;
});
try {
const priestHeal = activeIdentity.classId === "rom-priest"
? abilitiesForCharacter(activeIdentity.classId, level >= 10 ? activeIdentity.secondaryClassId : null)
.find((ability) => ability.target === "friendly"
&& ability.effects.some((effect) => effect.kind === "heal")
&& (ability.unlockLevel ?? 1) <= level)
: undefined;
for (let now = 100; now <= runLimitMs; now += 100) {
combat.tick(0.1, now);
advanceDungeonPartyCombat(now);
combat.advanceMobCombat(now);
if (priestHeal && !useCombatStore.getState().activeCast) {
const tank = usePartyStore.getState().members.find((member) => member.role === "tank");
if (tank && tank.health < tank.maxHealth * 0.65) {
usePartyStore.getState().selectMember(tank.id);
combat.castAbility(priestHeal.id, undefined, now);
}
}
const mob = useCombatStore.getState().mobs[spawn.id];
if (mob.dead) {
diedAtMs = mob.diedAt ?? now;
break;
}
}
} finally {
stop();
}
const mob = useCombatStore.getState().mobs[spawn.id];
const combatAfterPull = useCombatStore.getState();
const companionsAfterPull = usePartyStore.getState().members;
return {
identity: activeIdentity.name,
classId: activeIdentity.classId,
level,
runLimitMs,
target,
mobLevel: mob.level,
maxHealth: mob.maxHealth,
remainingHealth: mob.health,
mobDead: mob.dead,
mobAttackRevision: mob.attackRevision,
playerHealth: combatAfterPull.health,
defeatedCompanions: companionsAfterPull.filter((member) => member.health <= 0).map((member) => member.classId),
secondsFromFirstPartyHit: diedAtMs === null || firstPartyHitAtMs === null
? null
: (diedAtMs - firstPartyHitAtMs) / 1_000,
firstPartyHitAtMs,
incomingDamage,
healing,
partyClassIds: [activeIdentity.classId, ...companions.map((member) => member.classId)],
partyRoles: [activeIdentity.role, ...companions.map((member) => member.role)],
};
}
describe("native RoM five identity dungeon health diagnostic", () => {
it("keeps the player-origin RoM curve when each canonical identity enters actual WoW and RoM trash", () => {
const wowDungeon = requireDungeonDefinition("stormwind-stockade");
const wowTrashSpawn = wowDungeon.staticSpawns.find((spawn) => wowDungeon.entities[spawn.entityId]?.kind === "mob")!;
const wowTrash = wowDungeon.entities[wowTrashSpawn.entityId];
for (const identity of ROM_FIVE_IDENTITIES) {
expect(contentCategoryForClass(identity.classId)).toBe("rom");
for (const level of LEVELS) {
const expected = dungeonEnemyHealthBaseline(level, "rom");
const romActual = dungeonEnemyScaling(dungeon, trashEntity, trashSpawn.id, level, "normal", 1,
contentCategoryForClass(identity.classId));
const wowActual = dungeonEnemyScaling(wowDungeon, wowTrash, wowTrashSpawn.id, level, "normal", 1,
contentCategoryForClass(identity.classId));
expect(romActual.maxHealth).toBe(expected * (trashEntity.combat?.healthMultiplier ?? 1));
expect(wowActual.maxHealth).toBe(expected * (wowTrash.combat?.healthMultiplier ?? 1));
expect(romActual.level).toBe(level);
expect(wowActual.level).toBe(level);
}
}
});
it("keeps RoM origin scaling for a WoW-ruleset RoM player with WoW party members", () => {
const player = ROM_FIVE_IDENTITIES.find((identity) => identity.classId === "rom-priest")!;
const level = 30;
useCombatStore.getState().initializeCharacter({
classId: player.classId,
statRuleset: "wow",
raceId: player.raceId,
level,
});
usePartyStore.getState().initializeParty("rom-origin-mixed-party", level,
useCombatStore.getState().health, "healer", 5, "wow");
const source = contentCategoryForClass(useCombatStore.getState().classId);
const mixedParty = usePartyStore.getState();
expect(useCombatStore.getState().statRuleset).toBe("wow");
expect(source).toBe("rom");
expect(mixedParty.categoryId).toBe("wow");
expect(mixedParty.members.some((member) => !member.classId.startsWith("rom-"))).toBe(true);
const selectedDungeon = requireDungeonDefinition("stormwind-stockade");
const spawn = selectedDungeon.staticSpawns
.find((entry) => selectedDungeon.entities[entry.entityId]?.kind === "mob")!;
const entity = selectedDungeon.entities[spawn.entityId];
expect(dungeonEnemyScaling(selectedDungeon, entity, spawn.id, level, "normal", 1, source).maxHealth)
.toBe(dungeonEnemyHealthBaseline(level, "rom") * (entity.combat?.healthMultiplier ?? 1));
});
it("reports campaign health and observed party damage against real Barren Caves spawns", () => {
expect(trashEntity.kind).toBe("mob");
expect(angloEntity.name).toBe("Anglo");
expect(angloEntity.kind).toBe("boss");
for (const identity of ROM_FIVE_IDENTITIES) {
for (const level of LEVELS) {
const trash = measurePartyPull(identity, level, "trash");
const boss = measurePartyPull(identity, level, "Anglo");
samples.push(trash, boss);
const expectedTrashHealth = dungeonEnemyScaling(dungeon, trashEntity, trashSpawn.id,
level, "normal", 1, "rom").maxHealth;
const expectedAngloHealth = dungeonEnemyScaling(dungeon, angloEntity, angloSpawn.id,
level, "normal", 1, "rom").maxHealth;
expect(trash.maxHealth).toBe(Math.round(expectedTrashHealth));
expect(boss.maxHealth).toBe(Math.round(expectedAngloHealth));
expect(trash.mobLevel).toBe(scaledDungeonEnemyLevel(level, false));
expect(boss.mobLevel).toBe(scaledDungeonEnemyLevel(level, true));
expect(trash.partyClassIds).toHaveLength(5);
expect(new Set(trash.partyClassIds)).toEqual(new Set(ROM_FIVE_IDENTITIES.map((entry) => entry.classId)));
expect(boss.partyClassIds).toEqual(trash.partyClassIds);
expect(boss.partyRoles).toEqual(trash.partyRoles);
}
}
}, 240_000);
});
@@ -0,0 +1,261 @@
import { afterAll, describe, expect, it } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { contentCategoryForClass } from "../app/characterCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { subscribeCombatEvents } from "./combatEvents";
import { dungeonEnemyScaling } from "./dungeonBossScaling";
import { finalDungeonBoss } from "./dungeonRunStore";
import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { useGameStore } from "./store";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { BenchmarkController } from "./benchmark/benchmarkController";
import { BENCHMARK_SCENARIOS } from "./benchmark/benchmarkScenarios";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
import { romNativeNpcIdForEntity, romNativeNpcCombatant } from "./romNativeCombatResolution";
const LEVELS = [1, 10, 30, 60, 65, 66, 79, 80] as const;
const RUN_LIMIT_MS = 120_000;
const dungeon = requireDungeonDefinition("barren-caves");
const trashSpawn = dungeon.staticSpawns.find((spawn) => dungeon.entities[spawn.entityId]?.kind === "mob")!;
const trashEntity = dungeon.entities[trashSpawn.entityId];
const anglo = finalDungeonBoss(dungeon)!;
const angloSpawn = dungeon.staticSpawns.find((spawn) => spawn.id === anglo.id)!;
const angloEntity = dungeon.entities[angloSpawn.entityId];
interface HealthSample {
readonly identity: string;
readonly classId: string;
readonly level: number;
readonly runLimitMs: number;
readonly target: "trash" | "Anglo";
readonly mobLevel: number;
readonly runewakerNpcId: number | undefined;
readonly nativeCombatStatus: string;
readonly maxHealth: number;
readonly remainingHealth: number;
readonly mobDead: boolean;
readonly mobAttackRevision: number;
readonly playerHealth: number;
readonly defeatedCompanions: readonly string[];
readonly secondsFromFirstPartyHit: number | null;
readonly firstPartyHitAtMs: number | null;
readonly playerDamage: number;
readonly casts: number;
readonly resourceBlockedDecisions: number;
readonly invalidFailures: number;
readonly abilitiesUsed: Record<string, number>;
readonly incomingDamage: number;
readonly healing: number;
readonly partyClassIds: readonly string[];
readonly partyRoles: readonly string[];
}
const samples: HealthSample[] = [];
afterAll(() => {
if (process.env.ROM_FIVE_NATIVE_ROTATION_REPORT !== "1") return;
const directory = resolve("artifacts/rom-five-native-rotation");
mkdirSync(directory, { recursive: true });
writeFileSync(resolve(directory, "report.json"), `${JSON.stringify({
diagnostic: "Targeted native RoM five: active player controlled by the shared rotation policy, four native NPCs, actual spawn scaling/attacks. Static positioning; no scene encounter mechanics.",
levels: LEVELS,
runLimitMs: RUN_LIMIT_MS,
samples,
}, null, 2)}\n`);
});
function initializeNativeIdentityParty(activeIdentity: (typeof ROM_FIVE_IDENTITIES)[number], level: number, seed: string) {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useGameStore.getState().activateDungeon("barren-caves", "normal", { gameMode: "dungeon", bossDifficulty: 1 });
const secondaryClassId = level >= 10 ? activeIdentity.secondaryClassId : null;
const combat = useCombatStore.getState();
combat.initializeCharacter({
classId: activeIdentity.classId,
statRuleset: "rom",
raceId: activeIdentity.raceId,
secondaryClassId,
role: activeIdentity.role,
level,
});
combat.setPlayerPosition([activeIdentity.classId === "rom-knight" || activeIdentity.classId === "rom-rogue" ? -2 : activeIdentity.classId === "rom-scout" ? -5 : -15, 0, 0]);
usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health,
activeIdentity.role, 5, "rom");
const companions = ROM_FIVE_IDENTITIES
.filter((identity) => identity.classId !== activeIdentity.classId)
.map((identity, index) => createConfiguredPartyMember({
name: identity.name,
classId: identity.classId,
statRuleset: "rom",
secondaryClassId: level >= 10 ? identity.secondaryClassId : null,
raceId: identity.raceId,
specialization: "Primary",
talentTreeId: "",
role: identity.role,
}, level, index, seed));
usePartyStore.setState({ members: companions });
combat.synchronizeActors();
return companions;
}
function measurePartyPull(activeIdentity: (typeof ROM_FIVE_IDENTITIES)[number], level: number,
target: "trash" | "Anglo"): HealthSample {
const entity = target === "Anglo" ? angloEntity : trashEntity;
const spawn = target === "Anglo" ? angloSpawn : trashSpawn;
const boss = entity.kind === "boss";
const runLimitMs = target === "Anglo" && level === 1 ? 240_000 : RUN_LIMIT_MS;
const seed = `rom-five-native:${activeIdentity.classId}:${level}:${target}`;
const companions = initializeNativeIdentityParty(activeIdentity, level, seed);
const scaling = dungeonEnemyScaling(dungeon, entity, spawn.id, level, "normal", 1,
contentCategoryForClass(activeIdentity.classId), "rom");
const combat = useCombatStore.getState();
combat.registerMob(spawn.id, {
name: entity.name,
serverEntry: entity.combat?.serverEntry,
runewakerNpcId: entity.combat?.runewakerNpcId ?? romNativeNpcIdForEntity(entity.id),
boss,
...scaling,
attacks: entity.combat?.attacks,
xpReward: 0,
});
setMobPosition(spawn.id, [0, 0, 0]);
for (const member of companions) {
registerPartyRuntimePosition(member.id, [-partyCombatRangeBand(member).preferred, 0, 0]);
updatePartyRuntimeTargetVisibility(member.id, spawn.id, true);
}
// This opens the pull before event collection, matching the existing RoM durability diagnostic.
combat.damageMob(spawn.id, 1, 0);
combat.selectMob(spawn.id);
const controller = new BenchmarkController();
const scenario = { ...BENCHMARK_SCENARIOS[0], hostileTargets: 1 };
let casts = 0;
let resourceBlockedDecisions = 0;
let invalidFailures = 0;
let playerDamage = 0;
const abilitiesUsed: Record<string, number> = {};
let firstPartyHitAtMs: number | null = null;
let diedAtMs: number | null = null;
let incomingDamage = 0;
let healing = 0;
const stop = subscribeCombatEvents((event) => {
if (event.targetActorId === spawn.id && event.kind === "damage"
&& event.effectiveAmount > 0 ) {
firstPartyHitAtMs ??= event.occurredAt;
}
if (event.sourceActorId === PLAYER_AGGRO_ID && event.kind === "damage") playerDamage += event.effectiveAmount;
if (event.sourceActorId === PLAYER_AGGRO_ID && (event.kind === "damage" || event.kind === "healing")) {
abilitiesUsed[event.abilityId ?? "auto"] = (abilitiesUsed[event.abilityId ?? "auto"] ?? 0) + 1;
}
if (event.sourceActorId === spawn.id && event.kind === "damage") incomingDamage += event.effectiveAmount;
if (event.kind === "healing") healing += event.effectiveAmount;
});
try {
for (let now = 100; now <= runLimitMs; now += 100) {
combat.tick(0.1, now);
advanceDungeonPartyCombat(now);
combat.advanceMobCombat(now);
const targetBeforeDecision = useCombatStore.getState().mobs[spawn.id];
if (targetBeforeDecision.dead) {
diedAtMs = targetBeforeDecision.diedAt ?? now;
break;
}
const decision = controller.act(now, activeIdentity.role, scenario, false);
casts += Number(decision.cast);
resourceBlockedDecisions += Number(decision.resourceBlocked);
invalidFailures += decision.invalidFailures;
const mob = useCombatStore.getState().mobs[spawn.id];
if (mob.dead) {
diedAtMs = mob.diedAt ?? now;
break;
}
}
} finally {
stop();
}
const mob = useCombatStore.getState().mobs[spawn.id];
const combatAfterPull = useCombatStore.getState();
const companionsAfterPull = usePartyStore.getState().members;
return {
identity: activeIdentity.name,
classId: activeIdentity.classId,
level,
runLimitMs,
target,
mobLevel: mob.level,
runewakerNpcId: mob.runewakerNpcId,
nativeCombatStatus: mob.runewakerNpcId
? romNativeNpcCombatant(mob.runewakerNpcId, mob.level)?.combatStatus ?? "missing" : "missing",
maxHealth: mob.maxHealth,
remainingHealth: mob.health,
mobDead: mob.dead,
mobAttackRevision: mob.attackRevision,
playerHealth: combatAfterPull.health,
defeatedCompanions: companionsAfterPull.filter((member) => member.health <= 0).map((member) => member.classId),
secondsFromFirstPartyHit: diedAtMs === null || firstPartyHitAtMs === null
? null
: (diedAtMs - firstPartyHitAtMs) / 1_000,
firstPartyHitAtMs,
playerDamage,
casts,
resourceBlockedDecisions,
invalidFailures,
abilitiesUsed,
incomingDamage,
healing,
partyClassIds: [activeIdentity.classId, ...companions.map((member) => member.classId)],
partyRoles: [activeIdentity.role, ...companions.map((member) => member.role)],
};
}
describe("native RoM five full rotation diagnostic", () => {
it("reports player rotations and full-party damage against real Barren Caves spawns", () => {
expect(trashEntity.kind).toBe("mob");
expect(angloEntity.name).toBe("Anglo");
expect(angloEntity.kind).toBe("boss");
for (const identity of ROM_FIVE_IDENTITIES) {
for (const level of LEVELS) {
const trash = measurePartyPull(identity, level, "trash");
const boss = measurePartyPull(identity, level, "Anglo");
samples.push(trash, boss);
const expectedTrashHealth = dungeonEnemyScaling(dungeon, trashEntity, trashSpawn.id,
level, "normal", 1, "rom", "rom").maxHealth;
const expectedAngloHealth = dungeonEnemyScaling(dungeon, angloEntity, angloSpawn.id,
level, "normal", 1, "rom", "rom").maxHealth;
expect(trash.maxHealth).toBe(Math.round(expectedTrashHealth));
expect(boss.maxHealth).toBe(Math.round(expectedAngloHealth));
expect(trash.mobLevel).toBe(scaledDungeonEnemyLevel(level, false));
expect(boss.mobLevel).toBe(scaledDungeonEnemyLevel(level, true));
expect(trash.nativeCombatStatus).toBe("supported");
expect(boss.nativeCombatStatus).toBe("supported");
expect(trash.mobDead).toBe(true);
expect(boss.mobDead).toBe(true);
expect(trash.invalidFailures).toBe(0);
expect(boss.invalidFailures).toBe(0);
expect(trash.playerHealth).toBeGreaterThan(0);
expect(boss.playerHealth).toBeGreaterThan(0);
expect(trash.defeatedCompanions).toEqual([]);
expect(boss.defeatedCompanions).toEqual([]);
expect(trash.casts).toBeGreaterThan(0);
expect(boss.casts).toBeGreaterThan(0);
if (identity.role !== "healer") expect(boss.playerDamage).toBeGreaterThan(0);
expect(trash.partyClassIds).toHaveLength(5);
expect(new Set(trash.partyClassIds)).toEqual(new Set(ROM_FIVE_IDENTITIES.map((entry) => entry.classId)));
expect(boss.partyClassIds).toEqual(trash.partyClassIds);
expect(boss.partyRoles).toEqual(trash.partyRoles);
}
}
}, 240_000);
});
@@ -0,0 +1,365 @@
import { beforeEach, describe, expect, it } from "vitest";
import { auraAdjustedCharacterStats, PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
import { EMPTY_ITEM_STATS } from "./itemStats";
import {
createConfiguredPartyMember,
createSpectatorDungeonParty,
usePartyStore,
} from "./partyStore";
import type { InventoryItem } from "./lootTypes";
import { deriveCharacterStats } from "./wotlkStats";
import { resolvedEquipmentItems } from "./heirloomEquipment";
const LEVELS = [1, 9, 10, 30, 60, 79, 80] as const;
const FIVE_IDENTITY_SPECTATOR_SEED = "rom-native-five-generated-60";
function nativePriestTestTrinket(): InventoryItem {
const instanceId = "rom-native-priest-level-sync-trinket";
return {
instanceId,
itemId: 990_001,
itemKey: instanceId,
name: "Native Priest level-sync test trinket",
quality: "rare",
inventorySlot: "trinket",
armorType: null,
weaponType: null,
itemLevel: 30,
sourceItemLevel: 30,
stats: { ...EMPTY_ITEM_STATS, romNative: { stamina: 250, health: 500, mana: 180 } },
quantity: 1,
sourceBossKey: "integration-test",
sourceBossName: "Integration Test",
acquiredAt: 0,
};
}
describe("native RoM stats for the five live classes", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
usePartyStore.setState({ members: [] });
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", level: 1 });
});
it.each(LEVELS)("keeps player, generated, and configured actor stats aligned at level %i", (level) => {
usePartyStore.getState().initializeSpectatorParty(FIVE_IDENTITY_SPECTATOR_SEED, level, "rom");
const generatedMembers = usePartyStore.getState().members;
expect(generatedMembers).toHaveLength(5);
for (const identity of ROM_FIVE_IDENTITIES) {
const generated = generatedMembers.find((member) => member.name === identity.name);
expect(generated, identity.name).toBeDefined();
if (!generated) throw new Error(`Generated RoM party did not include ${identity.name}.`);
const secondaryClassId = level >= 10 ? identity.secondaryClassId : null;
expect(generated).toMatchObject({
classId: identity.classId,
raceId: identity.raceId,
role: identity.role,
level,
secondaryClassId,
statRuleset: "rom",
});
usePartyStore.setState({ members: generatedMembers });
useCombatStore.getState().initializeCharacter({
classId: identity.classId,
raceId: identity.raceId,
gender: generated.gender,
role: identity.role,
specializationId: generated.specializationId,
secondaryClassId,
level,
statRuleset: "rom",
});
useCombatStore.getState().synchronizeActors();
const playerState = useCombatStore.getState();
const playerActor = playerState.actors[PLAYER_AURA_ENTITY_ID];
const generatedActor = playerState.actors[generated.id];
expect(playerState.statRuleset).toBe("rom");
expect(playerActor).toMatchObject({ kind: "player", classId: identity.classId, statRuleset: "rom", level });
expect(generatedActor).toMatchObject({
kind: "companion", classId: identity.classId, statRuleset: "rom", level,
});
const configured = createConfiguredPartyMember({
name: identity.name,
classId: identity.classId,
secondaryClassId: identity.secondaryClassId,
raceId: identity.raceId,
gender: generated.gender,
specialization: generated.specialization,
specializationId: generated.specializationId ?? undefined,
role: identity.role,
statRuleset: "rom",
}, level, 0, `rom-native-configured-${identity.classId}-${level}`);
expect(configured).toMatchObject({ level, secondaryClassId, statRuleset: "rom" });
const playerGear = playerState.gearStats;
expect(generated.gearStats).toEqual(playerGear);
expect(configured.gearStats).toEqual(playerGear);
const playerBaseNative = deriveCharacterStats(
identity.classId, level, playerGear, identity.raceId, {}, secondaryClassId, generated.gender, "rom",
).romNative!;
const playerNative = auraAdjustedCharacterStats(
deriveCharacterStats(
identity.classId, level, playerGear, identity.raceId, {}, secondaryClassId, generated.gender, "rom",
),
playerState.auras,
PLAYER_AURA_ENTITY_ID,
).romNative!;
const generatedNative = deriveCharacterStats(
generated.classId, generated.level, generated.gearStats, generated.raceId, {},
generated.secondaryClassId, generated.gender, generated.statRuleset,
).romNative!;
const configuredNative = deriveCharacterStats(
configured.classId, configured.level, configured.gearStats, configured.raceId, {},
configured.secondaryClassId, configured.gender, configured.statRuleset,
).romNative!;
expect(generatedNative).toEqual(playerBaseNative);
expect(configuredNative).toEqual(playerBaseNative);
expect(playerState.maxHealth).toBeCloseTo(playerBaseNative.health.max, 5);
expect(generated.maxHealth).toBeCloseTo(generatedNative.health.max, 5);
expect(generatedActor?.maxHealth).toBeCloseTo(generatedNative.health.max, 5);
if (playerNative.usesMana) {
expect(playerState.maxResourcePools.mana).toBeCloseTo(playerNative.mana.max, 5);
expect(generated.resource).toBe(generated.maxResource);
expect(generated.resourceMaximums?.mana).toBeCloseTo(generatedNative.mana.max, 5);
expect(generatedActor?.resources.mana?.maximum).toBeCloseTo(generatedNative.mana.max, 5);
expect(configured.resource).toBe(configured.maxResource);
expect(configured.resourceMaximums?.mana).toBeCloseTo(configured.maxResource, 5);
} else {
expect(playerBaseNative.mana.max).toBe(0);
expect(playerState.maxResourcePools.mana ?? 0).toBe(0);
expect(generated.resourceMaximums?.mana ?? 0).toBe(0);
}
usePartyStore.setState({ members: [configured] });
useCombatStore.getState().synchronizeActors();
const configuredActor = useCombatStore.getState().actors[configured.id];
expect(configuredActor).toMatchObject({
kind: "companion", classId: identity.classId, statRuleset: "rom", level,
});
expect(configuredActor?.maxHealth).toBeCloseTo(configuredNative.health.max, 5);
if (configuredNative.usesMana) {
expect(configuredActor?.resources.mana?.maximum).toBeCloseTo(configuredNative.mana.max, 5);
}
}
});
it("applies the level 10 secondary unlock while party actors scale through level 80", () => {
usePartyStore.getState().initializeSpectatorParty(FIVE_IDENTITY_SPECTATOR_SEED, 1, "rom");
for (const level of LEVELS) {
usePartyStore.getState().syncLevel(level);
useCombatStore.getState().synchronizeActors();
for (const identity of ROM_FIVE_IDENTITIES) {
const member = usePartyStore.getState().members.find((candidate) => candidate.name === identity.name)!;
const secondaryClassId = level >= 10 ? identity.secondaryClassId : null;
const native = deriveCharacterStats(
identity.classId, level, member.gearStats, identity.raceId, {}, secondaryClassId, member.gender, "rom",
).romNative!;
const actor = useCombatStore.getState().actors[member.id];
expect(member).toMatchObject({ level, secondaryClassId, statRuleset: "rom" });
expect(member.maxHealth, `${identity.name} at level ${level}`).toBeCloseTo(native.health.max, 5);
expect(member.health, `${identity.name} full health at level ${level}`).toBe(member.maxHealth);
expect(actor, `${identity.name} actor at level ${level}`).toMatchObject({
level, statRuleset: "rom", maxHealth: member.maxHealth,
});
if (native.usesMana) {
expect(member.resourceMaximums?.mana).toBeCloseTo(native.mana.max, 5);
expect(member.resourcePools.mana).toBe(member.resourceMaximums?.mana);
expect(actor?.resources.mana?.maximum).toBeCloseTo(native.mana.max, 5);
expect(actor?.resources.mana?.current).toBe(actor?.resources.mana?.maximum);
}
}
}
});
it("retains wounded and defeated RoM members safely while their native caps grow", () => {
usePartyStore.getState().initializeSpectatorParty(FIVE_IDENTITY_SPECTATOR_SEED, 30, "rom");
const membersAt30 = usePartyStore.getState().members;
const wounded = membersAt30.find((member) => member.name === "Serenna")!;
const defeated = membersAt30.find((member) => member.name === "Rorik")!;
const woundedHealthAt30 = Math.round(wounded.maxHealth * 0.43);
const woundedManaAt30 = Math.round(wounded.maxResource * 0.37);
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === wounded.id
? {
...member,
health: woundedHealthAt30,
resource: woundedManaAt30,
resourcePools: { ...member.resourcePools, mana: woundedManaAt30 },
}
: member.id === defeated.id
? { ...member, health: 0, status: "down" }
: member),
}));
usePartyStore.getState().syncLevel(60);
const woundedAt60 = usePartyStore.getState().members.find((member) => member.id === wounded.id)!;
const defeatedAt60 = usePartyStore.getState().members.find((member) => member.id === defeated.id)!;
expect(woundedAt60.health).toBeLessThan(woundedAt60.maxHealth);
expect(woundedAt60.health).toBeLessThanOrEqual(woundedAt60.maxHealth);
expect(woundedAt60.health / woundedAt60.maxHealth)
.toBeCloseTo(woundedHealthAt30 / wounded.maxHealth, 2);
expect(woundedAt60.resource).toBeLessThan(woundedAt60.maxResource);
expect(woundedAt60.resource).toBeLessThanOrEqual(woundedAt60.maxResource);
expect(woundedAt60.resource / woundedAt60.maxResource)
.toBeCloseTo(woundedManaAt30 / wounded.maxResource, 2);
expect(defeatedAt60).toMatchObject({ health: 0, status: "down", level: 60 });
});
it("keeps a configured Priest's full native Mana exact and bounds wounded pools when gear changes", () => {
const identity = ROM_FIVE_IDENTITIES.find((candidate) => candidate.classId === "rom-priest")!;
const member = createConfiguredPartyMember({
name: identity.name,
classId: identity.classId,
secondaryClassId: identity.secondaryClassId,
raceId: identity.raceId,
gender: "male",
specialization: "Primary",
role: identity.role,
statRuleset: "rom",
}, 30, 0, "rom-native-priest-gear-sync");
expect(member.resource).toBe(member.maxResource);
expect(member.resourceMaximums?.mana).toBe(member.maxResource);
const healthAt30 = Math.round(member.maxHealth * 0.43);
const manaAt30 = Math.round(member.maxResource * 0.37);
const wounded = {
...member,
health: healthAt30,
resource: manaAt30,
resourcePools: { ...member.resourcePools, mana: manaAt30 },
};
usePartyStore.setState({ members: [wounded] });
const trinket = nativePriestTestTrinket();
usePartyStore.getState().syncEquipment({ [member.id]: { trinket: trinket.instanceId } }, [trinket]);
const resized = usePartyStore.getState().members[0]!;
const native = deriveCharacterStats(
identity.classId, 30, resized.gearStats, identity.raceId, {}, identity.secondaryClassId, "male", "rom",
).romNative!;
expect(resized.maxHealth).toBeCloseTo(native.health.max, 5);
expect(resized.health).toBeLessThanOrEqual(resized.maxHealth);
expect(resized.health).toBeLessThan(resized.maxHealth);
expect(resized.health / resized.maxHealth).toBeCloseTo(healthAt30 / member.maxHealth, 2);
expect(resized.maxResource).toBeCloseTo(native.mana.max, 5);
expect(resized.resourceMaximums?.mana).toBeCloseTo(native.mana.max, 5);
expect(resized.resource).toBeLessThanOrEqual(resized.maxResource);
expect(resized.resource).toBeLessThan(resized.maxResource);
expect(resized.resource / resized.maxResource).toBeCloseTo(manaAt30 / member.maxResource, 2);
});
it("selects native stats from the explicit mode, while omitted configured/player modes stay legacy WoW", () => {
for (const identity of ROM_FIVE_IDENTITIES) {
const configured = createConfiguredPartyMember({
name: identity.name,
classId: identity.classId,
secondaryClassId: identity.secondaryClassId,
raceId: identity.raceId,
gender: "male",
specialization: "Primary",
role: identity.role,
}, 30, 0, `rom-native-legacy-${identity.classId}`);
expect(configured.statRuleset).toBe("wow");
usePartyStore.setState({ members: [configured] });
useCombatStore.getState().initializeCharacter({
classId: identity.classId,
raceId: identity.raceId,
gender: "male",
role: identity.role,
specializationId: configured.specializationId,
secondaryClassId: identity.secondaryClassId,
level: 30,
});
useCombatStore.getState().synchronizeActors();
const state = useCombatStore.getState();
expect(state.statRuleset).toBe("wow");
expect(state.actors[PLAYER_AURA_ENTITY_ID]?.statRuleset).toBe("wow");
expect(state.actors[configured.id]?.statRuleset).toBe("wow");
expect(deriveCharacterStats(
identity.classId, 30, state.gearStats, identity.raceId, {}, identity.secondaryClassId, "male", "wow",
).romNative).toBeUndefined();
}
});
it("clamps configured and synchronized actor levels to the shared 1–80 range", () => {
const identity = ROM_FIVE_IDENTITIES[0]!;
const configured = (level: number) => createConfiguredPartyMember({
name: identity.name,
classId: identity.classId,
secondaryClassId: identity.secondaryClassId,
raceId: identity.raceId,
gender: "male",
specialization: "Primary",
role: identity.role,
statRuleset: "rom",
}, level, 0, `rom-native-level-cap-${String(level)}`);
expect(configured(81)).toMatchObject({ level: 80, secondaryClassId: identity.secondaryClassId });
for (const invalidLevel of [Number.NaN, Number.POSITIVE_INFINITY, 0]) {
expect(configured(invalidLevel)).toMatchObject({ level: 1, secondaryClassId: null });
}
usePartyStore.setState({ members: [configured(30)] });
for (const level of [81, Number.NaN, Number.POSITIVE_INFINITY, 0]) {
usePartyStore.getState().syncLevel(level);
const member = usePartyStore.getState().members[0]!;
expect(member.level).toBe(level === 81 ? 80 : 1);
expect(member.secondaryClassId).toBe(level === 81 ? identity.secondaryClassId : null);
}
});
it("keeps Training gear capped and owner-kind parity intact at levels 79 and 80", () => {
for (const identity of ROM_FIVE_IDENTITIES) {
const snapshots = new Map<number, ReturnType<typeof resolvedEquipmentItems>>();
for (const level of [79, 80]) {
const secondaryClassId = identity.secondaryClassId;
const specializationId = createConfiguredPartyMember({
name: identity.name,
classId: identity.classId,
secondaryClassId,
raceId: identity.raceId,
gender: "male",
specialization: "Primary",
role: identity.role,
statRuleset: "rom",
}, level, 0, `rom-native-training-${identity.classId}-${level}`).specializationId;
const context = {
classId: identity.classId,
secondaryClassId,
specializationId,
role: identity.role,
level,
} as const;
const playerItems = resolvedEquipmentItems({}, [], {
...context, ownerId: `training-player-${identity.classId}`, ownerKind: "player",
});
const partyItems = resolvedEquipmentItems({}, [], {
...context, ownerId: `training-party-${identity.classId}`, ownerKind: "party",
});
expect(playerItems.length).toBeGreaterThan(0);
expect(playerItems.every((item) => item.itemLevel === 79)).toBe(true);
expect(partyItems.map((item) => item.itemLevel)).toEqual(playerItems.map((item) => item.itemLevel));
expect(playerItems.every((item) => item.name.includes("Training")
&& item.sourceBossName === "Training Armory" && item.quality === "heirloom")).toBe(true);
expect(partyItems.map(({ instanceId: _instanceId, ...item }) => item)).toEqual(
playerItems.map(({ instanceId: _instanceId, ...item }) => item),
);
snapshots.set(level, playerItems);
}
const at79 = snapshots.get(79)!;
const at80 = snapshots.get(80)!;
expect(at80.map(({ instanceId: _instanceId, ...item }) => item)).toEqual(
at79.map(({ instanceId: _instanceId, ...item }) => item),
);
}
});
});
@@ -0,0 +1,183 @@
import { describe, expect, it } from "vitest";
import type { ClassId, RomClassId } from "../app/characterCatalog";
import type { AbilityDefinition } from "./abilityCatalog";
import { abilitiesForCharacter, learnedAbilityForBuild } from "./abilityCatalog";
import { aiHintsForAbility } from "./abilityRules";
import { evaluateCombatRotation, type RotationContext } from "./combatRotation";
import { nativeRotationAbilityAllowed, nativeRotationProfileFor } from "./romFiveRotation";
import type { PartyRole } from "./partyRoles";
const PRIMARY_RESOURCE: Readonly<Record<RomClassId, "mana" | "rage" | "energy" | "focus">> = {
"rom-warrior": "rage",
"rom-scout": "focus",
"rom-rogue": "energy",
"rom-mage": "mana",
"rom-priest": "mana",
"rom-knight": "mana",
"rom-warden": "mana",
"rom-druid": "mana",
"rom-warlock": "focus",
"rom-champion": "rage",
};
function nativeContext(
classId: RomClassId,
secondaryClassId: RomClassId,
role: PartyRole,
overrides: Partial<RotationContext> = {},
): RotationContext {
const abilities = abilitiesForCharacter(classId, secondaryClassId)
.map((ability) => learnedAbilityForBuild(ability, 30, {}))
.filter((ability): ability is AbilityDefinition => Boolean(ability && !ability.passive));
const knownDamagingAbilityNames = new Set(abilities.filter((ability) => {
const categories = aiHintsForAbility(ability).categories;
return categories.includes("damage") || categories.includes("execute");
}).map((ability) => ability.name));
return {
now: 1_000,
classId,
secondaryClassId,
statRuleset: "rom",
role,
level: 30,
health: 100,
maxHealth: 100,
selfId: `${classId}-player`,
resources: { mana: 100, rage: 100, energy: 100, focus: 100 },
maximumResources: { mana: 100, rage: 100, energy: 100, focus: 100 },
primaryResource: PRIMARY_RESOURCE[classId],
comboPoints: 0,
comboTargetId: null,
runes: [],
abilities,
knownDamagingAbilityNames,
hostileTarget: { id: "target", health: 20_000, maxHealth: 20_000, boss: true },
hostileCount: 1,
friendlyTargets: [],
...overrides,
};
}
function wowWarriorContext(): RotationContext {
const abilities = abilitiesForCharacter("warrior", null)
.map((ability) => learnedAbilityForBuild(ability, 80, {}))
.filter((ability): ability is AbilityDefinition => Boolean(ability && !ability.passive));
return {
now: 1_000,
classId: "warrior",
statRuleset: "wow",
specializationId: "warrior:protection",
role: "tank",
level: 80,
health: 100_000,
maxHealth: 100_000,
selfId: "wow-warrior",
resources: { rage: 100 },
maximumResources: { rage: 100 },
primaryResource: "rage",
comboPoints: 0,
comboTargetId: null,
runes: [],
abilities,
knownDamagingAbilityNames: new Set(abilities.filter((ability) => {
const categories = aiHintsForAbility(ability).categories;
return categories.includes("damage") || categories.includes("execute");
}).map((ability) => ability.name)),
hostileTarget: { id: "wow-target", health: 500_000, maxHealth: 500_000, boss: true },
hostileCount: 1,
};
}
describe("native RoM five profiles on the shared rotation evaluator", () => {
it.each([
["rom-knight", "rom-scout", "tank"],
["rom-priest", "rom-knight", "healer"],
["rom-rogue", "rom-warrior", "damage"],
["rom-scout", "rom-rogue", "damage"],
["rom-mage", "rom-warlock", "damage"],
] as const)("selects a learned native action for %s/%s as %s at level 30", (primary, secondary, role) => {
const context = nativeContext(primary, secondary, role, {
...(role === "tank" ? { targetIsLoose: true } : {}),
...(role === "healer" ? {
friendlyTargets: [
{ id: "tank", health: 55, maxHealth: 100, role: "tank" },
{ id: "scout", health: 75, maxHealth: 100, role: "damage" },
],
} : {}),
});
const profile = nativeRotationProfileFor(context);
const decision = evaluateCombatRotation(context);
expect(profile).not.toBeNull();
expect(decision).not.toBeNull();
expect(decision?.profileId).toBe(profile?.id);
expect(context.abilities.some((ability) => ability.name === decision?.ability.name)).toBe(true);
});
it("uses Group Heal on multiple injured allies when the priest is healthy", () => {
const context = nativeContext("rom-priest", "rom-knight", "healer", {
friendlyTargets: [
{ id: "tank", health: 65, maxHealth: 100, role: "tank" },
{ id: "scout", health: 75, maxHealth: 100, role: "damage" },
],
});
const groupHeal = context.abilities.find((ability) => ability.name === "Group Heal");
expect(groupHeal).toBeDefined();
expect(nativeRotationAbilityAllowed(groupHeal!, context)).toBe(true);
const decision = evaluateCombatRotation({ ...context, abilities: [groupHeal!] });
expect(decision?.ability.name).toBe("Group Heal");
expect(decision?.targetId).toBe(context.selfId);
});
it("keeps Urgent Heal ahead of Group Heal when one ally is critical", () => {
const context = nativeContext("rom-priest", "rom-knight", "healer", {
friendlyTargets: [
{ id: "tank", health: 20, maxHealth: 100, role: "tank" },
{ id: "scout", health: 70, maxHealth: 100, role: "damage" },
],
});
const urgentHeal = context.abilities.find((ability) => ability.name === "Urgent Heal");
const groupHeal = context.abilities.find((ability) => ability.name === "Group Heal");
expect(urgentHeal).toBeDefined();
expect(groupHeal).toBeDefined();
const decision = evaluateCombatRotation({ ...context, abilities: [groupHeal!, urgentHeal!] });
expect(decision?.ability.name).toBe("Urgent Heal");
expect(decision?.targetId).toBe("tank");
});
it("refreshes the Scout/Rogue DoT only for the current caster, target, and expiry window", () => {
const context = nativeContext("rom-scout", "rom-rogue", "damage");
const vampireArrows = context.abilities.find((ability) => ability.name === "Vampire Arrows");
expect(vampireArrows).toBeDefined();
const maintained = (sourceActorId: string, targetId: string, remainingMs: number) => [{
abilityId: vampireArrows!.id,
sourceActorId,
targetId,
remainingMs,
}];
expect(nativeRotationAbilityAllowed(vampireArrows!, {
...context,
maintainedPeriodicEffects: maintained(context.selfId!, "target", 10_000),
})).toBe(false);
expect(nativeRotationAbilityAllowed(vampireArrows!, {
...context,
maintainedPeriodicEffects: maintained("other-scout", "target", 10_000),
})).toBe(true);
expect(nativeRotationAbilityAllowed(vampireArrows!, {
...context,
maintainedPeriodicEffects: maintained(context.selfId!, "other-target", 10_000),
})).toBe(true);
expect(nativeRotationAbilityAllowed(vampireArrows!, {
...context,
maintainedPeriodicEffects: maintained(context.selfId!, "target", 1_000),
})).toBe(true);
});
it("leaves the WoW class profile selector on its original path", () => {
const context = wowWarriorContext();
expect(nativeRotationProfileFor(context)).toBeNull();
const decision = evaluateCombatRotation(context);
expect(decision).not.toBeNull();
expect(decision?.profileId).toBe("classic:warrior:protection");
});
});
+263
View File
@@ -0,0 +1,263 @@
import { describe, expect, it } from "vitest";
import type { AbilityDefinition } from "./abilityCatalog";
import type { RotationContext } from "./combatRotation";
import {
NATIVE_ROTATION_PROFILES,
ROM_ROTATION_EFFECT_REFRESH_WINDOW_MS,
nativeRotationAbilityAllowed,
nativeRotationProfileFor,
} from "./romFiveRotation";
function context(overrides: Partial<RotationContext> = {}): RotationContext {
return {
now: 1_000,
classId: "rom-priest",
secondaryClassId: "rom-knight",
statRuleset: "rom",
role: "healer",
level: 30,
health: 100,
maxHealth: 100,
selfId: "serenna",
resources: { mana: 100 },
maximumResources: { mana: 100 },
primaryResource: "mana",
comboPoints: 0,
comboTargetId: null,
runes: [],
abilities: [],
hostileTarget: { id: "target", health: 500, maxHealth: 500 },
friendlyTargets: [],
hostileCount: 1,
...overrides,
};
}
function ability(
name: string,
overrides: Partial<AbilityDefinition> = {},
): AbilityDefinition {
return {
id: `test-${name}`,
classId: "rom-priest",
dbcSpellId: 1,
name,
unlockLevel: 1,
icon: "",
sigil: "",
description: "test fixture",
target: "friendly",
range: { min: 0, max: 30 },
castMode: "instant",
castTimeMs: 0,
cooldownMs: 0,
gcdMs: 0,
cost: { resource: "mana", amount: 0 },
effects: [],
...overrides,
};
}
describe("native RoM five rotation profiles", () => {
it("only resolves exact pair, role, RoM ruleset, and secondary-class levels", () => {
expect(nativeRotationProfileFor(context())).toMatchObject({
id: "rom-five:rom-priest+rom-knight:healer",
classId: "rom-priest",
secondaryClassId: "rom-knight",
role: "healer",
});
expect(nativeRotationProfileFor(context({ secondaryClassId: "rom-scout" }))).toBeNull();
expect(nativeRotationProfileFor(context({ classId: "rom-knight", secondaryClassId: "rom-scout" }))).toBeNull();
expect(nativeRotationProfileFor(context({ role: "damage" }))).toBeNull();
expect(nativeRotationProfileFor(context({ statRuleset: "wow" }))).toBeNull();
expect(nativeRotationProfileFor(context({ secondaryClassId: null }))).toBeNull();
expect(nativeRotationProfileFor(context({ level: 9 }))).toBeNull();
});
it("keeps the five ordered pairs and a damaging fallback tail for the same build", () => {
expect(NATIVE_ROTATION_PROFILES.map(({ classId, secondaryClassId, role }) => [
classId,
secondaryClassId,
role,
])).toEqual([
["rom-knight", "rom-scout", "tank"],
["rom-priest", "rom-knight", "healer"],
["rom-rogue", "rom-warrior", "damage"],
["rom-scout", "rom-rogue", "damage"],
["rom-mage", "rom-warlock", "damage"],
]);
const profile = nativeRotationProfileFor(context({
knownDamagingAbilityNames: new Set(["Current CoA Ability", "Urgent Heal"]),
}));
expect(profile?.abilityPriority.slice(0, 2)).toEqual(["Urgent Heal", "Group Heal"]);
expect(profile?.abilityPriority.at(-1)).toBe("Current CoA Ability");
});
});
describe("native RoM rotation action guards", () => {
it("uses taunts only for a tank's actually loose target and gates shield spend defensively", () => {
const arrow = ability("Arrow of Vengeance", { classId: "rom-knight" });
expect(nativeRotationAbilityAllowed(arrow, context({
classId: "rom-knight",
secondaryClassId: "rom-scout",
role: "tank",
}))).toBe(false);
expect(nativeRotationAbilityAllowed(arrow, context({
classId: "rom-knight",
secondaryClassId: "rom-scout",
role: "tank",
targetIsLoose: true,
}))).toBe(true);
const shieldBash = ability("Truth Shield Bash", { classId: "rom-knight" });
expect(nativeRotationAbilityAllowed(shieldBash, context({
classId: "rom-knight",
secondaryClassId: "rom-scout",
role: "tank",
}))).toBe(false);
expect(nativeRotationAbilityAllowed(shieldBash, context({
classId: "rom-knight",
secondaryClassId: "rom-scout",
role: "tank",
incomingMeleeDanger: true,
}))).toBe(true);
});
it("keeps emergency single heals fast and requires multiple injured allies for Group Heal", () => {
const urgent = ability("Urgent Heal");
const group = ability("Group Heal");
const oneInjured = [{ id: "tank", health: 30, maxHealth: 100, role: "tank" as const }];
expect(nativeRotationAbilityAllowed(urgent, context({ friendlyTargets: oneInjured }))).toBe(true);
expect(nativeRotationAbilityAllowed(urgent, context({
friendlyTargets: [{ id: "tank", health: 70, maxHealth: 100, role: "tank" }],
}))).toBe(false);
expect(nativeRotationAbilityAllowed(group, context({ friendlyTargets: oneInjured }))).toBe(false);
expect(nativeRotationAbilityAllowed(group, context({
friendlyTargets: [
{ id: "tank", health: 70, maxHealth: 100, role: "tank" },
{ id: "scout", health: 80, maxHealth: 100, role: "damage" },
],
}))).toBe(true);
});
it("refreshes a target-owned periodic effect only inside its expiry window", () => {
const dot = ability("Soul Pain", {
classId: "rom-warlock",
secondaryClassId: "rom-mage",
target: "hostile",
effects: [{ kind: "dot", coefficient: 1, ticks: 12, intervalMs: 2_000, tag: "soul-pain" }],
});
const effect = (remainingMs: number, sourceActorId = "serenna", targetId = "target") => ({
abilityId: dot.id,
sourceActorId,
targetId,
remainingMs,
});
expect(nativeRotationAbilityAllowed(dot, context({
classId: "rom-mage",
secondaryClassId: "rom-warlock",
role: "damage",
selfId: "durnik",
maintainedPeriodicEffects: [effect(ROM_ROTATION_EFFECT_REFRESH_WINDOW_MS + 1, "durnik")],
}))).toBe(false);
expect(nativeRotationAbilityAllowed(dot, context({
classId: "rom-mage",
secondaryClassId: "rom-warlock",
role: "damage",
selfId: "durnik",
maintainedPeriodicEffects: [effect(ROM_ROTATION_EFFECT_REFRESH_WINDOW_MS, "durnik")],
}))).toBe(true);
expect(nativeRotationAbilityAllowed(dot, context({
classId: "rom-mage",
secondaryClassId: "rom-warlock",
role: "damage",
selfId: "durnik",
maintainedAbilityIds: new Set([dot.id]),
maintainedPeriodicEffects: [effect(10_000, "another-caster")],
}))).toBe(true);
expect(nativeRotationAbilityAllowed(dot, context({
classId: "rom-mage",
secondaryClassId: "rom-warlock",
role: "damage",
selfId: "durnik",
maintainedPeriodicEffects: [effect(10_000, "durnik", "another-target")],
}))).toBe(true);
expect(nativeRotationAbilityAllowed(dot, context({
classId: "rom-mage",
secondaryClassId: "rom-warlock",
role: "damage",
selfId: "durnik",
hostileTarget: { id: "small-target", health: 100, maxHealth: 100 },
}))).toBe(false);
});
it("does not spend old health-cost Sacrificial Cure data and only bursts on durable targets", () => {
const staleCure = ability("Sacrificial Cure", {
cost: { resource: "health", amount: 70, percentage: true },
});
expect(nativeRotationAbilityAllowed(staleCure, context({
friendlyTargets: [{ id: "tank", health: 10, maxHealth: 100 }],
}))).toBe(false);
const burst = ability("Assassins Rage", { classId: "rom-rogue" });
expect(nativeRotationAbilityAllowed(burst, context({
classId: "rom-rogue",
secondaryClassId: "rom-warrior",
role: "damage",
hostileTarget: { id: "small-mob", health: 150, maxHealth: 150 },
}))).toBe(false);
expect(nativeRotationAbilityAllowed(burst, context({
classId: "rom-rogue",
secondaryClassId: "rom-warrior",
role: "damage",
hostileTarget: { id: "boss", health: 10_000, maxHealth: 10_000, boss: true },
}))).toBe(true);
});
it("waits for Vampire Arrows before Deadly Poison Bite and applies AoE minimums", () => {
const bite = ability("Deadly Poison Bite", { classId: "rom-scout" });
const scout = {
classId: "rom-scout" as const,
secondaryClassId: "rom-rogue" as const,
role: "damage" as const,
selfId: "caelan",
};
expect(nativeRotationAbilityAllowed(bite, context(scout))).toBe(false);
expect(nativeRotationAbilityAllowed(bite, context({
...scout,
maintainedPeriodicEffects: [{
abilityId: "rom-scout-general-2",
sourceActorId: "caelan",
targetId: "target",
remainingMs: 5_000,
}],
}))).toBe(true);
const thunderstorm = ability("Thunderstorm", { classId: "rom-mage" });
const mage = { classId: "rom-mage" as const, secondaryClassId: "rom-warlock" as const, role: "damage" as const };
expect(nativeRotationAbilityAllowed(thunderstorm, context(mage))).toBe(false);
expect(nativeRotationAbilityAllowed(thunderstorm, context({ ...mage, hostileCount: 2 }))).toBe(true);
const areaAttack = ability("Current CoA area attack", {
classId: "rom-mage",
secondaryClassId: "rom-warlock",
target: "hostile",
effects: [{ kind: "damage", coefficient: 1 }],
targeting: { shape: "circle", radius: 8 },
});
expect(nativeRotationAbilityAllowed(areaAttack, context(mage))).toBe(false);
expect(nativeRotationAbilityAllowed(areaAttack, context({ ...mage, hostileCount: 2 }))).toBe(true);
});
it("leaves unsupported and pre-secondary builds to the existing generic fallback", () => {
const abilityUnderTest = ability("Holy Strike", { classId: "rom-knight" });
expect(nativeRotationAbilityAllowed(abilityUnderTest, context({ level: 9 }))).toBe(true);
expect(nativeRotationAbilityAllowed(abilityUnderTest, context({
classId: "rom-knight",
secondaryClassId: "rom-priest",
role: "tank",
}))).toBe(true);
});
});
+369
View File
@@ -0,0 +1,369 @@
import type { ClassId, RomClassId } from "../app/characterCatalog";
import type { AbilityDefinition } from "./abilityCatalog";
import { aiHintsForAbility } from "./abilityRules";
import type { RotationContext, RotationProfile } from "./combatRotation";
import type { PartyRole } from "./partyRoles";
/** An exact RoM five build. These profiles are clean-room heuristics, not imported add-on presets. */
export interface NativeRotationProfile extends RotationProfile {
readonly secondaryClassId: RomClassId;
readonly role: PartyRole;
}
/** Reapply a maintained effect only in this short window before it expires. */
export const ROM_ROTATION_EFFECT_REFRESH_WINDOW_MS = 1_500;
const nativeProfile = (
classId: RomClassId,
secondaryClassId: RomClassId,
role: PartyRole,
abilityPriority: readonly string[],
): NativeRotationProfile => {
const id = `rom-five:${classId}+${secondaryClassId}:${role}`;
return Object.freeze({
id,
specializationId: id,
classId,
secondaryClassId,
role,
abilityPriority: Object.freeze([...abilityPriority]),
});
};
/**
* Exact primary/secondary/role priorities for the native RoM five. Names are
* drawn from the project's active RoM character catalog; normal learned,
* cooldown, resource, target, weapon, and canUse gates still apply.
*/
export const NATIVE_ROTATION_PROFILES: readonly NativeRotationProfile[] = Object.freeze([
nativeProfile("rom-knight", "rom-scout", "tank", [
"Arrow of Vengeance",
"Whirlwind Shield",
"Holy Seal",
"Punishment",
"Holy Strike",
"Heavenly Arrow",
"Truth Shield Bash",
"Shock",
"Sacred Protection",
"Shield of Valor",
"Disregard Danger",
"Resolution",
]),
nativeProfile("rom-priest", "rom-knight", "healer", [
"Urgent Heal",
"Group Heal",
"Wave Armor",
"Heal",
"Regenerate",
"Cleanse",
"Resurrection",
"Divine Incarnation",
"Calm Heart",
"Rising Tide",
]),
nativeProfile("rom-rogue", "rom-warrior", "damage", [
"Assassins Rage",
"Death's Touch",
"Poisonous Infection",
"Decay",
"Wound Attack",
"Low Blow",
"Blind Spot",
"Poison Spray",
"Shadowstab",
"Combo Throw",
]),
nativeProfile("rom-scout", "rom-rogue", "damage", [
"Vampire Arrows",
"Piercing Arrow",
"Weak Spot",
"Deadly Poison Bite",
"Combo Shot",
"Reflected Shot",
"Snipe",
"Wind Arrows",
"Shot",
"Neck Strike",
"Sapping Arrow",
"Blinding Powder",
]),
nativeProfile("rom-mage", "rom-warlock", "damage", [
"Elemental Catalysis",
"Weakening Weave Curse",
"Soul Pain",
"Elemental Weakness",
"Fire Lightning Burst",
"Flame",
"Fireball",
"Lightning",
"Electric Bolt",
"Thunderstorm",
"Meteor Shower",
]),
]);
const PROFILE_BY_TUPLE = new Map<string, NativeRotationProfile>(
NATIVE_ROTATION_PROFILES.map((profile) => [tupleKey(profile.classId, profile.secondaryClassId, profile.role), profile]),
);
function tupleKey(classId: ClassId, secondaryClassId: ClassId, role: PartyRole): string {
return `${classId}|${secondaryClassId}|${role}`;
}
function isRomClassId(classId: ClassId): classId is RomClassId {
return classId.startsWith("rom-");
}
/**
* Return a priority profile only for an exact native five tuple. Pre-secondary
* levels intentionally keep the shared semantic fallback; they do not borrow
* a profile from the primary class alone.
*/
export function nativeRotationProfileFor(context: RotationContext): NativeRotationProfile | null {
if (context.statRuleset !== "rom" || context.level < 10 || !isRomClassId(context.classId)) return null;
const secondaryClassId = context.secondaryClassId;
if (!secondaryClassId || !isRomClassId(secondaryClassId)) return null;
const profile = PROFILE_BY_TUPLE.get(tupleKey(context.classId, secondaryClassId, context.role));
if (!profile) return null;
// Preserve every learned damaging action in the exact build as a lower
// priority fallback, so a newly learned/current-catalog skill is not starved
// merely because this deliberately small policy has not ranked it by name.
const preferred = new Set(profile.abilityPriority);
const fallbackDamage = [...(context.knownDamagingAbilityNames ?? [])]
.filter((name) => !preferred.has(name))
.sort((left, right) => left.localeCompare(right));
return fallbackDamage.length === 0
? profile
: Object.freeze({
...profile,
abilityPriority: Object.freeze([...profile.abilityPriority, ...fallbackDamage]),
});
}
const PERIODIC_ACTION_NAMES = new Set([
"Vampire Arrows",
"Piercing Arrow",
"Deadly Poison Bite",
"Death's Touch",
"Poisonous Infection",
"Poison Spray",
"Decay",
"Soul Pain",
"Weakening Weave Curse",
"Elemental Weakness",
]);
const PREPARATORY_DEBUFF_NAMES = new Set([
"Weak Spot",
"Elemental Weakness",
]);
const MIN_HOSTILE_TARGETS: Readonly<Record<string, number>> = Object.freeze({
"Whirlwind Shield": 2,
"Heavenly Arrow": 2,
"Poison Spray": 2,
"Reflected Shot": 2,
"Thunderstorm": 2,
"Meteor Shower": 2,
});
function healthFraction(health: number, maximum: number): number {
return maximum > 0 ? Math.max(0, Math.min(1, health / maximum)) : 1;
}
function selfHealthFraction(context: RotationContext): number {
return healthFraction(context.health, context.maxHealth);
}
function injuredFriendlyTargets(context: RotationContext) {
return (context.friendlyTargets ?? [])
.filter((target) => !target.fallen && target.health > 0 && target.maxHealth > 0 && target.health < target.maxHealth)
.sort((left, right) => healthFraction(left.health, left.maxHealth) - healthFraction(right.health, right.maxHealth)
|| left.id.localeCompare(right.id));
}
function selectedFriendlyTargetId(context: RotationContext): string | null {
return injuredFriendlyTargets(context)[0]?.id ?? null;
}
function isDurableHostile(context: RotationContext): boolean {
const target = context.hostileTarget;
return Boolean(target && (
target.boss
|| (target.healthMultiplier ?? 1) > 1
|| target.maxHealth >= context.maxHealth * 5
));
}
function hasUsefulSetupWindow(context: RotationContext): boolean {
const target = context.hostileTarget;
return Boolean(target
&& healthFraction(target.health, target.maxHealth) > 0.15
&& (isDurableHostile(context) || target.maxHealth >= context.maxHealth * 2));
}
function maintainedForTarget(
abilityId: string,
targetId: string,
context: RotationContext,
): boolean {
const actorId = context.selfId;
if (!actorId) return false;
if (context.maintainedPeriodicEffects !== undefined) return context.maintainedPeriodicEffects.some((effect) => (
effect.abilityId === abilityId
&& effect.sourceActorId === actorId
&& effect.targetId === targetId
&& effect.remainingMs > ROM_ROTATION_EFFECT_REFRESH_WINDOW_MS
));
// Older callers provide a target-scoped active ability set without duration.
// Treat it conservatively as active until it falls out of that set.
return context.maintainedAbilityIds?.has(abilityId) ?? false;
}
function periodicEffectIsMaintained(ability: AbilityDefinition, context: RotationContext): boolean {
const targetId = context.hostileTarget?.id;
if (!targetId) return false;
const isPeriodic = PERIODIC_ACTION_NAMES.has(ability.name)
|| ability.effects.some((effect) => effect.kind === "dot");
if (!isPeriodic) return false;
return maintainedForTarget(ability.id, targetId, context);
}
function isAreaDamage(ability: AbilityDefinition): boolean {
const hasDamageSemantics = ability.effects.some((effect) => (
effect.kind === "damage"
|| effect.kind === "dot"
|| effect.kind === "execute"
|| effect.kind === "finisher-damage"
));
const targeting = ability.targeting;
return hasDamageSemantics && (
aiHintsForAbility(ability).categories.includes("area-damage")
|| Boolean(targeting && (
targeting.shape !== "unit"
|| (targeting.maximumTargets ?? 1) > 1
|| (targeting.radius ?? 0) > 0
))
|| (ability.radius ?? 0) > 0
);
}
function periodicSetupHasEnoughUptime(ability: AbilityDefinition, context: RotationContext): boolean {
if (ability.target !== "hostile" || !context.hostileTarget) return true;
// Longer setups are for bosses/elites or targets with enough remaining life
// to tick. Skip them on a near-dead or very short-lived ordinary target.
return hasUsefulSetupWindow(context);
}
function selectedFriendlyPeriodicEffectIsMaintained(
ability: AbilityDefinition,
context: RotationContext,
): boolean {
const targetId = selectedFriendlyTargetId(context);
return targetId ? maintainedForTarget(ability.id, targetId, context) : false;
}
function hasPrerequisiteEffect(context: RotationContext, abilityId: string, targetId: string): boolean {
return maintainedForTarget(abilityId, targetId, context);
}
function isEmergencyHealingContext(context: RotationContext): boolean {
const lowest = injuredFriendlyTargets(context)[0];
return Boolean((lowest && healthFraction(lowest.health, lowest.maxHealth) <= 0.45)
|| context.incomingDefensiveMechanic
|| context.incomingMeleeDanger);
}
function allowNativeAction(ability: AbilityDefinition, context: RotationContext): boolean {
const hostileCount = Math.max(1, Math.trunc(context.hostileCount ?? 1));
const minimumHostiles = Math.max(MIN_HOSTILE_TARGETS[ability.name] ?? 1, isAreaDamage(ability) ? 2 : 1);
if (hostileCount < minimumHostiles) return false;
if (periodicEffectIsMaintained(ability, context)) return false;
if ((PERIODIC_ACTION_NAMES.has(ability.name) || ability.effects.some((effect) => effect.kind === "dot"))
&& !periodicSetupHasEnoughUptime(ability, context)) return false;
if (PREPARATORY_DEBUFF_NAMES.has(ability.name) && !hasUsefulSetupWindow(context)) return false;
switch (ability.name) {
case "Arrow of Vengeance":
return context.role === "tank" && context.targetIsLoose === true;
case "Truth Shield Bash":
return context.role === "tank"
&& (Boolean(context.incomingDefensiveMechanic) || context.incomingMeleeDanger === true || selfHealthFraction(context) <= 0.4);
case "Shock":
case "Holy Chains":
case "Neck Strike":
case "Sapping Arrow":
case "Blinding Powder":
return context.requiredControl === true || Boolean(context.hostileTarget?.activeCast?.interruptible);
case "Sacred Protection":
return Boolean(context.incomingDefensiveMechanic)
|| context.incomingMeleeDanger === true
|| context.hasDangerousThreat === true
|| selfHealthFraction(context) <= 0.35;
case "Shield of Valor":
return Boolean(context.incomingDefensiveMechanic)
|| context.incomingMeleeDanger === true
|| selfHealthFraction(context) <= 0.4;
case "Disregard Danger":
return context.needsEscape === true || context.requiredControl === true;
case "Resolution":
return selfHealthFraction(context) <= 0.6;
case "Heavenly Arrow":
return !context.moving && !context.incomingMeleeDanger;
case "Urgent Heal":
return isEmergencyHealingContext(context);
case "Group Heal":
return injuredFriendlyTargets(context).length >= 2;
case "Wave Armor": {
const lowest = injuredFriendlyTargets(context)[0];
return Boolean(context.incomingDefensiveMechanic)
|| context.incomingMeleeDanger === true
|| Boolean(lowest && healthFraction(lowest.health, lowest.maxHealth) <= 0.55);
}
case "Regenerate":
return injuredFriendlyTargets(context).length > 0
&& !selectedFriendlyPeriodicEffectIsMaintained(ability, context);
case "Sacrificial Cure": {
// The checked local catalog still contains the historical HP-cost row.
// Never spend health on that stale form; the current CoA version is MP-cost.
const staleHealthCost = (ability.costs?.length ? ability.costs : [ability.cost])
.some((cost) => cost.resource === "health" && cost.amount > 20);
if (staleHealthCost) return false;
const lowest = injuredFriendlyTargets(context)[0];
return Boolean((lowest && healthFraction(lowest.health, lowest.maxHealth) <= 0.3)
|| context.incomingDefensiveMechanic);
}
case "Calm Heart":
return context.requiredControl === true;
case "Divine Incarnation":
return injuredFriendlyTargets(context).length >= 2 || isEmergencyHealingContext(context);
case "Assassins Rage":
return hasUsefulSetupWindow(context);
case "Deadly Poison Bite":
// CoA requires Vampire Arrows' current target effect before this follow-up.
return Boolean(context.hostileTarget
&& hasPrerequisiteEffect(context, "rom-scout-general-2", context.hostileTarget.id));
case "Elemental Catalysis":
case "Fire Lightning Burst":
return hasUsefulSetupWindow(context);
case "Soul Stepping":
return context.needsEscape === true;
default:
return true;
}
}
/**
* Native-only semantic guards. A null exact profile means the caller should
* keep its existing generic fallback and should not apply a guessed pair rule.
*/
export function nativeRotationAbilityAllowed(
ability: AbilityDefinition,
context: RotationContext,
): boolean {
if (!nativeRotationProfileFor(context)) return true;
return allowNativeAction(ability, context);
}
@@ -0,0 +1,291 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
updatePartyRuntimeTargetVisibility,
} from "./partyRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { abilityDamageSchool, useCombatStore } from "./combatStore";
const BLOOD_ARROW_ID = "rom-scout-general-4";
const HOLY_STRIKE_ID = "rom-knight-general-1";
const SCOUT_SHOT_ID = "rom-scout-general-0";
const BLOOD_PUNISHMENT_TAG = "rom-blood-punishment";
const BLOOD_ARROW_TAG = "rom-self-health-drain";
function bloodArrowAura(targetId: string) {
return useCombatStore.getState().auras.find((aura) => aura.targetId === targetId
&& aura.sourceId === targetId
&& aura.definition.utilityTags?.includes(BLOOD_ARROW_TAG));
}
function runPlayerDamageComparison(passiveActive: boolean) {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 79, role: "tank",
});
if (!passiveActive) useCombatStore.getState().consumeActorAuraTag(PLAYER_AGGRO_ID, BLOOD_PUNISHMENT_TAG);
for (const id of ["blood-punishment-holy", "blood-punishment-physical"]) {
useCombatStore.getState().registerMob(id, { name: id, level: 1, maxHealth: 1_000_000, xpReward: 0 });
setMobPosition(id, [2, 0, 0]);
}
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().selectMob("blood-punishment-holy");
expect(useCombatStore.getState().castAbility(HOLY_STRIKE_ID, undefined, 4_000)).toMatchObject({ ok: true });
useCombatStore.getState().selectMob("blood-punishment-physical");
expect(useCombatStore.getState().castAbility(SCOUT_SHOT_ID, undefined, 6_000)).toMatchObject({ ok: true });
const events = useCombatStore.getState().combatEvents;
return {
holy: events.find((event) => event.kind === "damage" && event.abilityId === HOLY_STRIKE_ID),
physical: events.find((event) => event.kind === "damage" && event.abilityId === SCOUT_SHOT_ID),
};
}
function companionWithBuild(
seed: string,
name: string,
classId: "rom-knight" | "rom-mage",
statRuleset: "rom" | "wow" = "rom",
) {
return createConfiguredPartyMember({
name,
classId,
statRuleset,
...(classId === "rom-knight" ? { secondaryClassId: "rom-scout" as const } : {}),
raceId: "rom-human",
specialization: classId === "rom-knight" ? "Knight/Scout" : "Mage",
specializationId: classId === "rom-knight" ? "rom-knight:rom-scout" : "rom-mage",
talentTreeId: classId === "rom-knight" ? "rom-knight-rom-scout" : "rom-mage",
role: "damage",
}, 79, 0, seed);
}
function runOwnedHolyStrikeComparison(useBloodPunishment: boolean, statRuleset: "rom" | "wow") {
const seed = `rom-knight-scout-blood-punishment-owned-damage:${statRuleset}`;
useCombatStore.getState().initializeCharacter({ classId: "mage", raceId: "rom-human", level: 79, role: "damage" });
const owner = companionWithBuild(seed, "Knight/Scout", "rom-knight", statRuleset);
usePartyStore.getState().initializeParty(seed, 79, useCombatStore.getState().health, "damage", 1, statRuleset);
usePartyStore.setState({ members: [owner] });
registerPartyRuntimePosition(owner.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
const bloodArrow = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490434)!;
const sourceAura = bloodArrow.effects.find((effect) => effect.kind === "apply-aura"
&& effect.recipient === "caster" && effect.aura.utilityTags?.includes(BLOOD_ARROW_TAG));
if (sourceAura?.kind !== "apply-aura") throw new Error("Blood Arrow's source aura is missing.");
if (useBloodPunishment) {
usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === owner.id ? {
...member,
activeCast: {
abilityId: BLOOD_ARROW_ID,
school: "physical",
mode: "cast",
startedAt: 900,
completesAt: 1_000,
originalDurationMs: 100,
pushbackHits: 0,
origin: [0, 0, 0],
targetId: owner.id,
},
activeCastTargetId: owner.id,
status: "attacking" as const,
} : member) }));
useCombatStore.getState().synchronizeActors();
advanceDungeonPartyCombat(1_000);
} else {
// Apply the catalog's unmodified base Arrow aura to the same actor. The
// later Holy Strike still runs through the real owned-NPC damage path.
useCombatStore.getState().applyActorAura(owner.id, owner.id, bloodArrow, sourceAura, 1_000);
}
useCombatStore.getState().registerMob("owned-holy-strike-target", {
name: "Owned Holy Strike Target", level: 1, maxHealth: 1_000_000, xpReward: 0,
});
setMobPosition("owned-holy-strike-target", [2, 0, 0]);
updatePartyRuntimeTargetVisibility(owner.id, "owned-holy-strike-target", true);
usePartyStore.setState({ command: "attack", activeMobId: "owned-holy-strike-target" });
usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === owner.id ? {
...member,
activeCast: {
abilityId: HOLY_STRIKE_ID,
school: "holy",
mode: "cast",
startedAt: 3_900,
completesAt: 4_000,
originalDurationMs: 100,
pushbackHits: 0,
origin: [0, 0, 0],
targetId: "owned-holy-strike-target",
},
activeCastTargetId: "owned-holy-strike-target",
status: "attacking" as const,
} : member) }));
useCombatStore.getState().synchronizeActors();
advanceDungeonPartyCombat(4_000);
return useCombatStore.getState().combatEvents.find((event) => event.kind === "damage"
&& event.sourceActorId === owner.id && event.abilityId === HOLY_STRIKE_ID);
}
describe("Knight/Scout Blood Punishment current-CoA runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "mage", raceId: "rom-human", level: 79, role: "damage" });
});
it("resolves the player's 15% received healing and Holy-only 5% damage rider", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 79, role: "tank",
});
const maxHealth = useCombatStore.getState().maxHealth;
useCombatStore.setState({ health: maxHealth - 1_000 });
const withoutArrow = useCombatStore.getState().healActor(PLAYER_AGGRO_ID, 100, {
sourceActorId: "test-healer", abilityId: "blood-punishment-heal-baseline", school: "holy", now: 900,
});
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 1_000)).toMatchObject({ ok: true });
const liveAura = bloodArrowAura(PLAYER_AGGRO_ID);
expect(liveAura?.expiresAt).toBe(31_000);
const heal = useCombatStore.getState().healActor(PLAYER_AGGRO_ID, 100, {
sourceActorId: "test-healer", abilityId: "blood-punishment-heal-test", school: "holy", now: 2_000,
});
expect(withoutArrow.rawAmount).toBe(101); // Learned Devotion already gives +1% to this Knight.
expect(heal.rawAmount).toBe(116); // Devotion +1% and Blood Punishment +15% are additive.
const plain = runPlayerDamageComparison(false);
const upgraded = runPlayerDamageComparison(true);
expect(plain.holy?.kind).toBe("damage");
expect(upgraded.holy?.kind).toBe("damage");
expect(plain.physical?.kind).toBe("damage");
expect(upgraded.physical?.kind).toBe("damage");
if (plain.holy?.kind !== "damage" || upgraded.holy?.kind !== "damage"
|| plain.physical?.kind !== "damage" || upgraded.physical?.kind !== "damage") {
throw new Error("Blood Punishment comparison casts did not produce direct damage events.");
}
expect(upgraded.holy.school).toBe("holy");
expect(upgraded.physical.school).toBe("physical");
expect(upgraded.holy.rawAmount).toBe(Math.round(plain.holy.rawAmount * (1 + 0.01 + 0.05) / (1 + 0.01)));
// Blood Arrow's baseline physical ranged modifier is present in both runs;
// the Blood Punishment rider adds no further physical damage.
expect(upgraded.physical.rawAmount).toBe(plain.physical.rawAmount);
});
it("ends the player's rider when the actual Blood Arrow marker is canceled", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 79, role: "tank",
});
useCombatStore.setState({ health: useCombatStore.getState().maxHealth - 1_000 });
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 1_000)).toMatchObject({ ok: true });
expect(bloodArrowAura(PLAYER_AGGRO_ID)).toBeDefined();
useCombatStore.getState().consumeActorAuraTag(PLAYER_AGGRO_ID, BLOOD_ARROW_TAG);
expect(bloodArrowAura(PLAYER_AGGRO_ID)).toBeUndefined();
const afterCancellation = useCombatStore.getState().healActor(PLAYER_AGGRO_ID, 100, {
sourceActorId: "test-healer", abilityId: "blood-punishment-cancel-test", school: "holy", now: 2_000,
});
expect(afterCancellation.rawAmount).toBe(101); // Knight's independent Devotion stays; the +15% rider ends.
});
it("uses the explicit native school for Earth Chain and the physical Wind Arrows hit", () => {
for (const [sourceSkillId, expectedSchool] of [[494025, "nature"], [491128, "physical"]] as const) {
const ability = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === sourceSkillId);
expect(ability, `missing native source skill ${sourceSkillId}`).toBeDefined();
expect(abilityDamageSchool(ability!.classId, ability!), `source skill ${sourceSkillId}`).toBe(expectedSchool);
}
});
it("uses the owned Knight/Scout cast lifecycle, scopes benefits to that actor, and expires with Blood Arrow", () => {
const seed = "rom-knight-scout-blood-punishment-owned-runtime";
const knightScout = companionWithBuild(seed, "Knight/Scout", "rom-knight");
const bystander = companionWithBuild(`${seed}:bystander`, "Mage", "rom-mage");
useCombatStore.getState().initializeCharacter({
classId: "mage", raceId: "rom-human", level: 79, role: "damage",
});
usePartyStore.getState().initializeParty(seed, 79, useCombatStore.getState().health, "damage", 2, "rom");
usePartyStore.setState({ members: [{
...knightScout,
activeCast: {
abilityId: BLOOD_ARROW_ID,
school: "physical",
mode: "cast",
startedAt: 900,
completesAt: 1_000,
originalDurationMs: 100,
pushbackHits: 0,
origin: [0, 0, 0],
targetId: knightScout.id,
},
activeCastTargetId: knightScout.id,
status: "attacking",
}, bystander] });
registerPartyRuntimePosition(knightScout.id, [0, 0, 0]);
registerPartyRuntimePosition(bystander.id, [1, 0, 0]);
useCombatStore.getState().synchronizeActors();
advanceDungeonPartyCombat(1_000);
const activeAura = bloodArrowAura(knightScout.id);
expect(activeAura?.sourceId).toBe(knightScout.id);
expect(activeAura?.expiresAt).toBe(31_000);
expect(activeAura?.definition.modifiers).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "healing", direction: "taken", value: 0.15 }),
expect.objectContaining({ kind: "damage", direction: "dealt", school: "holy", value: 0.05 }),
]));
expect(bloodArrowAura(bystander.id)).toBeUndefined();
expect(bloodArrowAura(PLAYER_AGGRO_ID)).toBeUndefined();
usePartyStore.getState().damageMember(knightScout.id, 1_000);
const companionHeal = useCombatStore.getState().healActor(knightScout.id, 100, {
sourceActorId: PLAYER_AGGRO_ID, abilityId: "owned-blood-punishment-heal-test", school: "holy", now: 2_000,
});
expect(companionHeal.rawAmount).toBe(116); // The same learned Devotion baseline plus Blood Punishment.
usePartyStore.getState().damageMember(bystander.id, 1_000);
const bystanderHeal = useCombatStore.getState().healActor(bystander.id, 100, {
sourceActorId: PLAYER_AGGRO_ID, abilityId: "owned-blood-punishment-isolation-test", school: "holy", now: 2_000,
});
expect(bystanderHeal.rawAmount).toBe(100);
const playerHeal = useCombatStore.getState().healActor(PLAYER_AGGRO_ID, 100, {
sourceActorId: bystander.id, abilityId: "owned-blood-punishment-owner-isolation-test", school: "holy", now: 2_000,
});
expect(playerHeal.rawAmount).toBe(100);
useCombatStore.getState().tick(30.1, 31_001);
expect(bloodArrowAura(knightScout.id)).toBeUndefined();
usePartyStore.getState().damageMember(knightScout.id, 100);
const afterExpiryHeal = useCombatStore.getState().healActor(knightScout.id, 100, {
sourceActorId: PLAYER_AGGRO_ID, abilityId: "owned-blood-punishment-expiry-test", school: "holy", now: 31_002,
});
expect(afterExpiryHeal.rawAmount).toBe(101); // Devotion remains learned; only Blood Arrow's +15% ended.
});
it("resolves the owned NPC's Holy damage rider through its cast and damage path", () => {
for (const statRuleset of ["rom", "wow"] as const) {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
const base = runOwnedHolyStrikeComparison(false, statRuleset);
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
const upgraded = runOwnedHolyStrikeComparison(true, statRuleset);
expect(base?.kind).toBe("damage");
expect(upgraded?.kind).toBe("damage");
if (base?.kind !== "damage" || upgraded?.kind !== "damage") {
throw new Error(`Owned Knight/Scout did not resolve ${statRuleset} Holy Strike comparison casts.`);
}
expect(base.school).toBe("holy");
expect(upgraded.school).toBe("holy");
// Both runs include the Knight's learned 1% Devotion baseline.
expect(upgraded.rawAmount).toBe(Math.round(base.rawAmount * (1 + 0.01 + 0.05) / (1 + 0.01)));
}
});
});
+58
View File
@@ -0,0 +1,58 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
export const ROM_KNIGHT_SCOUT_BLOOD_PUNISHMENT_TAG = "rom-blood-punishment";
export const ROM_SCOUT_BLOOD_ARROW_AURA_TAG = "rom-self-health-drain";
const BLOOD_PUNISHMENT_MODIFIERS = [
{ kind: "healing", direction: "taken", operation: "percent", value: 0.15 },
{ kind: "damage", direction: "dealt", school: "holy", operation: "percent", value: 0.05 },
] as const satisfies NonNullable<Extract<AbilityEffect, { kind: "apply-aura" }>["aura"]["modifiers"]>;
function hasPassive(tags: readonly string[] | ReadonlySet<string>): boolean {
return Array.isArray(tags)
? tags.includes(ROM_KNIGHT_SCOUT_BLOOD_PUNISHMENT_TAG)
: (tags as ReadonlySet<string>).has(ROM_KNIGHT_SCOUT_BLOOD_PUNISHMENT_TAG);
}
/**
* Current CoA Blood Punishment modifies the active Blood Arrow aura itself.
* Keeping both modifiers on the source aura makes them actor-local and gives
* them the same removal/expiry lifecycle as Blood Arrow, without inventing a
* second timer for the passive.
*/
export function romKnightScoutBloodPunishmentActivationEffects(
ability: AbilityDefinition,
effects: readonly AbilityEffect[],
passiveTags: readonly string[] | ReadonlySet<string>,
): readonly AbilityEffect[] {
if (ability.source !== "runewaker"
|| ability.classId !== "rom-scout"
|| ability.secondaryClassId !== undefined
|| ability.dbcSpellId !== 490434
|| !hasPassive(passiveTags)) return effects;
let attachedToActiveArrow = false;
const updated = effects.map((effect) => {
if (effect.kind !== "apply-aura"
|| effect.recipient !== "caster"
|| !effect.aura.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG)) return effect;
attachedToActiveArrow = true;
const current = effect.aura.modifiers ?? [];
const modifiers = BLOOD_PUNISHMENT_MODIFIERS.filter((addition) => !current.some((modifier) => {
if (addition.kind === "healing") return modifier.kind === "healing"
&& modifier.direction === addition.direction
&& modifier.operation === addition.operation
&& modifier.value === addition.value;
return modifier.kind === "damage"
&& modifier.direction === addition.direction
&& modifier.school === addition.school
&& modifier.operation === addition.operation
&& modifier.value === addition.value;
}));
return modifiers.length === 0 ? effect : {
...effect,
aura: { ...effect.aura, modifiers: [...current, ...modifiers] },
};
});
return attachedToActiveArrow ? updated : effects;
}
@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
import { deriveCharacterStats } from "./wotlkStats";
import { nativeDamageCasterAmount } from "./romNativeCombatAmount";
describe("source ordinal damage in an actual native player cast", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === "rom-scout")!;
useCombatStore.getState().initializeCharacter({ ...identity, level: 80, statRuleset: "rom" });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.setState({ members: [] });
// Isolate ordinal power from learned generic ranged-damage auras.
useCombatStore.setState({ auras: [], combatEvents: [] });
});
it("applies Reflected Shot source falloff once to the three enumerated recipients", () => {
const before = useCombatStore.getState();
const ability = before.abilities.find((entry) => entry.name === "Reflected Shot")!;
expect(ability).toBeDefined();
const effect = ability.effects.find((entry) => entry.kind === "damage")!;
const native = deriveCharacterStats(before.classId, before.level, before.gearStats,
before.raceId, {}, before.secondaryClassId, before.gender, before.statRuleset).romNative!;
for (let index = 0; index < 3; index += 1) {
const id = `native-ordinal-${index}`;
before.registerMob(id, { name: id, level: 80, maxHealth: 10_000_000, xpReward: 0 });
setMobPosition(id, [2 + index, 0, 0]);
}
before.selectMob("native-ordinal-0");
const cast = useCombatStore.getState().castAbility(ability.id, undefined, 1_000);
expect(cast.ok, cast.reason).toBe(true);
const events = useCombatStore.getState().combatEvents.filter((event) =>
event.kind === "damage" && event.sourceActorId === PLAYER_AURA_ENTITY_ID
&& event.abilityId === ability.id);
expect(events).toHaveLength(3);
for (const [targetOrdinal, event] of events.entries()) {
const sourcePower = nativeDamageCasterAmount(effect, 80, native, { targetCount: 3, targetOrdinal });
expect(sourcePower).not.toBeNull();
expect(event.rawAmount).toBe(Math.round(sourcePower!));
}
});
});
@@ -0,0 +1,219 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { applyAura } from "./combatAuras";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { deriveCharacterStats, hastedDuration } from "./wotlkStats";
import { abilityAtLevel, abilitiesForCharacter } from "./abilityCatalog";
import { nativeDamageCasterAmount, nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
const TARGET_HP = 10_000_000;
function registerTarget(id: string, x = 2): void {
useCombatStore.getState().registerMob(id, { name: id, level: 1, maxHealth: TARGET_HP, xpReward: 0 });
setMobPosition(id, [x, 0, 0]);
}
function giveSemanticHaste(targetId: string, stat: "melee-haste" | "ranged-haste", value = 50): void {
const combat = useCombatStore.getState();
const result = applyAura(combat.auras, {
definition: {
id: `rom-auto-test-${stat}`,
name: "Test source haste",
disposition: "buff",
durationMs: null,
modifiers: [{ kind: "stat", stat, operation: "flat", value }],
},
sourceId: targetId,
targetId,
now: 1_000,
});
useCombatStore.setState({ auras: result.auras });
}
function playerNativeAuto(slot: "main-hand" | "off-hand" | "ranged") {
const state = useCombatStore.getState();
const stats = deriveCharacterStats(state.classId, state.level, state.gearStats, state.raceId,
{}, state.secondaryClassId, state.gender, state.statRuleset);
const profile = nativeTrainingAutoAttackProfile(stats.romNative, slot);
if (!profile) throw new Error(`Missing native ${slot} training profile: ${JSON.stringify(stats.romNative?.sourceInputs)}`);
return profile;
}
function partyNativeAuto(member: ReturnType<typeof createConfiguredPartyMember>, slot: "main-hand" | "off-hand" | "ranged") {
const stats = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId,
{}, member.secondaryClassId, member.gender, member.statRuleset);
const profile = nativeTrainingAutoAttackProfile(stats.romNative, slot);
if (!profile) throw new Error(`Missing native ${slot} companion training profile.`);
return profile;
}
function setSingleAttackingPartyMember(member: ReturnType<typeof createConfiguredPartyMember>, targetId: string, seed: string): void {
usePartyStore.getState().initializeParty(seed, member.level, useCombatStore.getState().health, "damage", 2);
usePartyStore.setState({
members: [{ ...member, status: "attacking", nextActionAt: 100_000 }],
command: "attack",
activeMobId: targetId,
});
registerPartyRuntimePosition(member.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().damageMob(targetId, 1, 100, { actorId: PLAYER_AURA_ENTITY_ID, role: "damage" });
updatePartyRuntimeTargetVisibility(member.id, targetId, true);
}
function autoDamageEvents(sourceActorId: string, abilityId: string) {
return useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.sourceActorId === sourceActorId && event.abilityId === abilityId
));
}
function playerAutoEvents(targetId: string) {
return useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.sourceActorId === PLAYER_AURA_ENTITY_ID && event.targetActorId === targetId
));
}
describe("native RoM auto attack integration", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "rom-knight", secondaryClassId: "rom-scout",
raceId: "rom-human", role: "tank", statRuleset: "rom", level: 70 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("rom-native-auto", 70, useCombatStore.getState().health, "damage", 2);
});
it("uses Main_DMG for player Knight hits and applies semantic melee haste to the source interval", () => {
const targetId = "native-player-knight-auto-target";
registerTarget(targetId);
giveSemanticHaste(PLAYER_AURA_ENTITY_ID, "melee-haste", 50);
const profile = playerNativeAuto("main-hand");
const expectedInterval = Math.max(500, Math.round(hastedDuration(profile.intervalMs, 50)));
useCombatStore.setState({ autoAttackTargetId: targetId, nextAutoAttackAt: 1_000 });
useCombatStore.getState().tick(0.1, 1_000);
const firstAutoAt = 1_000;
expect(playerAutoEvents(targetId)).toEqual([
expect.objectContaining({ occurredAt: firstAutoAt, rawAmount: Math.round(profile.damage) }),
]);
expect(useCombatStore.getState().nextAutoAttackAt).toBe(firstAutoAt + expectedInterval);
useCombatStore.getState().tick(0.1, firstAutoAt + expectedInterval - 1);
expect(playerAutoEvents(targetId)).toHaveLength(1);
useCombatStore.getState().tick(0.1, firstAutoAt + expectedInterval);
expect(playerAutoEvents(targetId).map((event) => event.occurredAt))
.toEqual([firstAutoAt, firstAutoAt + expectedInterval]);
});
it("starts physical auto-attacks after source Holy Strike without changing its holy direct hit", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-knight", secondaryClassId: null,
raceId: "rom-human", role: "tank", statRuleset: "rom", level: 1 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
const targetId = "native-player-knight-holy-strike-auto-target";
registerTarget(targetId);
useCombatStore.getState().selectMob(targetId);
const strike = useCombatStore.getState().abilities.find((ability) => ability.id === "rom-knight-general-1");
expect(strike).toMatchObject({ sourceAttackKind: "melee", source: "runewaker" });
const cast = useCombatStore.getState().castAbility(strike!.id,
{ targetActorId: targetId, origin: [0, 0, 0], now: 1_000 });
expect(cast).toMatchObject({ ok: true, damage: 16 });
expect(playerAutoEvents(targetId)).toContainEqual(expect.objectContaining({
abilityId: strike!.id,
rawAmount: 16,
school: "holy",
}));
expect(useCombatStore.getState().autoAttackTargetId).toBe(targetId);
useCombatStore.getState().tick(0.1, 1_000);
const postCastHits = playerAutoEvents(targetId);
expect(postCastHits).toHaveLength(2);
expect(postCastHits).toContainEqual(expect.objectContaining({
rawAmount: expect.any(Number),
school: "physical",
}));
expect(postCastHits.find((event) => event.school === "physical")?.rawAmount).toBeGreaterThan(0);
});
it("uses source ranged damage and interval for a native Scout companion", () => {
const targetId = "native-scout-companion-auto-target";
registerTarget(targetId);
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === "rom-scout")!;
const scout = createConfiguredPartyMember({ ...identity, secondaryClassId: "rom-rogue", statRuleset: "rom",
specialization: "Primary", talentTreeId: "" }, 70, 0, "native-scout-auto-integration");
setSingleAttackingPartyMember(scout, targetId, "native-scout-auto-integration");
giveSemanticHaste(scout.id, "ranged-haste", 50);
const profile = partyNativeAuto(usePartyStore.getState().members[0]!, "ranged");
const expectedInterval = Math.max(500, Math.round(hastedDuration(profile.intervalMs, 50)));
advanceDungeonPartyCombat(1_000);
expect(autoDamageEvents(scout.id, "party-auto-attack")).toContainEqual(expect.objectContaining({
occurredAt: 1_000,
rawAmount: Math.round(profile.damage),
}));
advanceDungeonPartyCombat(1_000 + expectedInterval - 1);
expect(autoDamageEvents(scout.id, "party-auto-attack")).toHaveLength(1);
advanceDungeonPartyCombat(1_000 + expectedInterval);
expect(autoDamageEvents(scout.id, "party-auto-attack").map((event) => event.occurredAt))
.toEqual([1_000, 1_000 + expectedInterval]);
});
it("uses the RoM Rogue Sec_DMG profile for companion off-hand swings", () => {
const targetId = "native-rogue-offhand-auto-target";
registerTarget(targetId);
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === "rom-rogue")!;
const rogue = createConfiguredPartyMember({ ...identity, secondaryClassId: "rom-warrior", statRuleset: "rom",
specialization: "Primary", talentTreeId: "" }, 70, 0, "native-rogue-offhand-auto-integration");
setSingleAttackingPartyMember(rogue, targetId, "native-rogue-offhand-auto-integration");
giveSemanticHaste(rogue.id, "melee-haste", 50);
const profile = partyNativeAuto(usePartyStore.getState().members[0]!, "off-hand");
const expectedInterval = Math.max(500, Math.round(hastedDuration(profile.intervalMs, 50)));
advanceDungeonPartyCombat(1_000);
expect(autoDamageEvents(rogue.id, "party-auto-attack-offhand")).toHaveLength(1);
const first = autoDamageEvents(rogue.id, "party-auto-attack-offhand")[0]!;
expect(first.rawAmount).toBeGreaterThanOrEqual(Math.round(profile.damage));
expect(first.rawAmount).toBeLessThanOrEqual(Math.round(profile.damage * 1.3));
advanceDungeonPartyCombat(1_000 + expectedInterval - 1);
expect(autoDamageEvents(rogue.id, "party-auto-attack-offhand")).toHaveLength(1);
advanceDungeonPartyCombat(1_000 + expectedInterval);
expect(autoDamageEvents(rogue.id, "party-auto-attack-offhand").map((event) => event.occurredAt))
.toEqual([1_000, 1_000 + expectedInterval]);
});
it("keeps player Scout Shot tied to the native ranged-weapon source binding", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-scout", secondaryClassId: "rom-rogue",
raceId: "rom-human", statRuleset: "rom", level: 70 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
const targetId = "native-player-scout-shot-target";
registerTarget(targetId, 10);
const scoutAbilities = abilitiesForCharacter("rom-scout", "rom-rogue");
const baseShot = scoutAbilities
.find((ability) => ability.effects.some((effect) => effect.kind === "damage" && effect.romNativeSource?.skillId === 491128));
expect(baseShot).toBeDefined();
const shot = abilityAtLevel(baseShot!, 70);
const effect = shot.effects.find((candidate) => candidate.kind === "damage");
expect(effect?.kind).toBe("damage");
if (effect?.kind !== "damage") return;
expect(effect.romNativeSource).toMatchObject({ skillId: 491128, kind: "damage" });
const state = useCombatStore.getState();
const stats = deriveCharacterStats(state.classId, state.level, state.gearStats, state.raceId,
{}, state.secondaryClassId, state.gender, state.statRuleset);
const sourceAmount = nativeDamageCasterAmount(effect, state.level, stats.romNative!);
expect(sourceAmount).not.toBeNull();
expect(sourceAmount).toBeGreaterThan(0);
const cast = useCombatStore.getState().castAbility(shot.id,
{ targetActorId: targetId, origin: [0, 0, 0], now: 1_000 });
expect(cast.ok).toBe(true);
const activeCast = useCombatStore.getState().activeCast;
if (activeCast) useCombatStore.getState().advanceMobCombat(activeCast.completesAt, [0, 0, 0]);
const shotHits = autoDamageEvents(PLAYER_AURA_ENTITY_ID, shot.id);
expect(shotHits).toHaveLength(1);
expect(shotHits[0]?.rawAmount).toBeGreaterThan(0);
});
});
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { abilitiesForCharacter, abilityAtLevel } from "./abilityCatalog";
import { equipmentAdjustedCombatAmount } from "./combatStore";
import { resolvedEquipmentStats } from "./heirloomEquipment";
import { nativeDamageCasterAmount } from "./romNativeCombatAmount";
import { deriveCharacterStats } from "./wotlkStats";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
import { createConfiguredPartyMember } from "./partyStore";
import { partyAbilityAmount } from "./partyAbilityAi";
const BASIC_ATTACKS = ["Holy Strike", "Rising Tide", "Shadowstab", "Shot", "Flame"];
describe("native RoM source amount dispatch", () => {
it("routes all five basic attacks through native source power for both player and companion gear", () => {
for (const level of [1, 30, 80]) for (const [index, identity] of ROM_FIVE_IDENTITIES.entries()) {
const secondaryClassId = level >= 10 ? identity.secondaryClassId : null;
const gear = resolvedEquipmentStats({}, [], { ownerId: "player", ownerKind: "player",
classId: identity.classId, secondaryClassId, specializationId: null, role: identity.role, level });
const native = deriveCharacterStats(identity.classId, level, gear, identity.raceId, {}, secondaryClassId, "male", "rom").romNative!;
const baseAbility = abilitiesForCharacter(identity.classId, secondaryClassId).find((ability) => ability.name === BASIC_ATTACKS[index])!;
const ability = abilityAtLevel(baseAbility, level);
const effect = ability.effects.find((effect) => effect.kind === "damage")!;
if (effect.kind !== "damage") throw new Error("Expected direct damage.");
const expected = nativeDamageCasterAmount(effect, level, native);
expect(expected, `${identity.classId}:${level}`).not.toBeNull();
expect(expected).toBeGreaterThan(0);
const playerAmount = equipmentAdjustedCombatAmount(effect, level, gear, identity.classId, ability,
0, undefined, identity.raceId, "male", "rom", secondaryClassId);
expect(playerAmount).toBe(Math.round(expected!));
const member = createConfiguredPartyMember({ ...identity, secondaryClassId, statRuleset: "rom",
specialization: "Primary", talentTreeId: "" }, level, index, "native-amount-parity");
expect(partyAbilityAmount(member, ability, effect)).toBe(playerAmount);
const unsupportedCustomGear = { ...gear, romNative: { ...gear.romNative, physicalDamage: (gear.romNative?.physicalDamage ?? 0) + 1 } };
const custom = deriveCharacterStats(identity.classId, level, unsupportedCustomGear, identity.raceId, {}, secondaryClassId, "male", "rom").romNative!;
expect(nativeDamageCasterAmount(effect, level, custom)).toBeNull();
}
});
it("resolves Reflected Shot per recipient for player and companion while context-free callers retain fallback", () => {
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === "rom-scout")!;
const level = 80;
const gear = resolvedEquipmentStats({}, [], { ownerId: "player", ownerKind: "player", ...identity,
specializationId: null, level });
const native = deriveCharacterStats(identity.classId, level, gear, identity.raceId, {},
identity.secondaryClassId, "male", "rom").romNative!;
const ability = abilityAtLevel(abilitiesForCharacter(identity.classId, identity.secondaryClassId)
.find((entry) => entry.name === "Reflected Shot")!, level);
const effect = ability.effects.find((entry) => entry.kind === "damage")!;
expect(effect.romNativeSource).toBeDefined();
expect(nativeDamageCasterAmount(effect, level, native)).toBeNull();
expect(equipmentAdjustedCombatAmount(effect, level, gear, identity.classId, ability,
0, undefined, identity.raceId, "male", "rom", identity.secondaryClassId)).toBeGreaterThan(0);
const member = createConfiguredPartyMember({ ...identity, statRuleset: "rom",
specialization: "Primary", talentTreeId: "" }, level, 0, "native-area-parity");
const powers = [0, 1, 2].map((targetOrdinal) => {
const context = { targetCount: 3, targetOrdinal };
const expected = nativeDamageCasterAmount(effect, level, native, context);
expect(expected).not.toBeNull();
const player = equipmentAdjustedCombatAmount(effect, level, gear, identity.classId, ability,
0, undefined, identity.raceId, "male", "rom", identity.secondaryClassId, native, context);
expect(player).toBe(Math.round(expected!));
expect(partyAbilityAmount(member, ability, effect, 0, undefined, native, context)).toBe(player);
return player;
});
expect(powers[0]).toBeGreaterThan(powers[1]);
expect(powers[1]).toBeGreaterThan(powers[2]);
});
});
+180
View File
@@ -0,0 +1,180 @@
import type { AbilityAmountScaling } from "./abilityCatalog";
import { clampPlayerLevel } from "./progression";
import { resolveRomNativeHolyHeal, resolveRomNativePeriodicHolyHeal, romNativeHolyHealSourceMetadata } from "./romNativeHealing";
import { resolveRomNativeTrainingWeaponProfile, findRomNativeAttackComponent, calculateRomNativeDamagePower, resolveRomNativeAutoAttackProfile, romNativeTrainingShieldDefense, type RomNativeDamageWeaponSlot } from "./romNativeDamage";
import { resolvedEquipmentStats } from "./heirloomEquipment";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
import { nativePeriodicDamageSnapshot, romNativePeriodicDamageBinding,
type RomNativePeriodicDamageCombatantStats, type RomNativePeriodicDamageMagicResistance } from "./romNativePeriodicDamage";
import type { RomNativeDerivedStats } from "./romNativeStatsTypes";
/** Simulator policy: skills train with the character; there is no separate TP/skill-level progression yet. */
export const romNativeMagicLevel = (level: number): number => clampPlayerLevel(level);
const bindings = romNativeHolyHealSourceMetadata.attackBindings.flatMap((ability) => ability.ranks.flatMap((rank) =>
rank.componentIds.map((component) => ({ abilityId: ability.abilityId, rank, component })),
));
export function romNativeAmountBinding(source: NonNullable<AbilityAmountScaling["romNativeSource"]>) {
return bindings.find(({ rank, component }) => rank.sourceSkillId === source.skillId
&& component.componentId === source.componentId) ?? null;
}
/** Source caster power only. Recipient modifiers are resolved at each real healing target. */
export function nativeHolyHealCasterAmount(
scaling: AbilityAmountScaling,
level: number,
caster: RomNativeDerivedStats,
): number | null {
const source = scaling.romNativeSource;
if (!source || source.kind !== "heal") return null;
const binding = romNativeAmountBinding(source);
if (!binding || binding.component.calculationType !== 5) return null;
const { rank, component } = binding;
return Math.max(0, resolveRomNativeHolyHeal({
source: {
componentId: component.componentId,
sourceSkillId: rank.sourceSkillId,
damagePower: component.damagePower,
damagePowerSkillLevelArg: component.damagePowerSkillLevelArg,
magicLevel: romNativeMagicLevel(level),
spellTimeSeconds: rank.spellTimeSeconds,
isIgnoreDmgEffect: rank.isIgnoreDmgEffect,
isFixDmgEffect: rank.isFixDmgEffect,
ignoreMagicAbsorb: component.ignoreMagicAbsorb,
randomRange: component.randomRange,
},
caster,
target: { healing: { ...caster.healing, targetHealAbsorbRate: 0, targetHealAbsorb: 0 } },
// Combat policy uses source mean variance; the common healing resolver handles crit separately.
randomUnit: 0.5,
}).amount);
}
export function nativeHolyHealTargetAmount(
amount: number,
scaling: AbilityAmountScaling,
target: RomNativeDerivedStats | undefined,
): number {
if (!target || !scaling.romNativeSource || scaling.romNativeSource.kind !== "heal") return amount;
const binding = romNativeAmountBinding(scaling.romNativeSource);
if (!binding || binding.component.calculationType !== 5 || binding.component.ignoreMagicAbsorb) return amount;
return Math.max(0, amount * (1 + target.healing.targetHealAbsorbRate / 100) + target.healing.targetHealAbsorb);
}
const referenceGearCache = new Map<string, Readonly<Record<string, number>>>();
export interface RomNativeDamageTargetContext {
readonly targetCount: number;
readonly targetOrdinal: number;
}
export function nativeDamageCasterAmount(
scaling: AbilityAmountScaling,
level: number,
caster: RomNativeDerivedStats,
targetContext?: RomNativeDamageTargetContext,
): number | null {
const source = scaling.romNativeSource;
if (!source || source.kind !== "damage") return null;
const binding = romNativeAmountBinding(source);
if (!binding) return null;
// The shared amount adapter runs before recipient enumeration. Avoid using
// first-target power for every recipient of a source share/decrease attack.
if (!targetContext && (binding.rank.isShareDamage || (binding.rank.rangeType !== 0 && binding.rank.decreaseArg !== 0))) return null;
const trainingGearProfileMatches = matchesNativeTrainingGear(caster, level);
const profile = resolveRomNativeTrainingWeaponProfile(caster, { trainingGearProfileMatches });
const component = findRomNativeAttackComponent({ abilityId: binding.abilityId,
sourceSkillId: source.skillId, componentId: source.componentId, rank: binding.rank.rank ?? 1 });
if (profile.status !== "supported" || component.status !== "supported") return null;
const result = calculateRomNativeDamagePower({ stats: caster, trainingWeapons: profile.value,
component: component.value, magicLevel: romNativeMagicLevel(level), ownerKind: "player",
// Native school modifiers remain on the shared outgoing-aura stage, once.
magicSchoolRate: 1, ...targetContext,
specialInputs: {
// Training profiles equip no throwing item. All-range BodyRate is neutral
// in this adapter; dealt-damage auras remain in the shared stage.
// residualFlatDamage is the source Fin.DMG bridge after weapon removal.
throwDamage: profile.value.residualFlatDamage * caster.rateFactors.physicalDamage,
// ShieldDef is Owner.BaseEQ's raw category sum, never aggregate DEF.
shieldDefense: caster.sourceInputs.classId === "rom-knight"
? romNativeTrainingShieldDefense("rom-knight", level) : 0,
} });
return result.status === "supported" ? result.value.damageBeforeTargetResolver : null;
}
const periodicBindings = romNativeHolyHealSourceMetadata.periodicHealBindings.flatMap((ability) =>
ability.ranks.flatMap((rank) => rank.componentIds.map((component) => ({ rank, component }))));
/** Source DotMagic stores a rank correction at application. Keep this amount frozen for the buff. */
export function nativePeriodicHealSnapshot(
scaling: AbilityAmountScaling,
level: number,
caster: RomNativeDerivedStats | undefined,
target?: RomNativeDerivedStats,
targetLevel = level,
) {
const source = scaling.romNativeSource;
if (!caster || !source || source.kind !== "hot") return undefined;
const binding = periodicBindings.find(({ rank, component }) => rank.sourceSkillId === source.skillId
&& component.componentId === source.componentId);
if (!binding) return undefined;
const { rank, component } = binding;
return resolveRomNativePeriodicHolyHeal({
source: { ...component, sourceSkillId: rank.sourceSkillId,
rawSpellTimeSeconds: rank.spellTimeSeconds, isFixDmgEffect: rank.isFixDmgEffect,
magicLevel: romNativeMagicLevel(level) },
caster: { ...caster, effectiveMagicPower: caster.magicPowerRate },
target: target ?? { healing: { ...caster.healing, targetHealAbsorbRate: 0, targetHealAbsorb: 0 } },
sourceIsPlayer: true, casterLevel: level, targetLevel,
});
}
export function matchesNativeTrainingGear(caster: RomNativeDerivedStats, level: number): boolean {
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === caster.sourceInputs.classId);
if (!identity) return false;
const key = `${identity.classId}:${level}:${caster.sourceInputs.secondaryClassId}`;
let referenceGear = referenceGearCache.get(key);
if (!referenceGear) {
referenceGear = resolvedEquipmentStats({}, [], {
ownerId: "native-source-reference", ownerKind: "player", classId: identity.classId,
secondaryClassId: caster.sourceInputs.secondaryClassId, specializationId: null, role: identity.role, level,
}).romNative as Readonly<Record<string, number>> | undefined;
if (!referenceGear) return false;
referenceGearCache.set(key, referenceGear);
}
const currentGear = caster.sourceInputs.gear as Readonly<Record<string, number>>;
return [...new Set([...Object.keys(referenceGear), ...Object.keys(currentGear)])]
.every((key) => Math.abs((referenceGear![key] ?? 0) - (currentGear[key] ?? 0)) < 0.00001);
}
export function nativeTrainingAutoAttackProfile(caster: RomNativeDerivedStats | undefined, slot: RomNativeDamageWeaponSlot) {
if (!caster) return undefined;
const profile = resolveRomNativeTrainingWeaponProfile(caster,
{ trainingGearProfileMatches: matchesNativeTrainingGear(caster, caster.sourceInputs.level) });
if (profile.status !== "supported") return undefined;
const result = resolveRomNativeAutoAttackProfile({ stats: caster, trainingWeapons: profile.value, slot });
return result.status === "supported" ? result.value : undefined;
}
/** Native outgoing DotMagic freezes its source rank and target defense at application. */
export function nativePeriodicDamageCasterSnapshot(
scaling: AbilityAmountScaling,
level: number,
caster: RomNativeDerivedStats | undefined,
target: RomNativePeriodicDamageCombatantStats | undefined,
targetLevel: number,
magicResistance?: RomNativePeriodicDamageMagicResistance,
effectiveMagicPower?: number,
) {
if (!caster || !target || scaling.romNativeSource?.kind !== "dot") return undefined;
const source = romNativePeriodicDamageBinding(scaling.romNativeSource);
if (!source) return undefined;
const mainHand = nativeTrainingAutoAttackProfile(caster, "main-hand");
const result = nativePeriodicDamageSnapshot({ source, caster, target,
sourceIsPlayer: true, magicLevel: romNativeMagicLevel(level), casterLevel: level, targetLevel,
mainDamage: mainHand?.damage, mainAttackSpeed: mainHand?.attackSpeed,
effectiveMagicPower, magicResistance,
});
return result.status === "supported" ? result.value : undefined;
}
@@ -0,0 +1,275 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { combatDefenderFromCharacterStats, resolveCombatResult, baseMissChancePercent } from "./combatResults";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { deriveCharacterStats } from "./wotlkStats";
import {
romNativeNpcCombatant,
romNativeNpcHitProfile,
romNativeNpcIdForEntity,
romNativeTrainingCombatant,
resolveRomNativeCombatResult,
} from "./romNativeCombatResolution";
import { matchesNativeTrainingGear } from "./romNativeCombatAmount";
const SOURCE_NPC_ENTITY_ID = "rw-barren-caves-101456";
const SOURCE_NPC_ID = 101_456;
const TARGET_ID = "native-resolution-target";
const NOW = 1_000;
const INCOMING_ATTACK_ID = "source-npc-native-auto";
function stableCombatUnit(seed: string): number {
let hash = 2_166_136_261;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 16_777_619);
}
return (hash >>> 0) / 0x1_0000_0000;
}
function nativeRolls(seed: string) {
return {
dodge: stableCombatUnit(`${seed}:native:dodge`),
miss: stableCombatUnit(`${seed}:native:miss`),
block: stableCombatUnit(`${seed}:native:block`),
parry: stableCombatUnit(`${seed}:native:parry`),
critical: stableCombatUnit(`${seed}:native:critical`),
hit: stableCombatUnit(`${seed}:native:hit`),
};
}
function wowRoll(stage: "outcome" | "resistance", seed: string): number {
return stableCombatUnit(`${seed}:${stage}`);
}
function nativePlayerCombatant() {
const state = useCombatStore.getState();
const stats = deriveCharacterStats(state.classId, state.level, state.gearStats, state.raceId,
{}, state.secondaryClassId, state.gender, state.statRuleset);
const result = romNativeTrainingCombatant(stats.romNative!, matchesNativeTrainingGear(stats.romNative!, state.level));
if (result.status !== "supported") throw new Error(`Native player snapshot unavailable: ${result.reason}`);
return result.value;
}
function registerNpc(): void {
useCombatStore.getState().registerMob(SOURCE_NPC_ENTITY_ID, {
name: "Barren Caves Cave Sand Bug",
level: 30,
maxHealth: 10_000_000,
xpReward: 0,
runewakerNpcId: SOURCE_NPC_ID,
});
setMobPosition(SOURCE_NPC_ENTITY_ID, [2, 0, 0]);
}
function eventFor(targetActorId: string) {
return [...useCombatStore.getState().combatEvents].reverse().find((event) => (
event.kind === "damage"
&& event.sourceActorId === SOURCE_NPC_ENTITY_ID
&& event.targetActorId === (targetActorId === PLAYER_AGGRO_ID ? PLAYER_AURA_ENTITY_ID : targetActorId)
&& event.abilityId === INCOMING_ATTACK_ID
));
}
describe("native RoM combat resolution integration", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({
classId: "rom-knight",
secondaryClassId: "rom-scout",
raceId: "rom-human",
role: "tank",
statRuleset: "rom",
level: 30,
});
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("native-resolution-mixed", 30,
useCombatStore.getState().health, "tank", 2, "wow");
});
it("resolves native player hit, critical chance, and damage against an imported source NPC snapshot", () => {
const sourceNpcId = romNativeNpcIdForEntity(SOURCE_NPC_ENTITY_ID);
expect(sourceNpcId).toBe(SOURCE_NPC_ID);
const npc = romNativeNpcCombatant(sourceNpcId!, 30);
expect(npc).toMatchObject({ npcId: SOURCE_NPC_ID, level: 30, combatStatus: "supported" });
expect(npc?.nativeCombatant).toMatchObject({ origin: "runewaker-npc", sourceId: SOURCE_NPC_ID });
expect(romNativeNpcHitProfile(SOURCE_NPC_ID, 492_289, 502_503)).toMatchObject({
effectType: 0,
hitRateFunc: 0,
hitRateArgs: [100, 0],
criticalKind: "physical",
});
registerNpc();
const attacker = nativePlayerCombatant();
expect(attacker.origin).toBe("runewaker-player");
const defender = npc!.nativeCombatant!;
const seed = "native-player-source-npc-hit";
const amount = 1_000;
const expected = resolveRomNativeCombatResult({
ruleset: "rom",
amount,
attackKind: "melee",
damageSchool: "physical",
damageChannel: "physical",
attacker,
defender,
hitProfile: { kind: "auto", effectType: 1, hitRateFunc: 0, hitRateArgs: [0, 0] },
rules: { isAutoAttack: true },
rolls: nativeRolls(seed),
});
expect(expected.status).toBe("resolved");
if (expected.status !== "resolved") return;
const dealt = useCombatStore.getState().damageMob(
SOURCE_NPC_ENTITY_ID,
amount,
NOW,
{ actorId: PLAYER_AGGRO_ID, role: "damage" },
"physical",
{ attackKind: "melee", seed, rules: { isAutoAttack: true } },
);
expect(dealt.amount).toBe(expected.combatResult.finalDamage);
expect(useCombatStore.getState().lastCombatResult).toMatchObject({
outcome: expected.combatResult.outcome,
landed: expected.combatResult.landed,
critical: expected.combatResult.critical,
rawDamage: amount,
finalDamage: expected.combatResult.finalDamage,
attackTable: expected.combatResult.attackTable,
});
});
it("uses native NPC combat for the RoM player and WoW combat for a mixed-mode companion", () => {
const sourceNpcId = romNativeNpcIdForEntity(SOURCE_NPC_ENTITY_ID);
expect(sourceNpcId).toBe(SOURCE_NPC_ID);
const npc = romNativeNpcCombatant(sourceNpcId!, 30);
expect(npc?.combatStatus).toBe("supported");
registerNpc();
const companion = createConfiguredPartyMember({
name: "WoW ruleset control",
classId: "mage",
statRuleset: "wow",
raceId: "human",
role: "damage",
specialization: "Primary",
talentTreeId: "",
talentRanks: {},
}, 30, 0, "native-resolution-wowside");
expect(companion.statRuleset).toBe("wow");
usePartyStore.setState({ members: [companion], command: "stop" });
registerPartyRuntimePosition(companion.id, [1, 0, 0]);
useCombatStore.getState().synchronizeActors();
const attack = {
id: INCOMING_ATTACK_ID,
name: "Source NPC auto attack",
delivery: "melee" as const,
target: "nearby-party" as const,
school: "physical" as const,
attackKind: "melee" as const,
damageOrigin: "runewaker" as const,
animation: "attack" as const,
castTimeMs: 0,
range: 10,
cooldownMs: 10_000,
initialCooldownMs: [0, 0] as const,
repeatCooldownMs: [10_000, 10_000] as const,
damageMultiplier: 1,
};
useCombatStore.getState().registerMob(SOURCE_NPC_ENTITY_ID, {
name: "Barren Caves Cave Sand Bug",
level: 30,
maxHealth: 10_000_000,
xpReward: 0,
runewakerNpcId: SOURCE_NPC_ID,
attacks: [attack],
});
setMobPosition(SOURCE_NPC_ENTITY_ID, [2, 0, 0]);
useCombatStore.getState().selectMob(SOURCE_NPC_ENTITY_ID);
useCombatStore.getState().damageMob(SOURCE_NPC_ENTITY_ID, 1, 100, {
actorId: PLAYER_AGGRO_ID,
role: "tank",
});
const before = useCombatStore.getState();
const normalizedMob = before.mobs[SOURCE_NPC_ENTITY_ID]!;
const normalizedAttack = normalizedMob.attacks[0]!;
expect(normalizedMob.runewakerNpcId).toBe(SOURCE_NPC_ID);
const attackRevision = normalizedMob.attackRevision;
const amount = normalizedMob.attackDamage;
const playerStats = deriveCharacterStats(before.classId, before.level, before.gearStats, before.raceId,
{}, before.secondaryClassId, before.gender, before.statRuleset);
const playerDefender = romNativeTrainingCombatant(playerStats.romNative!,
matchesNativeTrainingGear(playerStats.romNative!, before.level));
expect(playerDefender.status).toBe("supported");
if (playerDefender.status !== "supported") return;
const companionStats = deriveCharacterStats(companion.classId, companion.level, companion.gearStats,
companion.raceId, {}, companion.secondaryClassId, companion.gender, companion.statRuleset);
expect(companionStats.romNative).toBeUndefined();
const wowDefender = combatDefenderFromCharacterStats(companionStats);
const nativeAttacker = npc!.nativeCombatant!;
const nativeSeed = `${SOURCE_NPC_ENTITY_ID}:${normalizedAttack.id}:${PLAYER_AGGRO_ID}:${attackRevision}:${NOW}`;
const wowSeed = `${SOURCE_NPC_ENTITY_ID}:${normalizedAttack.id}:${companion.id}:${attackRevision}:${NOW}`;
const expectedPlayer = resolveRomNativeCombatResult({
ruleset: "rom",
amount,
attackKind: "melee",
damageSchool: "physical",
damageChannel: "physical",
attacker: nativeAttacker,
defender: playerDefender.value,
hitProfile: { kind: "auto", effectType: 1, hitRateFunc: 0, hitRateArgs: [0, 0] },
rules: { canMiss: true, canCrit: true, canDodge: true, canParry: true, canBlock: true },
rolls: nativeRolls(nativeSeed),
});
expect(expectedPlayer.status).toBe("resolved");
if (expectedPlayer.status !== "resolved") return;
const expectedCompanion = resolveCombatResult({
amount,
attackKind: "melee",
damageSchool: "physical",
attacker: { level: normalizedMob.level, criticalChancePercent: 5 },
defender: wowDefender,
rules: {
isAutoAttack: true,
canMiss: true,
canCrit: true,
canDodge: true,
canParry: true,
canBlock: true,
baseMissChancePercent: baseMissChancePercent("melee", normalizedMob.level, companionStats.level),
},
roll: (stage) => wowRoll(stage, wowSeed),
});
useCombatStore.getState().advanceMobCombat(NOW);
const playerEvent = eventFor(PLAYER_AGGRO_ID);
const companionEvent = eventFor(companion.id);
expect(playerEvent).toMatchObject({
outcome: expectedPlayer.combatResult.outcome,
critical: expectedPlayer.combatResult.critical,
rawAmount: expectedPlayer.combatResult.rawDamage,
});
expect(playerEvent?.effectiveAmount).toBeGreaterThan(0);
expect(playerEvent?.effectiveAmount).toBeLessThanOrEqual(expectedPlayer.combatResult.finalDamage);
expect(companionEvent).toMatchObject({
outcome: expectedCompanion.outcome,
critical: expectedCompanion.critical,
rawAmount: expectedCompanion.rawDamage,
effectiveAmount: expectedCompanion.finalDamage,
});
expect(useCombatStore.getState().lastCombatResult?.attackTable).toEqual(
expectedCompanion.attackTable,
);
expect([playerEvent?.targetActorId, companionEvent?.targetActorId].sort())
.toEqual([PLAYER_AGGRO_ID, companion.id].sort());
});
});
+357
View File
@@ -0,0 +1,357 @@
import { describe, expect, it } from "vitest";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { romNativeNpcEquipmentRecords } from "./romNativeNpcEquipment";
import { deriveRomNativeStats } from "./romNativeStats";
import {
nativeMagicInputsForType,
resolveRomNativeCombatResult,
romNativeNpcCombatant,
romNativeNpcIdForEntity,
romNativePlayerHitProfile,
romNativeTrainingCombatant,
type RomNativeCombatantSnapshot,
type ResolveRomNativeCombatInput,
} from "./romNativeCombatResolution";
function combatant(overrides: Partial<RomNativeCombatantSnapshot> = {}): RomNativeCombatantSnapshot {
return {
origin: "runewaker-player", level: 50,
attributes: { strength: 10, stamina: 10, intellect: 10, wisdom: 10, agility: 10 },
physicalAttack: 100, physicalDefense: 100, magicalAttack: 100, magicalDefense: 100,
physicalHit: 100, magicalHit: 100, physicalDodge: 0, magicalDodge: 0,
physicalCritical: 0, magicalCritical: 0,
physicalHitBase: 100, physicalHitRange: 0, magicalHitBase: 100, magicalHitRange: 0,
physicalCriticalPower: 2, magicalCriticalPower: 2,
physicalCriticalResist: 0, magicalCriticalResist: 0,
parryPoints: 0, parryRateFactor: 1, blockPoints: 0, blockRateFactor: 1,
parryResistPoints: 0, blockResistPoints: 0,
bodyHitRates: { physicalHitRate: 0, physicalDodgeRate: 0, magicalHitRate: 0, magicalDodgeRate: 0 },
skills: { weaponSkill: 100, defenseSkill: 100, rangedSkill: 100, policy: "explicit-source-value" },
baseRateArg: 0, baseResistArg: 0, levelRateArg: 0, levelResistArg: 0,
missLevelCoefficient: 0, criticalLevelCoefficient: 0,
nativeMagicResistances: Array.from({ length: 6 }, () => ({ value: 0, rateFactor: 1 })),
nativeMagicPowerRates: Array.from({ length: 6 }, () => 1), provenance: ["test source snapshot"],
...overrides,
};
}
const automatic = { kind: "auto" as const, effectType: 1 as const, hitRateFunc: 0 as const, hitRateArgs: [100, 0] as const };
const nativeHit = (overrides: Partial<ResolveRomNativeCombatInput> = {}) => resolveRomNativeCombatResult({
ruleset: "rom", amount: 100, attackKind: "melee", damageSchool: "physical", damageChannel: "physical",
attacker: combatant(), defender: combatant(), hitProfile: automatic, rules: {
canMiss: false, canDodge: false, canCrit: false, canBlock: false, canParry: false,
}, rolls: { hit: 0.5, critical: 0.5, block: 0.5, parry: 0.5 }, ...overrides,
} as ResolveRomNativeCombatInput);
describe("source-native Runewaker combat resolution", () => {
it("applies native ATK/DEF and MATK/MDEF ratios without WoW ratings", () => {
const physical = nativeHit();
expect(physical.status).toBe("resolved");
if (physical.status !== "resolved") return;
expect(physical.combatResult.finalDamage).toBe(66);
expect(physical.source.damageChannel).toBe("physical");
expect(physical.source.defenseRate).toBeCloseTo(2 / 3);
const magical = nativeHit({ damageChannel: "magical", damageSchool: "nature", attacker: combatant({
physicalAttack: 900, magicalAttack: 100,
}), defender: combatant({ physicalDefense: 0, magicalDefense: 100 }) });
expect(magical.status).toBe("resolved");
if (magical.status !== "resolved") return;
expect(magical.combatResult.finalDamage).toBe(66);
expect(magical.source.damageChannel).toBe("magical");
});
it("uses source physical dodge then skill miss ordering", () => {
const dodge = nativeHit({ attacker: combatant({ physicalHit: 10, physicalHitRange: 100 }), defender: combatant({ physicalDodge: 100 }),
rules: { canMiss: true, canDodge: true, canCrit: false, canBlock: false, canParry: false },
rolls: { dodge: 0, miss: 0.99 } });
expect(dodge.status).toBe("resolved");
if (dodge.status === "resolved") expect(dodge.combatResult.outcome).toBe("dodge");
const miss = nativeHit({ attacker: combatant({ skills: { weaponSkill: 0, defenseSkill: 100, policy: "explicit-source-value" } }),
rules: { canMiss: true, canDodge: true, canCrit: false, canBlock: false, canParry: false },
rolls: { dodge: 0.99, miss: 0 } });
expect(miss.status).toBe("resolved");
if (miss.status === "resolved") expect(miss.combatResult.outcome).toBe("miss");
});
it("uses NPC level-times-2.3 weapon skill without the player +10", () => {
const resolution = nativeHit({
attacker: combatant({
origin: "runewaker-npc", level: 5, physicalHit: 100,
skills: { weaponSkill: 11.5, defenseSkill: 0, policy: "npc-level-times-2.3" },
}),
defender: combatant({
level: 5, physicalDodge: 100,
skills: { weaponSkill: 0, defenseSkill: 100, policy: "explicit-source-value" },
}),
rules: { canMiss: true, canDodge: false, canCrit: false, canBlock: false, canParry: false },
rolls: { miss: 0.085 },
});
expect(resolution.status).toBe("resolved");
if (resolution.status !== "resolved") return;
expect(resolution.combatResult.outcome).toBe("hit");
expect(resolution.combatResult.attackTable.missPercent).toBe(8);
});
it("uses physical spell miss before its collect hit roll without auto-dodge", () => {
const resolution = nativeHit({
attacker: combatant({ physicalHit: 0, physicalHitBase: 0, physicalHitRange: 100,
skills: { weaponSkill: 0, defenseSkill: 0, policy: "explicit-source-value" } }),
defender: combatant({ physicalDodge: 100,
skills: { weaponSkill: 0, defenseSkill: 100, policy: "explicit-source-value" } }),
hitProfile: { kind: "magic", effectType: 1, hitRateFunc: 1, hitRateArgs: [100, 0] },
rules: { canMiss: true, canDodge: true, canCrit: false, canBlock: false, canParry: false },
rolls: { dodge: 0, miss: 0, hit: 0.99 },
});
expect(resolution.status).toBe("resolved");
if (resolution.status !== "resolved") return;
expect(resolution.combatResult.outcome).toBe("miss");
expect(resolution.source.stage).toBe("spell-physical-miss");
});
it("calculates shield block and parry from native points and FIX rate args", () => {
const blocked = nativeHit({ defender: combatant({ blockPoints: 100 }),
rules: { canMiss: false, canDodge: false, canCrit: false, canBlock: true, canParry: false },
rolls: { block: 0 } });
expect(blocked.status).toBe("resolved");
if (blocked.status === "resolved") {
expect(blocked.combatResult.outcome).toBe("block");
expect(blocked.combatResult.finalDamage).toBe(0);
}
const parried = nativeHit({ defender: combatant({ parryPoints: 100 }),
rules: { canMiss: false, canDodge: false, canCrit: false, canBlock: false, canParry: true },
rolls: { parry: 0 } });
expect(parried.status).toBe("resolved");
if (parried.status === "resolved") {
expect(parried.combatResult.outcome).toBe("parry");
expect(parried.combatResult.finalDamage).toBe(33);
}
});
it("applies NPC DLvRate, typed MPow and typed resistance before defense", () => {
const resolution = nativeHit({
amount: 100,
damageChannel: "magical",
damageSchool: "nature",
attacker: combatant({
origin: "runewaker-npc", level: 60,
nativeMagicPowerRates: [1, 1, 1.5, 1, 1, 1],
}),
defender: combatant({
level: 50,
nativeMagicResistances: [
{ value: 0, rateFactor: 1 }, { value: 0, rateFactor: 1 }, { value: 10, rateFactor: 0.5 },
{ value: 0, rateFactor: 1 }, { value: 0, rateFactor: 1 }, { value: 0, rateFactor: 1 },
],
}),
hitProfile: { kind: "magic", effectType: 0, hitRateFunc: 0, hitRateArgs: [100, 0], magicType: 2 },
rules: { canMiss: false, canDodge: false, canCrit: false, canBlock: false, canParry: false },
});
expect(resolution.status).toBe("resolved");
if (resolution.status !== "resolved") return;
expect(resolution.source.sourceLevelRate).toBe(2);
expect(resolution.source.nativeMagicPowerRate).toBe(1.5);
expect(resolution.source.sourceResistanceAmount).toBe(155);
expect(resolution.source.defenseRate).toBeCloseTo(2 / 3);
expect(resolution.combatResult.finalDamage).toBe(96);
expect(resolution.combatResult.resistedAmount).toBe(155);
});
it("multiplies critical power before DEF/MDEF and truncates damage once", () => {
const resolution = nativeHit({
amount: 100,
attacker: combatant({ physicalCritical: 10_000, physicalCriticalPower: 2 }),
defender: combatant(),
rules: { canMiss: false, canDodge: false, canCrit: true, canBlock: false, canParry: false },
rolls: { critical: 0 },
});
expect(resolution.status).toBe("resolved");
if (resolution.status !== "resolved") return;
expect(resolution.combatResult.critical).toBe(true);
expect(resolution.combatResult.finalDamage).toBe(133);
});
it("resolves exact imported player hit bindings and preserves typed magic channels", () => {
expect(romNativePlayerHitProfile(490019, 504896)).toMatchObject({
effectType: 1, hitRateFunc: 1, hitRateArgs: [99, 0], criticalRateBonus: 0,
});
expect(romNativePlayerHitProfile(490441, 500222)).toMatchObject({
effectType: 0, hitRateFunc: 4, damageChannel: "physical", criticalKind: "physical",
});
expect(romNativePlayerHitProfile(490148, 500155)).toMatchObject({
effectType: 1, damageChannel: "physical", criticalKind: "physical",
});
const snapshot = combatant({
nativeMagicPowerRates: [1, 2, 3, 4, 5, 6],
nativeMagicResistances: Array.from({ length: 6 }, (_, value) => ({ value, rateFactor: 0.5 + value / 10 })),
});
expect(nativeMagicInputsForType(snapshot, 3)).toEqual({
effectiveMagicPower: 4, magicResistance: { value: 3, rateFactor: 0.8 },
});
expect(nativeMagicInputsForType(snapshot, 6)).toBeUndefined();
});
it("keeps native NPC population mapping and levels while applying verified Barren NPC gear", () => {
expect(romNativeNpcIdForEntity("rw-aeternal-circle-104572")).toBe(104572);
const base = romNativeNpcCombatant(101456, 81);
expect(base).toMatchObject({ npcId: 101456, level: 81, maxEffectiveLevel: 81, combatStatus: "supported", mainAttackSpeed: 30 });
expect(base?.nativeMagicResistances).toHaveLength(6);
for (const npcId of [101456, 101457, 101458, 101462]) {
expect(romNativeNpcCombatant(npcId, 81)?.combatStatus, `NPC ${npcId}`).toBe("supported");
}
expect(romNativeNpcCombatant(101459, 81, { selectionSeed: "stable-actor-a" })).toMatchObject({
npcId: 101459,
combatStatus: "supported",
mainAttackSpeed: 20,
mainDamage: expect.any(Number),
equipment: {
mainHand: { sourceItemId: 210056, attackRange: 20, attackSpeed: 20 },
selectedCandidateIndices: { mainHand: expect.any(Number), offHand: expect.any(Number) },
},
nativeCombatant: { throwDamage: expect.any(Number) },
});
expect(romNativeNpcCombatant(101460, 81)).toMatchObject({
combatStatus: "supported", mainAttackSpeed: 20,
equipment: { mainHand: { sourceItemId: 210061, attackSpeed: 20 } },
nativeCombatant: { throwDamage: expect.any(Number) },
});
expect(romNativeNpcCombatant(101461, 81)).toMatchObject({
combatStatus: "supported", mainAttackSpeed: 30,
equipment: { offHand: { sourceItemId: 210666, attackSpeed: 20 } },
nativeCombatant: { throwDamage: expect.any(Number) },
});
const bowNpc = romNativeNpcCombatant(102425);
expect(bowNpc).toMatchObject({
combatStatus: "supported", bowAttackSpeed: 20, bowAttackRange: 20,
equipment: { bow: { sourceItemId: 212011, attackSpeed: 20, attackRange: 20 } },
});
expect(bowNpc?.bowDamage).toBeCloseTo(
(bowNpc?.periodicStats.physicalDamage ?? 0)
+ 19 * (bowNpc?.equipment?.wornItems.find((worn) => worn.slot === "bow")?.wearPower ?? 0),
6,
);
expect(romNativeNpcCombatant(101459, 9, { selectionSeed: "stable-actor-a" })).toMatchObject({
combatStatus: "supported", nativeCombatant: { sourceId: 101459, level: 9 },
});
expect(romNativeNpcCombatant(101462, 81)).toMatchObject({
level: 81, combatStatus: "supported", nativeCombatant: { sourceId: 101462, level: 81 },
mainDamage: expect.any(Number), mainAttackSpeed: 30,
});
expect(romNativeNpcCombatant(101459, 81, { selectionSeed: "actor-10" })?.equipment?.selectedCandidateIndices)
.not.toEqual(romNativeNpcCombatant(101459, 81, { selectionSeed: "stable-actor-a" })?.equipment?.selectedCandidateIndices);
expect(romNativeNpcCombatant(101459, 81, { mainHandCandidateIndex: 3, offHandCandidateIndex: 2 })?.equipment?.selectedCandidateIndices)
.toEqual({ mainHand: 3, offHand: 2 });
expect(romNativeNpcCombatant(102425)).toMatchObject({
combatStatus: "supported", bowAttackSpeed: expect.any(Number), bowAttackRange: expect.any(Number),
bowDamage: expect.any(Number), equipment: { bow: { sourceItemId: 212011 } },
});
expect(romNativeNpcCombatant(101459, 81, { mainHandCandidateIndex: 99 })?.combatStatus).toBe("unsupported");
expect(romNativeNpcCombatant(101459, 81, { mainHandCandidateIndex: 99 })?.combatReasons).toContain("missing-mainHand-candidate");
expect(romNativeNpcCombatant(101459, 81)?.nativeCombatant?.throwDamage).toBeGreaterThan(0);
expect(romNativeNpcCombatant(101459, 81)?.nativeCombatant?.throwDamage).toEqual(
romNativeNpcCombatant(101459, 81)?.periodicStats.physicalDamage,
);
expect(romNativeNpcCombatant(101459, 81)?.equipment?.wornItems[0]?.wearPower).toBe(1.2);
expect(romNativeNpcCombatant(101461, 81)?.equipment?.wornItems[0]?.wearPower).toBe(0.2);
expect(romNativeNpcCombatant(101459, 81)?.combatReasons).toEqual([]);
});
it("treats the source None wear index as a no-op and keeps real off-hand damage source-derived", () => {
const none = romNativeNpcCombatant(102_024, 60, { mainHandCandidateIndex: 0, offHandCandidateIndex: 0 });
expect(none?.combatStatus).toBe("supported");
expect(none?.equipment?.wornItems.some((worn) => worn.item.sourceItemId === 211_800
&& worn.item.eqTypeValuePairs.some((pair) => pair.eqType === 0 && pair.value === 35))).toBe(true);
const dualWielder = romNativeNpcCombatant(100_614, undefined, { mainHandCandidateIndex: 0, offHandCandidateIndex: 0 });
expect(dualWielder).toMatchObject({
combatStatus: "supported", offHandDamage: expect.any(Number), offHandAttackSpeed: expect.any(Number),
offHandAttackRange: expect.any(Number), equipment: { offHand: { sourceItemId: 210_179 } },
});
expect(dualWielder?.offHandDamage).toBeCloseTo(
((dualWielder?.periodicStats.physicalDamage ?? 0) + 48 * (dualWielder?.equipment?.wornItems.find((worn) => worn.slot === "offHand")?.wearPower ?? 0)) * 0.7,
6,
);
});
it("supports every default source NPC and every pair of the five weighted equipment candidates", () => {
const records = romNativeNpcEquipmentRecords();
expect(records).toHaveLength(440);
expect(records.filter((record) => record.bowEquipmentId !== 0)).toHaveLength(10);
expect(records.filter((record) => record.ornamentEquipmentId !== 0)).toHaveLength(0);
for (const source of records) {
expect(source.mainHandCandidates, `NPC ${source.npcId} main-hand candidates`).toHaveLength(5);
expect(source.offHandCandidates, `NPC ${source.npcId} off-hand candidates`).toHaveLength(5);
expect(romNativeNpcCombatant(source.npcId)?.combatStatus, `NPC ${source.npcId} default selection`).toBe("supported");
for (let mainHandCandidateIndex = 0; mainHandCandidateIndex < 5; mainHandCandidateIndex += 1) {
for (let offHandCandidateIndex = 0; offHandCandidateIndex < 5; offHandCandidateIndex += 1) {
const result = romNativeNpcCombatant(source.npcId, undefined, { mainHandCandidateIndex, offHandCandidateIndex });
expect(result?.combatStatus,
`NPC ${source.npcId} candidates main=${mainHandCandidateIndex}, off=${offHandCandidateIndex}: ${result?.combatReasons.join(",")}`,
).toBe("supported");
}
}
}
});
it("uses NPC sub-vocation base growth for the four exported mismatched identities", () => {
const results = [100_074, 100_594, 100_611, 100_614].map((npcId) => romNativeNpcCombatant(npcId));
expect(results.map((result) => result?.combatStatus)).toEqual(["supported", "supported", "supported", "supported"]);
const expected = [
{ strength: 96.01254580816881, stamina: 727351.5944823021, intellect: 200215.74972639457, wisdom: 217.14540043337144, agility: 156.43585959741122, health: 3699221.536940561 },
{ strength: 323.4585167252618, stamina: 351.6135443023084, intellect: 254.3492841800948, wisdom: 289.5809184460161, agility: 278.2859423066797, health: 2201.004504068569 },
{ strength: 390.8469573219641, stamina: 392.11979293362884, intellect: 383.3228416811658, wisdom: 385.6335706035044, agility: 384.29577503068447, health: 97957.46094801048 },
{ strength: 183.53160823428422, stamina: 182.93154632118186, intellect: 178.8528561407282, wisdom: 180.04705000022486, agility: 179.28553058887175, health: 3273.7736103378793 },
];
for (const [index, result] of results.entries()) {
for (const key of ["strength", "stamina", "intellect", "wisdom", "agility"] as const) {
expect(result?.attributes[key], `NPC ${[100_074, 100_594, 100_611, 100_614][index]} ${key}`).toBeCloseTo(expected[index][key], 8);
}
expect(result?.health.max, `NPC ${[100_074, 100_594, 100_611, 100_614][index]} health`).toBeCloseTo(expected[index].health, 8);
}
});
it("applies verified item DMG/MDMG with source WearEQ power", () => {
const equipped = romNativeNpcCombatant(100_606, 60, { mainHandCandidateIndex: 0, offHandCandidateIndex: 0 });
const neutral = romNativeNpcCombatant(100_606, 60, { mainHandCandidateIndex: 1, offHandCandidateIndex: 0 });
expect(equipped?.combatStatus).toBe("supported");
expect(neutral?.combatStatus).toBe("supported");
expect(equipped?.equipment?.wornItems[0]).toMatchObject({ item: { sourceItemId: 210_048 }, wearPower: 1.2 });
expect(equipped?.mainDamage).toBeCloseTo((equipped?.periodicStats.physicalDamage ?? 0) + 213 * 1.2, 6);
expect(equipped?.periodicStats.magicalDamage).toBeCloseTo((neutral?.periodicStats.magicalDamage ?? 0) + 167 * 1.2, 6);
expect(equipped?.nativeCombatant?.magicalDamage).toBe(equipped?.periodicStats.magicalDamage);
expect(equipped?.nativeCombatant?.throwDamage).toBe(equipped?.periodicStats.physicalDamage);
const staminaAndParry = romNativeNpcCombatant(106_315, 60, { mainHandCandidateIndex: 0, offHandCandidateIndex: 0 });
expect(staminaAndParry?.combatStatus).toBe("supported");
expect(staminaAndParry?.nativeCombatant?.parryPoints).toBeCloseTo(175 * 0.2, 6);
expect(staminaAndParry?.equipment?.wornItems[0]?.wearPower).toBe(0.2);
});
it("uses compact canonical training profiles and fails closed for mismatched gear", () => {
const stats = deriveRomNativeStats({ classId: "rom-mage", level: 50, raceId: "rom-human", gender: "male", gear: EMPTY_ITEM_STATS.romNative });
expect(romNativeTrainingCombatant(stats, false).status).toBe("unsupported");
const trained = romNativeTrainingCombatant(stats, true);
expect(trained.status).toBe("supported");
if (trained.status === "supported") {
expect(trained.value.skills.policy).toBe("source-table-max-simulator-policy");
expect(trained.value.nativeMagicResistances).toHaveLength(6);
expect(trained.value.nativeMagicPowerRates).toEqual([1, 1, 1, 1, 1, 1]);
}
});
it("keeps mitigation snapshots frozen, honors immunity flags, and rejects cross-ruleset reads", () => {
const frozen = nativeHit({ targetMitigationSnapshotted: true,
attacker: combatant({ physicalCritical: 10_000 }), defender: combatant({ physicalDefense: 10_000 }),
rules: { targetMitigationSnapshotted: true, canMiss: true, canCrit: true, immune: false } });
expect(frozen.status).toBe("resolved");
if (frozen.status === "resolved") expect(frozen.combatResult.finalDamage).toBe(100);
const immune = nativeHit({ rules: { immune: true } });
expect(immune.status).toBe("resolved");
if (immune.status === "resolved") expect(immune.combatResult).toMatchObject({ outcome: "resist", fullyResisted: true, finalDamage: 0 });
expect(nativeHit({ ruleset: "wow" } as unknown as Partial<ResolveRomNativeCombatInput>).status).toBe("unsupported");
});
});
File diff suppressed because it is too large. Load diff
+342
View File
@@ -0,0 +1,342 @@
import { describe, expect, it } from "vitest";
import { applyRomNativeStatModifiers, deriveRomNativeStats } from "./romNativeStats";
import {
calculateRomNativeDamagePower,
findRomNativeAttackComponent,
resolveRomNativeAutoAttackProfile,
resolveRomNativeTrainingWeaponProfile,
romNativeTrainingShieldDefense,
} from "./romNativeDamage";
import type { RomClassId } from "../app/characterCatalog";
it("keeps ShieldDef separate from fixed DEF effects in training shields", () => {
// Source shield 220772: 84 base shield DEF plus 6 fixed generic DEF at level 5.
expect(romNativeTrainingShieldDefense("rom-knight", 5)).toBe(84);
expect(romNativeTrainingShieldDefense("rom-knight", 1)).toBe(17);
expect(romNativeTrainingShieldDefense("rom-knight", 80)).toBe(9072);
expect(romNativeTrainingShieldDefense("rom-rogue", 80)).toBeUndefined();
});
const trainingCases = [
{
classId: "rom-knight",
secondaryClassId: "rom-scout",
gear: { physicalDamage: 2482, magicalDamage: 824 },
expectedMain: { damage: 2482, speed: 30 },
expectedWeaponSlots: 2482,
},
{
classId: "rom-priest",
secondaryClassId: "rom-knight",
gear: { physicalDamage: 1057, magicalDamage: 2361 },
expectedMain: { damage: 1057, speed: 30 },
expectedWeaponSlots: 1057,
},
{
classId: "rom-rogue",
secondaryClassId: "rom-warrior",
gear: { physicalDamage: 2978, magicalDamage: 1648 },
expectedMain: { damage: 1489, speed: 18 },
expectedWeaponSlots: 2978,
expectedOffHand: { damage: 1489, speed: 18 },
},
{
classId: "rom-scout",
secondaryClassId: "rom-rogue",
gear: { physicalDamage: 3484, magicalDamage: 824 },
expectedMain: { damage: 1489, speed: 18 },
expectedWeaponSlots: 3484,
expectedRanged: { damage: 1995, speed: 30 },
},
{
classId: "rom-mage",
secondaryClassId: "rom-warlock",
gear: { physicalDamage: 1057, magicalDamage: 2361 },
expectedMain: { damage: 1057, speed: 30 },
expectedWeaponSlots: 1057,
},
] as const;
function statsFor(
classId: RomClassId,
secondaryClassId: RomClassId | null,
gear: { readonly physicalDamage: number; readonly magicalDamage: number; readonly magicalDamagePoint?: number },
level = 70,
) {
return deriveRomNativeStats({ classId, secondaryClassId, level, gear });
}
function binding(input: { abilityId: string; sourceSkillId: number; componentId: number }) {
const result = findRomNativeAttackComponent({ ...input, rank: 1 });
if (result.status !== "supported") throw new Error(`Source component lookup failed: ${result.reason}`);
return result.value;
}
describe("Runewaker native outgoing damage bridge", () => {
it("resolves source training weapon curves and checks player/NPC native gear parity for all five active pairs", () => {
for (const sample of trainingCases) {
const playerStats = statsFor(sample.classId, sample.secondaryClassId, sample.gear);
const npcStats = statsFor(sample.classId, sample.secondaryClassId, sample.gear);
expect(npcStats.physicalDamage).toBe(playerStats.physicalDamage);
expect(npcStats.magicalDamage).toBe(playerStats.magicalDamage);
const playerProfile = resolveRomNativeTrainingWeaponProfile(playerStats, { trainingGearProfileMatches: true });
const npcProfile = resolveRomNativeTrainingWeaponProfile(npcStats, { trainingGearProfileMatches: true });
expect(playerProfile.status).toBe("supported");
expect(npcProfile).toEqual(playerProfile);
if (playerProfile.status !== "supported") continue;
expect(playerProfile.value.mainHand.sourceDamage).toBe(sample.expectedMain.damage);
expect(playerProfile.value.mainHand.speedTenths).toBe(sample.expectedMain.speed);
expect(playerProfile.value.trainingWeaponSlotDamage).toBe(sample.expectedWeaponSlots);
expect(playerProfile.value.residualFlatDamage).toBe(0);
if ("expectedOffHand" in sample) {
expect(playerProfile.value.offHand?.sourceDamage).toBe(sample.expectedOffHand.damage);
expect(playerProfile.value.offHand?.speedTenths).toBe(sample.expectedOffHand.speed);
}
if ("expectedRanged" in sample) {
expect(playerProfile.value.ranged?.sourceDamage).toBe(sample.expectedRanged.damage);
expect(playerProfile.value.ranged?.speedTenths).toBe(sample.expectedRanged.speed);
}
}
});
it("keeps pre-secondary levels supported and rejects custom or unverified training gear", () => {
const beforeUnlock = statsFor("rom-knight", null, { physicalDamage: 118, magicalDamage: 59 }, 9);
const preUnlockProfile = resolveRomNativeTrainingWeaponProfile(beforeUnlock, { trainingGearProfileMatches: true });
expect(preUnlockProfile.status, JSON.stringify(preUnlockProfile)).toBe("supported");
if (preUnlockProfile.status === "supported") expect(preUnlockProfile.value.secondaryClassId).toBeNull();
const customDamage = statsFor("rom-knight", "rom-scout", { physicalDamage: 2483, magicalDamage: 824 });
expect(resolveRomNativeTrainingWeaponProfile(customDamage, { trainingGearProfileMatches: true })).toEqual({
status: "unsupported",
reason: "native-gear-damage-does-not-match-training-profile",
});
const standardTraining = statsFor("rom-knight", "rom-scout", { physicalDamage: 2482, magicalDamage: 824 });
expect(resolveRomNativeTrainingWeaponProfile(standardTraining, { trainingGearProfileMatches: false })).toEqual({
status: "unsupported",
reason: "training-gear-profile-not-confirmed",
});
const wrongPair = statsFor("rom-knight", "rom-warrior", { physicalDamage: 2482, magicalDamage: 824 });
expect(resolveRomNativeTrainingWeaponProfile(wrongPair, { trainingGearProfileMatches: true }).status).toBe("unsupported");
});
it("exposes Main_DMG, Sec_DMG, and Bow_DMG with native source speeds for training weapons", () => {
const knightStats = statsFor("rom-knight", "rom-scout", { physicalDamage: 2482, magicalDamage: 824 });
const knight = resolveRomNativeTrainingWeaponProfile(knightStats, { trainingGearProfileMatches: true });
expect(knight.status).toBe("supported");
if (knight.status === "supported") {
expect(resolveRomNativeAutoAttackProfile({ stats: knightStats, trainingWeapons: knight.value, slot: "main-hand" })).toMatchObject({
status: "supported",
value: { sourceDamageChannel: "Main_DMG", damage: 2482, attackSpeed: 30, intervalMs: 3000 },
});
}
const rogueStats = statsFor("rom-rogue", "rom-warrior", { physicalDamage: 2978, magicalDamage: 1648 });
const rogue = resolveRomNativeTrainingWeaponProfile(rogueStats, { trainingGearProfileMatches: true });
expect(rogue.status).toBe("supported");
if (rogue.status === "supported") {
expect(resolveRomNativeAutoAttackProfile({ stats: rogueStats, trainingWeapons: rogue.value, slot: "off-hand" })).toMatchObject({
status: "supported",
value: { sourceDamageChannel: "Sec_DMG", damage: 1042.3, attackSpeed: 18, intervalMs: 1800 },
});
}
const scoutStats = statsFor("rom-scout", "rom-rogue", { physicalDamage: 3484, magicalDamage: 824 });
const scout = resolveRomNativeTrainingWeaponProfile(scoutStats, { trainingGearProfileMatches: true });
expect(scout.status).toBe("supported");
if (scout.status === "supported") {
expect(resolveRomNativeAutoAttackProfile({ stats: scoutStats, trainingWeapons: scout.value, slot: "ranged" })).toMatchObject({
status: "supported",
value: { sourceDamageChannel: "Bow_DMG", damage: 1995, attackSpeed: 30, intervalMs: 3000 },
});
}
});
it("removes training weapon type-25 DMG once, retains flat DMG, then uses Main_DMG and source speed", () => {
const base = statsFor("rom-knight", "rom-scout", { physicalDamage: 2482, magicalDamage: 824 });
const modified = applyRomNativeStatModifiers(base, {
channels: { physicalDamage: 100 },
rates: { physicalDamage: 1.5 },
});
const profile = resolveRomNativeTrainingWeaponProfile(modified, { trainingGearProfileMatches: true });
expect(profile.status).toBe("supported");
if (profile.status !== "supported") return;
expect(profile.value.residualFlatDamage).toBe(150);
const component = binding({ abilityId: "rom-knight-primary-12", sourceSkillId: 490154, componentId: 500263 });
const result = calculateRomNativeDamagePower({
stats: modified,
trainingWeapons: profile.value,
component,
magicLevel: 10,
ownerKind: "player",
magicSchoolRate: 1,
});
expect(result.status, JSON.stringify(result)).toBe("supported");
if (result.status !== "supported") return;
const mainDamage = (150 + 2482) * 1.5;
const dps = mainDamage * 10 / 30;
const expectedPower = (-0.5 * (1 + 10 * 10 / 100)) * dps - dps * 1.4;
expect(result.value.sourcePower).toBeCloseTo(expectedPower, 6);
expect(result.value.damageBeforeTargetResolver).toBeCloseTo(-expectedPower, 6);
expect(result.value.sourceRandomRange).toBe(2);
expect(result.value.sourceCriticalRate).toBe(0);
});
it("uses source MDMG and the verified player/NPC ExRate distinction without inferring MagicLv from rank", () => {
const stats = statsFor("rom-mage", "rom-warlock", { physicalDamage: 1057, magicalDamage: 2361 });
const profile = resolveRomNativeTrainingWeaponProfile(stats, { trainingGearProfileMatches: true });
const component = binding({
abilityId: "rom-mage-primary-1",
sourceSkillId: 491150,
componentId: 501531,
});
expect(component.rank).toBe(1);
expect(component.configuredCollectMagicLevel).toBe(0);
expect(component.damagePower).toBe(-70);
expect(component.fixedType).toBe(5);
expect(component.fixedValue).toBe(0);
expect(profile.status).toBe("supported");
if (profile.status !== "supported") return;
const player = calculateRomNativeDamagePower({
stats, trainingWeapons: profile.value, component, magicLevel: 30, ownerKind: "player", magicSchoolRate: 1,
});
const npc = calculateRomNativeDamagePower({
stats, trainingWeapons: profile.value, component, magicLevel: 30, ownerKind: "npc", magicSchoolRate: 1,
});
expect(player.status, JSON.stringify(player)).toBe("supported");
expect(npc.status, JSON.stringify(npc)).toBe("supported");
if (player.status !== "supported" || npc.status !== "supported") return;
const orgPower = -70 * (1 + 30 * 30 / 100);
const powerRate = component.rangeType === 0 ? 1 : 0.6;
const spellTime = Math.max(1, component.spellTime);
expect(player.value.sourcePower).toBeCloseTo(orgPower - (0.6 + 30 * 0.01) * 2361 * 1.6 * powerRate * spellTime, 6);
expect(npc.value.sourcePower).toBeCloseTo(orgPower - 2361 * 1.6 * powerRate * spellTime, 6);
expect(Math.abs(npc.value.sourcePower)).toBeGreaterThan(Math.abs(player.value.sourcePower));
const typedFixed = binding({
abilityId: "rom-mage-primary-1", sourceSkillId: 491150, componentId: 500379,
});
const fixedTerm = typedFixed.fixedValue * stats.attributes.intellect
* (1 + 30 * typedFixed.fixedDamageSkillLevelArg / 100);
const fixedResult = calculateRomNativeDamagePower({
stats, trainingWeapons: profile.value, component: typedFixed,
magicLevel: 30, ownerKind: "player", magicSchoolRate: 1,
});
expect(fixedResult.status, JSON.stringify(fixedResult)).toBe("supported");
if (fixedResult.status === "supported") expect(fixedResult.value.sourceFixedContribution).toBeCloseTo(fixedTerm, 6);
expect(calculateRomNativeDamagePower({
stats, trainingWeapons: profile.value, component: { ...typedFixed, fixedType: 12 },
magicLevel: 30, ownerKind: "player", magicSchoolRate: 1,
})).toEqual({ status: "unsupported", reason: "fixed-damage-type-unsupported" });
const otherStats = statsFor("rom-knight", "rom-scout", { physicalDamage: 2482, magicalDamage: 824 });
expect(calculateRomNativeDamagePower({
stats: otherStats, trainingWeapons: profile.value, component,
magicLevel: 30, ownerKind: "player", magicSchoolRate: 1,
})).toEqual({ status: "unsupported", reason: "training-weapon-profile-unsupported" });
});
it("resolves Shoot from source ranged DMG/speed and leaves crit, variance, and target defenses outside", () => {
const stats = statsFor("rom-scout", "rom-rogue", { physicalDamage: 3484, magicalDamage: 824 });
const profile = resolveRomNativeTrainingWeaponProfile(stats, { trainingGearProfileMatches: true });
const component = binding({ abilityId: "rom-scout-primary-1", sourceSkillId: 491128, componentId: 501677 });
expect(profile.status).toBe("supported");
if (profile.status !== "supported") return;
expect(component.calculationType).toBe(3);
const result = calculateRomNativeDamagePower({
stats, trainingWeapons: profile.value, component, magicLevel: 10, ownerKind: "player",
});
expect(result.status, JSON.stringify(result)).toBe("supported");
if (result.status !== "supported") return;
const orgPower = -0.35 * (1 + 8 * 10 / 100);
const bowDamage = 1995;
const expected = orgPower * (bowDamage * 10 / 30) - bowDamage * 1.4;
expect(result.value.sourcePower).toBeCloseTo(expected, 6);
expect(result.value.damageBeforeTargetResolver).toBeCloseTo(-expected, 6);
expect(result.value.ignoreMagicAbsorb).toBe(false);
});
it("uses explicit native throw, signed prior-hit, and raw shield-category inputs", () => {
const stats = statsFor("rom-knight", "rom-scout", { physicalDamage: 2482, magicalDamage: 824 });
const profile = resolveRomNativeTrainingWeaponProfile(stats, { trainingGearProfileMatches: true });
if (profile.status !== "supported") throw new Error(profile.reason);
const source = binding({ abilityId: "rom-knight-primary-12", sourceSkillId: 490154, componentId: 500263 });
const cases = [
{ fixedType: 4, input: { throwDamage: 120 }, value: 120 },
{ fixedType: 10, input: { lastSkillHitPoint: -400 }, value: -400 },
{ fixedType: 11, input: { lastPhysicalHitPoint: -250 }, value: -250 },
{ fixedType: 12, input: { shieldDefense: 358 }, value: 358 },
];
for (const entry of cases) {
const component = { ...source, fixedType: entry.fixedType, fixedValue: -0.1, fixedDamageSkillLevelArg: 2 };
const result = calculateRomNativeDamagePower({ stats, trainingWeapons: profile.value,
component, magicLevel: 30, ownerKind: "player", magicSchoolRate: 1, specialInputs: entry.input });
expect(result.status).toBe("supported");
if (result.status !== "supported") continue;
expect(result.value.sourceFixedContribution).toBeCloseTo(-0.1 * entry.value * 1.6);
expect(result.value.damageBeforeTargetResolver).toBeCloseTo(
Math.max(0, -(result.value.sourcePower - 0.1 * entry.value * 1.6)));
}
});
it("returns explicit unsupported cases and applies source share/decrease scaling once", () => {
const missing = findRomNativeAttackComponent({
abilityId: "rom-knight-primary-12", sourceSkillId: 490154, rank: 1, componentId: -1,
});
expect(missing).toEqual({ status: "unsupported", reason: "attack-component-not-found" });
const stats = statsFor("rom-knight", "rom-scout", { physicalDamage: 2482, magicalDamage: 824 });
const profile = resolveRomNativeTrainingWeaponProfile(stats, { trainingGearProfileMatches: true });
const source = binding({ abilityId: "rom-knight-primary-12", sourceSkillId: 490154, componentId: 500263 });
expect(profile.status).toBe("supported");
if (profile.status !== "supported") return;
expect(calculateRomNativeDamagePower({
stats,
trainingWeapons: profile.value,
component: { ...source, fixedValue: -1, fixedType: 12 },
magicLevel: 10,
ownerKind: "player",
magicSchoolRate: 1,
})).toEqual({ status: "unsupported", reason: "fixed-damage-type-unsupported" });
const typedSource = { ...source, fixedValue: -0.1, fixedType: 5 };
const fixedPower = typedSource.fixedValue * stats.attributes.intellect
* (1 + 10 * typedSource.fixedDamageSkillLevelArg / 100);
const laterTarget = calculateRomNativeDamagePower({
stats,
trainingWeapons: profile.value,
component: { ...typedSource, decreaseArg: 25 },
magicLevel: 10,
ownerKind: "player",
magicSchoolRate: 1,
targetCount: 3,
targetOrdinal: 1,
});
const shared = calculateRomNativeDamagePower({
stats,
trainingWeapons: profile.value,
component: { ...typedSource, decreaseArg: 25, isShareDamage: true },
magicLevel: 10,
ownerKind: "player",
magicSchoolRate: 1,
targetCount: 3,
});
expect(laterTarget.status, JSON.stringify(laterTarget)).toBe("supported");
expect(shared.status, JSON.stringify(shared)).toBe("supported");
if (laterTarget.status !== "supported" || shared.status !== "supported") return;
expect(laterTarget.value.targetPowerMultiplier).toBeCloseTo(0.75, 6);
expect(shared.value.targetPowerMultiplier).toBeCloseTo(1 / 3, 6);
expect(laterTarget.value.sourceFixedContribution).toBeCloseTo(fixedPower, 6);
expect(shared.value.sourceFixedContribution).toBeCloseTo(fixedPower, 6);
expect(laterTarget.value.damageBeforeTargetResolver).toBeCloseTo(
-(laterTarget.value.sourcePower * 0.75 + fixedPower), 6,
);
expect(shared.value.damageBeforeTargetResolver).toBeCloseTo(
-((shared.value.sourcePower + fixedPower) / 3), 6,
);
});
});
+699
View File
@@ -0,0 +1,699 @@
import attackCatalog from "./generated/romNativeHolyHealBindings.generated.json";
import nativeDamageProfiles from "./generated/romNativeDamageTrainingProfiles.generated.json";
import nativeStatRules from "./generated/romNativeStatRules.generated.json";
import { romNativeGearReference } from "./romNativeGear";
import type { RomClassId } from "../app/characterCatalog";
import type { RomNativeDerivedStats } from "./romNativeStatsTypes";
export type RomNativeDamageCalculationType = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
export type RomNativeDamageWeaponSlot = "main-hand" | "off-hand" | "ranged";
export interface RomNativeAttackComponentDescriptor {
readonly abilityId: string;
readonly classId: RomClassId;
readonly group: string;
readonly rank: number;
readonly sourceSkillId: number;
readonly configuredCollectMagicLevel: number;
/** Raw MagicCollect value used directly by damage formulas after the source min-one rule. */
readonly spellTime: number;
readonly rangeType: number;
readonly decreaseArg: number;
readonly isIgnoreDmgEffect: boolean;
readonly isFixDmgEffect: boolean;
readonly isShareDamage: boolean;
readonly componentId: number;
readonly magicFunc: number;
readonly magicType: number;
readonly effectType: number;
readonly attackType: number;
readonly calculationType: number;
readonly damagePower: number;
readonly damagePowerSkillLevelArg: number;
readonly fixedValue: number;
readonly fixedType: number;
readonly fixedDamageSkillLevelArg: number;
readonly randomRange: number;
readonly criticalRate: number;
readonly ignoreMagicAbsorb: boolean;
}
export interface RomNativeDamageTrainingWeapon {
readonly slot: RomNativeDamageWeaponSlot;
readonly sourceItemIds: readonly number[];
/** Native weapon main/ranged/off-hand damage before class/BodyRate formulas. */
readonly sourceDamage: number;
/** Raw source attack speed in tenths, consumed by RoleAttr as its base speed unit. */
readonly speedTenths: number;
}
export interface RomNativeTrainingWeaponProfile {
readonly status: "supported";
readonly classId: RomClassId;
readonly secondaryClassId: RomClassId | null;
readonly level: number;
readonly mainHand: RomNativeDamageTrainingWeapon;
readonly offHand?: RomNativeDamageTrainingWeapon;
readonly ranged?: RomNativeDamageTrainingWeapon;
/** RoleAttr::_DMG-equivalent remainder after removing training weapon DMG from the common sidecar. */
readonly residualFlatDamage: number;
readonly expectedGearPhysicalDamage: number;
readonly expectedGearMagicalDamage: number;
readonly expectedGearMagicalDamagePoint: number;
readonly trainingWeaponSlotDamage: number;
readonly sourceSemantics: "runewaker-training-weapon-profile-bridge";
}
export type RomNativeDamageUnsupportedReason =
| "training-gear-profile-not-confirmed"
| "class-pair-outside-active-five"
| "native-gear-damage-does-not-match-training-profile"
| "source-weapon-profile-missing"
| "source-and-native-weapon-damage-disagree"
| "source-fix-coefficient-missing"
| "attack-component-not-found"
| "attack-component-is-not-direct-hp"
| "attack-calculation-type-unsupported"
| "fixed-damage-type-unsupported"
| "training-weapon-profile-unsupported"
| "runtime-magic-level-invalid"
| "target-count-invalid"
| "target-ordinal-invalid"
| "magic-school-rate-required"
| "required-training-weapon-missing"
| "invalid-source-formula-input";
export type RomNativeDamageResult<T> =
| { readonly status: "supported"; readonly value: T }
| { readonly status: "unsupported"; readonly reason: RomNativeDamageUnsupportedReason };
interface RawAttackComponent {
readonly componentId: number;
readonly magicFunc: number;
readonly magicType: number;
readonly effectType: number;
readonly attackType: number;
readonly calculationType: number;
readonly damagePower: number;
readonly damagePowerSkillLevelArg: number;
readonly fixedValue: number;
readonly fixedType: number;
readonly fixedDamageSkillLevelArg: number;
readonly randomRange: number;
readonly criticalRate: number;
readonly ignoreMagicAbsorb: boolean;
}
interface RawAttackRank {
readonly rank: number | null;
readonly sourceSkillId: number;
readonly configuredCollectMagicLevel: number;
readonly spellTimeSeconds: number;
readonly rangeType: number;
readonly decreaseArg: number;
readonly isIgnoreDmgEffect: boolean;
readonly isFixDmgEffect: boolean;
readonly isShareDamage: boolean;
readonly componentIds: readonly RawAttackComponent[];
}
interface RawAttackBinding {
readonly abilityId: string;
readonly classId: RomClassId;
readonly group: string;
readonly ranks: readonly RawAttackRank[];
}
interface RawWeaponProfileRow {
readonly level: number;
readonly sourceItemIds: readonly number[];
readonly weaponDamage: number;
readonly magicalWeaponDamage: number;
readonly speedTenths: number | null;
readonly shieldDefense?: number;
}
type RawDamageProfileCatalog = {
readonly profiles: Readonly<Record<string, Readonly<Record<string, readonly RawWeaponProfileRow[]>>>>;
};
type RawStatRules = {
readonly classRows: Readonly<Record<string, { readonly args: { readonly damageRate: number } }>>;
readonly playerFix: {
readonly secondHandDamageCoefficient: number;
readonly magicDamageCoefficient: number;
readonly magicMagicalDamageCoefficient: number;
};
};
const attackBindings = (attackCatalog as unknown as { readonly attackBindings: readonly RawAttackBinding[] }).attackBindings;
const damageProfiles = nativeDamageProfiles as unknown as RawDamageProfileCatalog;
/** Raw BaseEQ.Def[Shield] curve; excludes fixed WearEqType_DEF enchantments. */
export function romNativeTrainingShieldDefense(classId: string, level: number): number | undefined {
return damageProfiles.profiles[classId]?.["off-hand"]?.[Math.min(79, Math.max(1, Math.trunc(level))) - 1]?.shieldDefense;
}
const statRules = nativeStatRules as unknown as RawStatRules;
const CLASS_PAIRS: Readonly<Record<string, readonly string[]>> = Object.freeze({
"rom-knight": ["rom-scout"],
"rom-priest": ["rom-knight"],
"rom-rogue": ["rom-warrior"],
"rom-scout": ["rom-rogue"],
"rom-mage": ["rom-warlock"],
});
function finite(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function near(left: number, right: number): boolean {
return Math.abs(left - right) <= 1e-5;
}
function weaponRow(classId: RomClassId, slot: RomNativeDamageWeaponSlot, level: number): RawWeaponProfileRow | undefined {
const curve = damageProfiles.profiles[classId]?.[slot];
return curve?.[Math.max(0, Math.min(78, Math.trunc(level) - 1))];
}
function isTrainingWeaponActive(
classId: RomClassId,
secondaryClassId: RomClassId | null,
slot: RomNativeDamageWeaponSlot,
level: number,
): boolean {
if (slot === "main-hand") return true;
if (slot === "off-hand") {
return (classId === "rom-rogue" && level >= 16)
|| (classId === "rom-scout" && secondaryClassId === "rom-rogue" && level >= 16);
}
if (slot === "ranged") {
return classId === "rom-scout"
|| (classId === "rom-knight" && secondaryClassId === "rom-scout" && level >= 10)
|| (classId === "rom-rogue" && level >= 12);
}
return false;
}
function expectedGearDamage(classId: RomClassId, secondaryClassId: RomClassId | null, level: number): {
readonly physicalDamage: number;
readonly magicalDamage: number;
readonly magicalDamagePoint: number;
} {
const catalogClass = damageProfiles.profiles[classId];
if (!catalogClass) return { physicalDamage: Number.NaN, magicalDamage: Number.NaN, magicalDamagePoint: Number.NaN };
const total = { physicalDamage: 0, magicalDamage: 0, magicalDamagePoint: 0 };
for (const slot of Object.keys(catalogClass)) {
if ((slot === "off-hand" || slot === "ranged")
&& !isTrainingWeaponActive(classId, secondaryClassId, slot as RomNativeDamageWeaponSlot, level)) continue;
const gear = romNativeGearReference(classId, slot as Parameters<typeof romNativeGearReference>[1], level);
total.physicalDamage += gear?.physicalDamage ?? 0;
total.magicalDamage += gear?.magicalDamage ?? 0;
total.magicalDamagePoint += gear?.magicalDamagePoint ?? 0;
}
return total;
}
function resolveWeapon(
classId: RomClassId,
secondaryClassId: RomClassId | null,
slot: RomNativeDamageWeaponSlot,
level: number,
): RomNativeDamageResult<RomNativeDamageTrainingWeapon | undefined> {
if (!isTrainingWeaponActive(classId, secondaryClassId, slot, level)) return { status: "supported", value: undefined };
const row = weaponRow(classId, slot, level);
const native = romNativeGearReference(classId, slot, level);
if (!row || !native || row.speedTenths === null || row.weaponDamage <= 0) {
return slot === "main-hand"
? { status: "unsupported", reason: "source-weapon-profile-missing" }
: { status: "supported", value: undefined };
}
if (!near(row.weaponDamage, native.physicalDamage ?? 0)) {
return { status: "unsupported", reason: "source-and-native-weapon-damage-disagree" };
}
return {
status: "supported",
value: Object.freeze({
slot,
sourceItemIds: row.sourceItemIds,
sourceDamage: row.weaponDamage,
speedTenths: row.speedTenths,
}),
};
}
/**
* Reverses the RoM training-weapon stat bridge for the five explicit active class pairs.
* The caller must first compare the complete native gear payload with automatic training gear;
* source gear stats contain no item provenance or weapon speed. This function also checks the
* aggregate type-25 DMG channel against the compact native training curve. Custom/unverified
* equipment must pass `trainingGearProfileMatches: false` and remains unsupported.
*/
export function resolveRomNativeTrainingWeaponProfile(
stats: RomNativeDerivedStats,
evidence: { readonly trainingGearProfileMatches: boolean },
): RomNativeDamageResult<RomNativeTrainingWeaponProfile> {
if (!evidence.trainingGearProfileMatches) {
return { status: "unsupported", reason: "training-gear-profile-not-confirmed" };
}
const classId = stats.sourceInputs.classId;
const secondaryClassId = stats.sourceInputs.secondaryClassId;
const level = Math.max(1, Math.min(80, Math.trunc(stats.sourceInputs.level)));
const primaryOnlyBeforeSecondaryUnlock = secondaryClassId === null && level < 10 && CLASS_PAIRS[classId] !== undefined;
if (!primaryOnlyBeforeSecondaryUnlock && (!secondaryClassId || !CLASS_PAIRS[classId]?.includes(secondaryClassId))) {
return { status: "unsupported", reason: "class-pair-outside-active-five" };
}
const expectedGear = expectedGearDamage(classId, secondaryClassId, level);
if (!Object.values(expectedGear).every(finite)
|| !near(stats.sourceInputs.gear.physicalDamage ?? 0, expectedGear.physicalDamage)
|| !near(stats.sourceInputs.gear.magicalDamage ?? 0, expectedGear.magicalDamage)
|| !near(stats.sourceInputs.gear.magicalDamagePoint ?? 0, expectedGear.magicalDamagePoint)) {
return { status: "unsupported", reason: "native-gear-damage-does-not-match-training-profile" };
}
const main = resolveWeapon(classId, secondaryClassId, "main-hand", level);
const off = resolveWeapon(classId, secondaryClassId, "off-hand", level);
const ranged = resolveWeapon(classId, secondaryClassId, "ranged", level);
for (const result of [main, off, ranged]) if (result.status === "unsupported") return result;
if (main.status !== "supported" || !main.value) {
return { status: "unsupported", reason: "source-weapon-profile-missing" };
}
const weapons = [main.value, ...(off.status === "supported" && off.value ? [off.value] : []), ...(ranged.status === "supported" && ranged.value ? [ranged.value] : [])];
const trainingWeaponSlotDamage = weapons.reduce((sum, weapon) => sum + weapon.sourceDamage, 0);
const classDamageRate = statRules.classRows[classId]?.args.damageRate;
if (!finite(classDamageRate)) return { status: "unsupported", reason: "source-fix-coefficient-missing" };
const residualFlatDamage = Math.max(0,
stats.physicalDamage - trainingWeaponSlotDamage * classDamageRate * stats.rateFactors.physicalDamage);
return {
status: "supported",
value: Object.freeze({
status: "supported",
classId,
secondaryClassId,
level,
mainHand: main.value,
...(off.status === "supported" && off.value ? { offHand: off.value } : {}),
...(ranged.status === "supported" && ranged.value ? { ranged: ranged.value } : {}),
residualFlatDamage,
expectedGearPhysicalDamage: expectedGear.physicalDamage,
expectedGearMagicalDamage: expectedGear.magicalDamage,
expectedGearMagicalDamagePoint: expectedGear.magicalDamagePoint,
trainingWeaponSlotDamage,
sourceSemantics: "runewaker-training-weapon-profile-bridge",
}),
};
}
export function findRomNativeAttackComponent(input: {
readonly abilityId: string;
readonly sourceSkillId: number;
readonly rank: number;
readonly componentId: number;
}): RomNativeDamageResult<RomNativeAttackComponentDescriptor> {
const ability = attackBindings.find((candidate) => candidate.abilityId === input.abilityId);
const rank = ability?.ranks.find((candidate) => candidate.sourceSkillId === input.sourceSkillId && candidate.rank === input.rank);
const component = rank?.componentIds.find((candidate) => candidate.componentId === input.componentId);
if (!ability || !rank || !component) return { status: "unsupported", reason: "attack-component-not-found" };
return {
status: "supported",
value: Object.freeze({
abilityId: ability.abilityId,
classId: ability.classId,
group: ability.group,
rank: input.rank,
sourceSkillId: rank.sourceSkillId,
configuredCollectMagicLevel: rank.configuredCollectMagicLevel,
spellTime: rank.spellTimeSeconds,
rangeType: rank.rangeType,
decreaseArg: rank.decreaseArg,
isIgnoreDmgEffect: rank.isIgnoreDmgEffect,
isFixDmgEffect: rank.isFixDmgEffect,
isShareDamage: rank.isShareDamage,
componentId: component.componentId,
magicFunc: component.magicFunc,
magicType: component.magicType,
effectType: component.effectType,
attackType: component.attackType,
calculationType: component.calculationType,
damagePower: component.damagePower,
damagePowerSkillLevelArg: component.damagePowerSkillLevelArg,
fixedValue: component.fixedValue,
fixedType: component.fixedType,
fixedDamageSkillLevelArg: component.fixedDamageSkillLevelArg,
randomRange: component.randomRange,
criticalRate: component.criticalRate,
ignoreMagicAbsorb: component.ignoreMagicAbsorb,
}),
};
}
export interface RomNativeDamageCalculation {
readonly abilityId: string;
readonly sourceSkillId: number;
readonly rank: number;
readonly componentId: number;
readonly calculationType: number;
readonly ownerKind: "player" | "npc";
readonly magicLevel: number;
/** Signed source power formula before the downstream Attack.FixValue contribution. */
readonly sourcePower: number;
/** Attack.FixValue * GetValue(Attack.FixType) * (1 + MagicLv * FixDmg_SkillLVArg / 100). */
readonly sourceFixedContribution: number;
/** Positive magnitude after the source target-count/decrease factor, before target resolution. */
readonly damageBeforeTargetResolver: number;
readonly targetPowerMultiplier: number;
readonly rangePowerMultiplier: number;
readonly ignoreMagicAbsorb: boolean;
readonly sourceRandomRange: number;
readonly sourceCriticalRate: number;
readonly magicType: number;
readonly sourceSemantics: "runewaker-magicprocess-direct-hp-outgoing-power";
}
export interface RomNativeAutoAttackProfile {
readonly slot: RomNativeDamageWeaponSlot;
readonly sourceDamageChannel: "Main_DMG" | "Sec_DMG" | "Bow_DMG";
/** RoleAttr-derived weapon damage input before MainAtkDamage target/armor/random resolution. */
readonly damage: number;
readonly attackSpeed: number;
/** Runewaker attack-speed source units are converted by MagicProcess scheduling at ×100 ms. */
readonly intervalMs: number;
readonly physicalAttack: number;
readonly sourceItemIds: readonly number[];
readonly sourceSemantics: "runewaker-roleattr-auto-weapon-channel";
}
export interface RomNativeWeaponRates {
/** Known weapon-type/all-weapon multiplier beyond the native DMG_Per BodyRate. */
readonly mainDamage?: number;
readonly offHandDamage?: number;
readonly rangedDamage?: number;
readonly mainAttackSpeed?: number;
readonly offHandAttackSpeed?: number;
readonly rangedAttackSpeed?: number;
}
/** Exact native channels; prior-hit values are signed source HP deltas. */
export interface RomNativeSpecialDamageInputs {
readonly throwDamage?: number;
readonly lastSkillHitPoint?: number;
readonly lastPhysicalHitPoint?: number;
/** Raw BaseEQ.Def[EM_Armor_Shield], before BodyRate or aggregate DEF. */
readonly shieldDefense?: number;
}
function profileMatchesStats(stats: RomNativeDerivedStats, profile: RomNativeTrainingWeaponProfile): boolean {
return profile.status === "supported"
&& profile.classId === stats.sourceInputs.classId
&& profile.secondaryClassId === stats.sourceInputs.secondaryClassId
&& profile.level === stats.sourceInputs.level;
}
/**
* Resolves the Runewaker outgoing Main_DMG, Sec_DMG, or Bow_DMG channel and attack interval.
* This is not the completed auto-hit formula: MainAtkDamage/SecAtkDamage still apply source
* weapon variance, target armor absorption, and the attacker-vs-target ATK/DEF ratio.
*/
export function resolveRomNativeAutoAttackProfile(input: {
readonly stats: RomNativeDerivedStats;
readonly trainingWeapons: RomNativeTrainingWeaponProfile;
readonly slot: RomNativeDamageWeaponSlot;
readonly weaponRates?: RomNativeWeaponRates;
}): RomNativeDamageResult<RomNativeAutoAttackProfile> {
const { stats, trainingWeapons, slot } = input;
if (!profileMatchesStats(stats, trainingWeapons)) return { status: "unsupported", reason: "training-weapon-profile-unsupported" };
const weapon = slot === "main-hand" ? trainingWeapons.mainHand
: slot === "off-hand" ? trainingWeapons.offHand
: trainingWeapons.ranged;
if (!weapon) return { status: "unsupported", reason: "required-training-weapon-missing" };
const rate = slot === "main-hand" ? input.weaponRates?.mainDamage ?? 1
: slot === "off-hand" ? input.weaponRates?.offHandDamage ?? 1
: input.weaponRates?.rangedDamage ?? 1;
const speedRate = slot === "main-hand" ? input.weaponRates?.mainAttackSpeed ?? 1
: slot === "off-hand" ? input.weaponRates?.offHandAttackSpeed ?? 1
: input.weaponRates?.rangedAttackSpeed ?? 1;
if (![rate, speedRate].every(finite) || rate < 0 || speedRate < 0) {
return { status: "unsupported", reason: "invalid-source-formula-input" };
}
let damage: number;
let attackSpeed: number;
let sourceDamageChannel: RomNativeAutoAttackProfile["sourceDamageChannel"];
if (slot === "main-hand") {
damage = Math.max(1,
(trainingWeapons.residualFlatDamage + weapon.sourceDamage) * stats.rateFactors.physicalDamage * rate);
attackSpeed = Math.max(5, weapon.speedTenths * speedRate);
sourceDamageChannel = "Main_DMG";
} else if (slot === "off-hand") {
damage = Math.max(0,
(trainingWeapons.residualFlatDamage + weapon.sourceDamage)
* stats.rateFactors.physicalDamage * statRules.playerFix.secondHandDamageCoefficient * rate);
attackSpeed = Math.max(5, weapon.speedTenths * speedRate);
sourceDamageChannel = "Sec_DMG";
} else {
damage = Math.max(0,
trainingWeapons.residualFlatDamage + weapon.sourceDamage * stats.rateFactors.physicalDamage * rate);
attackSpeed = weapon.speedTenths * speedRate;
if (attackSpeed <= 0) return { status: "unsupported", reason: "invalid-source-formula-input" };
sourceDamageChannel = "Bow_DMG";
}
return {
status: "supported",
value: Object.freeze({
slot,
sourceDamageChannel,
damage,
attackSpeed,
intervalMs: attackSpeed * 100,
physicalAttack: stats.physicalAttack,
sourceItemIds: weapon.sourceItemIds,
sourceSemantics: "runewaker-roleattr-auto-weapon-channel",
}),
};
}
/**
* Resolves MagicAttackCalENUM's verified GetValue overload. Special channels
* require explicit source inputs; aggregate DEF and unsigned damage are invalid substitutes.
*/
function resolveFixedDamageStat(input: {
readonly stats: RomNativeDerivedStats;
readonly trainingWeapons: RomNativeTrainingWeaponProfile;
readonly component: RomNativeAttackComponentDescriptor;
readonly weaponRates?: RomNativeWeaponRates;
readonly specialInputs?: RomNativeSpecialDamageInputs;
}): RomNativeDamageResult<number> {
switch (input.component.fixedType) {
case 0: // EM_MagicAttackCal_None
return { status: "supported", value: 1 };
case 1: // EM_MagicAttackCal_MPow
return { status: "supported", value: input.stats.magicPowerRate };
case 2: { // EM_MagicAttackCal_Weapon
const profile = resolveRomNativeAutoAttackProfile({
stats: input.stats, trainingWeapons: input.trainingWeapons, slot: "main-hand", weaponRates: input.weaponRates,
});
return profile.status === "supported" ? { status: "supported", value: profile.value.damage } : profile;
}
case 3: { // EM_MagicAttackCal_Shoot
const profile = resolveRomNativeAutoAttackProfile({
stats: input.stats, trainingWeapons: input.trainingWeapons, slot: "ranged", weaponRates: input.weaponRates,
});
return profile.status === "supported" ? { status: "supported", value: profile.value.damage } : profile;
}
case 4:
case 10:
case 11:
case 12: {
const value = input.component.fixedType === 4 ? input.specialInputs?.throwDamage
: input.component.fixedType === 10 ? input.specialInputs?.lastSkillHitPoint
: input.component.fixedType === 11 ? input.specialInputs?.lastPhysicalHitPoint
: input.specialInputs?.shieldDefense;
return value !== undefined && finite(value)
? { status: "supported", value }
: { status: "unsupported", reason: "fixed-damage-type-unsupported" };
}
case 5: // EM_MagicAttackCal_INT
return { status: "supported", value: input.stats.attributes.intellect };
case 6: // EM_MagicAttackCal_STR
return { status: "supported", value: input.stats.attributes.strength };
case 7: // EM_MagicAttackCal_AGI
return { status: "supported", value: input.stats.attributes.agility };
case 8: // EM_MagicAttackCal_STA
return { status: "supported", value: input.stats.attributes.stamina };
case 9: // EM_MagicAttackCal_MND
return { status: "supported", value: input.stats.attributes.wisdom };
default:
return { status: "unsupported", reason: "fixed-damage-type-unsupported" };
}
}
function targetPowerMultiplier(component: RomNativeAttackComponentDescriptor, targetCount: number, targetOrdinal: number): number {
if (component.isShareDamage) return Math.max(component.decreaseArg / 100, 1 / targetCount);
return Math.pow(1 - component.decreaseArg / 100, targetOrdinal);
}
/**
* Source-backed MagicProcess outgoing-power branches used by the five RoM training pairs.
* Typed Attack.FixType additions supported by the audited RoleData overload are included; an
* unported nonzero type returns an explicit unsupported result. This deliberately stops before
* target armor/MDEF, hit, crit, variance, and auras owned by another resolver. Callers must bypass
* common gear scaling and apply the returned target power exactly once in their target resolver.
*/
export function calculateRomNativeDamagePower(input: {
readonly stats: RomNativeDerivedStats;
readonly trainingWeapons: RomNativeTrainingWeaponProfile;
readonly component: RomNativeAttackComponentDescriptor;
/** Runtime MagicProc.MagicLv. Never inferred from unlock level or catalog rank. */
readonly magicLevel: number;
readonly ownerKind: "player" | "npc";
readonly magicSchoolRate?: number;
readonly targetCount?: number;
readonly targetOrdinal?: number;
readonly weaponRates?: RomNativeWeaponRates;
readonly specialInputs?: RomNativeSpecialDamageInputs;
}): RomNativeDamageResult<RomNativeDamageCalculation> {
const { stats, component, trainingWeapons } = input;
if (!profileMatchesStats(stats, trainingWeapons)) return { status: "unsupported", reason: "training-weapon-profile-unsupported" };
if (!finite(input.magicLevel) || input.magicLevel < 0) return { status: "unsupported", reason: "runtime-magic-level-invalid" };
if (component.magicFunc !== 0 || component.attackType !== 0 || component.damagePower >= 0) {
return { status: "unsupported", reason: "attack-component-is-not-direct-hp" };
}
if (![0, 1, 3, 9].includes(component.calculationType)) {
return { status: "unsupported", reason: "attack-calculation-type-unsupported" };
}
const magicLevel = input.magicLevel;
const targetCount = input.targetCount ?? 1;
const targetOrdinal = input.targetOrdinal ?? 0;
if (!Number.isInteger(targetCount) || targetCount < 1) return { status: "unsupported", reason: "target-count-invalid" };
if (!Number.isInteger(targetOrdinal) || targetOrdinal < 0 || targetOrdinal >= targetCount) {
return { status: "unsupported", reason: "target-ordinal-invalid" };
}
const fix = statRules.playerFix;
const powerRate = component.rangeType === 0 ? 1 : 0.6;
const spellTime = component.isFixDmgEffect || component.spellTime < 1 ? 1 : component.spellTime;
const orgPower = component.damagePower
+ component.damagePower * component.damagePowerSkillLevelArg * magicLevel / 100;
const mainDamageRate = input.weaponRates?.mainDamage ?? 1;
const rangedDamageRate = input.weaponRates?.rangedDamage ?? 1;
const mainAttackSpeedRate = input.weaponRates?.mainAttackSpeed ?? 1;
const rangedAttackSpeedRate = input.weaponRates?.rangedAttackSpeed ?? 1;
if (![powerRate, spellTime, orgPower, mainDamageRate, rangedDamageRate, mainAttackSpeedRate, rangedAttackSpeedRate].every(finite)) {
return { status: "unsupported", reason: "invalid-source-formula-input" };
}
let purePower: number;
let rangePowerMultiplier = 1;
if (component.calculationType === 0 || component.calculationType === 9) {
const mainDamage = Math.max(1,
(trainingWeapons.residualFlatDamage + trainingWeapons.mainHand.sourceDamage)
* stats.rateFactors.physicalDamage * mainDamageRate);
if (component.calculationType === 0) {
const mainAttackSpeed = Math.max(5, trainingWeapons.mainHand.speedTenths * mainAttackSpeedRate);
const dps = mainDamage * 10 * spellTime / mainAttackSpeed;
purePower = component.isIgnoreDmgEffect ? orgPower : orgPower * dps;
if (purePower < 0) purePower -= dps * fix.magicDamageCoefficient * powerRate;
} else {
purePower = orgPower;
if (purePower < 0 && !component.isIgnoreDmgEffect) {
const spellMultiplier = component.isFixDmgEffect ? 1 : spellTime;
purePower -= mainDamage * fix.magicDamageCoefficient * powerRate * spellMultiplier;
}
}
if (purePower < 0 && component.calculationType !== 0 && component.isIgnoreDmgEffect) {
// Physics_Point's source ignore flag removes its Main_DMG additive branch entirely.
}
if (purePower < 0 && component.calculationType === 0 && component.effectType === 0) {
const schoolRate = resolveMagicSchoolRate(stats.magicPowerRate, component.magicType, input.magicSchoolRate);
if (schoolRate === null) return { status: "unsupported", reason: "magic-school-rate-required" };
purePower *= schoolRate;
}
if (purePower < 0 && component.calculationType === 9 && component.effectType === 0) {
const schoolRate = resolveMagicSchoolRate(stats.magicPowerRate, component.magicType, input.magicSchoolRate);
if (schoolRate === null) return { status: "unsupported", reason: "magic-school-rate-required" };
purePower *= schoolRate;
}
} else if (component.calculationType === 1) {
const schoolRate = resolveMagicSchoolRate(stats.magicPowerRate, component.magicType, input.magicSchoolRate);
if (schoolRate === null) return { status: "unsupported", reason: "magic-school-rate-required" };
const exRate = input.ownerKind === "player" ? 0.6 + Math.abs(magicLevel) * 0.01 : 1;
purePower = orgPower;
if (purePower < 0 && !component.isIgnoreDmgEffect) {
const spellMultiplier = component.isFixDmgEffect ? 1 : spellTime;
purePower -= exRate * stats.magicalDamage * fix.magicMagicalDamageCoefficient * powerRate * spellMultiplier;
}
purePower *= schoolRate;
if (orgPower < 0) {
purePower -= stats.magicalDamagePoint;
if (purePower > -1) purePower = -1;
}
} else {
const ranged = trainingWeapons.ranged;
if (!ranged) return { status: "unsupported", reason: "required-training-weapon-missing" };
const rangedDamage = Math.max(0,
trainingWeapons.residualFlatDamage + ranged.sourceDamage * stats.rateFactors.physicalDamage * rangedDamageRate);
const rangedAttackSpeed = Math.max(5, ranged.speedTenths * rangedAttackSpeedRate);
purePower = orgPower * (rangedDamage * 10 / rangedAttackSpeed);
if (purePower < 0) purePower -= rangedDamage * fix.magicDamageCoefficient * powerRate;
}
if (!finite(purePower)) return { status: "unsupported", reason: "invalid-source-formula-input" };
let sourceFixedContribution = 0;
if (component.fixedValue !== 0) {
const fixedStat = resolveFixedDamageStat({
stats, trainingWeapons, component, weaponRates: input.weaponRates, specialInputs: input.specialInputs,
});
if (fixedStat.status !== "supported") return fixedStat;
sourceFixedContribution = component.fixedValue * fixedStat.value
* (1 + magicLevel * component.fixedDamageSkillLevelArg / 100);
}
if (!finite(sourceFixedContribution)) return { status: "unsupported", reason: "invalid-source-formula-input" };
const targetMultiplier = targetPowerMultiplier(component, targetCount, targetOrdinal);
rangePowerMultiplier = powerRate;
// For non-shared targets MagicProcess attenuates PurePower in its target loop,
// then CalPhyDamage/CalMagicDamage adds the typed fixed term per target. Shared
// damage passes FinFixPowerRate into that later target resolver, scaling both.
const signedPower = component.isShareDamage
? (purePower + sourceFixedContribution) * targetMultiplier
: purePower * targetMultiplier + sourceFixedContribution;
return {
status: "supported",
value: Object.freeze({
abilityId: component.abilityId,
sourceSkillId: component.sourceSkillId,
rank: component.rank,
componentId: component.componentId,
calculationType: component.calculationType,
ownerKind: input.ownerKind,
magicLevel,
sourcePower: purePower,
sourceFixedContribution,
damageBeforeTargetResolver: Math.max(0, -signedPower),
targetPowerMultiplier: targetMultiplier,
rangePowerMultiplier,
ignoreMagicAbsorb: component.ignoreMagicAbsorb,
sourceRandomRange: component.randomRange,
sourceCriticalRate: component.criticalRate,
magicType: component.magicType,
sourceSemantics: "runewaker-magicprocess-direct-hp-outgoing-power",
}),
};
}
function resolveMagicSchoolRate(baseMagicPowerRate: number, magicType: number, schoolRate?: number): number | null {
if (magicType >= 0 && magicType <= 5) {
return finite(schoolRate) ? baseMagicPowerRate * schoolRate : null;
}
return baseMagicPowerRate;
}
/** Source FIX rows are retained for targeted formula tests and callers that need exact raw coefficients. */
export function getRomNativeDamageFixCoefficients(): {
readonly secondHandDamage: number;
readonly physicalMagicPowerCoefficient: number;
readonly magicalMagicPowerCoefficient: number;
} | null {
const fix = statRules.playerFix;
if (![fix.secondHandDamageCoefficient, fix.magicDamageCoefficient, fix.magicMagicalDamageCoefficient].every(finite)) return null;
return Object.freeze({
secondHandDamage: fix.secondHandDamageCoefficient,
physicalMagicPowerCoefficient: fix.magicDamageCoefficient,
magicalMagicPowerCoefficient: fix.magicMagicalDamageCoefficient,
});
}
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { dungeonEnemyScaling } from "./dungeonBossScaling";
import { bossDifficultyMultipliers } from "./dungeonDifficulty";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { finalDungeonBoss } from "./dungeonRunStore";
import { useCombatStore } from "./combatStore";
const dungeon = requireDungeonDefinition("barren-caves");
const boss = finalDungeonBoss(dungeon)!;
const bossSpawn = dungeon.staticSpawns.find((spawn) => spawn.id === boss.id)!;
const trashSpawn = dungeon.staticSpawns.find((spawn) => dungeon.entities[spawn.entityId].kind === "mob")!;
describe("native RoM dungeon health budgets", () => {
it("calibrates the level-one native Anglo boss without changing trash health", () => {
const nativeBoss = dungeonEnemyScaling(dungeon, dungeon.entities[bossSpawn.entityId], boss.id,
1, "normal", 1, "rom", "rom");
const legacyBoss = dungeonEnemyScaling(dungeon, dungeon.entities[bossSpawn.entityId], boss.id,
1, "normal", 1, "rom", "wow");
const nativeTrash = dungeonEnemyScaling(dungeon, dungeon.entities[trashSpawn.entityId], trashSpawn.id,
1, "normal", 1, "rom", "rom");
const legacyTrash = dungeonEnemyScaling(dungeon, dungeon.entities[trashSpawn.entityId], trashSpawn.id,
1, "normal", 1, "rom", "wow");
expect(nativeBoss.maxHealth).toBe(1_848);
expect(nativeBoss.maxHealth).toBeCloseTo(legacyBoss.maxHealth * 0.2);
expect(nativeTrash.maxHealth).toBeCloseTo(legacyTrash.maxHealth * 0.4);
expect(nativeTrash.nativeDamageBudgetScale).toBe(1);
});
it("keeps native training budgets independent of legacy stats and authored difficulty", () => {
for (const level of [1, 30, 66, 79, 80]) for (const spawn of [trashSpawn, bossSpawn]) {
const entity = dungeon.entities[spawn.entityId];
const legacy = dungeonEnemyScaling(dungeon, entity, spawn.id, level, "normal", 1, "rom");
expect(dungeonEnemyScaling(dungeon, entity, spawn.id, level, "normal", 1, "rom", "wow")).toEqual(legacy);
const native = dungeonEnemyScaling(dungeon, entity, spawn.id, level, "normal", 1, "rom", "rom");
expect(native.maxHealth).not.toBe(legacy.maxHealth);
expect(native.damageMultiplier).toBe(legacy.damageMultiplier);
expect(legacy.nativeDamageBudgetScale).toBe(1);
expect(native.nativeDamageBudgetScale).toBeGreaterThanOrEqual(1);
expect(native.level).toBe(legacy.level);
for (const source of ["wow", "coa"] as const) {
expect(dungeonEnemyScaling(dungeon, entity, spawn.id, level, "normal", 1, source, "rom"))
.toEqual(dungeonEnemyScaling(dungeon, entity, spawn.id, level, "normal", 1, source));
}
}
});
it("retains the level-80 cap and 79-to-80 native boss health plateau", () => {
const entity = dungeon.entities[bossSpawn.entityId];
const at = (level: number) => dungeonEnemyScaling(dungeon, entity, boss.id, level, "normal", 1, "rom", "rom");
expect(at(79)).toEqual(at(80));
expect(at(120)).toEqual(at(80));
expect(at(80).level).toBe(80);
});
it("does not compound damage during live registration or retain native pressure after a mode switch", () => {
const entity = dungeon.entities[bossSpawn.entityId];
const native = dungeonEnemyScaling(dungeon, entity, boss.id, 80, "normal", 1, "rom", "rom");
const legacy = dungeonEnemyScaling(dungeon, entity, boss.id, 80, "normal", 1, "rom", "wow");
const combat = useCombatStore.getState();
combat.initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 80, statRuleset: "rom" });
combat.registerMob("legacy-reference", { name: "Anglo", boss: true, ...legacy, xpReward: 0 });
combat.registerMob("live-registration", { name: "Anglo", boss: true, ...native, xpReward: 0 });
const first = useCombatStore.getState().mobs["live-registration"];
combat.registerMob("live-registration", { name: "Anglo", boss: true, ...native, xpReward: 0 });
expect(useCombatStore.getState().mobs["live-registration"].attackDamage).toBe(first.attackDamage);
expect(useCombatStore.getState().mobs["live-registration"].attackPowerDamageContribution).toBe(first.attackPowerDamageContribution);
combat.registerMob("live-registration", { name: "Anglo", boss: true, ...legacy, xpReward: 0 });
const switched = useCombatStore.getState().mobs["live-registration"];
const reference = useCombatStore.getState().mobs["legacy-reference"];
expect(switched.attackDamage).toBe(reference.attackDamage);
expect(switched.attackPowerDamageContribution).toBe(reference.attackPowerDamageContribution);
expect(switched.nativeDamageBudgetScale).toBe(1);
});
it("keeps each difficulty multiplier and leaves trash independent of the boss slider", () => {
const entity = dungeon.entities[bossSpawn.entityId];
const easy = dungeonEnemyScaling(dungeon, entity, boss.id, 30, "normal", 1, "rom", "rom");
for (let difficulty = 2; difficulty <= 10; difficulty += 1) {
const scaled = dungeonEnemyScaling(dungeon, entity, boss.id, 30, "normal", difficulty, "rom", "rom");
expect(scaled.maxHealth).toBeCloseTo(easy.maxHealth * bossDifficultyMultipliers(difficulty).health);
expect(scaled.damageMultiplier).toBeCloseTo(easy.damageMultiplier * bossDifficultyMultipliers(difficulty).damage);
expect(dungeonEnemyScaling(dungeon, dungeon.entities[trashSpawn.entityId], trashSpawn.id,
30, "normal", difficulty, "rom", "rom"))
.toEqual(dungeonEnemyScaling(dungeon, dungeon.entities[trashSpawn.entityId], trashSpawn.id,
30, "normal", 1, "rom", "rom"));
}
});
});
@@ -0,0 +1,133 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
abilitiesForClass,
abilityAtLevel,
learnedAbilityForBuild,
type AbilityDefinition,
} from "./abilityCatalog";
import { useCombatStore } from "./combatStore";
import {
evaluateCombatRotation,
nativeEarlyPriestHealReserveBlocksAbility,
type RotationContext,
} from "./combatRotation";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { healingAbilityCanAidOthers } from "./healingAbilityRules";
import { BenchmarkController } from "./benchmark/benchmarkController";
import { BENCHMARK_SCENARIOS } from "./benchmark/benchmarkScenarios";
const urgentHeal = abilityAtLevel(abilitiesForClass("rom-priest").find((ability) => ability.name === "Urgent Heal")!, 1);
const risingTide = abilityAtLevel(abilitiesForClass("rom-priest").find((ability) => ability.name === "Rising Tide")!, 1);
function manaCost(ability: AbilityDefinition): number {
return (ability.costs?.length ? ability.costs : [ability.cost])
.filter((cost) => cost.resource === "mana")
.reduce((total, cost) => total + cost.amount, 0);
}
function rotationContext(overrides: Partial<RotationContext> = {}): RotationContext {
const mana = overrides.resources?.mana ?? 36;
return {
now: 1_000,
classId: "rom-priest",
statRuleset: "rom",
role: "healer",
level: 1,
health: 100,
maxHealth: 100,
selfId: "priest",
resources: { mana },
maximumResources: { mana: 123 },
primaryResource: "mana",
comboPoints: 0,
comboTargetId: null,
runes: [],
abilities: [risingTide],
learnedAbilities: [urgentHeal, risingTide],
manaCostForAbility: manaCost,
canUseAbility: (ability) => manaCost(ability) <= mana,
hostileTarget: { id: "enemy", health: 1_000, maxHealth: 1_000 },
hostileCount: 1,
...overrides,
};
}
describe("native early RoM Priest healer Mana reserve", () => {
it("keeps the learned direct-heal cost at 25 Mana across the 36/37 spend boundary", () => {
expect(manaCost(urgentHeal)).toBe(25);
expect(manaCost(risingTide)).toBe(12);
const learnedDirectHealCosts = abilitiesForClass("rom-priest")
.map((ability) => learnedAbilityForBuild(ability, 1, {}))
.filter((ability): ability is AbilityDefinition => ability !== null)
.filter((ability) => !ability.passive
&& (ability.target === "friendly" || ability.target === "any")
&& healingAbilityCanAidOthers(ability)
&& ability.effects.some((effect) => effect.kind === "heal"))
.map((ability) => `${ability.name}:${manaCost(ability)}`)
.sort();
expect(learnedDirectHealCosts).toEqual(["Urgent Heal:25"]);
const at36 = rotationContext();
expect(at36.canUseAbility?.(risingTide)).toBe(true);
expect(evaluateCombatRotation(at36)).toBeNull();
const at37 = rotationContext({ resources: { mana: 37 } });
expect(evaluateCombatRotation(at37)?.ability.id).toBe(risingTide.id);
});
it("uses resolved Mana costs and leaves Urgent Heal's own target checks intact", () => {
const expensiveResolvedStrike = rotationContext({
resources: { mana: 38 },
manaCostForAbility: (ability) => manaCost(ability) + (ability.id === risingTide.id ? 2 : 0),
canUseAbility: () => true,
});
expect(evaluateCombatRotation(expensiveResolvedStrike)).toBeNull();
const heal = evaluateCombatRotation(rotationContext({
resources: { mana: 25 },
abilities: [urgentHeal],
friendlyTargets: [{ id: "ally", health: 20, maxHealth: 100, role: "damage" }],
}));
expect(heal).toMatchObject({ ability: { id: urgentHeal.id }, targetId: "ally" });
const invalidHealTarget = evaluateCombatRotation(rotationContext({
resources: { mana: 25 },
abilities: [urgentHeal],
friendlyTargets: [{ id: "fallen-ally", health: 0, maxHealth: 100, fallen: true }],
}));
expect(invalidHealTarget).toBeNull();
});
it("applies only to sub-10 RoM Priests acting as healers", () => {
const base = rotationContext();
expect(nativeEarlyPriestHealReserveBlocksAbility(risingTide, base)).toBe(true);
expect(nativeEarlyPriestHealReserveBlocksAbility(risingTide, { ...base, level: 10 })).toBe(false);
expect(nativeEarlyPriestHealReserveBlocksAbility(risingTide, { ...base, role: "damage" })).toBe(false);
expect(nativeEarlyPriestHealReserveBlocksAbility(risingTide, { ...base, statRuleset: "wow" })).toBe(false);
});
it("keeps the real level-one benchmark actor from spending its 36-Mana heal reserve", () => {
const combat = useCombatStore.getState();
combat.initializeCharacter({ classId: "rom-priest", statRuleset: "rom", role: "healer", level: 1, raceId: "rom-human" });
combat.setPlayerPosition([0, 0, 0]);
combat.registerMob("reserve-target", { name: "Reserve target", level: 1, maxHealth: 100_000, xpReward: 0 });
setMobPosition("reserve-target", [10, 0, 0]);
combat.selectMob("reserve-target");
useCombatStore.setState((state) => ({
resource: 36,
resourcePools: { ...state.resourcePools, mana: 36 },
}));
const controller = new BenchmarkController();
const scenario = { ...BENCHMARK_SCENARIOS[0], hostileTargets: 1 };
const at36 = controller.act(100, "healer", scenario, false);
expect(at36.cast).toBe(false);
expect(useCombatStore.getState().resourcePools.mana).toBe(36);
});
});
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
});
@@ -0,0 +1,250 @@
import { afterAll, describe, expect, it } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { subscribeCombatEvents } from "./combatEvents";
import { subscribeCombatPresentation } from "./combatPresentation";
import { useGameStore } from "./store";
import { usePartyStore, createConfiguredPartyMember } from "./partyStore";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition,
updatePartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { dungeonEnemyScaling } from "./dungeonBossScaling";
import { BenchmarkController } from "./benchmark/benchmarkController";
import { BENCHMARK_SCENARIOS } from "./benchmark/benchmarkScenarios";
import type { EnemyAttackDefinition } from "./enemyAttacks";
const dungeon = requireDungeonDefinition("barren-caves");
const bossEntity = dungeon.entities["rw-barren-caves-101462"];
const bossSpawn = dungeon.staticSpawns.find((spawn) => spawn.entityId === bossEntity.id)!;
const BOSS_ID = bossSpawn.id;
const priest = ROM_FIVE_IDENTITIES.find((identity) => identity.classId === "rom-priest")!;
const report: unknown[] = [];
afterAll(() => {
if (process.env.ROM_NATIVE_PRESSURE_REPORT !== "1") return;
const directory = resolve("artifacts/rom-enemy-followup");
mkdirSync(directory, { recursive: true });
writeFileSync(resolve(directory, "pressure.json"), `${JSON.stringify({
scope: "Explicit native five / Barren Caves Anglo diagnostic, levels 10/30/60/80. Scripted positions, normal HP budgets; no scene navigation or readiness certification.",
samples: report,
}, null, 2)}\n`);
});
function setup(level = 30, stacked = false, attacks?: readonly EnemyAttackDefinition[], activeIdentity = priest) {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useGameStore.getState().activateDungeon("barren-caves", "normal", { bossDifficulty: 1 });
const combat = useCombatStore.getState();
combat.initializeCharacter({ ...activeIdentity, level, statRuleset: "rom" });
combat.setPlayerPosition([stacked || activeIdentity.role === "tank" ? 2 : 10, 0, 0]);
usePartyStore.getState().initializeParty("native-pressure", level, useCombatStore.getState().health, activeIdentity.role, 5, "rom");
const members = ROM_FIVE_IDENTITIES.filter((identity) => identity !== activeIdentity).map((identity, index) =>
createConfiguredPartyMember({ ...identity, statRuleset: "rom", specialization: "Primary",
talentTreeId: "" }, level, index, "native-pressure"));
usePartyStore.setState({ members, command: "defend" });
combat.synchronizeActors();
const scaling = dungeonEnemyScaling(dungeon, bossEntity, BOSS_ID, level, "normal", 1, "rom", "rom");
combat.registerMob(BOSS_ID, { name: "Anglo", boss: true, runewakerNpcId: 101462,
...scaling, xpReward: 0, attacks: attacks ?? bossEntity.combat?.attacks });
setMobPosition(BOSS_ID, [0, 0, 0]);
for (const member of members) {
const distance = stacked || member.classId === "rom-knight" || member.classId === "rom-rogue" ? 2
: member.classId === "rom-scout" ? 8 : 10;
registerPartyRuntimePosition(member.id, [distance, 0, 0]);
updatePartyRuntimeTargetVisibility(member.id, BOSS_ID, true);
}
const tank = members.find((member) => member.role === "tank")
?? { id: PLAYER_AGGRO_ID, maxHealth: useCombatStore.getState().maxHealth };
combat.damageMob(BOSS_ID, 1, 1_000, { actorId: tank.id, role: "tank" });
combat.selectMob(BOSS_ID);
return { tank, members };
}
function measurePressure(level: number, stacked: boolean, activeHealer: boolean) {
const { tank } = setup(level, stacked);
const controller = new BenchmarkController();
let incoming = 0;
let rawIncoming = 0;
let absorbed = 0;
let effectiveHealing = 0;
let overheal = 0;
let healerCasts = 0;
let invalid = 0;
let resourceBlocked = 0;
let tankLow = 1;
let partyLow = 1;
let manaLow = 1;
let endAt = 1_000;
const outgoingByAbility: Record<string, number> = {};
const castsByAbility: Record<string, number> = {};
const controls = new Set<string>();
const otherDamage: Record<string, number> = {};
const invalidDetails: unknown[] = [];
const castAbility = useCombatStore.getState().castAbility;
useCombatStore.setState({ castAbility: (...args) => {
const result = castAbility(...args);
if (!result.ok && result.reason) invalidDetails.push({ abilityId: result.abilityId, reason: result.reason,
duringCompletion: Boolean(args[3]) });
return result;
} });
const stopCasts = subscribeCombatPresentation((message) => {
if (message.kind !== "event" || message.event.sourceActorId !== PLAYER_AGGRO_ID || message.event.phase !== "release") return;
const id = `${message.event.abilityId}:${message.event.name}`;
castsByAbility[id] = (castsByAbility[id] ?? 0) + 1;
});
const stop = subscribeCombatEvents((event) => {
if (event.sourceActorId === BOSS_ID && event.kind === "damage") {
incoming += event.effectiveAmount;
rawIncoming += event.rawAmount;
absorbed += event.absorbed;
}
if (event.sourceActorId === PLAYER_AGGRO_ID && event.kind === "healing") {
effectiveHealing += event.effectiveAmount;
overheal += event.overheal;
const id = event.abilityId ?? "unknown";
outgoingByAbility[id] = (outgoingByAbility[id] ?? 0) + event.effectiveAmount;
}
if (event.kind === "damage" && event.sourceActorId !== BOSS_ID && event.targetActorId !== BOSS_ID) {
const id = `${event.sourceActorId}:${event.abilityId}`;
otherDamage[id] = (otherDamage[id] ?? 0) + event.effectiveAmount;
}
});
try {
for (let now = 1_100; now <= 121_000; now += 100) {
endAt = now;
const combat = useCombatStore.getState();
combat.tick(0.1, now);
combat.advanceMobCombat(now);
advanceDungeonPartyCombat(now);
if (activeHealer && !useCombatStore.getState().mobs[BOSS_ID].dead && useCombatStore.getState().health > 0) {
const decision = controller.act(now, "healer", { ...BENCHMARK_SCENARIOS[0], hostileTargets: 1 }, false);
healerCasts += Number(decision.cast);
invalid += decision.invalidFailures;
resourceBlocked += Number(decision.resourceBlocked);
}
const state = useCombatStore.getState();
const members = usePartyStore.getState().members;
tankLow = Math.min(tankLow, state.actors[tank.id]?.health! / tank.maxHealth);
partyLow = Math.min(partyLow, state.health / state.maxHealth,
...members.map((member) => member.health / member.maxHealth));
manaLow = Math.min(manaLow, (state.resourcePools.mana ?? 0) / (state.maxResourcePools.mana ?? 1));
for (const aura of state.auras) if (aura.sourceId === BOSS_ID && aura.definition.id.startsWith("enemy-control:")) {
controls.add(aura.targetId);
}
if (state.mobs[BOSS_ID].dead || state.health <= 0) break;
}
} finally { stop(); stopCasts(); useCombatStore.setState({ castAbility }); }
const state = useCombatStore.getState();
const result = { level, stacked, activeHealer, seconds: (endAt - 1_000) / 1_000,
clear: state.mobs[BOSS_ID].dead, damage: incoming, rawIncoming, absorbed, effectiveHealing, overheal,
playerMaxHealth: state.maxHealth, tankMaxHealth: state.actors[tank.id].maxHealth,
healerCasts, resourceBlocked, invalid, tankLow, partyLow, manaLow,
finalMana: state.resourcePools.mana, maxMana: state.maxResourcePools.mana,
playerDead: state.health <= 0,
deadCompanions: usePartyStore.getState().members.filter((member) => member.health <= 0).map((member) => member.classId),
mechanics: state.mobs[BOSS_ID].abilityCastCounts, controlledActors: [...controls], outgoingByAbility, castsByAbility, otherDamage, invalidDetails };
report.push(result);
return result;
}
describe("native RoM encounter and healing pressure", () => {
it.each(["Trample", "Bash"])("resolves %s against current positions after its warning", (name) => {
const attack = bossEntity.combat!.attacks.find((entry) => entry.name === name)!;
const { members } = setup(30, true, [{ ...attack, initialCooldownMs: [0, 0] }]);
const releases: string[] = [];
const stopPresentation = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.sourceActorId === BOSS_ID) releases.push(message.event.phase);
});
useCombatStore.getState().advanceMobCombat(1_100);
expect(useCombatStore.getState().mobs[BOSS_ID].activeCast?.name).toBe(name);
useCombatStore.getState().setPlayerPosition([8, 0, 0]);
const mage = members.find((member) => member.classId === "rom-mage")!;
updatePartyRuntimePosition(mage.id, 12, 0, 0);
useCombatStore.getState().advanceMobCombat(2_899);
expect(useCombatStore.getState().combatEvents.some((event) => event.abilityId === attack.id)).toBe(false);
useCombatStore.getState().advanceMobCombat(2_900);
stopPresentation();
const hits = useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage" && event.abilityId === attack.id);
expect(new Set(hits.map((event) => event.targetActorId))).toEqual(new Set(members.filter((member) => member.id !== mage.id).map((member) => member.id)));
expect(hits.every((event) => event.school === "physical")).toBe(true);
expect(releases).toContain("cast-start");
expect(releases).toContain("release");
});
it("lets dispelling Gulp restore the controlled actor before natural expiry", () => {
const attack = bossEntity.combat!.attacks.find((entry) => entry.name === "Gulp")!;
const { tank } = setup(30, true, [{ ...attack, initialCooldownMs: [0, 0] }]);
useCombatStore.getState().advanceMobCombat(1_100);
useCombatStore.getState().advanceMobCombat(2_900);
expect(useCombatStore.getState().controlMechanic).toBe("stun");
expect(useCombatStore.getState().actors[tank.id].controlledUntil).toBeGreaterThan(2_900);
expect(useCombatStore.getState().dispelActor(PLAYER_AGGRO_ID, ["magic"], 1, 3_000)).toBe(1);
expect(useCombatStore.getState().controlledUntil).toBeLessThanOrEqual(3_000);
expect(useCombatStore.getState().dispelActor(tank.id, ["magic"], 1, 3_000)).toBe(1);
expect(useCombatStore.getState().actors[tank.id].controlledUntil).toBeLessThanOrEqual(3_000);
});
it("lets a native companion counter an interruptible boss warning", () => {
const attack = bossEntity.combat!.attacks.find((entry) => entry.name === "Trample")!;
setup(30, true, [{ ...attack, initialCooldownMs: [0, 0] }]);
useCombatStore.getState().advanceMobCombat(1_100);
expect(useCombatStore.getState().mobs[BOSS_ID].activeCast).not.toBeNull();
const counters: string[] = [];
const stop = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.sourceActorId === BOSS_ID) counters.push(message.event.phase);
});
for (let now = 1_200; now < 2_900; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
const boss = useCombatStore.getState().mobs[BOSS_ID];
stop();
expect(boss.activeCast).toBeNull();
expect(counters).toContain("cast-cancel");
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "damage" && event.abilityId === attack.id)).toBe(false);
});
it("lets a ranged native Priest companion dispel Gulp from the player tank", () => {
const attack = bossEntity.combat!.attacks.find((entry) => entry.name === "Gulp")!;
const knight = ROM_FIVE_IDENTITIES.find((identity) => identity.classId === "rom-knight")!;
const { members } = setup(30, false, [{ ...attack, initialCooldownMs: [0, 0] }], knight);
const healer = members.find((member) => member.classId === "rom-priest")!;
useCombatStore.getState().advanceMobCombat(1_100);
useCombatStore.getState().advanceMobCombat(2_900);
expect(useCombatStore.getState().controlledUntil).toBe(5_900);
expect(useCombatStore.getState().actors[healer.id].controlledUntil).toBeLessThan(2_900);
for (let now = 3_000; now < 5_900 && useCombatStore.getState().controlledUntil > now; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
expect(useCombatStore.getState().controlledUntil).toBe(0);
expect(useCombatStore.getState().controlMechanic).toBeNull();
});
it("records active-healer and idle-healer pressure in the explicit native cohort", () => {
for (const level of [10, 30, 60, 80]) for (const stacked of [false, true]) {
const active = measurePressure(level, stacked, true);
const idle = measurePressure(level, stacked, false);
// Record failures before asserting the cohort, retaining every level in
// the pressure report when a new demand pattern exposes a policy gap.
expect(active.damage).toBeGreaterThan(0);
expect(active.healerCasts).toBeGreaterThan(0);
// A safe pull may permit the healer to deal damage throughout; require
// healing across the cohort rather than inventing demand in every pull.
expect(active.effectiveHealing).toBeGreaterThanOrEqual(0);
expect(idle.healerCasts).toBe(0);
expect(idle.effectiveHealing).toBe(0);
expect(Number.isFinite(active.manaLow)).toBe(true);
expect(Number.isFinite(active.partyLow)).toBe(true);
}
expect(report.some((sample) => (sample as { effectiveHealing: number }).effectiveHealing > 0)).toBe(true);
expect(report.filter((sample) => (sample as { activeHealer: boolean }).activeHealer)
.map((sample) => (sample as { invalid: number }).invalid)).toEqual(Array(8).fill(0));
}, 180_000);
});
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { useGameStore } from "./store";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
import { deriveCharacterStats } from "./wotlkStats";
import { resolveRomNativeEnemyDirectDamage, romNativeTrainingEnemyAbsorbInputs } from "./romNativeEnemyDamage";
import { romNativeNpcCombatant } from "./romNativeCombatResolution";
const dungeon = requireDungeonDefinition("treasure-trove");
describe("source-bound fixed and percentage enemy packets", () => {
for (const [npcId, spellId, componentId, calculationType] of [
[101255, 492857, 503019, 4], [101535, 492438, 501098, 8],
] as const) for (const target of ["player", "companion"] as const) for (const level of [30, 80]) for (const build of ROM_FIVE_IDENTITIES) {
it(`uses CalType ${calculationType} for ${target} ${build.classId} at ${level}, without double mitigation`, () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
usePartyStore.setState({ members: [] });
useGameStore.getState().activateDungeon(dungeon.id, "normal");
const identity = target === "player" ? build
: ROM_FIVE_IDENTITIES.find((entry) => entry.classId === (build.classId === "rom-priest" ? "rom-knight" : "rom-priest"))!;
useCombatStore.getState().initializeCharacter({ ...identity, level, statRuleset: "rom" });
useCombatStore.getState().setPlayerPosition([target === "player" ? 2 : 20, 0, 0]);
usePartyStore.getState().initializeParty("native-enemy-formula", level,
useCombatStore.getState().health, identity.role, 1, "rom");
const member = target === "companion" ? createConfiguredPartyMember({ ...build,
specialization: "Primary", talentTreeId: "", statRuleset: "rom" }, level, 0, "native-enemy-formula") : undefined;
usePartyStore.setState({ members: member ? [member] : [] });
if (member) registerPartyRuntimePosition(member.id, [2, 0, 0]);
useCombatStore.getState().synchronizeActors();
const state = useCombatStore.getState();
const actorId = member?.id ?? PLAYER_AGGRO_ID;
const stats = deriveCharacterStats(member?.classId ?? state.classId, level,
member?.gearStats ?? state.gearStats, member?.raceId ?? state.raceId, {},
member?.secondaryClassId ?? state.secondaryClassId, member?.gender ?? state.gender, "rom").romNative!;
const absorb = romNativeTrainingEnemyAbsorbInputs(stats, true);
expect(absorb.status).toBe("supported");
const source = resolveRomNativeEnemyDirectDamage({ npcId, sourceSkillId: spellId, componentId,
targetMaxHp: state.actors[actorId].maxHealth,
targetAbsorb: absorb.status === "supported" ? absorb.value : undefined,
sourceAoeMagicPowerRate: 1 });
expect(source.status, JSON.stringify(source)).toBe("supported");
if (source.status !== "supported") return;
expect(romNativeNpcCombatant(npcId, level)?.combatStatus).toBe("supported");
const entity = dungeon.entities[`rw-treasure-trove-${npcId}`];
const attack = entity.combat!.attacks.find((entry) => entry.spellId === spellId)!;
expect(attack).toBeDefined();
const mobId = `native-source-${npcId}`;
const damageMultiplier = 1.3;
const nativeDamageBudgetScale = level === 80 ? 40 : 3;
state.registerMob(mobId, { name: entity.name, runewakerNpcId: npcId, level, maxHealth: 1_000_000,
damageMultiplier, nativeDamageBudgetScale, xpReward: 0,
attacks: [{ ...attack, initialCooldownMs: [0, 0] }] });
setMobPosition(mobId, [0, 0, 0]);
if (member) updatePartyRuntimeTargetVisibility(member.id, mobId, true);
state.damageMob(mobId, 1, 1_000, { actorId, role: "tank" });
let landed;
for (let now = 1_100; now < 100_000 && !landed; now += 100) {
useCombatStore.getState().advanceMobCombat(now);
landed = useCombatStore.getState().combatEvents.find((event) => event.sourceActorId === mobId
&& event.targetActorId === actorId && event.kind === "damage" && event.effectiveAmount > 0);
}
expect(landed).toBeDefined();
expect(landed?.critical).toBe(false);
expect(landed?.rawAmount).toBe(Math.round(source.value.amount * damageMultiplier));
// The manual source branches apply neither ATK/DEF nor typed resistance.
expect(useCombatStore.getState().lastCombatResult?.blocked).toBe(false);
});
}
});
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import { resolvedEquipmentStats } from "./heirloomEquipment";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
import { deriveRomNativeStats } from "./romNativeStats";
import { romNativeNpcCombatant } from "./romNativeCombatResolution";
import {
resolveRomNativeEnemyDirectDamage,
romNativeTrainingEnemyAbsorbInputs,
type RomNativeEnemyTargetAbsorbInputs,
} from "./romNativeEnemyDamage";
const neutralAbsorb: RomNativeEnemyTargetAbsorbInputs = Object.freeze({
physicalFlatAbsorb: 0,
physicalAbsorbRate: 0,
magicalAbsorbRate: 0,
aoeMagicDefenseRate: 1,
provenance: ["unit-test source-channel fixture"],
});
describe("native RuneWaker enemy direct formulas", () => {
it("resolves exact imported CalType 4 fixed damage with native magic absorption", () => {
const neutral = resolveRomNativeEnemyDirectDamage({
npcId: 101_255, sourceSkillId: 492_857, componentId: 503_019, targetAbsorb: neutralAbsorb,
});
expect(neutral).toMatchObject({
status: "supported",
value: {
calculationType: 4, magicLevel: 9, amount: 1_000, signedPower: -1_000,
sourceHitEffectType: 0, componentEffectType: 1, magicType: -1,
resolverDamageChannel: "none", sourcePowerStagesAlreadyApplied: true,
nativeDamageBudgetScale: "skip-authored-native-damage-budget",
authoredBudgetPolicy: "apply-imported-attack-damageMultiplier; skip-native-damage-budget-scale-for-source-formula; keep-authored-encounter-health",
sourceStages: {
sourceLevelDamageRate: "not-applicable", sourceMagicPowerRate: "not-applicable",
sourceTypedResistance: "not-applicable", targetFormulaAbsorption: "applied",
genericAuras: "not-applied", ordinaryShieldAbsorption: "not-applied",
sourceOrdinalPowerRate: 1, sourceShareDamageRate: 1,
},
},
});
const resisted = resolveRomNativeEnemyDirectDamage({
npcId: 101_255, sourceSkillId: 492_857, componentId: 503_019,
targetAbsorb: { ...neutralAbsorb, magicalAbsorbRate: 0.5 },
});
expect(resisted).toMatchObject({ status: "supported", value: { amount: 500, signedPower: -500 } });
const ordinal = resolveRomNativeEnemyDirectDamage({
npcId: 101_255, sourceSkillId: 492_857, componentId: 503_019,
targetCount: 2, targetOrdinal: 1, targetAbsorb: neutralAbsorb,
});
expect(ordinal).toMatchObject({ status: "supported", value: {
amount: 990,
sourceMultiTargetFactor: 0.99,
sourceStages: { sourceOrdinalPowerRate: 0.99, sourceShareDamageRate: 1 },
} });
});
it("resolves exact imported CalType 8 from recipient MaxHP before source absorb/truncation", () => {
const resolved = resolveRomNativeEnemyDirectDamage({
npcId: 101_535, sourceSkillId: 492_438, componentId: 501_098,
targetMaxHp: 100_000, targetAbsorb: neutralAbsorb,
});
expect(resolved).toMatchObject({
status: "supported",
value: {
calculationType: 8, magicLevel: 24, amount: 25_000, signedPower: -25_000,
sourceHitEffectType: 0, componentEffectType: 1, magicType: 5,
formulaMitigation: "weapon-absorb", resolverDamageChannel: "none",
nativeDamageBudgetScale: "skip-authored-native-damage-budget",
sourcePowerStagesAlreadyApplied: true,
sourceStages: {
sourceLevelDamageRate: "not-applicable", sourceMagicPowerRate: "not-applicable",
sourceTypedResistance: "not-applicable", targetFormulaAbsorption: "applied",
},
},
});
const targetDefense = resolveRomNativeEnemyDirectDamage({
npcId: 101_535, sourceSkillId: 492_438, componentId: 501_098,
targetMaxHp: 100_000,
targetAbsorb: { ...neutralAbsorb, physicalFlatAbsorb: 500, physicalAbsorbRate: 0.2 },
});
expect(targetDefense).toMatchObject({ status: "supported", value: { amount: 19_996, signedPower: -19_996 } });
const sharing = resolveRomNativeEnemyDirectDamage({
npcId: 101_535, sourceSkillId: 492_438, componentId: 501_098,
targetMaxHp: 100_000, targetCount: 2, targetOrdinal: 1, targetAbsorb: neutralAbsorb,
});
expect(sharing).toMatchObject({ status: "supported", value: {
amount: 22_000, sourceMultiTargetFactor: 0.88,
sourceStages: { sourceOrdinalPowerRate: 0.88, sourceShareDamageRate: 1 },
} });
});
it("applies source AoE magic rates after CalType 4 target magic absorption", () => {
const missingRates = resolveRomNativeEnemyDirectDamage({
npcId: 102_476, sourceSkillId: 493_985, componentId: 503_044, targetAbsorb: neutralAbsorb,
});
expect(missingRates).toEqual({ status: "unsupported", reason: "native-enemy-source-aoe-magic-rates-required" });
// Verified equipment channels now support this NPC's equipment snapshot;
// its AoE packet still requires explicit source magic-rate inputs above.
expect(romNativeNpcCombatant(102_476)?.combatStatus).toBe("supported");
const resolved = resolveRomNativeEnemyDirectDamage({
npcId: 102_476, sourceSkillId: 493_985, componentId: 503_044,
targetAbsorb: { ...neutralAbsorb, magicalAbsorbRate: 0.5, aoeMagicDefenseRate: 1.2 },
sourceAoeMagicPowerRate: 0.5,
});
expect(resolved).toMatchObject({ status: "supported", value: {
amount: 1_500,
sourceStages: { sourceAoeMagicRates: "applied" },
} });
});
it("fails closed when fixed attributes, target MaxHP, or exact NPC skill bindings are absent", () => {
expect(resolveRomNativeEnemyDirectDamage({
npcId: 100_072, sourceSkillId: 490_476, componentId: 501_026, targetAbsorb: neutralAbsorb,
})).toEqual({ status: "unsupported", reason: "native-enemy-fixed-attribute-input-required" });
expect(resolveRomNativeEnemyDirectDamage({
npcId: 101_535, sourceSkillId: 492_438, componentId: 501_098, targetAbsorb: neutralAbsorb,
})).toEqual({ status: "unsupported", reason: "native-enemy-target-max-hp-required" });
// Day of Rain is catalogued as a Throw, but no imported NPC binding identifies its caster.
expect(resolveRomNativeEnemyDirectDamage({
npcId: 101_535, sourceSkillId: 491_183, componentId: 501_278,
targetAbsorb: neutralAbsorb,
})).toEqual({ status: "unsupported", reason: "native-enemy-skill-binding-missing" });
});
it("requires canonical source training gear and explicit neutral native absorb auras", () => {
const identity = ROM_FIVE_IDENTITIES[0];
const gear = resolvedEquipmentStats({}, [], {
ownerId: "enemy-formula-test", ownerKind: "player", classId: identity.classId,
secondaryClassId: identity.secondaryClassId, specializationId: null, role: identity.role, level: 30,
}).romNative;
expect(gear).toBeDefined();
const stats = deriveRomNativeStats({
classId: identity.classId, secondaryClassId: identity.secondaryClassId,
raceId: identity.raceId, level: 30, gear,
});
expect(romNativeTrainingEnemyAbsorbInputs(stats, true)).toMatchObject({
status: "supported",
value: { physicalFlatAbsorb: 0, physicalAbsorbRate: 0, magicalAbsorbRate: 0, aoeMagicDefenseRate: 1 },
});
expect(romNativeTrainingEnemyAbsorbInputs(stats, false)).toEqual({
status: "unsupported", reason: "native-target-absorb-aura-state-unverified",
});
const custom = deriveRomNativeStats({
classId: identity.classId, secondaryClassId: identity.secondaryClassId,
raceId: identity.raceId, level: 30, gear: { ...gear, physicalDefense: (gear?.physicalDefense ?? 0) + 1 },
});
expect(romNativeTrainingEnemyAbsorbInputs(custom, true)).toEqual({
status: "unsupported", reason: "native-target-training-gear-profile-unverified",
});
});
});
+376
View File
@@ -0,0 +1,376 @@
import type { RomNativeNpcSpellBinding } from "./romNativeCombatResolution";
import { romNativeNpcCombatant } from "./romNativeCombatResolution";
import type { RomNativeDerivedStats } from "./romNativeStatsTypes";
import { matchesNativeTrainingGear } from "./romNativeCombatAmount";
type SourceComponent = Readonly<Record<string, unknown>>;
export interface RomNativeEnemyTargetAbsorbInputs {
/** Fin.AB, added to signed damage before the source Abr rate. */
readonly physicalFlatAbsorb: number;
/** Fin.Abr, the native rate subtracted from one in CalWeaponDMG. */
readonly physicalAbsorbRate: number;
/** Fin.MAbr, the native rate subtracted from one in CalMagicDMG. */
readonly magicalAbsorbRate: number;
/** WearEqType_AEMagicDefRate BodyRate; used only for non-target magical rows. */
readonly aoeMagicDefenseRate: number;
readonly provenance: readonly string[];
}
export type RomNativeEnemyTargetAbsorbInputResult =
| { readonly status: "supported"; readonly value: RomNativeEnemyTargetAbsorbInputs }
| { readonly status: "unsupported"; readonly reason: string };
/**
* The canonical source training set has neutral AB/Abr/MAbr and AoE magic-defense
* channels. Require both the source-gear match and an explicit aura-neutrality
* assertion; shared WoW armor is never used as a substitute for those channels.
*/
export function romNativeTrainingEnemyAbsorbInputs(
stats: RomNativeDerivedStats,
nativeAbsorbAurasVerifiedNeutral: boolean,
): RomNativeEnemyTargetAbsorbInputResult {
if (!nativeAbsorbAurasVerifiedNeutral) {
return { status: "unsupported", reason: "native-target-absorb-aura-state-unverified" };
}
if (!matchesNativeTrainingGear(stats, stats.sourceInputs.level)) {
return { status: "unsupported", reason: "native-target-training-gear-profile-unverified" };
}
return Object.freeze({
status: "supported",
value: Object.freeze({
physicalFlatAbsorb: 0,
physicalAbsorbRate: 0,
magicalAbsorbRate: 0,
aoeMagicDefenseRate: 1,
provenance: Object.freeze([
"RoleValueData::Init_BaseEQValue zeroes Body and initializes every BodyRate to 1",
"all 999 selected native training items/affixes have fully resolved channels and none supplies AB/Abr/MAbr/AEMagicDefRate",
"caller-verified no native absorb-channel aura is active",
]),
}),
});
}
export interface RomNativeEnemyDirectDamageInput {
readonly npcId: number;
/** Runtime NPC level used only by CalType 2 source DLvRate; MagicLv stays on the source binding. */
readonly npcLevel?: number;
readonly sourceSkillId: number;
readonly componentId: number;
/** Required only by CalType 8; source uses the recipient's Fin.MaxHP. */
readonly targetMaxHp?: number;
readonly targetCount?: number;
/** Ordinal in the final selected-recipient order used by MagicProcess. */
readonly targetOrdinal?: number;
/** Explicit source target Fin.AB/Abr/MAbr and AoE magic-defense BodyRate. */
readonly targetAbsorb?: RomNativeEnemyTargetAbsorbInputs;
/** NPC WearEqType_AEMagicPowerRate; required for non-target magical packets. */
readonly sourceAoeMagicPowerRate?: number;
/** GetValue(FixType) only for nonzero fixed terms outside the known None type. */
readonly fixedAttributeValue?: number;
}
export interface RomNativeEnemyDirectDamageSourceStages {
readonly bindingIdentityVerified: true;
readonly npcMagicLevelIncluded: true;
readonly formulaPowerApplied: true;
readonly sourceLevelDamageRate: "applied" | "not-applicable";
readonly sourceNpcDamageRate: "applied" | "not-applicable";
readonly sourceMagicPowerRate: "applied" | "not-applicable";
readonly sourceTypedResistance: "applied" | "not-applicable";
readonly targetFormulaAbsorption: "applied" | "pending-after-combat-table" | "bypassed";
readonly sourceAoeMagicRates: "applied" | "not-applicable";
readonly sourceMultiTargetFactor: "applied" | "pending-after-target-formula";
readonly sourceOrdinalPowerRate: number;
readonly sourceShareDamageRate: number;
readonly authoredEncounterDamageMultiplier: "not-applied";
readonly genericAuras: "not-applied";
readonly ordinaryShieldAbsorption: "not-applied";
}
export interface RomNativeEnemyDirectDamageValue {
readonly npcId: number;
readonly sourceSkillId: number;
readonly componentId: number;
readonly calculationType: 2 | 4 | 8;
readonly magicLevel: number;
/** Positive amount suitable as native base damage before authored encounter scaling. */
readonly amount: number;
/** Signed damage after the source-owned formula absorption when that stage is before hit resolution. */
readonly signedPower: number;
readonly formulaMitigation: "weapon-absorb" | "magic-absorb" | "bypassed";
readonly nativeDamageBudgetScale: "skip-authored-native-damage-budget";
readonly sourceHitEffectType: number;
readonly componentEffectType: number;
readonly magicType: number;
readonly sourceMultiTargetFactor: number;
readonly resolverDamageChannel: "none" | "physical";
readonly resolverCriticalKind?: "physical";
readonly resolverRules: Readonly<{
canCrit: boolean;
canBlock: boolean;
canParry: boolean;
ignoreArmor: boolean;
}>;
/** True means the formula has resolved or deliberately omitted these source power stages. */
readonly sourcePowerStagesAlreadyApplied: boolean;
readonly sourceStages: RomNativeEnemyDirectDamageSourceStages;
readonly authoredBudgetPolicy: "apply-imported-attack-damageMultiplier; skip-native-damage-budget-scale-for-source-formula; keep-authored-encounter-health";
}
export type RomNativeEnemyDirectDamageResult =
| { readonly status: "supported"; readonly value: RomNativeEnemyDirectDamageValue }
| { readonly status: "unsupported"; readonly reason: string };
const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
const numeric = (source: SourceComponent, key: string): number | undefined => {
const value = source[key];
return finite(value) ? value : undefined;
};
const flag = (source: SourceComponent, key: string): boolean | undefined => {
const value = source[key];
return typeof value === "boolean" ? value : undefined;
};
const unsupported = (reason: string): RomNativeEnemyDirectDamageResult => ({ status: "unsupported", reason });
function sourceMagicLevel(binding: RomNativeNpcSpellBinding): number | undefined {
return Number.isSafeInteger(binding.magicLevel) && binding.magicLevel >= 0 ? binding.magicLevel : undefined;
}
function targetAbsorbedPower(power: number, kind: "weapon" | "magic", input: RomNativeEnemyTargetAbsorbInputs): number {
if (power >= 0) return power;
const value = kind === "weapon"
? (power + input.physicalFlatAbsorb) * (1 - input.physicalAbsorbRate)
: power * (1 - input.magicalAbsorbRate);
return Math.min(-1, Math.trunc(value));
}
function targetHpPercentPower(
powerPercent: number,
targetMaxHp: number,
kind: "weapon" | "magic",
input: RomNativeEnemyTargetAbsorbInputs | undefined,
bypassAbsorb: boolean,
): number | undefined {
const rawPower = targetMaxHp * powerPercent;
if (bypassAbsorb) return rawPower / 100;
if (!input) return undefined;
if (kind === "weapon") {
if (![input.physicalFlatAbsorb, input.physicalAbsorbRate].every(finite)) return undefined;
const absorbed = Math.min(-1, Math.trunc((rawPower + input.physicalFlatAbsorb) * (1 - input.physicalAbsorbRate)));
return absorbed / 100;
}
if (!finite(input.magicalAbsorbRate)) return undefined;
const absorbed = Math.min(-1, Math.trunc(rawPower * (1 - input.magicalAbsorbRate)));
return absorbed / 100;
}
function bindingFor(input: RomNativeEnemyDirectDamageInput): {
readonly binding: RomNativeNpcSpellBinding;
readonly component: SourceComponent;
readonly magicLevel: number;
} | RomNativeEnemyDirectDamageResult {
if (![input.npcId, input.sourceSkillId, input.componentId].every((value) => Number.isSafeInteger(value) && value > 0)) {
return unsupported("native-enemy-source-identity-invalid");
}
if (input.npcLevel !== undefined && (!Number.isSafeInteger(input.npcLevel) || input.npcLevel < 1)) {
return unsupported("native-enemy-runtime-level-invalid");
}
const npc = romNativeNpcCombatant(input.npcId, input.npcLevel);
if (!npc) return unsupported("native-enemy-npc-binding-missing");
const binding = npc.spellBindings.find((entry) => entry.sourceSkillId === input.sourceSkillId);
if (!binding) return unsupported("native-enemy-skill-binding-missing");
const component = binding.components.find((entry) => numeric(entry, "componentId") === input.componentId);
if (!component) return unsupported("native-enemy-component-binding-missing");
const magicLevel = sourceMagicLevel(binding);
if (magicLevel === undefined) return unsupported("native-enemy-magic-level-missing");
if (!binding.magicCollect || ![0, 1].includes(binding.magicCollect.effectType)) {
return unsupported("native-enemy-collect-hit-binding-incomplete");
}
if (numeric(component, "magicFunc") !== 0 || numeric(component, "attackType") !== 0) {
return unsupported("native-enemy-component-is-not-direct-hp-damage");
}
return { binding, component, magicLevel };
}
function baseValue(
input: RomNativeEnemyDirectDamageInput,
formula: 2 | 4 | 8,
magicLevel: number,
amount: number,
signedPower: number,
formulaMitigation: RomNativeEnemyDirectDamageValue["formulaMitigation"],
resolverDamageChannel: RomNativeEnemyDirectDamageValue["resolverDamageChannel"],
sourceHitEffectType: number,
componentEffectType: number,
magicType: number,
sourceMultiTargetFactor: number,
sourceOrdinalPowerRate: number,
sourceShareDamageRate: number,
stages: RomNativeEnemyDirectDamageSourceStages,
options: { readonly sourcePowerStagesAlreadyApplied: boolean; readonly crit?: "physical" },
): RomNativeEnemyDirectDamageResult {
if (![amount, signedPower].every(Number.isFinite) || amount < 0) return unsupported("native-enemy-formula-produced-invalid-damage");
return Object.freeze({
status: "supported",
value: Object.freeze({
npcId: input.npcId,
sourceSkillId: input.sourceSkillId,
componentId: input.componentId,
calculationType: formula,
magicLevel,
amount,
signedPower,
formulaMitigation,
nativeDamageBudgetScale: "skip-authored-native-damage-budget",
sourceHitEffectType,
componentEffectType,
magicType,
sourceMultiTargetFactor,
resolverDamageChannel,
...(options.crit ? { resolverCriticalKind: options.crit } : {}),
resolverRules: Object.freeze({
canCrit: formula === 2,
canBlock: formula === 2,
canParry: formula === 2,
ignoreArmor: formula === 2,
}),
sourcePowerStagesAlreadyApplied: options.sourcePowerStagesAlreadyApplied,
sourceStages: stages,
authoredBudgetPolicy: "apply-imported-attack-damageMultiplier; skip-native-damage-budget-scale-for-source-formula; keep-authored-encounter-health",
}),
});
}
const sourceStages = (
values: Omit<RomNativeEnemyDirectDamageSourceStages, "bindingIdentityVerified" | "npcMagicLevelIncluded" | "formulaPowerApplied" | "authoredEncounterDamageMultiplier" | "genericAuras" | "ordinaryShieldAbsorption">,
): RomNativeEnemyDirectDamageSourceStages => Object.freeze({
bindingIdentityVerified: true,
npcMagicLevelIncluded: true,
formulaPowerApplied: true,
authoredEncounterDamageMultiplier: "not-applied",
genericAuras: "not-applied",
ordinaryShieldAbsorption: "not-applied",
...values,
});
/** Resolve an exact imported NPC spell component; no skill/name/Day-of-Rain inference is used. */
export function resolveRomNativeEnemyDirectDamage(
input: RomNativeEnemyDirectDamageInput,
): RomNativeEnemyDirectDamageResult {
const resolved = bindingFor(input);
if ("status" in resolved) return resolved;
const { binding, component, magicLevel } = resolved;
const calculationType = numeric(component, "calculationType");
const damagePower = numeric(component, "damagePower");
const damagePowerSkillLevelArg = numeric(component, "damagePowerSkillLevelArg");
const fixedValue = numeric(component, "fixedValue");
const fixedType = numeric(component, "fixedType");
const fixedDamageSkillLevelArg = numeric(component, "fixedDamageSkillLevelArg");
const ignoreMagicAbsorb = flag(component, "ignoreMagicAbsorb");
const isFixDotPower = flag(component, "isFixDotPower");
const collect = binding.magicCollect;
const rangeType = numeric(collect as unknown as SourceComponent, "rangeType");
const decreaseArg = numeric(collect as unknown as SourceComponent, "decreaseArg");
const isShareDamage = flag(collect as unknown as SourceComponent, "isShareDamage");
const magicType = numeric(component, "magicType");
const componentEffectType = numeric(component, "effectType");
if (calculationType === undefined || damagePower === undefined || damagePowerSkillLevelArg === undefined
|| fixedValue === undefined || fixedType === undefined || fixedDamageSkillLevelArg === undefined
|| rangeType === undefined || decreaseArg === undefined || magicType === undefined
|| componentEffectType === undefined || ignoreMagicAbsorb === undefined
|| isFixDotPower === undefined || isShareDamage === undefined) {
return unsupported("native-enemy-component-source-fields-incomplete");
}
if (![2, 4, 8].includes(calculationType)) return unsupported("native-enemy-calculation-type-unsupported");
if ((calculationType === 2 || calculationType === 4)
&& fixedType !== 0 && fixedValue !== 0 && !finite(input.fixedAttributeValue)) {
return unsupported("native-enemy-fixed-attribute-input-required");
}
if (input.fixedAttributeValue !== undefined && !finite(input.fixedAttributeValue)) {
return unsupported("native-enemy-fixed-attribute-input-invalid");
}
const fixedAttributeValue = fixedType === 0 ? 1 : (input.fixedAttributeValue ?? 0);
const orgPower = damagePower * (1 + damagePowerSkillLevelArg * magicLevel / 100);
const fixedPower = fixedValue * fixedAttributeValue * (1 + magicLevel * fixedDamageSkillLevelArg / 100);
if (!Number.isFinite(orgPower) || !Number.isFinite(fixedPower)) return unsupported("native-enemy-component-power-invalid");
const targetCount = input.targetCount ?? 1;
const targetOrdinal = input.targetOrdinal ?? 0;
if (!Number.isInteger(targetCount) || targetCount < 1 || !Number.isInteger(targetOrdinal)
|| targetOrdinal < 0 || targetOrdinal >= targetCount) return unsupported("native-enemy-target-order-invalid");
const shareRate = Math.max(decreaseArg / 100, 1 / targetCount);
const ordinalPowerRate = (100 - decreaseArg) / 100;
const ordinalRate = isShareDamage ? 1 : ordinalPowerRate ** targetOrdinal;
if (calculationType === 4) {
let power = orgPower * ordinalRate + fixedPower;
if (power >= 0) return unsupported("native-enemy-fixed-formula-is-not-negative-direct-damage");
let mitigation: RomNativeEnemyDirectDamageValue["formulaMitigation"] = "bypassed";
if (!ignoreMagicAbsorb) {
if (!input.targetAbsorb || ![input.targetAbsorb.magicalAbsorbRate, input.targetAbsorb.aoeMagicDefenseRate].every(finite)) {
return unsupported("native-enemy-target-magic-absorb-input-required");
}
power = targetAbsorbedPower(power, "magic", input.targetAbsorb);
mitigation = "magic-absorb";
}
if (rangeType !== 0) {
if (!input.targetAbsorb || !finite(input.targetAbsorb.aoeMagicDefenseRate)
|| !finite(input.sourceAoeMagicPowerRate)) return unsupported("native-enemy-source-aoe-magic-rates-required");
power *= input.sourceAoeMagicPowerRate * input.targetAbsorb.aoeMagicDefenseRate;
}
const afterShare = power * (isShareDamage ? shareRate : 1);
const signedPower = Math.trunc(-afterShare) * -1;
return baseValue(input, 4, magicLevel, Math.max(0, -signedPower), signedPower, mitigation, "none",
binding.magicCollect.effectType, componentEffectType, magicType,
isShareDamage ? shareRate : ordinalRate, ordinalRate, isShareDamage ? shareRate : 1,
sourceStages({ sourceLevelDamageRate: "not-applicable", sourceNpcDamageRate: "not-applicable", sourceMagicPowerRate: "not-applicable",
sourceTypedResistance: "not-applicable", targetFormulaAbsorption: mitigation === "bypassed" ? "bypassed" : "applied",
sourceAoeMagicRates: rangeType === 0 ? "not-applicable" : "applied", sourceMultiTargetFactor: "applied",
sourceOrdinalPowerRate: ordinalRate, sourceShareDamageRate: isShareDamage ? shareRate : 1 }),
{ sourcePowerStagesAlreadyApplied: true });
}
if (calculationType === 8) {
if (orgPower >= 0) return unsupported("native-enemy-component-is-not-negative-direct-damage");
if (!finite(input.targetMaxHp) || (input.targetMaxHp as number) <= 0) {
return unsupported("native-enemy-target-max-hp-required");
}
const percentPower = orgPower * ordinalRate;
if (percentPower >= 0) return unsupported("native-enemy-percent-formula-is-not-negative-direct-damage");
let mitigation: RomNativeEnemyDirectDamageValue["formulaMitigation"] = "bypassed";
const absorbBypass = ignoreMagicAbsorb || isFixDotPower;
const effectType = componentEffectType;
if (effectType !== 0 && effectType !== 1) return unsupported("native-enemy-percent-effect-type-unsupported");
const absorbKind = effectType === 1 ? "weapon" : "magic";
const power = targetHpPercentPower(percentPower, input.targetMaxHp as number, absorbKind, input.targetAbsorb, absorbBypass);
if (power === undefined) return unsupported(absorbBypass ? "native-enemy-percent-formula-invalid" : "native-enemy-target-absorb-input-required");
if (!absorbBypass) mitigation = absorbKind === "weapon" ? "weapon-absorb" : "magic-absorb";
let adjustedPower = power;
// FixDotPower takes the source's early raw-MaxHP path and skips the AoE
// BodyRate multiplication below the ordinary CalMagicDMG branch.
if (absorbKind === "magic" && rangeType !== 0 && !isFixDotPower) {
if (!input.targetAbsorb || !finite(input.targetAbsorb.aoeMagicDefenseRate)
|| !finite(input.sourceAoeMagicPowerRate)) return unsupported("native-enemy-source-aoe-magic-rates-required");
adjustedPower *= input.sourceAoeMagicPowerRate * input.targetAbsorb.aoeMagicDefenseRate;
}
const integerDamage = Math.trunc(-adjustedPower);
const sourceShareDamageRate = isShareDamage ? shareRate : 1;
const signedPower = -(integerDamage * sourceShareDamageRate);
return baseValue(input, 8, magicLevel, Math.max(0, -signedPower), signedPower, mitigation, "none",
binding.magicCollect.effectType, effectType, magicType,
isShareDamage ? shareRate : ordinalRate, ordinalRate, sourceShareDamageRate,
sourceStages({ sourceLevelDamageRate: "not-applicable", sourceNpcDamageRate: "not-applicable", sourceMagicPowerRate: "not-applicable",
sourceTypedResistance: "not-applicable", targetFormulaAbsorption: mitigation === "bypassed" ? "bypassed" : "applied",
sourceAoeMagicRates: rangeType === 0 || absorbKind === "weapon" || isFixDotPower ? "not-applicable" : "applied",
sourceMultiTargetFactor: "applied", sourceOrdinalPowerRate: ordinalRate,
sourceShareDamageRate }),
{ sourcePowerStagesAlreadyApplied: true });
}
// Throw's source target CalWeaponDMG stage runs after the shared critical,
// block, and parry table. The runtime currently has no completion hook for
// that exact order, and the imported NPC catalog has no bound CalType 2 row.
// Do not return an apparently final amount while absorption/share are pending.
return unsupported("native-enemy-throw-post-table-absorption-runtime-unwired");
}
@@ -0,0 +1,471 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import {
auraAdjustedCharacterStats,
equipmentAdjustedCombatAmount,
PLAYER_AURA_ENTITY_ID,
useCombatStore,
} from "./combatStore";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
import {
nativeHolyHealTargetAmount,
romNativeAmountBinding,
romNativeMagicLevel,
nativePeriodicHealSnapshot,
} from "./romNativeCombatAmount";
import { resolveRomNativeHolyHeal } from "./romNativeHealing";
import { ROM_PRIEST_HOLY_BLESSING_TAG, romPriestHolyBlessingWisdom } from "./romPriestHolyBlessingRuntime";
import { resolveHealingValue } from "./combatAuras";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { abilityAtLevel, abilityById } from "./abilityCatalog";
import { deriveCharacterStats } from "./wotlkStats";
const LEVEL = 30;
const CAST_NOW = 10_000;
const NATIVE_CASTER_BONUS = Object.freeze({ magicalDamage: 240, healPowerRateFactor: 1.35 });
const NATIVE_RECIPIENT_BONUS = Object.freeze({ healAbsorbRate: 27, healAbsorb: 19 });
const HEAL_ABILITY_IDS = ["rom-priest-primary-2", "rom-priest-general-0"] as const;
const REGENERATE_ABILITY_ID = "rom-priest-general-2";
function nativeGear(gearStats: typeof EMPTY_ITEM_STATS, native: Readonly<Record<string, number>>) {
return {
...gearStats,
romNative: { ...gearStats.romNative, ...native },
};
}
function directHealAbility(abilityId: string) {
const catalog = abilityById(abilityId);
if (!catalog) throw new Error(`Missing RoM heal ability ${abilityId}.`);
const ability = abilityAtLevel(catalog, LEVEL);
const effect = ability.effects.find((candidate) => candidate.kind === "heal");
if (!effect || effect.kind !== "heal" || !effect.romNativeSource) {
throw new Error(`${ability.name} has no source-linked direct heal effect.`);
}
return { ability, effect };
}
function regenerateAbility() {
const catalog = abilityById(REGENERATE_ABILITY_ID);
if (!catalog) throw new Error("Missing RoM Priest Regenerate ability.");
const ability = abilityAtLevel(catalog, LEVEL);
const effect = ability.effects.find((candidate) => candidate.kind === "hot");
if (!effect || effect.kind !== "hot" || effect.romNativeSource?.kind !== "hot") {
throw new Error("Regenerate has no source-linked native HoT effect.");
}
return { ability, effect };
}
function expectedDirectSourceAmount(
abilityId: string,
effectiveCaster: NonNullable<ReturnType<typeof deriveCharacterStats>["romNative"]>,
) {
const { effect } = directHealAbility(abilityId);
const sourceDescriptor = effect.romNativeSource;
if (!sourceDescriptor) throw new Error(`${abilityId} has no native source descriptor.`);
const binding = romNativeAmountBinding(sourceDescriptor);
if (!binding) throw new Error(`No source binding for ${sourceDescriptor.skillId}:${sourceDescriptor.componentId}.`);
const { rank, component } = binding;
return resolveRomNativeHolyHeal({
source: {
componentId: component.componentId,
sourceSkillId: rank.sourceSkillId,
damagePower: component.damagePower,
damagePowerSkillLevelArg: component.damagePowerSkillLevelArg,
magicLevel: romNativeMagicLevel(LEVEL),
spellTimeSeconds: rank.spellTimeSeconds,
isIgnoreDmgEffect: rank.isIgnoreDmgEffect,
isFixDmgEffect: rank.isFixDmgEffect,
ignoreMagicAbsorb: component.ignoreMagicAbsorb,
randomRange: component.randomRange,
},
caster: effectiveCaster,
target: {
healing: { ...effectiveCaster.healing, targetHealAbsorbRate: 0, targetHealAbsorb: 0 },
},
randomUnit: 0.5,
}).amount;
}
function preparePartyTarget(seed: string) {
const identity = ROM_FIVE_IDENTITIES.find((candidate) => candidate.role === "tank")!;
const base = createConfiguredPartyMember({
...identity,
gender: "male",
specialization: "Primary",
statRuleset: "rom",
}, LEVEL, 0, seed);
const target = {
...base,
gearStats: nativeGear(base.gearStats, NATIVE_RECIPIENT_BONUS),
health: 1,
status: "ready" as const,
activeCast: null,
activeCastTargetId: null,
nextActionAt: 0,
};
usePartyStore.setState({ members: [target] });
registerPartyRuntimePosition(target.id, [2, 0, 0]);
useCombatStore.getState().synchronizeActors();
return usePartyStore.getState().members[0]!;
}
function installCasterGear() {
const state = useCombatStore.getState();
const gearStats = nativeGear(state.gearStats, NATIVE_CASTER_BONUS);
useCombatStore.setState({ gearStats });
return gearStats;
}
function playerEffectiveStats(now: number) {
const state = useCombatStore.getState();
const base = deriveCharacterStats(
state.classId, state.level, state.gearStats, state.raceId, {}, state.secondaryClassId,
state.gender, state.statRuleset,
);
const effective = auraAdjustedCharacterStats(base, state.auras, PLAYER_AURA_ENTITY_ID, now).romNative;
if (!effective) throw new Error("Expected native RoM Priest stats.");
return { base: base.romNative!, effective, auras: state.auras };
}
function npcEffectiveStats(memberId: string, now: number) {
const state = useCombatStore.getState();
const member = usePartyStore.getState().members.find((candidate) => candidate.id === memberId);
if (!member) throw new Error(`Missing party member ${memberId}.`);
const base = deriveCharacterStats(
member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId,
member.gender, member.statRuleset ?? "wow",
);
const effective = auraAdjustedCharacterStats(base, state.auras, memberId, now).romNative;
if (!effective) throw new Error("Expected native RoM party Priest stats.");
return { base: base.romNative!, effective, auras: state.auras };
}
function expectedRecipientAdjustedAmount(
abilityId: string,
effectiveCaster: NonNullable<ReturnType<typeof deriveCharacterStats>["romNative"]>,
effectiveTarget: NonNullable<ReturnType<typeof deriveCharacterStats>["romNative"]>,
auras: readonly import("./combatAuras").ActiveAura[],
sourceActorId: string,
targetActorId: string,
) {
const { effect } = directHealAbility(abilityId);
const source = expectedDirectSourceAmount(abilityId, effectiveCaster);
const targetAdjusted = nativeHolyHealTargetAmount(Math.round(source), effect, effectiveTarget);
const done = resolveHealingValue(targetAdjusted, auras.filter((aura) => aura.targetId === sourceActorId), "done");
const taken = resolveHealingValue(done, auras.filter((aura) => aura.targetId === targetActorId), "taken");
return Math.round(taken);
}
function assertBlessedCasterInputs(base: NonNullable<ReturnType<typeof deriveCharacterStats>["romNative"]>, effective: NonNullable<ReturnType<typeof deriveCharacterStats>["romNative"]>, auras: readonly { targetId: string; definition: { utilityTags?: readonly string[] } }[], targetAuraId: string) {
expect(auras.some((aura) => aura.targetId === targetAuraId
&& aura.definition.utilityTags?.includes(ROM_PRIEST_HOLY_BLESSING_TAG)),
`missing Holy Blessing on ${targetAuraId}; present target auras: ${JSON.stringify(auras.filter(aura => aura.targetId === targetAuraId).map(aura => aura.definition.utilityTags))}`).toBe(true);
expect(effective.attributes.wisdom).toBeCloseTo(romPriestHolyBlessingWisdom(base.attributes.wisdom), 8);
const noGear = deriveCharacterStats(
"rom-priest", LEVEL, EMPTY_ITEM_STATS, "rom-human", {}, "rom-knight", "male", "rom",
).romNative!;
expect(effective.magicalDamage).toBeGreaterThan(noGear.magicalDamage);
expect(effective.healing.healPowerRate).toBeGreaterThan(noGear.healing.healPowerRate);
}
describe("native RoM Priest direct-heal integration", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
usePartyStore.setState({ members: [] });
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
raceId: "rom-human",
gender: "male",
role: "healer",
level: LEVEL,
statRuleset: "rom",
});
usePartyStore.getState().initializeParty("rom-native-heal-direct", LEVEL,
useCombatStore.getState().health, "healer", 1, "rom");
installCasterGear();
});
it.each(HEAL_ABILITY_IDS)("resolves a real player cast of %s from native source and recipient inputs", (abilityId) => {
const target = preparePartyTarget(`rom-native-player-heal:${abilityId}`);
const { ability, effect } = directHealAbility(abilityId);
const { base, effective } = playerEffectiveStats(CAST_NOW);
const targetBase = deriveCharacterStats(
target.classId, target.level, target.gearStats, target.raceId, {}, target.secondaryClassId,
target.gender, target.statRuleset ?? "wow",
);
const targetEffective = auraAdjustedCharacterStats(
targetBase, useCombatStore.getState().auras, target.id, CAST_NOW,
).romNative!;
assertBlessedCasterInputs(base, effective, useCombatStore.getState().auras, PLAYER_AURA_ENTITY_ID);
expect(targetEffective.healing.targetHealAbsorbRate).toBe(NATIVE_RECIPIENT_BONUS.healAbsorbRate);
expect(targetEffective.healing.targetHealAbsorb).toBe(NATIVE_RECIPIENT_BONUS.healAbsorb);
const expectedRaw = expectedRecipientAdjustedAmount(abilityId, effective, targetEffective,
useCombatStore.getState().auras, PLAYER_AURA_ENTITY_ID, target.id);
const preCastHealth = target.health;
const started = useCombatStore.getState().castAbility(ability.id, {
targetActorId: target.id,
now: CAST_NOW,
});
expect(started.ok, JSON.stringify(started)).toBe(true);
if (useCombatStore.getState().activeCast?.abilityId === ability.id) {
useCombatStore.getState().tick(0.1, useCombatStore.getState().activeCast!.completesAt);
}
const healing = useCombatStore.getState().combatEvents.find((event) => event.kind === "healing"
&& event.sourceActorId === PLAYER_AGGRO_ID
&& event.targetActorId === target.id
&& event.abilityId === ability.id);
expect(healing).toBeDefined();
expect(healing?.rawAmount).toBe(Math.round(expectedRaw * (healing?.critical ? 1.5 : 1)));
expect(healing?.effectiveAmount).toBeGreaterThan(0);
expect(usePartyStore.getState().members.find((member) => member.id === target.id)!.health)
.toBe(preCastHealth + healing!.effectiveAmount);
expect(effect.romNativeSource?.kind).toBe("heal");
});
it.each(HEAL_ABILITY_IDS)("resolves a real companion cast of %s with its own blessing and recipient", (abilityId) => {
const priestIdentity = ROM_FIVE_IDENTITIES.find((candidate) => candidate.classId === "rom-priest")!;
const tankIdentity = ROM_FIVE_IDENTITIES.find((candidate) => candidate.role === "tank")!;
const healerBase = createConfiguredPartyMember({
...priestIdentity,
gender: "male",
specialization: "Primary",
statRuleset: "rom",
}, LEVEL, 0, `rom-native-npc-heal:${abilityId}`);
const { ability } = directHealAbility(abilityId);
const healer = {
...healerBase,
gearStats: nativeGear(healerBase.gearStats, NATIVE_CASTER_BONUS),
status: "healing" as const,
activeCast: {
abilityId,
school: "holy" as const,
mode: "cast" as const,
startedAt: CAST_NOW - 1_000,
completesAt: CAST_NOW,
originalDurationMs: 1_000,
pushbackHits: 0,
origin: [0, 0, 0] as const,
targetId: "pending-target",
},
activeCastTargetId: "pending-target",
nextActionAt: CAST_NOW,
};
const targetBase = createConfiguredPartyMember({
...tankIdentity,
gender: "male",
specialization: "Primary",
statRuleset: "rom",
}, LEVEL, 1, `rom-native-npc-target:${abilityId}`);
const target = {
...targetBase,
gearStats: nativeGear(targetBase.gearStats, NATIVE_RECIPIENT_BONUS),
health: 1,
status: "ready" as const,
activeCast: null,
activeCastTargetId: null,
nextActionAt: 0,
};
const configuredHealer = {
...healer,
activeCast: { ...healer.activeCast, targetId: target.id },
activeCastTargetId: target.id,
};
const configuredTarget = target;
usePartyStore.setState({ members: [configuredHealer, configuredTarget] });
registerPartyRuntimePosition(configuredHealer.id, [0, 0, 0]);
registerPartyRuntimePosition(configuredTarget.id, [2, 0, 0]);
useCombatStore.getState().synchronizeActors();
const synchronizedMembers = usePartyStore.getState().members;
const synchronizedHealer = synchronizedMembers.find((member) => member.id === healer.id)!;
const synchronizedTarget = synchronizedMembers.find((member) => member.id === configuredTarget.id)!;
const firstCastState = useCombatStore.getState();
expect(firstCastState.auras.some((aura) => aura.targetId === healer.id
&& aura.definition.utilityTags?.includes(ROM_PRIEST_HOLY_BLESSING_TAG))).toBe(false);
advanceDungeonPartyCombat(CAST_NOW);
const finishedState = useCombatStore.getState();
const { base, effective } = npcEffectiveStats(synchronizedHealer.id, CAST_NOW);
const recipientBase = deriveCharacterStats(
synchronizedTarget.classId, synchronizedTarget.level, synchronizedTarget.gearStats,
synchronizedTarget.raceId, {}, synchronizedTarget.secondaryClassId, synchronizedTarget.gender, "rom",
);
const recipientEffective = auraAdjustedCharacterStats(
recipientBase, finishedState.auras, synchronizedTarget.id, CAST_NOW + 5_000,
).romNative!;
assertBlessedCasterInputs(base, effective, finishedState.auras, synchronizedHealer.id);
expect(recipientEffective.healing.targetHealAbsorbRate).toBe(NATIVE_RECIPIENT_BONUS.healAbsorbRate);
expect(recipientEffective.healing.targetHealAbsorb).toBe(NATIVE_RECIPIENT_BONUS.healAbsorb);
const expectedRaw = expectedRecipientAdjustedAmount(abilityId, effective, recipientEffective,
finishedState.auras, synchronizedHealer.id, synchronizedTarget.id);
const healing = finishedState.combatEvents.find((event) => event.kind === "healing"
&& event.sourceActorId === synchronizedHealer.id
&& event.targetActorId === synchronizedTarget.id
&& event.abilityId === abilityId);
expect(healing, `${abilityId} companion cast did not resolve`).toBeDefined();
expect(healing?.rawAmount).toBe(Math.round(expectedRaw * (healing?.critical ? 1.5 : 1)));
expect(healing?.effectiveAmount).toBeGreaterThan(0);
expect(ability.id).toBe(abilityId);
});
it("snapshots a player Regenerate tick with native target absorb and does not apply it again per tick", () => {
const target = preparePartyTarget("rom-native-player-regenerate");
const { ability, effect } = regenerateAbility();
const { effective } = playerEffectiveStats(CAST_NOW);
const targetBase = deriveCharacterStats(
target.classId, target.level, target.gearStats, target.raceId, {}, target.secondaryClassId,
target.gender, target.statRuleset ?? "wow",
);
const targetEffective = auraAdjustedCharacterStats(
targetBase, useCombatStore.getState().auras, target.id, CAST_NOW,
).romNative!;
const snapshot = nativePeriodicHealSnapshot(effect, LEVEL, effective, targetEffective, target.level);
expect(snapshot).toBeDefined();
expect(snapshot?.tickAmount).toBeGreaterThan(0);
const started = useCombatStore.getState().castAbility(ability.id, {
targetActorId: target.id,
now: CAST_NOW,
});
expect(started.ok, JSON.stringify(started)).toBe(true);
if (useCombatStore.getState().activeCast?.abilityId === ability.id) {
useCombatStore.getState().tick(0.1, useCombatStore.getState().activeCast!.completesAt);
}
const applied = useCombatStore.getState().effects.find((candidate) => candidate.kind === "hot"
&& candidate.sourceAbilityId === ability.id && candidate.targetId === target.id);
expect(applied).toMatchObject({ fixedAmount: snapshot!.tickAmount, remainingTicks: snapshot!.dotTimes });
const auras = useCombatStore.getState().auras;
const expectedTickRaw = Math.round(resolveHealingValue(
resolveHealingValue(snapshot!.tickAmount, auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID), "done"),
auras.filter((aura) => aura.targetId === target.id), "taken",
));
useCombatStore.getState().tick(0.1, applied!.nextTickAt);
const tick = useCombatStore.getState().combatEvents.find((event) => event.kind === "healing"
&& event.sourceActorId === PLAYER_AGGRO_ID && event.targetActorId === target.id
&& event.abilityId === ability.id);
expect(tick?.rawAmount).toBe(expectedTickRaw);
expect(tick?.rawAmount).toBeLessThan(Math.round(snapshot!.tickAmount
* (1 + targetEffective.healing.targetHealAbsorbRate / 100) + targetEffective.healing.targetHealAbsorb));
});
it("records self Regenerate ticks with the same effective healing as the health ledger", () => {
const { ability } = regenerateAbility();
useCombatStore.setState({ health: 1 });
useCombatStore.getState().synchronizeActors();
const started = useCombatStore.getState().castAbility(ability.id, {
now: CAST_NOW,
});
expect(started.ok, JSON.stringify(started)).toBe(true);
const applied = useCombatStore.getState().effects.find((candidate) => candidate.kind === "hot"
&& candidate.sourceAbilityId === ability.id && !candidate.targetId);
expect(applied).toBeDefined();
const healthBefore = useCombatStore.getState().health;
useCombatStore.getState().tick(0, applied!.nextTickAt);
const finished = useCombatStore.getState();
const tick = finished.combatEvents.find((event) => event.kind === "healing"
&& event.sourceActorId === PLAYER_AGGRO_ID && event.targetActorId === PLAYER_AGGRO_ID
&& event.abilityId === ability.id);
expect(tick?.effectiveAmount).toBeGreaterThan(0);
expect(tick?.effectiveAmount).toBe(finished.health - healthBefore);
expect(tick!.effectiveAmount + tick!.overheal).toBe(tick!.rawAmount);
});
it("snapshots a companion Regenerate tick from the completed player-build cast", () => {
const targetBase = createConfiguredPartyMember({
...ROM_FIVE_IDENTITIES.find((candidate) => candidate.role === "tank")!,
gender: "male", specialization: "Primary", statRuleset: "rom",
}, LEVEL, 1, "rom-native-npc-regenerate-target");
const target = {
...targetBase,
gearStats: nativeGear(targetBase.gearStats, NATIVE_RECIPIENT_BONUS),
health: 1,
status: "ready" as const,
activeCast: null,
activeCastTargetId: null,
nextActionAt: 0,
};
const healerBase = createConfiguredPartyMember({
...ROM_FIVE_IDENTITIES.find((candidate) => candidate.classId === "rom-priest")!,
gender: "male", specialization: "Primary", statRuleset: "rom",
}, LEVEL, 0, "rom-native-npc-regenerate-healer");
const { ability } = regenerateAbility();
const healer = {
...healerBase,
gearStats: nativeGear(healerBase.gearStats, NATIVE_CASTER_BONUS),
status: "healing" as const,
activeCast: {
abilityId: ability.id,
school: "holy" as const,
mode: "cast" as const,
startedAt: CAST_NOW - 1_000,
completesAt: CAST_NOW,
originalDurationMs: 1_000,
pushbackHits: 0,
origin: [0, 0, 0] as const,
targetId: target.id,
},
activeCastTargetId: target.id,
nextActionAt: CAST_NOW,
};
usePartyStore.setState({ members: [healer, target] });
registerPartyRuntimePosition(healer.id, [0, 0, 0]);
registerPartyRuntimePosition(target.id, [2, 0, 0]);
useCombatStore.getState().synchronizeActors();
const [synchronizedHealer, synchronizedTarget] = usePartyStore.getState().members;
advanceDungeonPartyCombat(CAST_NOW);
const finished = useCombatStore.getState();
const casterBase = deriveCharacterStats(
synchronizedHealer!.classId, synchronizedHealer!.level, synchronizedHealer!.gearStats,
synchronizedHealer!.raceId, {}, synchronizedHealer!.secondaryClassId,
synchronizedHealer!.gender, "rom",
);
const effectiveCaster = auraAdjustedCharacterStats(
casterBase, finished.auras, synchronizedHealer!.id, CAST_NOW,
).romNative!;
const recipientBase = deriveCharacterStats(
synchronizedTarget!.classId, synchronizedTarget!.level, synchronizedTarget!.gearStats,
synchronizedTarget!.raceId, {}, synchronizedTarget!.secondaryClassId,
synchronizedTarget!.gender, "rom",
);
const effectiveRecipient = auraAdjustedCharacterStats(
recipientBase, finished.auras, synchronizedTarget!.id, CAST_NOW,
).romNative!;
const { effect } = regenerateAbility();
const snapshot = nativePeriodicHealSnapshot(
effect, LEVEL, effectiveCaster, effectiveRecipient, synchronizedTarget!.level,
);
expect(snapshot).toBeDefined();
const applied = finished.effects.find((candidate) => candidate.kind === "hot"
&& candidate.sourceAbilityId === ability.id && candidate.targetId === synchronizedTarget!.id);
expect(applied).toMatchObject({ fixedAmount: snapshot!.tickAmount, remainingTicks: snapshot!.dotTimes });
});
it("keeps the same legacy amount when source metadata is present under the WoW ruleset", () => {
const { ability, effect } = directHealAbility("rom-priest-primary-2");
const gear = {
...EMPTY_ITEM_STATS,
spellPower: 725,
romNative: NATIVE_CASTER_BONUS,
};
const { romNativeSource: _ignored, ...withoutDescriptor } = effect;
const withMetadata = equipmentAdjustedCombatAmount(
effect, LEVEL, gear, "rom-priest", ability, 0, undefined, "rom-human", "male", "wow", null,
);
const legacyEffect = equipmentAdjustedCombatAmount(
withoutDescriptor, LEVEL, gear, "rom-priest", ability, 0, undefined, "rom-human", "male", "wow", null,
);
expect(withMetadata).toBe(legacyEffect);
});
});
+266
View File
@@ -0,0 +1,266 @@
import { describe, expect, it } from "vitest";
import { deriveRomNativeStats } from "./romNativeStats";
import {
resolveRomNativeHolyHeal,
resolveRomNativePeriodicHolyHeal,
romNativeHolyHealSourceMetadata,
type RomNativeHolyHealCasterStats,
type RomNativeHolyHealSource,
type RomNativePeriodicHolyHealSource,
type RomNativeHolyHealTargetStats,
} from "./romNativeHealing";
function casterStats(overrides: {
readonly wisdom?: number;
readonly magicalDamage?: number;
readonly magicPowerRate?: number;
readonly healPowerRate?: number;
readonly healPoint?: number;
} = {}): RomNativeHolyHealCasterStats {
const base = deriveRomNativeStats({ classId: "rom-priest", level: 1 });
return {
attributes: { ...base.attributes, wisdom: overrides.wisdom ?? 100 },
magicalDamage: overrides.magicalDamage ?? 200,
magicPowerRate: overrides.magicPowerRate ?? 1.5,
healing: {
...base.healing,
healPowerRate: overrides.healPowerRate ?? 2,
healPoint: overrides.healPoint ?? 10,
holyHealMindCoefficient: 0.3,
holyHealMindCapMultiplier: 6,
holyHealMagicDamageCoefficient: 0.5,
},
};
}
function targetStats(overrides: {
readonly targetHealAbsorbRate?: number;
readonly targetHealAbsorb?: number;
} = {}): RomNativeHolyHealTargetStats {
const base = deriveRomNativeStats({ classId: "rom-priest", level: 1 });
return {
healing: {
...base.healing,
targetHealAbsorbRate: overrides.targetHealAbsorbRate ?? 50,
targetHealAbsorb: overrides.targetHealAbsorb ?? 4,
},
};
}
const source = (overrides: Partial<RomNativeHolyHealSource> = {}): RomNativeHolyHealSource => ({
componentId: 500395,
sourceSkillId: 490261,
damagePower: 100,
damagePowerSkillLevelArg: 20,
magicLevel: 3,
spellTimeSeconds: 2,
isIgnoreDmgEffect: false,
isFixDmgEffect: false,
ignoreMagicAbsorb: false,
randomRange: 2,
...overrides,
});
const periodicSource = (
overrides: Partial<RomNativePeriodicHolyHealSource> = {},
): RomNativePeriodicHolyHealSource => ({
componentId: 500469,
sourceSkillId: 490269,
magicType: 4,
effectType: 0,
isDotMagic: true,
isFixDotPower: false,
dotTimeSeconds: 2,
dotType: 0,
dotBase: 20,
dotSkillLevelArg: 18,
effectTimeSeconds: 20,
effectTimeSkillLevelArg: 0,
levelDeltaEffectTime: 0,
rawSpellTimeSeconds: 0,
isFixDmgEffect: false,
ignoreMagicAbsorb: false,
magicLevel: 3,
...overrides,
});
describe("RoM native HolyHeal amount resolver", () => {
it("applies source OrgPower, MND, MDMG, MPow, healing rates, target modifiers, and source variance once", () => {
const result = resolveRomNativeHolyHeal({
source: source(),
caster: casterStats(),
target: targetStats(),
randomUnit: 0,
});
expect(result.orgPower).toBe(160);
expect(result.spellTime).toBe(2);
expect(result.mindPower).toBe(60);
expect(result.purePower).toBe(1280);
expect(result.rolledPower).toBeCloseTo(1254.4);
expect(result.amount).toBeCloseTo(1885.6);
});
it("uses the MND cap branch and applies the fixed/ignored MDMG flags independently", () => {
const capped = resolveRomNativeHolyHeal({
source: source({ magicLevel: 0, spellTimeSeconds: 4 }),
caster: casterStats({ wisdom: 1000, magicalDamage: 0, healPowerRate: 1, magicPowerRate: 1, healPoint: 0 }),
target: targetStats({ targetHealAbsorbRate: 0, targetHealAbsorb: 0 }),
randomUnit: 0.5,
});
expect(capped.mindPower).toBe(1200);
expect(capped.purePower).toBe(600);
const fixedAndIgnored = resolveRomNativeHolyHeal({
source: source({ magicLevel: 0, spellTimeSeconds: 4, isFixDmgEffect: true, isIgnoreDmgEffect: true }),
caster: casterStats({ wisdom: 1000, magicalDamage: 400, healPowerRate: 1, magicPowerRate: 1, healPoint: 0 }),
target: targetStats({ targetHealAbsorbRate: 0, targetHealAbsorb: 0 }),
randomUnit: 0.5,
});
expect(fixedAndIgnored.spellTime).toBe(1);
expect(fixedAndIgnored.mindPower).toBe(300);
expect(fixedAndIgnored.purePower).toBe(400);
const fixedWithMdmg = resolveRomNativeHolyHeal({
source: source({ magicLevel: 0, spellTimeSeconds: 4, isFixDmgEffect: true }),
caster: casterStats({ wisdom: 0, magicalDamage: 400, healPowerRate: 1, magicPowerRate: 1, healPoint: 0 }),
target: targetStats({ targetHealAbsorbRate: 0, targetHealAbsorb: 0 }),
randomUnit: 0.5,
});
expect(fixedWithMdmg.purePower).toBe(300);
});
it("uses native absorb flags, returns undead damage sign, and suppresses it out of combat", () => {
const baseInput = {
source: source({ ignoreMagicAbsorb: true, magicLevel: 0, randomRange: 0 }),
caster: casterStats({ wisdom: 0, magicalDamage: 0, magicPowerRate: 1, healPowerRate: 1, healPoint: 0 }),
target: targetStats({ targetHealAbsorbRate: 200, targetHealAbsorb: 50 }),
randomUnit: 0.5,
} as const;
const targetPower = resolveRomNativeHolyHeal(baseInput);
expect(targetPower.amount).toBe(100);
expect(resolveRomNativeHolyHeal({ ...baseInput, targetIsUndead: true }).amount).toBe(-100);
expect(resolveRomNativeHolyHeal({ ...baseInput, targetIsUndead: true, targetIsInCombat: false }).amount).toBe(0);
const absorbApplied = resolveRomNativeHolyHeal({
...baseInput,
source: { ...baseInput.source, ignoreMagicAbsorb: false },
});
expect(absorbApplied.amount).toBe(350);
});
it("requires a source random sample and retains priest primary/general raw bindings", () => {
expect(() => resolveRomNativeHolyHeal({
source: source(),
caster: casterStats(),
target: targetStats(),
randomUnit: 1.1,
})).toThrow("randomUnit must be between 0 and 1");
const rows = romNativeHolyHealSourceMetadata.holyHealBindings;
const heal = rows.find((row) => row.abilityId === "rom-priest-primary-2");
const groupHeal = rows.find((row) => row.abilityId === "rom-priest-primary-8");
const urgentHeal = rows.find((row) => row.abilityId === "rom-priest-general-0");
expect(heal?.ranks[0]).toMatchObject({ sourceSkillId: 490261, spellTimeSeconds: 2, isFixDmgEffect: false });
expect(heal?.ranks[0]?.componentIds[0]).toMatchObject({ componentId: 500395, calculationType: 5, damagePower: 90, damagePowerSkillLevelArg: 30 });
expect(groupHeal?.ranks[0]).toMatchObject({ sourceSkillId: 490281, spellTimeSeconds: 3 });
expect(urgentHeal?.ranks[0]).toMatchObject({ sourceSkillId: 491147, spellTimeSeconds: 1 });
expect(romNativeHolyHealSourceMetadata.formula.magicLevel).toContain("required runtime input");
expect(romNativeHolyHealSourceMetadata.formula.classification).toContain("no Chronicles of Arcadia values");
});
it("resolves the distinct Regenerate DotMagic rank snapshot and integer tick", () => {
const rows = romNativeHolyHealSourceMetadata.periodicHealBindings;
const regenerate = rows.find((row) => row.abilityId === "rom-priest-general-2");
expect(regenerate?.ranks[0]).toMatchObject({ sourceSkillId: 490269, spellTimeSeconds: 0 });
expect(regenerate?.ranks[0]?.componentIds).toEqual(expect.arrayContaining([
expect.objectContaining({ componentId: 500469, isDotMagic: true, dotType: 0, dotBase: 20, dotSkillLevelArg: 18 }),
expect.objectContaining({ componentId: 621548, isDotMagic: true, dotSkillLevelArg: 25 }),
]));
const base = deriveRomNativeStats({ classId: "rom-priest", level: 1 });
const result = resolveRomNativePeriodicHolyHeal({
source: periodicSource(),
caster: {
attributes: { ...base.attributes, wisdom: 100 },
magicalDamage: 200,
effectiveMagicPower: 1.5,
healing: { healPowerRate: 2, healPoint: 10 },
},
target: targetStats(),
sourceIsPlayer: true,
casterLevel: 30,
targetLevel: 30,
});
expect(result).toMatchObject({
componentId: 500469,
sourceSkillId: 490269,
spellTime: 1,
effectTime: 20,
dotTimes: 10,
dotBasePower: 30.8,
magicLevelAdjustment: 50,
buffPower: 53,
tickAmount: 210,
});
expect(result?.powerAtApplication).toBeCloseTo(213.57);
});
it("uses the player-build ExRate policy, snapshots absorb only at application, and preserves fixed DotPower", () => {
const base = deriveRomNativeStats({ classId: "rom-priest", level: 1 });
const common = {
source: periodicSource(),
caster: {
attributes: { ...base.attributes, wisdom: 100 },
magicalDamage: 200,
effectiveMagicPower: 1.5,
healing: { healPowerRate: 2, healPoint: 10 },
},
target: targetStats({ targetHealAbsorbRate: 0, targetHealAbsorb: 0 }),
casterLevel: 30,
targetLevel: 30,
} as const;
const playerBuild = resolveRomNativePeriodicHolyHeal({ ...common, sourceIsPlayer: true });
const sourceNpc = resolveRomNativePeriodicHolyHeal({ ...common, sourceIsPlayer: false });
expect(playerBuild?.tickAmount).toBe(138);
expect(sourceNpc?.tickAmount).toBe(160);
const ignoredAbsorb = resolveRomNativePeriodicHolyHeal({
...common,
sourceIsPlayer: true,
target: targetStats({ targetHealAbsorbRate: 200, targetHealAbsorb: 50 }),
source: periodicSource({ ignoreMagicAbsorb: true }),
});
expect(ignoredAbsorb?.tickAmount).toBe(playerBuild?.tickAmount);
const fixed = resolveRomNativePeriodicHolyHeal({
...common,
sourceIsPlayer: true,
source: periodicSource({ isFixDotPower: true }),
});
expect(fixed).toMatchObject({ magicLevelAdjustment: 0, buffPower: 3, tickAmount: 30 });
});
it("refuses unsupported DotMagic types instead of treating them as HolyHeal", () => {
const base = deriveRomNativeStats({ classId: "rom-priest", level: 1 });
const input = {
source: periodicSource({ dotType: 5 }),
caster: {
attributes: base.attributes,
magicalDamage: 0,
effectiveMagicPower: 1,
healing: { healPowerRate: 1, healPoint: 0 },
},
target: targetStats(),
sourceIsPlayer: true,
casterLevel: 30,
targetLevel: 30,
} as const;
expect(resolveRomNativePeriodicHolyHeal(input)).toBeUndefined();
expect(resolveRomNativePeriodicHolyHeal({
...input,
source: periodicSource({ isDotMagic: false }),
})).toBeUndefined();
});
});
+291
View File
@@ -0,0 +1,291 @@
import sourceMetadata from "./generated/romNativeHolyHealBindings.generated.json";
import type { RomNativeDerivedStats } from "./romNativeStatsTypes";
/** Source fields from the MagicObject attack payload and its MagicCollect row. */
export interface RomNativeHolyHealSource {
readonly componentId: number;
readonly sourceSkillId: number;
/** MagicBase.Attack.DmgPower, before its MagicLv argument is applied. */
readonly damagePower: number;
readonly damagePowerSkillLevelArg: number;
/** MagicProc.MagicLv. This is supplied by the native caller; no rank is guessed. */
readonly magicLevel: number;
/** Raw MagicCollect SpellTime in seconds. MagicProcess applies the <1/fixed clamp. */
readonly spellTimeSeconds: number;
readonly isIgnoreDmgEffect: boolean;
readonly isFixDmgEffect: boolean;
/** MagicBase.SettingEx.IgnoreMagicAbsorb from the source component. */
readonly ignoreMagicAbsorb: boolean;
/** MagicBase.Attack.Rand; use a randomUnit sampled by the combat caller. */
readonly randomRange: number;
}
export type RomNativeHolyHealCasterStats = Pick<RomNativeDerivedStats, "attributes" | "magicalDamage" | "magicPowerRate">
& { readonly healing: Pick<RomNativeDerivedStats["healing"],
| "healPowerRate"
| "healPoint"
| "holyHealMindCoefficient"
| "holyHealMindCapMultiplier"
| "holyHealMagicDamageCoefficient"
> };
export type RomNativeHolyHealTargetStats = Pick<RomNativeDerivedStats, "healing">;
export interface RomNativeHolyHealInput {
readonly source: RomNativeHolyHealSource;
readonly caster: RomNativeHolyHealCasterStats;
readonly target: RomNativeHolyHealTargetStats;
readonly randomUnit: number;
readonly targetIsUndead?: boolean;
readonly targetIsInCombat?: boolean;
}
export interface RomNativeHolyHealResult {
/** Source OrgPower after the runtime MagicLv argument. */
readonly orgPower: number;
/** MagicProcess's local formula SpellTime, after its source clamp. */
readonly spellTime: number;
readonly mindPower: number;
/** PurePower after caster MPow, HealPowerRate, and HealPoint. */
readonly purePower: number;
readonly rolledPower: number;
/** Signed output: negative for undead damage; zero for out-of-combat undead. */
readonly amount: number;
}
/** Raw positive magical HP DotMagic fields and the MagicCollect cast inputs. */
export interface RomNativePeriodicHolyHealSource {
readonly componentId: number;
readonly sourceSkillId: number;
readonly magicType: number;
readonly effectType: number;
readonly isDotMagic: boolean;
readonly isFixDotPower: boolean;
readonly dotTimeSeconds: number;
readonly dotType: number;
readonly dotBase: number;
readonly dotSkillLevelArg: number;
readonly effectTimeSeconds: number;
readonly effectTimeSkillLevelArg: number;
readonly levelDeltaEffectTime: number;
readonly rawSpellTimeSeconds: number;
readonly isFixDmgEffect: boolean;
readonly ignoreMagicAbsorb: boolean;
/** MagicProc.MagicLv at buff application; this is not guessed from rank. */
readonly magicLevel: number;
}
export type RomNativePeriodicHolyHealCasterStats = Pick<RomNativeDerivedStats, "attributes" | "magicalDamage">
& { readonly effectiveMagicPower: number }
& { readonly healing: Pick<RomNativeDerivedStats["healing"], "healPowerRate" | "healPoint"> };
export type RomNativePeriodicHolyHealTargetStats = Pick<RomNativeDerivedStats, "healing">;
export interface RomNativePeriodicHolyHealInput {
readonly source: RomNativePeriodicHolyHealSource;
readonly caster: RomNativePeriodicHolyHealCasterStats;
readonly target: RomNativePeriodicHolyHealTargetStats;
/** Simulation policy: active player-build companions use the player ExRate branch. */
readonly sourceIsPlayer: boolean;
readonly casterLevel: number;
readonly targetLevel: number;
}
export interface RomNativePeriodicHolyHealResult {
readonly componentId: number;
readonly sourceSkillId: number;
readonly spellTime: number;
readonly effectTime: number;
readonly dotTimes: number;
readonly dotBasePower: number;
readonly powerAtApplication: number;
readonly magicLevelAdjustment: number;
/** Freeze this on application; source ticks reuse Buff.Power. */
readonly buffPower: number;
/** The final integer HP delta emitted by BuffProc for one tick. */
readonly tickAmount: number;
}
interface NativeHealingFormulaMetadata {
readonly formula: {
readonly fixHealingDotMagicDamageCoefficient: number;
};
}
const healingFormulaMetadata = sourceMetadata as unknown as NativeHealingFormulaMetadata;
const HEALING_DOT_MDMG_COEFFICIENT = healingFormulaMetadata.formula.fixHealingDotMagicDamageCoefficient;
const finite = (value: number): boolean => Number.isFinite(value);
function assertFinite(label: string, value: number): void {
if (!finite(value)) throw new RangeError(`${label} must be a finite number.`);
}
/**
* Resolves the Runewaker MagicProcess HolyHeal branch from its raw source row
* and already-effective native caster/target stats. Use the returned `amount`
* directly as the native health delta; do not apply the generic healing
* coefficient, healing-done, or healing-taken modifiers again. Direct casts
* and channel pulses use this branch when their MagicBase invokes AtkCalType
* HolyHeal (5). DotMagic healing has the separate buff-rank and tick resolver
* below and must not be routed through this formula.
*
* The caller supplies the effective MagicLv and one uniform random sample.
* This helper never substitutes character level or a Chronicles of Arcadia
* rank formula for the source's `MagicProc.MagicLv` input.
*/
export function resolveRomNativeHolyHeal(input: RomNativeHolyHealInput): RomNativeHolyHealResult {
const { source, caster, target } = input;
const sourceValues = [
source.damagePower,
source.damagePowerSkillLevelArg,
source.magicLevel,
source.spellTimeSeconds,
source.randomRange,
input.randomUnit,
caster.attributes.wisdom,
caster.magicalDamage,
caster.magicPowerRate,
caster.healing.healPowerRate,
caster.healing.healPoint,
caster.healing.holyHealMindCoefficient,
caster.healing.holyHealMindCapMultiplier,
caster.healing.holyHealMagicDamageCoefficient,
target.healing.targetHealAbsorbRate,
target.healing.targetHealAbsorb,
];
sourceValues.forEach((value, index) => assertFinite(`HolyHeal input ${index + 1}`, value));
if (input.randomUnit < 0 || input.randomUnit > 1) {
throw new RangeError("HolyHeal randomUnit must be between 0 and 1.");
}
const orgPower = source.damagePower
+ source.damagePower * source.damagePowerSkillLevelArg * source.magicLevel / 100;
const spellTime = source.spellTimeSeconds < 1 || source.isFixDmgEffect
? 1
: source.spellTimeSeconds;
const mindPower = caster.attributes.wisdom * caster.healing.holyHealMindCoefficient * spellTime;
let purePower = orgPower * 5 < mindPower
? orgPower * caster.healing.holyHealMindCapMultiplier
: orgPower + mindPower;
if (!source.isIgnoreDmgEffect) {
purePower += caster.magicalDamage
* caster.healing.holyHealMagicDamageCoefficient
* (source.isFixDmgEffect ? 1 : spellTime);
}
purePower = purePower * caster.magicPowerRate * caster.healing.healPowerRate
+ caster.healing.healPoint * spellTime;
if (purePower <= 0) {
return Object.freeze({ orgPower, spellTime, mindPower, purePower, rolledPower: 0, amount: 0 });
}
const randomVariationPercent = source.randomRange * (1 - 2 * input.randomUnit);
const rolledPower = purePower * (1 - randomVariationPercent / 100);
let targetPower = rolledPower;
if (!source.ignoreMagicAbsorb) {
targetPower = targetPower * (1 + target.healing.targetHealAbsorbRate / 100)
+ target.healing.targetHealAbsorb;
}
if (targetPower < 0) targetPower = 0;
const amount = input.targetIsUndead
? input.targetIsInCombat === false ? 0 : -targetPower
: targetPower;
return Object.freeze({ orgPower, spellTime, mindPower, purePower, rolledPower, amount });
}
/**
* Resolves the separate Runewaker DotMagic HP-heal rank snapshot and tick.
* This is not the HolyHeal branch: CalAddDotMagicLv bakes caster/recipient
* channels into the buff's Power at application, then BuffProc emits integer
* Base/Power ticks without reapplying HealAbsorb each tick.
*
* `effectiveMagicPower` is the source `Attr.MagicPow(MagicType)` value. The
* current sidecar retains the all-magic neutral MPow channel but does not
* model element-specific BodyRate channels, so callers must provide the
* effective native value rather than imply those rates are universally 1.
*/
export function resolveRomNativePeriodicHolyHeal(
input: RomNativePeriodicHolyHealInput,
): RomNativePeriodicHolyHealResult | undefined {
const { source, caster, target } = input;
if (!source.isDotMagic || source.effectType !== 0 || source.dotType !== 0
|| source.dotTimeSeconds <= 0 || source.dotBase <= 0) return undefined;
const values = [
source.magicType,
source.dotTimeSeconds,
source.dotBase,
source.dotSkillLevelArg,
source.effectTimeSeconds,
source.effectTimeSkillLevelArg,
source.levelDeltaEffectTime,
source.rawSpellTimeSeconds,
source.magicLevel,
input.casterLevel,
input.targetLevel,
caster.attributes.wisdom,
caster.magicalDamage,
caster.effectiveMagicPower,
caster.healing.healPowerRate,
caster.healing.healPoint,
target.healing.targetHealAbsorbRate,
target.healing.targetHealAbsorb,
HEALING_DOT_MDMG_COEFFICIENT,
];
values.forEach((value, index) => assertFinite(`Periodic HolyHeal input ${index + 1}`, value));
if (!Number.isInteger(source.magicLevel)) throw new RangeError("Periodic HolyHeal magicLevel must be an integer.");
const effectTime = Math.trunc(source.effectTimeSeconds) < 0
? 100_000_000
: Math.trunc(source.effectTimeSeconds * (1 + source.effectTimeSkillLevelArg * source.magicLevel / 100)
- (input.casterLevel - input.targetLevel) * source.levelDeltaEffectTime);
const dotTimes = Math.max(1, Math.trunc(effectTime / source.dotTimeSeconds));
const spellTime = Math.trunc(source.rawSpellTimeSeconds < 1 || source.isFixDmgEffect
? 1
: source.rawSpellTimeSeconds);
const dotBasePower = source.dotBase * (1 + source.magicLevel * source.dotSkillLevelArg / 100);
let powerForRankCorrection = 0;
if (!source.isFixDotPower && source.dotSkillLevelArg !== 0) {
const exRate = input.sourceIsPlayer ? 0.6 + Math.abs(dotBasePower) / 600 : 1;
let power = dotBasePower + exRate * caster.magicalDamage * spellTime * HEALING_DOT_MDMG_COEFFICIENT / dotTimes;
const mindPower = caster.attributes.wisdom * 0.3 * spellTime / dotTimes;
if (power * 5 < mindPower) power *= 6;
else power += mindPower;
power = power * caster.healing.healPowerRate + caster.healing.healPoint * spellTime / dotTimes;
if (!source.ignoreMagicAbsorb) {
power = power * (1 + target.healing.targetHealAbsorbRate / 100)
+ target.healing.targetHealAbsorb / dotTimes;
}
if (power < 0) power = 0;
powerForRankCorrection = power * caster.effectiveMagicPower;
}
const dotSkillLevelDamage = source.dotBase * source.dotSkillLevelArg / 100;
const magicLevelAdjustment = source.isFixDotPower || source.dotSkillLevelArg === 0 || dotSkillLevelDamage === 0
? 0
: Math.trunc((powerForRankCorrection - source.dotBase) / dotSkillLevelDamage - source.magicLevel);
const buffPower = magicLevelAdjustment + source.magicLevel;
const tickDamage = Math.trunc(source.dotBase * -1 * (100 + source.dotSkillLevelArg * buffPower) / 100);
const tickAmount = -tickDamage;
return Object.freeze({
componentId: source.componentId,
sourceSkillId: source.sourceSkillId,
spellTime,
effectTime,
dotTimes,
dotBasePower,
powerAtApplication: powerForRankCorrection,
magicLevelAdjustment,
buffPower,
tickAmount,
});
}
/** Raw, version-hashed RuneWaker attack bindings used by the focused resolver and integrations. */
export const romNativeHolyHealSourceMetadata = sourceMetadata;
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import {
resolveRomNativeNpcEquipment,
romNativeNpcEquipmentRecord,
} from "./romNativeNpcEquipment";
describe("native RoM NPC source equipment", () => {
it("preserves the five weighted Barren Caves choices and resolves their exact item rows", () => {
const sources = [101_459, 101_460, 101_461].map((npcId) => romNativeNpcEquipmentRecord(npcId));
expect(sources.every(Boolean)).toBe(true);
expect(sources[0]?.mainHandCandidates.map((candidate) => candidate.sourceItemId)).toEqual([
210_056, 210_056, 210_056, 210_056, 210_056,
]);
expect(sources[1]?.mainHandCandidates.map((candidate) => candidate.sourceItemId)).toEqual([
210_061, 210_061, 210_061, 210_061, 210_061,
]);
expect(sources[2]?.offHandCandidates.map((candidate) => candidate.sourceItemId)).toEqual([
210_666, 210_666, 210_666, 210_666, 210_666,
]);
for (const source of sources) {
expect(source?.mainHandCandidates.map((candidate) => candidate.selectionWeightPercent)).toEqual([20, 20, 20, 20, 20]);
expect(source?.offHandCandidates.map((candidate) => candidate.selectionWeightPercent)).toEqual([20, 20, 20, 20, 20]);
}
const caveGuard = resolveRomNativeNpcEquipment(101_459, { selectionSeed: "actor-cave-1" });
const caveArcher = resolveRomNativeNpcEquipment(101_460, { selectionSeed: "actor-cave-1" });
const caveWarden = resolveRomNativeNpcEquipment(101_461, { selectionSeed: "actor-cave-1" });
expect(caveGuard.status).toBe("supported");
expect(caveArcher.status).toBe("supported");
expect(caveWarden.status).toBe("supported");
if (caveGuard.status !== "supported" || caveArcher.status !== "supported" || caveWarden.status !== "supported") return;
expect(caveGuard.value.mainHand).toMatchObject({
sourceItemId: 210_056,
sourceCatalog: "weaponobject.db",
durable: 70,
refineLevel: 0,
attackRange: 20,
attackSpeed: 20,
eqTypeValuePairs: Array.from({ length: 10 }, (_, slot) => ({ slot, eqType: 0, value: 0 })),
dropAbilityIdRatePairs: Array.from({ length: 6 }, (_, slot) => ({ slot, attributeId: 0, rate: 0 })),
});
expect(caveArcher.value.mainHand).toMatchObject({ sourceItemId: 210_061, durable: 55, attackSpeed: 20 });
expect(caveWarden.value.offHand).toMatchObject({ sourceItemId: 210_666, durable: 1426, gearPosition: 2, attackSpeed: 20 });
expect(caveGuard.value.wornItems[0]).toMatchObject({ fieldQuality: 100, fieldDurability: 10_000, fieldPowerQuality: 10, durabilityPowerRate: 1.2, wearPower: 1.2 });
expect(caveWarden.value.wornItems[0]).toMatchObject({ slot: "offHand", durabilityPowerRate: 0.2, wearPower: 0.2 });
});
it("selects deterministically from source weights without averaging or discarding candidate slots", () => {
const source = romNativeNpcEquipmentRecord(100_052);
expect(source?.mainHandCandidates.map((candidate) => candidate.sourceItemId)).toEqual([
210_059, 0, 210_059, 0, 210_059,
]);
expect(source?.mainHandCandidates.map((candidate) => candidate.selectionWeightPercent)).toEqual([20, 20, 20, 20, 20]);
const first = resolveRomNativeNpcEquipment(100_052, { selectionSeed: "same-actor" });
const repeated = resolveRomNativeNpcEquipment(100_052, { selectionSeed: "same-actor" });
expect(first.status).toBe("supported");
expect(repeated.status).toBe("supported");
if (first.status !== "supported" || repeated.status !== "supported") return;
expect(first.value.selectedCandidateIndices).toEqual(repeated.value.selectedCandidateIndices);
expect(first.value.selectedCandidateIndices.mainHand).toBeGreaterThanOrEqual(0);
expect(first.value.selectedCandidateIndices.mainHand).toBeLessThan(5);
const selected = resolveRomNativeNpcEquipment(100_052, {
mainHandCandidateIndex: 0,
offHandCandidateIndex: 1,
selectionSeed: "ignored-where-index-is-explicit",
});
expect(selected.status).toBe("supported");
if (selected.status !== "supported") return;
expect(selected.value.mainHand?.sourceItemId).toBe(210_059);
expect(selected.value.offHand).toBeNull();
expect(selected.value.selectedCandidateIndices).toEqual({ mainHand: 0, offHand: 1 });
});
it("resolves fixed BowEQ rows as source metadata while retaining exact ability pairs", () => {
const source = romNativeNpcEquipmentRecord(102_425);
expect(source?.bowEquipmentId).toBe(212_011);
const result = resolveRomNativeNpcEquipment(102_425, {
mainHandCandidateIndex: 0,
offHandCandidateIndex: 0,
});
expect(result.status).toBe("supported");
if (result.status !== "supported") return;
expect(result.value.bow).toMatchObject({
sourceItemId: 212_011,
sourceCatalog: "weaponobject.db",
eqTypeValuePairs: expect.arrayContaining([
{ slot: 0, eqType: 25, value: 19 },
{ slot: 1, eqType: 191, value: 7 },
]),
});
});
});
+266
View File
@@ -0,0 +1,266 @@
import catalogJson from "./generated/romNativeNpcEquipment.generated.json";
export interface RomNativeNpcEquipmentItem {
readonly sourceItemId: number;
readonly sourceCatalog: "armorobject.db" | "weaponobject.db";
readonly sourceRecordIndex: number;
readonly objectKind: "equipment-item";
readonly objectType: number;
readonly quality: number;
readonly itemLevel: number;
readonly gearType: number;
readonly gearPosition: number;
readonly eqTypeValuePairs: readonly { readonly slot: number; readonly eqType: number; readonly value: number }[];
/** GameObjectItemStruct::DropItemAbility rows; these are drop metadata, not equipment affixes. */
readonly dropAbilityIdRatePairs: readonly { readonly slot: number; readonly attributeId: number; readonly rate: number }[];
readonly durable: number;
readonly refineTableId: number;
readonly attackRange: number | null;
readonly attackSpeed: number | null;
readonly refineLevel: number;
readonly addPowerQuality: number;
readonly collectionNpcId: number;
}
export interface RomNativeNpcEquipmentCandidate {
readonly candidateIndex: number;
readonly sourceItemId: number;
readonly selectionWeightPercent: number;
}
export interface RomNativeNpcEquipmentRecord {
readonly npcId: number;
readonly sourceRecordIndex: number;
readonly mainHandCandidates: readonly RomNativeNpcEquipmentCandidate[];
readonly offHandCandidates: readonly RomNativeNpcEquipmentCandidate[];
readonly bowEquipmentId: number;
readonly ornamentEquipmentId: number;
}
export interface RomNativeNpcEquipmentSelection {
readonly mainHandCandidateIndex?: number;
readonly offHandCandidateIndex?: number;
/** Stable seed for the simulator's weighted candidate choice; defaults to npcId. */
readonly selectionSeed?: string | number;
}
export interface RomNativeNpcRefineAbility {
readonly refineObjectId: number;
readonly sourceRecordIndex: number;
readonly refineType: number;
readonly refineLevel: number;
readonly eqTypeValuePairs: readonly { readonly slot: number; readonly eqType: number; readonly value: number }[];
readonly exEqPowerRate: number;
}
export interface RomNativeNpcWornItem {
readonly slot: "mainHand" | "offHand" | "bow" | "ornament";
readonly item: RomNativeNpcEquipmentItem;
readonly fieldQuality: number;
readonly fieldDurability: number;
readonly fieldPowerQuality: number;
readonly fieldRare: number;
readonly effectiveRefineLevel: number;
readonly durabilityPowerRate: number;
readonly wearPower: number;
readonly refineAbilities: readonly RomNativeNpcRefineAbility[];
readonly inherentAttributeIds: readonly number[];
readonly runeIds: readonly number[];
}
export interface RomNativeResolvedNpcEquipment {
readonly mainHand: RomNativeNpcEquipmentItem | null;
readonly offHand: RomNativeNpcEquipmentItem | null;
readonly bow: RomNativeNpcEquipmentItem | null;
readonly ornament: RomNativeNpcEquipmentItem | null;
readonly wornItems: readonly RomNativeNpcWornItem[];
readonly selectedCandidateIndices: {
readonly mainHand: number | null;
readonly offHand: number | null;
};
}
export type RomNativeNpcEquipmentResult =
| { readonly status: "supported"; readonly value: RomNativeResolvedNpcEquipment }
| { readonly status: "unsupported"; readonly reason: string };
interface GeneratedCatalog {
readonly npcs: readonly RomNativeNpcEquipmentRecord[];
readonly itemsById: Readonly<Record<string, RomNativeNpcEquipmentItem | null>>;
readonly refineItemsById: Readonly<Record<string, RomNativeNpcRefineAbility | null>>;
readonly source: {
readonly npcWearDefaults: {
readonly fieldDurable: number;
readonly fieldQuality: number;
readonly fieldPowerQuality: number;
readonly fieldRare: number;
readonly inherentIds: readonly number[];
readonly runeIds: readonly number[];
};
};
}
const catalog = catalogJson as unknown as GeneratedCatalog;
const npcById = new Map(catalog.npcs.map((npc) => [npc.npcId, npc]));
export function romNativeNpcEquipmentRecord(npcId: number): RomNativeNpcEquipmentRecord | undefined {
return npcById.get(npcId);
}
/** All exported source NPC equipment rows, in generated source order. */
export function romNativeNpcEquipmentRecords(): readonly RomNativeNpcEquipmentRecord[] {
return catalog.npcs;
}
function deterministicBucket(seed: string | number, npcId: number, slot: "mainHand" | "offHand"): number {
const key = `rom-native-npc-equipment/v1:${npcId}:${slot}:${String(seed)}`;
let hash = 0x811c9dc5;
for (let index = 0; index < key.length; index += 1) {
hash ^= key.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return Math.floor((hash >>> 0) * 100 / 0x1_0000_0000);
}
function deterministicCandidate(
npcId: number,
slot: "mainHand" | "offHand",
candidates: readonly RomNativeNpcEquipmentCandidate[],
seed: string | number,
): RomNativeNpcEquipmentCandidate | undefined {
const weights = candidates.map((candidate) => candidate.selectionWeightPercent);
if (weights.some((weight) => !Number.isFinite(weight) || weight < 0)
|| Math.abs(weights.reduce((sum, weight) => sum + weight, 0) - 100) > 0.00001) return undefined;
const bucket = deterministicBucket(seed, npcId, slot);
let cumulative = 0;
for (const candidate of candidates) {
cumulative += candidate.selectionWeightPercent;
if (bucket < cumulative) return candidate;
}
return undefined;
}
function resolveCandidate(
npcId: number,
slot: "mainHand" | "offHand",
candidates: readonly RomNativeNpcEquipmentCandidate[],
requestedIndex: number | undefined,
seed: string | number,
): { readonly status: "supported"; readonly item: RomNativeNpcEquipmentItem | null; readonly index: number }
| { readonly status: "unsupported"; readonly reason: string } {
if (!candidates.length) return { status: "unsupported", reason: `missing-${slot}-candidates` };
let candidate: RomNativeNpcEquipmentCandidate | undefined;
if (requestedIndex !== undefined) {
if (!Number.isInteger(requestedIndex) || requestedIndex < 0) {
return { status: "unsupported", reason: `invalid-${slot}-candidate-index` };
}
candidate = candidates.find((entry) => entry.candidateIndex === requestedIndex);
if (!candidate) return { status: "unsupported", reason: `missing-${slot}-candidate` };
} else {
candidate = deterministicCandidate(npcId, slot, candidates, seed);
if (!candidate) return { status: "unsupported", reason: `invalid-${slot}-candidate-weights` };
}
if (candidate.sourceItemId === 0) return { status: "supported", item: null, index: candidate.candidateIndex };
const item = catalog.itemsById[String(candidate.sourceItemId)];
if (!item) return { status: "unsupported", reason: `missing-${slot}-source-item-${candidate.sourceItemId}` };
return { status: "supported", item, index: candidate.candidateIndex };
}
function resolveFixedItem(
slot: "bow" | "ornament",
itemId: number,
): { readonly status: "supported"; readonly item: RomNativeNpcEquipmentItem | null }
| { readonly status: "unsupported"; readonly reason: string } {
if (itemId === 0) return { status: "supported", item: null };
const item = catalog.itemsById[String(itemId)];
if (!item) return { status: "unsupported", reason: `missing-${slot}-source-item-${itemId}` };
return { status: "supported", item };
}
function sourceDurabilityPowerRate(item: RomNativeNpcEquipmentItem, fieldQuality: number, fieldDurability: number): number {
const orgDurability = Math.trunc(item.durable * fieldQuality / 100);
const fieldDurabilityHundreds = Math.trunc(fieldDurability / 100);
if (fieldDurabilityHundreds > 100 || fieldDurabilityHundreds > orgDurability) return 1.2;
if (fieldDurabilityHundreds > Math.trunc(orgDurability / 2)) return 1;
if (fieldDurabilityHundreds > Math.trunc(orgDurability / 5)) return 0.8;
return 0.2;
}
function wornItem(
slot: RomNativeNpcWornItem["slot"],
item: RomNativeNpcEquipmentItem,
): RomNativeNpcWornItem | { readonly reason: string } {
const defaults = catalog.source.npcWearDefaults;
const refineLevel = Math.min(item.refineLevel, 20);
const refineAbilities: RomNativeNpcRefineAbility[] = [];
if (refineLevel > 0) {
if (!Number.isSafeInteger(item.refineTableId) || item.refineTableId <= 0) {
return { reason: `missing-${slot}-refine-table-id-${item.sourceItemId}` };
}
for (let level = 1; level <= refineLevel; level += 1) {
const refineId = item.refineTableId + level - 1;
const refine = catalog.refineItemsById[String(refineId)];
if (!refine) return { reason: `missing-${slot}-refine-ability-${refineId}` };
refineAbilities.push(refine);
}
}
const durabilityPowerRate = sourceDurabilityPowerRate(item, defaults.fieldQuality, defaults.fieldDurable);
return Object.freeze({
slot,
item,
fieldQuality: defaults.fieldQuality,
fieldDurability: defaults.fieldDurable,
fieldPowerQuality: defaults.fieldPowerQuality,
fieldRare: defaults.fieldRare,
effectiveRefineLevel: refineLevel,
durabilityPowerRate,
wearPower: defaults.fieldPowerQuality / 10 * durabilityPowerRate,
refineAbilities: Object.freeze(refineAbilities),
inherentAttributeIds: defaults.inherentIds,
runeIds: defaults.runeIds,
});
}
/**
* Resolves one stable weighted NPC equipment outcome. The source's five
* candidate slots and 20-percent weights stay intact; a caller seed selects
* a reproducible outcome without averaging candidate stats.
*/
export function resolveRomNativeNpcEquipment(
npcId: number,
selection: RomNativeNpcEquipmentSelection = {},
): RomNativeNpcEquipmentResult {
const npc = npcById.get(npcId);
if (!npc) return { status: "unsupported", reason: "missing-source-npc" };
const seed = selection.selectionSeed ?? npcId;
const mainHand = resolveCandidate(npcId, "mainHand", npc.mainHandCandidates, selection.mainHandCandidateIndex, seed);
if (mainHand.status !== "supported") return mainHand;
const offHand = resolveCandidate(npcId, "offHand", npc.offHandCandidates, selection.offHandCandidateIndex, seed);
if (offHand.status !== "supported") return offHand;
const bow = resolveFixedItem("bow", npc.bowEquipmentId);
if (bow.status !== "supported") return bow;
const ornament = resolveFixedItem("ornament", npc.ornamentEquipmentId);
if (ornament.status !== "supported") return ornament;
const items = [
["mainHand", mainHand.item], ["offHand", offHand.item], ["bow", bow.item], ["ornament", ornament.item],
] as const;
const wornItems: RomNativeNpcWornItem[] = [];
for (const [slot, item] of items) {
if (!item) continue;
const worn = wornItem(slot, item);
if ("reason" in worn) return { status: "unsupported", reason: worn.reason };
wornItems.push(worn);
}
return {
status: "supported",
value: {
mainHand: mainHand.item,
offHand: offHand.item,
bow: bow.item,
ornament: ornament.item,
wornItems: Object.freeze(wornItems),
selectedCandidateIndices: { mainHand: mainHand.index, offHand: offHand.index },
},
};
}
@@ -0,0 +1,357 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilityAtLevel, abilityById, type AbilityEffect } from "./abilityCatalog";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { resetPartyRuntimeClock } from "./partyRuntime";
import { createConfiguredPartyMember, type PartyMember, usePartyStore } from "./partyStore";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
import { nativePeriodicDamageTick, romNativePeriodicDamageBinding } from "./romNativePeriodicDamage";
import { romNativeNpcCombatant } from "./romNativeCombatResolution";
import type { EnemyAttackDefinition } from "./enemyAttacks";
const LEVEL = 80;
const NOW = 1_000;
const SOURCE_ABILITY_ID = "rom-scout-general-2";
const SOURCE_SKILL_ID = 491292;
const SOURCE_COMPONENT_ID = 501690;
const NPC_ID = 102041;
const NPC_DOT_SPELL_ID = 493231;
const NPC_DOT_COMPONENT_ID = 503485;
function vampireArrows() {
const catalog = abilityById(SOURCE_ABILITY_ID);
if (!catalog) throw new Error("Missing active-five Vampire Arrows catalog row.");
const ability = abilityAtLevel(catalog, LEVEL);
const effect = ability.effects.find((candidate): candidate is Extract<AbilityEffect, { kind: "dot" }> => (
candidate.kind === "dot"
&& candidate.romNativeSource?.kind === "dot"
&& candidate.romNativeSource.skillId === SOURCE_SKILL_ID
&& candidate.romNativeSource.componentId === SOURCE_COMPONENT_ID
));
if (!effect) throw new Error("Vampire Arrows is missing its exact native DotMagic component binding.");
return { ability, effect };
}
function setupScoutPlayer() {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
usePartyStore.setState({ members: [] });
useCombatStore.getState().initializeCharacter({
classId: "rom-scout",
secondaryClassId: "rom-rogue",
raceId: "rom-human",
role: "damage",
level: LEVEL,
statRuleset: "rom",
});
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
}
function registerNativeTarget(id: string, maxHealth = 2_000_000) {
useCombatStore.getState().registerMob(id, {
name: id,
runewakerNpcId: NPC_ID,
level: 1,
maxHealth,
xpReward: 0,
attacks: [],
});
setMobPosition(id, [2, 0, 0]);
}
function createScoutPartyMember(statRuleset: "rom" | "wow", seed: string) {
const identity = ROM_FIVE_IDENTITIES.find((candidate) => candidate.classId === "rom-scout")!;
return {
...createConfiguredPartyMember({
...identity,
gender: "male",
specialization: "Primary",
statRuleset,
}, LEVEL, statRuleset === "wow" ? 1 : 0, seed),
status: "ready" as const,
activeCast: null,
activeCastTargetId: null,
nextActionAt: 100_000,
};
}
function installPartyMembers(members: readonly PartyMember[]) {
if (members.length < 1 || members.length > 5) throw new Error("The party fixture expects one to five members.");
usePartyStore.getState().initializeParty("native-periodic-combat", LEVEL,
useCombatStore.getState().health, "damage", members.length as 1 | 2 | 3 | 4 | 5, "rom");
usePartyStore.setState({ members });
for (const member of members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
}
describe("native Runewaker periodic damage through combatStore", () => {
beforeEach(() => setupScoutPlayer());
it("snapshots a real player Vampire Arrows cast and does not reroll crit or target mitigation on ticks", () => {
const targetId = "native-periodic-player-target";
registerNativeTarget(targetId);
const { ability } = vampireArrows();
const cast = useCombatStore.getState().castAbility(ability.id, {
targetActorId: targetId,
origin: [0, 0, 0],
now: NOW,
});
expect(cast.ok, JSON.stringify(cast)).toBe(true);
const activeCast = useCombatStore.getState().activeCast;
if (activeCast) useCombatStore.getState().advanceMobCombat(activeCast.completesAt, [0, 0, 0]);
const applied = useCombatStore.getState().effects.find((entry) => (
entry.kind === "dot" && entry.sourceAbilityId === ability.id && entry.targetId === targetId
));
expect(applied?.nativePeriodicDamageSnapshot).toMatchObject({
sourceSkillId: SOURCE_SKILL_ID,
componentId: SOURCE_COMPONENT_ID,
targetDefenseStage: "dot-application-rank-correction",
sourceSemantics: "runewaker-caladddotmagiclv-buffproc-dot",
});
expect(applied?.targetMitigationSnapshotted).toBe(true);
expect(applied?.nativePeriodicDamageSnapshot?.isDotMagicNoKill).toBe(false);
if (!applied?.nativePeriodicDamageSnapshot) throw new Error("Player DoT application did not keep its native snapshot.");
const beforeTick = useCombatStore.getState();
const mob = beforeTick.mobs[targetId]!;
useCombatStore.setState({ mobs: {
...beforeTick.mobs,
[targetId]: {
...mob,
armor: 9_999_999,
resistances: { arcane: 9_999_999, fire: 9_999_999, frost: 9_999_999,
nature: 9_999_999, shadow: 9_999_999 },
},
}, auras: beforeTick.auras.filter((aura) => !(
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.name.startsWith("Ranged Weapon Mastery:")
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "damage" && modifier.direction === "dealt")
)) });
expect(useCombatStore.getState().auras.some((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.name.startsWith("Ranged Weapon Mastery:")
&& aura.definition.modifiers?.some((modifier) => modifier.kind === "damage" && modifier.direction === "dealt")
))).toBe(false);
useCombatStore.getState().tick(0.1, NOW + applied.intervalMs);
const tick = useCombatStore.getState().combatEvents.find((event) => (
event.kind === "damage" && event.sourceActorId === PLAYER_AURA_ENTITY_ID
&& event.targetActorId === targetId && event.abilityId === ability.id
&& event.occurredAt === NOW + applied.intervalMs
));
const expectedTick = nativePeriodicDamageTick(applied.nativePeriodicDamageSnapshot);
if (!expectedTick) throw new Error("Expected the player DoT snapshot to resolve a positive tick.");
expect(tick).toMatchObject({
rawAmount: expectedTick.damage,
critical: false,
});
});
it("snapshots an active-five magical DotMagic with target resistance and explicit native MagicPower", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
secondaryClassId: "rom-knight",
raceId: "rom-human",
role: "healer",
level: LEVEL,
statRuleset: "rom",
});
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
const targetId = "native-periodic-magical-target";
registerNativeTarget(targetId);
const catalog = abilityById("rom-priest-primary-4");
if (!catalog) throw new Error("Missing active-five Bone Chill catalog row.");
const ability = abilityAtLevel(catalog, LEVEL);
const effect = ability.effects.find((candidate): candidate is Extract<AbilityEffect, { kind: "dot" }> => (
candidate.kind === "dot"
&& candidate.romNativeSource?.kind === "dot"
&& candidate.romNativeSource.skillId === 491165
&& candidate.romNativeSource.componentId === 501548
));
expect(effect).toBeDefined();
const nativeTarget = romNativeNpcCombatant(NPC_ID, 1);
expect(nativeTarget?.nativeCombatant?.magicalDefense).toBeGreaterThan(0);
expect(nativeTarget?.nativeCombatant?.nativeMagicResistances[1]?.value).toBeGreaterThanOrEqual(0);
const priestIdentity = ROM_FIVE_IDENTITIES.find((candidate) => candidate.classId === "rom-priest")!;
const nativePriestBase = createConfiguredPartyMember({
...priestIdentity,
specialization: "Primary",
statRuleset: "rom",
}, LEVEL, 0, "native-periodic-magical-priest");
const nativePriest: PartyMember = {
...nativePriestBase,
status: "ready",
activeCast: null,
activeCastTargetId: null,
nextActionAt: 100_000,
};
installPartyMembers([nativePriest]);
expect(useCombatStore.getState().applyTimedActorEffect(
nativePriest.id, targetId, ability, effect!, NOW,
)).toBe(true);
const applied = useCombatStore.getState().effects.find((entry) => (
entry.kind === "dot" && entry.sourceActorId === nativePriest.id
&& entry.sourceAbilityId === ability.id && entry.targetId === targetId
));
expect(applied?.nativePeriodicDamageSnapshot).toMatchObject({
sourceSkillId: 491165,
componentId: 501548,
targetDefenseStage: "dot-application-rank-correction",
sourceSemantics: "runewaker-caladddotmagiclv-buffproc-dot",
});
expect(applied?.nativePeriodicDamageSnapshot?.sourceTickDamage).toBeGreaterThan(0);
expect(applied?.nativePeriodicDamageSnapshot?.buffPower).toBeGreaterThan(0);
if (!applied?.nativePeriodicDamageSnapshot) throw new Error("Magical DotMagic application did not keep its native snapshot.");
const beforeTick = useCombatStore.getState();
const mob = beforeTick.mobs[targetId]!;
useCombatStore.setState({ mobs: {
...beforeTick.mobs,
[targetId]: {
...mob,
resistances: { arcane: 9_999_999, fire: 9_999_999, frost: 9_999_999,
nature: 9_999_999, shadow: 9_999_999 },
},
} });
useCombatStore.getState().tick(0.1, NOW + applied.intervalMs);
const tick = useCombatStore.getState().combatEvents.find((event) => (
event.kind === "damage" && event.sourceActorId === nativePriest.id
&& event.targetActorId === targetId && event.abilityId === ability.id
&& event.occurredAt === NOW + applied.intervalMs
));
const expectedTick = nativePeriodicDamageTick(applied.nativePeriodicDamageSnapshot);
if (!expectedTick) throw new Error("Expected the magical DoT snapshot to resolve a positive tick.");
expect(tick).toMatchObject({ rawAmount: expectedTick.damage, critical: false });
});
it("uses the companion's RoM snapshot while preserving the WoW companion fallback", () => {
const romMember = createScoutPartyMember("rom", "native-periodic-rom-companion");
const wowMember = createScoutPartyMember("wow", "native-periodic-wow-companion");
installPartyMembers([romMember, wowMember]);
const romTarget = "native-periodic-rom-companion-target";
const wowTarget = "native-periodic-wow-companion-target";
registerNativeTarget(romTarget);
registerNativeTarget(wowTarget);
const { ability, effect } = vampireArrows();
expect(useCombatStore.getState().applyTimedActorEffect(
romMember.id, romTarget, ability, effect, NOW, 100,
)).toBe(true);
expect(useCombatStore.getState().applyTimedActorEffect(
wowMember.id, wowTarget, ability, effect, NOW, 100,
)).toBe(true);
const romDot = useCombatStore.getState().effects.find((entry) => entry.targetId === romTarget)!;
const wowDot = useCombatStore.getState().effects.find((entry) => entry.targetId === wowTarget)!;
expect(romDot.nativePeriodicDamageSnapshot).toMatchObject({
sourceSkillId: SOURCE_SKILL_ID,
componentId: SOURCE_COMPONENT_ID,
});
expect(romDot.targetMitigationSnapshotted).toBe(true);
expect(wowDot.nativePeriodicDamageSnapshot).toBeUndefined();
expect(wowDot.fixedAmount).toBeGreaterThan(0);
useCombatStore.getState().tick(0.1, NOW + effect.intervalMs);
const romTick = useCombatStore.getState().combatEvents.find((event) => (
event.kind === "damage" && event.sourceActorId === romMember.id
&& event.targetActorId === romTarget && event.abilityId === ability.id
&& event.occurredAt === NOW + effect.intervalMs
));
const wowTick = useCombatStore.getState().combatEvents.find((event) => (
event.kind === "damage" && event.sourceActorId === wowMember.id
&& event.targetActorId === wowTarget && event.abilityId === ability.id
&& event.occurredAt === NOW + effect.intervalMs
));
expect(romTick?.critical).toBe(false);
expect(wowTick?.critical).toBe(true);
});
it("applies an imported NPC HP_Per DoT and reads current target MaxHP on its later tick", () => {
const npc = romNativeNpcCombatant(NPC_ID, 81);
expect(npc?.combatStatus).toBe("supported");
const binding = romNativePeriodicDamageBinding({
npcId: NPC_ID,
spellId: NPC_DOT_SPELL_ID,
componentId: NPC_DOT_COMPONENT_ID,
kind: "dot",
});
expect(binding?.component).toMatchObject({
isDotMagic: true,
dotType: 5,
dotBase: -35,
isDotMagicNoKill: false,
});
const attack: EnemyAttackDefinition = {
id: "npc-native-hp-percent-dot",
name: "Source HP_Per DoT",
spellId: NPC_DOT_SPELL_ID,
delivery: "projectile",
target: "primary",
school: "physical",
attackKind: "spell",
runewakerEffectType: 1,
damageOrigin: "runewaker",
animation: "cast",
range: 30,
castTimeMs: 0,
cooldownMs: 60_000,
initialCooldownMs: [0, 0],
damageMultiplier: 1,
effect: {
kind: "dot",
multiplier: 1,
ticks: 5,
intervalMs: 2_000,
sourceSkillId: NPC_DOT_SPELL_ID,
componentId: NPC_DOT_COMPONENT_ID,
},
};
const sourceMobId = "native-periodic-npc-source";
useCombatStore.getState().registerMob(sourceMobId, {
name: "Imported Runewaker DoT caster",
runewakerNpcId: NPC_ID,
level: 81,
maxHealth: 1_000_000,
xpReward: 0,
attacks: [attack],
});
setMobPosition(sourceMobId, [2, 0, 0]);
useCombatStore.getState().damageMob(sourceMobId, 1, NOW,
{ actorId: PLAYER_AURA_ENTITY_ID, role: "damage" });
useCombatStore.setState((state) => ({
mobs: {
...state.mobs,
[sourceMobId]: {
...state.mobs[sourceMobId]!,
engaged: true,
targetActorId: PLAYER_AURA_ENTITY_ID,
abilityReadyAt: { [attack.id]: NOW },
},
},
}));
useCombatStore.getState().advanceMobCombat(NOW, [0, 0, 0]);
const applied = useCombatStore.getState().enemyPeriodicEffects.find((entry) => (
entry.sourceMob.id === sourceMobId && entry.attack.spellId === NPC_DOT_SPELL_ID
));
expect(applied?.nativeSnapshot).toMatchObject({
sourceSkillId: NPC_DOT_SPELL_ID,
componentId: NPC_DOT_COMPONENT_ID,
percentHealthDot: true,
isDotMagicNoKill: false,
sourceTickDamage: 35,
});
if (!applied?.nativeSnapshot) throw new Error("NPC DoT application did not keep its native snapshot.");
const healthAtApplication = useCombatStore.getState().maxHealth;
expect(applied.nativeSnapshot.targetMaxHealthAtApplication).toBe(healthAtApplication);
useCombatStore.setState({ maxHealth: healthAtApplication * 2, health: healthAtApplication * 2 });
useCombatStore.getState().advanceMobCombat(NOW + applied.intervalMs, [0, 0, 0]);
const expectedTick = nativePeriodicDamageTick(applied.nativeSnapshot, healthAtApplication * 2)!.damage;
expect(useCombatStore.getState().health).toBe(healthAtApplication * 2 - expectedTick);
});
});
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { romNativePeriodicDamageBinding, romNativeEnemyPeriodicDamageSources,
nativePeriodicDamageSnapshot, nativePeriodicDamageTick } from "./romNativePeriodicDamage";
import { deriveRomNativeStats } from "./romNativeStats";
import { romNativeNpcCombatant } from "./romNativeCombatResolution";
describe("imported NPC native periodic damage integration", () => {
it("resolves an exact NPC spell/component at a scaled level above 80 and preserves its slot MagicLv", () => {
const npcId = 102041;
const spellId = 493231;
const componentId = 503485;
const sources = romNativeEnemyPeriodicDamageSources(spellId, npcId);
const source = sources.find((candidate) => candidate.component.componentId === componentId);
expect(source).toMatchObject({
abilityId: "rom-npc-102041",
npcIds: [npcId],
sourceSkillId: spellId,
maxCasterLevel: 200,
maxMagicLevel: 1,
rawSpellTimeSeconds: 0,
component: {
componentId,
magicFunc: 1,
effectType: 1,
isFixDotPower: true,
dotType: 5,
dotBase: -35,
dotSkillLevelArg: 0,
},
});
expect(romNativePeriodicDamageBinding({ spellId, componentId, npcId, kind: "dot" })).toEqual(source);
if (!source) throw new Error("The imported NPC source binding is missing.");
const npc = romNativeNpcCombatant(npcId, 81);
expect(npc).toBeDefined();
expect(npc).toMatchObject({ npcId, level: 81, combatStatus: "supported" });
const spellSlot = npc?.spellBindings.find((candidate) => candidate.sourceSkillId === spellId);
expect(spellSlot).toMatchObject({ sourceSkillId: spellId, magicLevel: 1 });
const caster = npc!.periodicStats;
const target = deriveRomNativeStats({
classId: "rom-knight", level: 80, raceId: "rom-human", gender: "male", gear: EMPTY_ITEM_STATS.romNative,
});
const result = nativePeriodicDamageSnapshot({
source,
caster,
target,
sourceIsPlayer: false,
magicLevel: spellSlot!.magicLevel,
casterLevel: npc!.level,
targetLevel: 80,
});
expect(result.status).toBe("supported");
if (result.status !== "supported") return;
expect(result.value).toMatchObject({
sourceSkillId: spellId,
componentId,
magicLevel: 1,
spellTime: 1,
effectTimeSeconds: 100_000_000,
tickIntervalSeconds: 2,
rankCorrectionTicks: 1,
buffPower: 1,
sourceTickDamage: 35,
percentHealthDot: true,
targetDefenseStage: "none",
});
expect(nativePeriodicDamageTick(result.value)).toEqual({
damage: Math.trunc(35 * target.health.max / 100),
healthDelta: -Math.trunc(35 * target.health.max / 100),
firesPeriodicAttackEvents: true,
});
});
});
+251
View File
@@ -0,0 +1,251 @@
import { describe, expect, it } from "vitest";
import { deriveRomNativeStats } from "./romNativeStats";
import { EMPTY_ITEM_STATS } from "./itemStats";
import {
nativePeriodicDamageSnapshot,
nativePeriodicDamageTick,
romNativePeriodicDamageBinding,
romNativePeriodicDamageComponent,
type RomNativePeriodicDamageSource,
} from "./romNativePeriodicDamage";
const LEVEL = 30;
function nativeStats(classId: "rom-mage" | "rom-rogue" = "rom-mage") {
const stats = deriveRomNativeStats({
classId,
level: LEVEL,
raceId: "rom-human",
gender: "male",
gear: EMPTY_ITEM_STATS.romNative,
});
return {
...stats,
attributes: { ...stats.attributes, wisdom: 100 },
health: { max: 1_000 },
physicalAttack: 400,
physicalDamage: 200,
physicalDefense: 200,
magicalAttack: 500,
magicalDamage: 100,
magicalDefense: 100,
healing: {
...stats.healing,
healPowerRate: 1,
healPoint: 0,
targetHealAbsorbRate: 0,
targetHealAbsorb: 0,
},
};
}
function sourceFor(componentId: number, sourceSkillId: number): RomNativePeriodicDamageSource {
const binding = romNativePeriodicDamageBinding({ skillId: sourceSkillId, componentId, kind: "dot" });
if (!binding) throw new Error(`Missing generated DotMagic binding ${sourceSkillId}:${componentId}.`);
return binding;
}
describe("native RoM periodic damage", () => {
it("exports exact active-five source rows and can find shared component metadata", () => {
const binding = sourceFor(620313, 490306);
expect(binding).toMatchObject({
abilityId: "rom-rogue-general-0",
classId: "rom-rogue",
rank: 1,
sourceSkillId: 490306,
component: { componentId: 620313, kind: "dot", dotType: 0, dotBase: -5, dotTimeSeconds: 2 },
});
expect(romNativePeriodicDamageComponent(620313)).toEqual(binding.component);
expect(romNativePeriodicDamageBinding({ skillId: 490306, componentId: 620313, kind: "hot" })).toBeUndefined();
});
it("snapshots magical target resistance and magic-versus-defense power into Buff.Power", () => {
const source = sourceFor(501550, 491168);
const result = nativePeriodicDamageSnapshot({
source,
caster: nativeStats(),
target: nativeStats("rom-rogue"),
sourceIsPlayer: true,
magicLevel: 10,
casterLevel: 30,
targetLevel: 30,
spellTimeSeconds: 2,
effectiveMagicPower: 1,
magicResistance: { value: 5, rateFactor: 0.9 },
});
expect(result.status).toBe("supported");
if (result.status !== "supported") return;
expect(result.value).toMatchObject({
componentId: 501550,
sourceSkillId: 491168,
magicLevel: 10,
spellTime: 2,
effectTimeSeconds: 12,
tickIntervalSeconds: 2,
ticks: 6,
rankCorrectionTicks: 6,
magicLevelAdjustment: 16,
buffPower: 26,
sourceTickDamage: 91,
targetDefenseStage: "dot-application-rank-correction",
tickEventType: "magic",
});
expect(nativePeriodicDamageTick(result.value)).toMatchObject({
damage: 91,
healthDelta: -91,
firesPeriodicAttackEvents: true,
});
});
it("keeps raw duration rank-correction ticks distinct from the applied effect duration", () => {
const source = sourceFor(620313, 490306);
const shifted = {
...source,
component: {
...source.component,
effectTimeSeconds: 20,
effectTimeSkillLevelArg: 50,
levelDeltaEffectTime: 1,
isFixDotPower: true,
},
};
const result = nativePeriodicDamageSnapshot({
source: shifted,
caster: nativeStats("rom-rogue"),
target: nativeStats(),
sourceIsPlayer: true,
magicLevel: 10,
casterLevel: 30,
targetLevel: 20,
spellTimeSeconds: 1,
});
expect(result.status).toBe("supported");
if (result.status !== "supported") return;
expect(result.value.effectTimeSeconds).toBe(110);
expect(result.value.ticks).toBe(55);
expect(result.value.rankCorrectionTicks).toBe(10);
});
it("uses Main_DMG and Main_ATKSpeed for physical DotMagic rank correction", () => {
const source = sourceFor(620313, 490306);
const physical = {
...source,
component: { ...source.component, effectType: 1, magicType: -1, dotBase: -10, dotSkillLevelArg: 50 },
};
const result = nativePeriodicDamageSnapshot({
source: physical,
caster: nativeStats("rom-rogue"),
target: nativeStats(),
sourceIsPlayer: true,
magicLevel: 10,
casterLevel: 30,
targetLevel: 30,
spellTimeSeconds: 2,
mainDamage: 200,
mainAttackSpeed: 20,
});
expect(result.status).toBe("supported");
if (result.status !== "supported") return;
expect(result.value).toMatchObject({
rankCorrectionTicks: 5,
magicLevelAdjustment: 38,
buffPower: 48,
sourceTickDamage: 250,
tickEventType: "skill",
});
});
it("uses raw MagicLv for fixed-power rows and ShowPowerLv suppresses it", () => {
const source = sourceFor(501550, 491168);
const fixed = { ...source, component: { ...source.component, isFixDotPower: true } };
const fixedResult = nativePeriodicDamageSnapshot({
source: fixed, caster: nativeStats(), target: nativeStats("rom-rogue"), sourceIsPlayer: true,
magicLevel: 10, casterLevel: 30, targetLevel: 30,
});
expect(fixedResult.status).toBe("supported");
if (fixedResult.status === "supported") {
expect(fixedResult.value).toMatchObject({ magicLevelAdjustment: 0, buffPower: 10, sourceTickDamage: 40 });
}
const shown = { ...source, component: { ...source.component, isShowPowerLevel: true } };
const shownResult = nativePeriodicDamageSnapshot({
source: shown, caster: nativeStats(), target: nativeStats("rom-rogue"), sourceIsPlayer: true,
magicLevel: 10, casterLevel: 30, targetLevel: 30,
spellTimeSeconds: 2, effectiveMagicPower: 1, magicResistance: { value: 0, rateFactor: 1 },
});
expect(shownResult.status).toBe("supported");
if (shownResult.status === "supported") {
expect(shownResult.value).toMatchObject({ buffPower: 0, sourceTickDamage: 8 });
}
});
it("uses current MaxHP for HP_Per pulses and never rerolls source critical damage", () => {
const source = sourceFor(500549, 490304);
const result = nativePeriodicDamageSnapshot({
source, caster: nativeStats("rom-rogue"), target: nativeStats(), sourceIsPlayer: true,
magicLevel: 10, casterLevel: 30, targetLevel: 30,
});
expect(result.status).toBe("supported");
if (result.status !== "supported") return;
expect(result.value).toMatchObject({ percentHealthDot: true, sourceTickDamage: 6, tickDamageAtApplication: 60 });
expect(nativePeriodicDamageTick(result.value, 1_500)).toMatchObject({
damage: 90,
healthDelta: -90,
firesPeriodicAttackEvents: true,
});
expect(nativePeriodicDamageTick(result.value, Number.NaN)).toBeUndefined();
});
it("fails closed when source-required elemental resistance or physical weapon inputs are missing", () => {
const elemental = nativePeriodicDamageSnapshot({
source: sourceFor(501550, 491168), caster: nativeStats(), target: nativeStats("rom-rogue"),
sourceIsPlayer: true, magicLevel: 10, casterLevel: 30, targetLevel: 30,
spellTimeSeconds: 2, effectiveMagicPower: 1,
});
expect(elemental).toEqual({ status: "unsupported", reason: "periodic-damage-magical-resistance-required" });
const physicalSource = sourceFor(620313, 490306);
const physical = nativePeriodicDamageSnapshot({
source: { ...physicalSource, component: { ...physicalSource.component, effectType: 1 } },
caster: nativeStats("rom-rogue"), target: nativeStats(), sourceIsPlayer: true,
magicLevel: 10, casterLevel: 30, targetLevel: 30,
});
expect(physical).toEqual({ status: "unsupported", reason: "periodic-damage-physical-weapon-profile-required" });
});
it("fails closed when a dot refresh can retain or increase the source buff power", () => {
const result = nativePeriodicDamageSnapshot({
source: sourceFor(620313, 490306), caster: nativeStats("rom-rogue"), target: nativeStats(),
sourceIsPlayer: true, magicLevel: 10, casterLevel: 30, targetLevel: 30, isReapplication: true,
});
expect(result).toEqual({ status: "unsupported", reason: "periodic-damage-reapplication-stacking-unsupported" });
});
it("keeps the player cap at 80 while accepting NPC levels within exported source bounds", () => {
const source = sourceFor(620313, 490306);
const enemyBoundedSource = {
...source,
maxCasterLevel: 100,
maxMagicLevel: 100,
component: { ...source.component, isFixDotPower: true },
};
const npcResult = nativePeriodicDamageSnapshot({
source: enemyBoundedSource, caster: nativeStats(), target: nativeStats(), sourceIsPlayer: false,
magicLevel: 90, casterLevel: 90, targetLevel: 90,
});
expect(npcResult.status).toBe("supported");
const playerResult = nativePeriodicDamageSnapshot({
source: enemyBoundedSource, caster: nativeStats(), target: nativeStats(), sourceIsPlayer: true,
magicLevel: 90, casterLevel: 90, targetLevel: 90,
});
expect(playerResult).toEqual({ status: "unsupported", reason: "periodic-damage-input-invalid" });
const unboundedNpcResult = nativePeriodicDamageSnapshot({
source: { ...source, component: { ...source.component, isFixDotPower: true } },
caster: nativeStats(), target: nativeStats(), sourceIsPlayer: false,
magicLevel: 90, casterLevel: 90, targetLevel: 90,
});
expect(unboundedNpcResult).toEqual({ status: "unsupported", reason: "periodic-damage-npc-source-bounds-required" });
});
});
+446
View File
@@ -0,0 +1,446 @@
import periodicDamageMetadata from "./generated/romNativePeriodicDamageBindings.generated.json";
import nativeStatRules from "./generated/romNativeStatRules.generated.json";
import type { RomNativeDerivedStats } from "./romNativeStatsTypes";
export interface RomNativePeriodicDamageComponent {
readonly kind: "dot";
readonly componentId: number;
readonly magicFunc: number;
readonly magicType: number;
/** Runewaker EM_Magic_Magic (0) or EM_Magic_Phy (1). */
readonly effectType: number;
readonly effectTimeSeconds: number;
readonly levelDeltaEffectTime: number;
readonly effectTimeSkillLevelArg: number;
readonly maxBuffLevelBase: number;
readonly maxBuffLevelSkillArg: number;
readonly settingFlags: number;
readonly isDotMagic: boolean;
readonly isShowPowerLevel: boolean;
readonly isDotMagicNoKill: boolean;
readonly isMaxMagicLevelTargetBound: boolean;
readonly isFixDotPower: boolean;
readonly dotTimeSeconds: number;
/** HP is 0; HP_Per is 5. Other source DotType resources are not damage effects. */
readonly dotType: number;
/** Signed source Base; negative values emit damage. */
readonly dotBase: number;
readonly dotSkillLevelArg: number;
readonly buffMaxLevel: number;
readonly isIgnoreMagicAbsorb: boolean;
readonly rawSettingExFlags: number;
}
export interface RomNativePeriodicDamageBinding {
readonly abilityId: string;
readonly classId: string;
readonly npcIds?: readonly number[];
readonly maxCasterLevel?: number;
readonly secondaryClassId?: string;
readonly group: string;
readonly unlockLevel: number;
readonly semanticKind: "dot";
readonly sourceSkillIds: readonly number[];
readonly ranks: readonly {
readonly rank: number | null;
readonly unlockLevel: number;
readonly sourceSkillId: number;
readonly maxCasterLevel?: number;
readonly maxMagicLevel?: number;
readonly configuredCollectMagicLevel: number;
readonly rawSpellTimeSeconds: number;
readonly rawCollectMode?: number;
readonly rawMagicCollectSettingExFlags?: number;
readonly isFixDmgEffect: boolean;
readonly componentIds: readonly RomNativePeriodicDamageComponent[];
}[];
}
export interface RomNativePeriodicDamageSource {
readonly abilityId?: string;
readonly classId?: string;
readonly rank?: number | null;
readonly npcIds?: readonly number[];
readonly maxCasterLevel?: number;
readonly maxMagicLevel?: number;
readonly sourceSkillId: number;
readonly component: RomNativePeriodicDamageComponent;
/** MagicCollect inputs are rank-specific, while the DotMagic component is shared by ranks. */
readonly rawSpellTimeSeconds: number;
readonly isFixDmgEffect: boolean;
}
export interface RomNativePeriodicDamageCombatantStats {
readonly attributes: Pick<RomNativeDerivedStats["attributes"], "wisdom">;
readonly health: { readonly max: number };
readonly physicalAttack: number;
readonly physicalDamage: number;
readonly physicalDefense: number;
readonly magicalAttack: number;
readonly magicalDamage: number;
readonly magicalDefense: number;
readonly healing: Pick<RomNativeDerivedStats["healing"],
"healPowerRate" | "healPoint" | "targetHealAbsorbRate" | "targetHealAbsorb">;
readonly sourceClassId?: string;
}
export interface RomNativePeriodicDamageMagicResistance {
/** Target Resist._ArrList[MagicType], converted to the source integer channel. */
readonly value: number;
/** Target ResistPer._ArrList[MagicType] multiplicative factor. */
readonly rateFactor: number;
}
export interface RomNativePeriodicDamageSnapshot {
readonly sourceSkillId: number;
readonly componentId: number;
readonly abilityId?: string;
readonly rank?: number | null;
readonly sourceClassId?: string;
readonly targetClassId?: string;
readonly magicLevel: number;
readonly spellTime: number;
readonly effectTimeSeconds: number;
readonly tickIntervalSeconds: number;
readonly ticks: number;
readonly rankCorrectionTicks: number;
readonly magicLevelAdjustment: number;
readonly buffPower: number;
/** Integer source damage before per-tick HP_Per scaling and target shields. */
readonly sourceTickDamage: number;
readonly percentHealthDot: boolean;
readonly targetMaxHealthAtApplication: number;
readonly tickDamageAtApplication: number;
readonly isDotMagicNoKill: boolean;
readonly tickEventType: "magic" | "skill";
/** Native armor/resist terms are consumed when Buff.Power is calculated. */
readonly targetDefenseStage: "dot-application-rank-correction" | "none";
/** BuffProc reuses Buff.Power and has no tick-time critical roll. */
readonly sourceSemantics: "runewaker-caladddotmagiclv-buffproc-dot";
}
export type RomNativePeriodicDamageUnsupportedReason =
| "periodic-damage-source-not-found"
| "periodic-damage-source-shape-unsupported"
| "periodic-damage-npc-source-bounds-required"
| "periodic-damage-is-not-negative-hp"
| "periodic-damage-effect-type-unsupported"
| "periodic-damage-input-invalid"
| "periodic-damage-reapplication-stacking-unsupported"
| "periodic-damage-magical-fix-arg-required"
| "periodic-damage-magical-power-rate-required"
| "periodic-damage-magical-resistance-required"
| "periodic-damage-physical-weapon-profile-required"
| "periodic-damage-target-health-required";
export type RomNativePeriodicDamageResult<T> =
| { readonly status: "supported"; readonly value: T }
| { readonly status: "unsupported"; readonly reason: RomNativePeriodicDamageUnsupportedReason };
interface RawPeriodicDamageMetadata {
readonly activeDamageBindings: readonly RomNativePeriodicDamageBinding[];
readonly enemyDamageBindings?: readonly RomNativePeriodicDamageBinding[];
readonly formula: { readonly fixDotMagicalDamageCoefficient: number };
}
const metadata = periodicDamageMetadata as unknown as RawPeriodicDamageMetadata;
const sourceFix = nativeStatRules as unknown as {
readonly playerFix: { readonly dotMagicalDamageCoefficient: number };
};
const bindings = metadata.activeDamageBindings;
const allBindings = [...bindings, ...(metadata.enemyDamageBindings ?? [])];
/** Finds rank-specific MagicCollect values by the descriptor attached to a RoM `dot` effect. */
export function romNativePeriodicDamageBinding(source: {
readonly skillId?: number;
/** Runtime attack descriptors use the MagicCollect identifier as `spellId`. */
readonly spellId?: number;
readonly componentId: number;
readonly npcId?: number;
readonly kind?: string;
}): RomNativePeriodicDamageSource | undefined {
if (source.kind && source.kind !== "dot") return undefined;
const skillId = source.skillId ?? source.spellId;
if (skillId === undefined) return undefined;
const candidates = source.npcId === undefined
? allBindings
: allBindings.filter((ability) => ability.npcIds?.includes(source.npcId!));
for (const ability of candidates) {
for (const rank of ability.ranks) {
if (rank.sourceSkillId !== skillId) continue;
const component = rank.componentIds.find((candidate) => candidate.componentId === source.componentId);
if (component) return Object.freeze({
abilityId: ability.abilityId,
classId: ability.classId,
...(ability.npcIds ? { npcIds: ability.npcIds } : {}),
...(rank.maxCasterLevel ?? ability.maxCasterLevel
? { maxCasterLevel: rank.maxCasterLevel ?? ability.maxCasterLevel } : {}),
...(rank.maxMagicLevel !== undefined ? { maxMagicLevel: rank.maxMagicLevel } : {}),
rank: rank.rank,
sourceSkillId: rank.sourceSkillId,
component,
rawSpellTimeSeconds: rank.rawSpellTimeSeconds,
isFixDmgEffect: rank.isFixDmgEffect,
});
}
}
return undefined;
}
/** Returns every supported enemy DotMagic component bound to an imported NPC MagicCollect ID. */
export function romNativeEnemyPeriodicDamageSources(
spellId: number,
npcId?: number,
): readonly RomNativePeriodicDamageSource[] {
const sources: RomNativePeriodicDamageSource[] = [];
for (const ability of metadata.enemyDamageBindings ?? []) {
if (npcId !== undefined && !ability.npcIds?.includes(npcId)) continue;
for (const rank of ability.ranks) {
if (rank.sourceSkillId !== spellId) continue;
for (const component of rank.componentIds) {
if (component.kind !== "dot" || !component.isDotMagic || component.dotBase >= 0
|| ![0, 5].includes(component.dotType) || component.magicFunc !== 1) continue;
sources.push(Object.freeze({
abilityId: ability.abilityId,
classId: ability.classId,
...(ability.npcIds ? { npcIds: ability.npcIds } : {}),
...(rank.maxCasterLevel ?? ability.maxCasterLevel
? { maxCasterLevel: rank.maxCasterLevel ?? ability.maxCasterLevel } : {}),
...(rank.maxMagicLevel !== undefined ? { maxMagicLevel: rank.maxMagicLevel } : {}),
rank: rank.rank,
sourceSkillId: rank.sourceSkillId,
component,
rawSpellTimeSeconds: rank.rawSpellTimeSeconds,
isFixDmgEffect: rank.isFixDmgEffect,
}));
}
}
}
return Object.freeze(sources);
}
/** Component-only lookup for a selected NPC spell that shares an imported player component. */
export function romNativePeriodicDamageComponent(componentId: number): RomNativePeriodicDamageComponent | undefined {
const rows = allBindings.flatMap((ability) => ability.ranks.flatMap((rank) => rank.componentIds))
.filter((component) => component.componentId === componentId);
if (!rows.length) return undefined;
const fingerprint = JSON.stringify(rows[0]);
return rows.every((row) => JSON.stringify(row) === fingerprint) ? rows[0] : undefined;
}
/** Source-bound negative HP DotMagic application. Pass source-native stats and the effective runtime MagicLv. */
export function nativePeriodicDamageSnapshot(input: {
readonly source: RomNativePeriodicDamageSource;
readonly caster: RomNativePeriodicDamageCombatantStats;
readonly target: RomNativePeriodicDamageCombatantStats;
/** Actual MagicProc owner kind; active player-build companions may deliberately pass true. */
readonly sourceIsPlayer: boolean;
readonly magicLevel: number;
readonly casterLevel: number;
readonly targetLevel: number;
/** MagicProcess SpellTime after source weapon/timing rules; omitted uses the bound MagicCollect value. */
readonly spellTimeSeconds?: number;
/** Exact source Main_DMG and Main_ATKSpeed, required by negative physical DotMagic rank correction. */
readonly mainDamage?: number;
readonly mainAttackSpeed?: number;
/** Exact Attr.MagicPow(MagicType); native all-magic MPow does not prove elemental BodyRate. */
readonly effectiveMagicPower?: number;
/** Exact target Resist and ResistPer channels, required by elemental negative magical DoTs. */
readonly magicResistance?: RomNativePeriodicDamageMagicResistance;
/** NPC-specific FIX.MDmg_DotArg; player builds use the verified player FIX row. */
readonly dotMagicalDamageCoefficient?: number;
/** Supply true when replacing an existing stack can change Buff.Power. */
readonly isReapplication?: boolean;
}): RomNativePeriodicDamageResult<RomNativePeriodicDamageSnapshot> {
const { source, caster, target } = input;
const component = source.component;
if (input.isReapplication) return unsupported("periodic-damage-reapplication-stacking-unsupported");
if (component.kind !== "dot" || !component.isDotMagic || component.dotTimeSeconds <= 0
|| component.dotBase >= 0 || ![0, 5].includes(component.dotType)) {
return unsupported("periodic-damage-is-not-negative-hp");
}
if (component.magicFunc !== 1 || ![0, 1].includes(component.effectType)) {
return unsupported("periodic-damage-effect-type-unsupported");
}
const magicLevel = input.magicLevel;
const casterLevel = input.casterLevel;
const targetLevel = input.targetLevel;
const maxCasterLevel = input.sourceIsPlayer ? 80 : source.maxCasterLevel;
const maxMagicLevel = input.sourceIsPlayer ? 80 : source.maxMagicLevel;
if (!input.sourceIsPlayer && (maxCasterLevel === undefined || maxMagicLevel === undefined
|| !Number.isSafeInteger(maxCasterLevel) || !Number.isSafeInteger(maxMagicLevel))) {
return unsupported("periodic-damage-npc-source-bounds-required");
}
const rawSpellTime = input.spellTimeSeconds ?? source.rawSpellTimeSeconds;
const values = [
magicLevel, casterLevel, targetLevel, rawSpellTime,
component.effectTimeSeconds, component.levelDeltaEffectTime, component.effectTimeSkillLevelArg,
component.maxBuffLevelBase, component.maxBuffLevelSkillArg, component.buffMaxLevel,
component.dotTimeSeconds, component.dotBase, component.dotSkillLevelArg,
caster.physicalAttack, caster.physicalDamage, caster.magicalAttack, caster.magicalDamage,
caster.attributes.wisdom, caster.healing.healPowerRate, caster.healing.healPoint,
target.physicalDefense, target.magicalDefense, target.healing.targetHealAbsorbRate, target.healing.targetHealAbsorb,
];
if (!values.every(Number.isFinite) || !Number.isSafeInteger(magicLevel) || magicLevel < 0 || magicLevel > maxMagicLevel!
|| !Number.isSafeInteger(casterLevel) || casterLevel < 1 || casterLevel > maxCasterLevel!
|| !Number.isInteger(targetLevel) || targetLevel < 1 || component.buffMaxLevel < 0) {
return unsupported("periodic-damage-input-invalid");
}
const spellTime = Math.trunc(rawSpellTime < 1 || source.isFixDmgEffect ? 1 : rawSpellTime);
const duration = component.effectTimeSeconds < 0
? 100_000_000
: Math.trunc(component.effectTimeSeconds * (1 + component.effectTimeSkillLevelArg * magicLevel / 100)
- (casterLevel - targetLevel) * component.levelDeltaEffectTime);
const effectTimeSeconds = duration === -1 ? 0 : Math.max(0, duration);
if (effectTimeSeconds <= 0) return unsupported("periodic-damage-input-invalid");
const ticks = Math.max(1, Math.trunc(effectTimeSeconds / component.dotTimeSeconds));
// CalAddDotMagicLv uses the raw MagicBase EffectTime, before MagicProcess extends the applied buff.
const rankCorrectionTicks = Math.max(1, Math.trunc(component.effectTimeSeconds / component.dotTimeSeconds));
const dotBasePower = component.dotBase * (1 + magicLevel * component.dotSkillLevelArg / 100);
let magicLevelAdjustment = 0;
const canRankCorrect = !component.isFixDotPower && component.dotType === 0 && component.dotSkillLevelArg !== 0;
if (canRankCorrect) {
const exRate = input.sourceIsPlayer ? 0.6 + Math.abs(dotBasePower) / 600 : 1;
let power: number;
let powerRate: number;
if (component.effectType === 0) {
const fixCoefficient = input.dotMagicalDamageCoefficient
?? (input.sourceIsPlayer ? sourceFix.playerFix.dotMagicalDamageCoefficient : undefined);
if (fixCoefficient === undefined || !Number.isFinite(fixCoefficient)) {
return unsupported("periodic-damage-magical-fix-arg-required");
}
if (input.effectiveMagicPower === undefined || !Number.isFinite(input.effectiveMagicPower)) {
return unsupported("periodic-damage-magical-power-rate-required");
}
power = dotBasePower - exRate * caster.magicalDamage * spellTime * fixCoefficient / rankCorrectionTicks;
powerRate = sourcePowerRate(caster.magicalAttack, target.magicalDefense);
if (power < 0) {
if (component.magicType >= 0 && component.magicType <= 5) {
const resist = input.magicResistance;
if (!resist || !Number.isFinite(resist.value) || !Number.isFinite(resist.rateFactor)) {
return unsupported("periodic-damage-magical-resistance-required");
}
power = applyMagicResistance(power * rankCorrectionTicks, resist) / rankCorrectionTicks;
}
power *= powerRate;
} else {
const mindPower = caster.attributes.wisdom * 0.3 * spellTime / rankCorrectionTicks;
if (power * 5 < mindPower) power *= 6;
else power += mindPower;
power = power * caster.healing.healPowerRate
+ caster.healing.healPoint * spellTime / rankCorrectionTicks;
if (!component.isIgnoreMagicAbsorb) {
power = power * (1 + target.healing.targetHealAbsorbRate / 100)
+ target.healing.targetHealAbsorb / rankCorrectionTicks;
}
if (power < 0) power = 0;
}
power *= input.effectiveMagicPower;
} else {
if (!Number.isFinite(input.mainDamage) || !Number.isFinite(input.mainAttackSpeed) || input.mainAttackSpeed! <= 0) {
return unsupported("periodic-damage-physical-weapon-profile-required");
}
const fixCoefficient = input.dotMagicalDamageCoefficient
?? (input.sourceIsPlayer ? sourceFix.playerFix.dotMagicalDamageCoefficient : undefined);
if (fixCoefficient === undefined || !Number.isFinite(fixCoefficient)) {
return unsupported("periodic-damage-magical-fix-arg-required");
}
power = dotBasePower - exRate * 3 * input.mainDamage! * spellTime * fixCoefficient * 10
/ input.mainAttackSpeed! / rankCorrectionTicks;
powerRate = sourcePowerRate(caster.physicalAttack, target.physicalDefense);
if (power < 0) power *= powerRate;
}
const skillLevelDamage = component.dotBase * component.dotSkillLevelArg / 100;
if (skillLevelDamage !== 0) {
const finDamage = power;
magicLevelAdjustment = Math.trunc((finDamage - component.dotBase) / skillLevelDamage - magicLevel);
}
}
let adjustedMagicLevel = component.isShowPowerLevel ? 0 : magicLevel + magicLevelAdjustment;
if (adjustedMagicLevel < 0) adjustedMagicLevel = 0;
let maximumSkillLevel = Math.trunc(component.maxBuffLevelBase
+ component.maxBuffLevelSkillArg * adjustedMagicLevel / 100);
if (maximumSkillLevel < adjustedMagicLevel) maximumSkillLevel = adjustedMagicLevel;
if (component.isMaxMagicLevelTargetBound) maximumSkillLevel = targetLevel;
if (maximumSkillLevel > component.buffMaxLevel) maximumSkillLevel = component.buffMaxLevel;
const buffPower = Math.min(adjustedMagicLevel, maximumSkillLevel);
const rawTickDamage = Math.trunc(-component.dotBase
* (100 + component.dotSkillLevelArg * buffPower) / 100);
const percentHealthDot = component.dotType === 5;
const targetMaxHealthAtApplication = target.health.max;
if (!Number.isFinite(targetMaxHealthAtApplication) || targetMaxHealthAtApplication < 0) {
return unsupported("periodic-damage-target-health-required");
}
const tickDamageAtApplication = percentHealthDot
? Math.trunc(rawTickDamage * targetMaxHealthAtApplication / 100)
: rawTickDamage;
const binding = romNativePeriodicDamageBinding({ skillId: source.sourceSkillId, componentId: component.componentId });
return {
status: "supported",
value: Object.freeze({
sourceSkillId: source.sourceSkillId,
componentId: component.componentId,
...(binding ? {
abilityId: binding.abilityId,
} : {}),
...(source.abilityId ? { abilityId: source.abilityId } : {}),
...(source.rank !== undefined ? { rank: source.rank } : {}),
...(caster.sourceClassId ? { sourceClassId: caster.sourceClassId } : {}),
...(target.sourceClassId ? { targetClassId: target.sourceClassId } : {}),
magicLevel,
spellTime,
effectTimeSeconds,
tickIntervalSeconds: component.dotTimeSeconds,
ticks,
rankCorrectionTicks,
magicLevelAdjustment,
buffPower,
sourceTickDamage: rawTickDamage,
percentHealthDot,
targetMaxHealthAtApplication,
tickDamageAtApplication,
isDotMagicNoKill: component.isDotMagicNoKill,
tickEventType: component.effectType === 0 ? "magic" : "skill",
targetDefenseStage: canRankCorrect ? "dot-application-rank-correction" : "none",
sourceSemantics: "runewaker-caladddotmagiclv-buffproc-dot",
}),
};
}
/** BuffProc has no new random or critical roll. HP_Per reads current MaxHP again for every pulse. */
export function nativePeriodicDamageTick(
snapshot: RomNativePeriodicDamageSnapshot,
targetMaxHealth = snapshot.targetMaxHealthAtApplication,
): Readonly<{ damage: number; healthDelta: number; firesPeriodicAttackEvents: boolean }> | undefined {
if (!Number.isFinite(targetMaxHealth) || targetMaxHealth < 0) return undefined;
const damage = snapshot.percentHealthDot
? Math.trunc(snapshot.sourceTickDamage * targetMaxHealth / 100)
: snapshot.sourceTickDamage;
return Object.freeze({ damage, healthDelta: -damage, firesPeriodicAttackEvents: damage > 0 });
}
function sourcePowerRate(attack: number, defense: number): number {
const ownerAttack = attack + 100;
const targetDefense = defense + 200;
return 1 + (ownerAttack - targetDefense) / Math.max(ownerAttack, targetDefense) / 2;
}
function applyMagicResistance(
damage: number,
resistance: RomNativePeriodicDamageMagicResistance,
): number {
const value = Math.trunc(resistance.value);
let result: number;
if (Math.abs(damage) / 2 < Math.abs(value)) {
result = value < 0 ? damage + damage / 2 : damage - damage / 2;
} else {
result = damage + value;
}
return result * resistance.rateFactor;
}
function unsupported(reason: RomNativePeriodicDamageUnsupportedReason): RomNativePeriodicDamageResult<never> {
return { status: "unsupported", reason };
}
@@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AURA_ENTITY_ID, auraAdjustedCharacterStats, useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { deriveCharacterStats } from "./wotlkStats";
import { romNativePeriodicManaEffectDuring, romNativeRecoveryPulseAmounts } from "./romNativeRecovery";
const ACTIVE_FIVE = ["rom-knight", "rom-priest", "rom-scout", "rom-rogue", "rom-mage"] as const;
describe("native RoM recovery runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 30 });
usePartyStore.getState().initializeParty("native-recovery-tests", 30, useCombatStore.getState().health);
});
it("pulses source HP and Mana on native player builds while ignoring the WoW mana-spend lockout", () => {
for (const classId of ACTIVE_FIVE) {
useCombatStore.getState().initializeCharacter({
classId,
raceId: "rom-human",
gender: "male",
level: 30,
statRuleset: "rom",
});
const initialized = useCombatStore.getState();
const nativeBase = deriveCharacterStats(
classId, 30, initialized.gearStats, initialized.raceId, {}, initialized.secondaryClassId,
initialized.gender, "rom",
);
expect(nativeBase.romNative, classId).toBeDefined();
const startAt = Date.now() + 50_000;
useCombatStore.setState((state) => ({
health: Math.max(1, state.maxHealth - 100),
resourcePools: { ...state.resourcePools, ...(nativeBase.romNative!.usesMana ? { mana: 0 } : {}) },
lastResourceSpentAt: { ...state.lastResourceSpentAt, mana: startAt + 1_500 },
}));
const startState = useCombatStore.getState();
const startHealth = startState.health;
const startMana = startState.resourcePools.mana ?? 0;
useCombatStore.getState().tick(1, startAt + 1_000);
const beforePulse = useCombatStore.getState();
const pulseAt = startAt + 2_000;
const livePulseAuras = beforePulse.auras.filter((aura) => (
aura.expiresAt === null || aura.expiresAt > pulseAt
));
const nativeAtPulse = auraAdjustedCharacterStats(
nativeBase,
livePulseAuras,
PLAYER_AURA_ENTITY_ID,
pulseAt,
).romNative!;
const pulse = romNativeRecoveryPulseAmounts(nativeAtPulse, "player", false);
const manaOverTime = romNativePeriodicManaEffectDuring(
beforePulse.auras.filter((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID),
startAt + 1_000,
pulseAt,
beforePulse.maxResourcePools.mana ?? beforePulse.maxResource,
);
useCombatStore.getState().tick(1, pulseAt);
const recovered = useCombatStore.getState();
expect(recovered.health).toBeCloseTo(Math.min(recovered.maxHealth, startHealth + pulse.health), 6);
if (nativeBase.romNative!.usesMana) {
expect(recovered.resourcePools.mana).toBeCloseTo(
Math.min(recovered.maxResourcePools.mana!, startMana + pulse.mana + manaOverTime), 6,
);
} else {
expect(recovered.maxResourcePools.mana ?? 0).toBe(0);
}
expect(recovered.romNativeRecoveryClock?.elapsedMs).toBe(0);
}
});
it("uses the owned-NPC six-second pulse and party-wide combat gate, even while stopped", () => {
const configured = createConfiguredPartyMember({
name: "Native Recovery Priest",
classId: "rom-priest",
raceId: "rom-human",
specialization: "Primary",
role: "healer",
statRuleset: "rom",
}, 30, 0, "native-recovery-party-member");
const member = {
...configured,
health: configured.maxHealth - 100,
resourcePools: { ...configured.resourcePools, mana: 0 },
nextActionAt: Number.MAX_SAFE_INTEGER,
};
usePartyStore.getState().initializeParty("native-recovery-party", 30, useCombatStore.getState().health, "healer", 1, "rom");
usePartyStore.setState({ members: [member], command: "stop", activeMobId: null });
useCombatStore.getState().synchronizeActors();
const baseStats = deriveCharacterStats(
member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId,
member.gender, "rom",
);
const startAt = Date.now() + 100_000;
advanceDungeonPartyCombat(startAt);
for (let second = 1; second <= 5; second += 1) advanceDungeonPartyCombat(startAt + second * 1_000);
const beforePulse = usePartyStore.getState().members[0]!;
expect(beforePulse.health).toBe(member.health);
expect(beforePulse.resourcePools.mana).toBe(0);
// Stop suppresses companion commands, but an engaged enemy still holds source attack mode.
useCombatStore.getState().registerMob("native-recovery-engaged-mob", {
name: "Recovery combat gate", maxHealth: 5_000, xpReward: 0,
});
useCombatStore.getState().damageMob("native-recovery-engaged-mob", 1, startAt + 5_500);
advanceDungeonPartyCombat(startAt + 6_000);
const inCombat = usePartyStore.getState().members[0]!;
expect(inCombat.health).toBe(member.health);
const expectedMana = romNativeRecoveryPulseAmounts(baseStats.romNative!, "party-npc", true).mana;
expect(inCombat.resourcePools.mana).toBeCloseTo(expectedMana, 6);
});
it("clears clock state on player and companion death, immediate revive, and party reset", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest", raceId: "rom-human", level: 30, statRuleset: "rom",
});
const startAt = Date.now() + 200_000;
useCombatStore.getState().tick(1, startAt);
expect(useCombatStore.getState().romNativeRecoveryClock?.elapsedMs).toBe(1_000);
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10, "physical", 30, startAt + 100);
expect(useCombatStore.getState().romNativeRecoveryClock).toBeUndefined();
useCombatStore.getState().revivePlayer();
expect(useCombatStore.getState().romNativeRecoveryClock).toBeUndefined();
const configured = createConfiguredPartyMember({
name: "Clock reset companion",
classId: "rom-priest",
raceId: "rom-human",
specialization: "Primary",
role: "healer",
statRuleset: "rom",
}, 30, 0, "native-recovery-clock-reset");
const elapsed = {
ruleset: "rom" as const,
actorKind: "party-npc" as const,
elapsedMs: 5_000,
};
usePartyStore.setState({ members: [{ ...configured, romNativeRecoveryClock: elapsed }] });
usePartyStore.getState().damageMember(configured.id, configured.maxHealth * 10);
expect(usePartyStore.getState().members[0]?.romNativeRecoveryClock).toBeUndefined();
usePartyStore.getState().reviveMember(configured.id);
expect(usePartyStore.getState().members[0]?.romNativeRecoveryClock).toBeUndefined();
usePartyStore.getState().resetCombat(30, useCombatStore.getState().health);
expect(usePartyStore.getState().members.every((candidate) => !candidate.romNativeRecoveryClock)).toBe(true);
});
});
+143
View File
@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import { applyAura } from "./combatAuras";
import { PLAYER_AURA_ENTITY_ID, auraAdjustedCharacterStats } from "./combatStore";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { deriveCharacterStats } from "./wotlkStats";
import {
advanceRomNativeRecoveryClock,
romNativePeriodicManaEffectDuring,
romNativePeriodicManaEffectPerSecond,
romNativeRecoveryPulseAmounts,
type RomNativeRecoveryClock,
} from "./romNativeRecovery";
const ACTIVE_FIVE = ["rom-knight", "rom-priest", "rom-scout", "rom-rogue", "rom-mage"] as const;
function aura(id: string, modifiers: NonNullable<Parameters<typeof applyAura>[1]["definition"]["modifiers"]>[number][], now: number, durationMs: number) {
return applyAura([], {
definition: {
id,
name: "Recovery test aura",
disposition: "buff",
durationMs,
modifiers,
},
sourceId: "recovery-test-source",
targetId: PLAYER_AURA_ENTITY_ID,
now,
}).auras[0]!;
}
describe("native RoM recovery policy", () => {
it("uses the same source ReHP/ReMP derivation for all five active class builds", () => {
for (const classId of ACTIVE_FIVE) {
const stats = deriveCharacterStats(
classId, 30, EMPTY_ITEM_STATS, "rom-human", {}, null, "male", "rom",
).romNative;
expect(stats, classId).toBeDefined();
expect(stats!.healthRecovery).toBeGreaterThan(0);
const playerIdle = romNativeRecoveryPulseAmounts(stats!, "player", false);
const npcIdle = romNativeRecoveryPulseAmounts(stats!, "party-npc", false);
const playerCombat = romNativeRecoveryPulseAmounts(stats!, "player", true);
const npcCombat = romNativeRecoveryPulseAmounts(stats!, "party-npc", true);
expect(playerIdle.health).toBeCloseTo(stats!.healthRecovery / 3);
expect(npcIdle.health).toBeCloseTo(stats!.healthRecovery * 0.66);
expect(playerCombat.health).toBe(0);
expect(npcCombat.health).toBe(0);
if (stats!.usesMana) {
expect(playerCombat.mana).toBeCloseTo(stats!.manaRecovery / 24);
expect(npcCombat.mana).toBeCloseTo(stats!.manaRecovery / 24);
expect(npcIdle.mana).toBeCloseTo(stats!.manaRecovery * 0.66);
} else {
expect(playerIdle.mana).toBe(0);
expect(npcCombat.mana).toBe(0);
}
}
});
it("retains the source player and owned-NPC pulse cadence across frame partitions", () => {
for (const actorKind of ["player", "party-npc"] as const) {
const period = actorKind === "player" ? 2_000 : 6_000;
let clock: RomNativeRecoveryClock | undefined;
let absolute = 0;
const pulses: number[] = [];
for (let i = 0; i < period / 100; i += 1) {
const advance = advanceRomNativeRecoveryClock(clock, "rom", actorKind, 100);
clock = advance.clock;
pulses.push(...advance.pulseOffsetsMs.map((offset) => absolute + offset));
absolute += 100;
}
expect(pulses).toEqual([period]);
expect(clock?.elapsedMs).toBe(0);
const partitioned = [40, 60, 250, period - 350].reduce<RomNativeRecoveryClock | undefined>(
(current, delta) => advanceRomNativeRecoveryClock(current, "rom", actorKind, delta).clock,
undefined,
);
expect(partitioned?.elapsedMs).toBe(0);
}
});
it("drops remainder on ruleset or actor-kind switches and never adds a WoW mana rule", () => {
const elapsed: RomNativeRecoveryClock = { ruleset: "rom", actorKind: "player", elapsedMs: 1_500 };
expect(advanceRomNativeRecoveryClock(elapsed, "wow", "player", 1_000)).toEqual({
clock: undefined,
pulseOffsetsMs: [],
});
expect(advanceRomNativeRecoveryClock(elapsed, "rom", "party-npc", 1_000)).toEqual({
clock: { ruleset: "rom", actorKind: "party-npc", elapsedMs: 0 },
pulseOffsetsMs: [],
});
const afterModeChange = advanceRomNativeRecoveryClock(undefined, "rom", "player", 500);
expect(afterModeChange.pulseOffsetsMs).toEqual([]);
expect(afterModeChange.clock?.elapsedMs).toBe(500);
});
it("keeps sitting, combat, and fractional recovery amounts in source point units", () => {
const stats = { healthRecovery: 3.75, manaRecovery: 7.125, usesMana: true };
expect(romNativeRecoveryPulseAmounts(stats, "player", false, true)).toEqual({
health: 3.75 * 0.66,
mana: 7.125 * 0.66,
});
expect(romNativeRecoveryPulseAmounts(stats, "player", true)).toEqual({ health: 0, mana: 7.125 / 24 });
expect(romNativeRecoveryPulseAmounts({ ...stats, usesMana: false }, "party-npc", false).mana).toBe(0);
});
it("maps only source-tagged ReHP/ReMP aura modifiers and samples expiry at pulse time", () => {
const base = deriveCharacterStats("rom-priest", 30, EMPTY_ITEM_STATS, "rom-human", {}, null, "male", "rom");
const sourceHealth = aura("rom-aura-test-health-regeneration", [
{ kind: "stat", stat: "health-regeneration", operation: "flat", value: 6 },
], 1_000, 1_000);
const sourceMana = aura("rom-aura-test-mana-regeneration", [
{ kind: "resource", resource: "mana", property: "regeneration", operation: "flat", value: 9 },
], 1_000, 1_000);
const unmarked = aura("compatibility:test-mana-regeneration", [
{ kind: "resource", resource: "mana", property: "regeneration", operation: "flat", value: 999 },
], 1_000, 1_000);
const sourceAuras = [sourceHealth, sourceMana, unmarked];
const aurasAt = (time: number) => sourceAuras.filter((active) => (
active.expiresAt === null || active.expiresAt > time
));
const beforeExpiry = auraAdjustedCharacterStats(base, aurasAt(1_999), PLAYER_AURA_ENTITY_ID, 1_999).romNative!;
const atExpiry = auraAdjustedCharacterStats(base, aurasAt(2_000), PLAYER_AURA_ENTITY_ID, 2_000).romNative!;
expect(beforeExpiry.healthRecovery).toBeCloseTo(base.romNative!.healthRecovery + 6);
expect(beforeExpiry.manaRecovery).toBeCloseTo(base.romNative!.manaRecovery + 9);
expect(atExpiry.healthRecovery).toBeCloseTo(base.romNative!.healthRecovery);
expect(atExpiry.manaRecovery).toBeCloseTo(base.romNative!.manaRecovery);
});
it("keeps explicit periodic Mana restoration separate from ReMP and excludes unmarked regen", () => {
const sourceFlat = aura("rom-resource-over-time-test-flat", [
{ kind: "resource", resource: "mana", property: "regeneration", operation: "flat", value: 4 },
], 1_000, 2_000);
const sourcePercent = aura("rom-resource-over-time-test-percent", [
{ kind: "stat", stat: "mana-regeneration-max-percent-per-second", operation: "flat", value: 0.01 },
], 2_000, 1_000);
const foreign = aura("wow:test-regen", [
{ kind: "resource", resource: "mana", property: "regeneration", operation: "flat", value: 1_000 },
], 1_000, 10_000);
expect(romNativePeriodicManaEffectPerSecond([sourceFlat, foreign], 500)).toBe(4);
expect(romNativePeriodicManaEffectDuring([sourceFlat, foreign], 1_500, 3_000, 500)).toBe(6);
expect(romNativePeriodicManaEffectDuring([sourcePercent], 2_000, 3_000, 500)).toBe(5);
});
});
+140
View File
@@ -0,0 +1,140 @@
import type { CharacterStatRuleset } from "./characterStatRuleset";
import type { ActiveAura } from "./combatAuras";
import type { RomNativeDerivedStats } from "./romNativeStatsTypes";
export type RomNativeRecoveryActorKind = "player" | "party-npc";
/** Runtime remainder for the source's 2 s player / 6 s owned-NPC recovery pulses. */
export interface RomNativeRecoveryClock {
readonly ruleset: "rom";
readonly actorKind: RomNativeRecoveryActorKind;
/** Elapsed milliseconds since the last pulse, always below the actor period. */
readonly elapsedMs: number;
}
export interface RomNativeRecoveryClockAdvance {
readonly clock: RomNativeRecoveryClock | undefined;
/** Pulse locations measured from the start of this update interval. */
readonly pulseOffsetsMs: readonly number[];
}
export interface RomNativeRecoveryPulseAmounts {
readonly health: number;
readonly mana: number;
}
export const ROM_NATIVE_PLAYER_RECOVERY_PERIOD_MS = 2_000;
export const ROM_NATIVE_OWNED_NPC_RECOVERY_PERIOD_MS = 6_000;
export function romNativeRecoveryPeriodMs(actorKind: RomNativeRecoveryActorKind): number {
return actorKind === "player"
? ROM_NATIVE_PLAYER_RECOVERY_PERIOD_MS
: ROM_NATIVE_OWNED_NPC_RECOVERY_PERIOD_MS;
}
/**
* Advances the source pulse clock without borrowing time across ruleset or actor changes.
* An uninitialized clock starts accumulating immediately; a mismatched clock is reset
* and deliberately does not credit the interval that crossed the mode boundary.
*/
export function advanceRomNativeRecoveryClock(
previous: RomNativeRecoveryClock | undefined,
statRuleset: CharacterStatRuleset,
actorKind: RomNativeRecoveryActorKind,
deltaMs: number,
): RomNativeRecoveryClockAdvance {
if (statRuleset !== "rom") return { clock: undefined, pulseOffsetsMs: [] };
const period = romNativeRecoveryPeriodMs(actorKind);
const compatible = previous?.ruleset === "rom" && previous.actorKind === actorKind;
if (previous && !compatible) {
return {
clock: Object.freeze({ ruleset: "rom", actorKind, elapsedMs: 0 }),
pulseOffsetsMs: [],
};
}
const elapsedBefore = compatible && Number.isFinite(previous.elapsedMs)
? Math.max(0, Math.min(period - Number.EPSILON, previous.elapsedMs))
: 0;
const safeDelta = Number.isFinite(deltaMs) ? Math.max(0, deltaMs) : 0;
const total = elapsedBefore + safeDelta;
const pulseCount = Math.floor(total / period);
const pulseOffsetsMs = Array.from({ length: pulseCount }, (_, index) => (
Math.max(0, period - elapsedBefore) + index * period
));
return {
clock: Object.freeze({ ruleset: "rom", actorKind, elapsedMs: total % period }),
pulseOffsetsMs,
};
}
/** One native HP/MP pulse; point values intentionally remain fractional. */
export function romNativeRecoveryPulseAmounts(
stats: Pick<RomNativeDerivedStats, "healthRecovery" | "manaRecovery" | "usesMana">,
actorKind: RomNativeRecoveryActorKind,
inCombat: boolean,
sitting = false,
): RomNativeRecoveryPulseAmounts {
const sourceHealthRecovery = Number.isFinite(stats.healthRecovery) ? Math.max(0, stats.healthRecovery) : 0;
const sourceManaRecovery = stats.usesMana && Number.isFinite(stats.manaRecovery)
? Math.max(0, stats.manaRecovery)
: 0;
if (inCombat) return Object.freeze({ health: 0, mana: sourceManaRecovery / 24 });
const idleFactor = actorKind === "party-npc" || sitting ? 0.66 : 1 / 3;
return Object.freeze({
health: sourceHealthRecovery * idleFactor,
mana: sourceManaRecovery * idleFactor,
});
}
/**
* Simulator representation of explicit source RoM resource-over-time bindings.
* These are kept separate from the character's source ReMP pulse calculation.
* Unmarked/shared WoW regeneration auras do not pass this source-ID gate.
*/
export function romNativePeriodicManaEffectPerSecond(
auras: readonly ActiveAura[],
maximumMana: number,
): number {
let flatPointsPerSecond = 0;
let percentOfMaximumPerSecond = 0;
for (const aura of auras) {
if (!aura.definition.id.startsWith("rom-resource-over-time-")) continue;
for (const modifier of aura.definition.modifiers ?? []) {
const amount = modifier.value * (modifier.perStack === false ? 1 : aura.stacks);
if (modifier.kind === "resource"
&& modifier.resource === "mana"
&& modifier.property === "regeneration"
&& modifier.operation === "flat") {
flatPointsPerSecond += amount;
} else if (modifier.kind === "stat"
&& modifier.stat === "mana-regeneration-max-percent-per-second"
&& modifier.operation === "flat") {
percentOfMaximumPerSecond += amount;
}
}
}
const safeMaximum = Number.isFinite(maximumMana) ? Math.max(0, maximumMana) : 0;
return flatPointsPerSecond + safeMaximum * percentOfMaximumPerSecond;
}
/** Integrates explicit RoM periodic Mana effects (including drains) over their live interval. */
export function romNativePeriodicManaEffectDuring(
auras: readonly ActiveAura[],
intervalStartAt: number,
intervalEndAt: number,
maximumMana: number,
): number {
if (!Number.isFinite(intervalStartAt) || !Number.isFinite(intervalEndAt) || intervalEndAt <= intervalStartAt) return 0;
let total = 0;
for (const aura of auras) {
if (!aura.definition.id.startsWith("rom-resource-over-time-")) continue;
const activeStart = Math.max(intervalStartAt, aura.appliedAt);
const activeEnd = Math.min(intervalEndAt, aura.expiresAt ?? intervalEndAt);
if (activeEnd <= activeStart) continue;
const perSecond = romNativePeriodicManaEffectPerSecond([aura], maximumMana);
total += perSecond * (activeEnd - activeStart) / 1_000;
}
return total;
}
@@ -0,0 +1,294 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import nativeStatRulesJson from "./generated/romNativeStatRules.generated.json";
import { deriveCharacterStats } from "./wotlkStats";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
import { createConfiguredPartyMember, usePartyStore, type PartyMember } from "./partyStore";
const LEVEL = 30;
const START_AT = 100_000;
const FIVE_PARTY_SEED = "rom-native-recovery-parity-five";
type RecoveryCoefficientRow = Readonly<Record<string, number>>;
const nativeSourceRules = nativeStatRulesJson as unknown as {
readonly variants: Readonly<Record<string, Readonly<Record<string, Readonly<Record<string, {
readonly coefficients: RecoveryCoefficientRow;
}>>>>>>;
};
function expectedSourceRecovery(member: PartyMember, channel: "healthRecovery" | "manaRecovery"): number {
const native = deriveCharacterStats(
member.classId,
member.level,
member.gearStats,
member.raceId,
{},
member.secondaryClassId,
member.gender,
"rom",
).romNative;
if (!native) throw new Error(`Expected native RoM stats for ${member.name}.`);
const coefficients = nativeSourceRules.variants[member.classId][member.raceId][member.gender].coefficients;
const health = channel === "healthRecovery";
const base = coefficients[health ? "baseHealthRecovery" : "baseManaRecovery"] ?? 0;
const attribute = health ? native.attributes.stamina : native.attributes.wisdom;
const coefficient = coefficients[health ? "healthRecoveryStamina" : "manaRecoveryWisdom"] ?? 0;
const bodyRecovery = member.gearStats.romNative?.[channel] ?? 0;
return Math.max(0, base + attribute * coefficient + bodyRecovery);
}
function memberNow(id: string): PartyMember {
const member = usePartyStore.getState().members.find((candidate) => candidate.id === id);
if (!member) throw new Error(`Missing party member ${id}.`);
return member;
}
function advancePartyAt(now: number): void {
advanceDungeonPartyCombat(now);
}
function advanceFromStartToPulse(startAt: number): void {
advancePartyAt(startAt);
for (const elapsed of [100, 1_100, 2_100, 3_100, 4_100, 5_100, 5_999, 6_000]) {
advancePartyAt(startAt + elapsed);
}
}
function configuredNativeFive(): readonly PartyMember[] {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
raceId: "rom-human",
gender: "male",
level: LEVEL,
statRuleset: "rom",
});
usePartyStore.getState().initializeSpectatorParty(FIVE_PARTY_SEED, LEVEL, "rom");
const members = usePartyStore.getState().members.map((member) => {
const native = deriveCharacterStats(
member.classId,
member.level,
member.gearStats,
member.raceId,
{},
member.secondaryClassId,
member.gender,
"rom",
).romNative!;
const resourcePools = {
...member.resourcePools,
...(native.usesMana ? { mana: 0 } : {}),
};
return {
...member,
health: Math.round(member.maxHealth * 0.4),
resource: member.resourceType === "mana" ? 0 : member.resource,
resourcePools,
status: "ready" as const,
nextActionAt: Number.MAX_SAFE_INTEGER,
};
});
usePartyStore.setState({ members, command: "stop", activeMobId: null, memberTargetIds: {} });
useCombatStore.getState().synchronizeActors();
return usePartyStore.getState().members;
}
function assertNoRecoveryBeforePulse(members: readonly PartyMember[], startAt: number): void {
const starting = new Map(members.map((member) => [member.id, {
health: memberNow(member.id).health,
mana: memberNow(member.id).resourcePools.mana,
}]));
advancePartyAt(startAt);
for (const elapsed of [100, 1_100, 2_100, 3_100, 4_100, 5_100, 5_999]) {
advancePartyAt(startAt + elapsed);
for (const member of members) {
const before = starting.get(member.id)!;
const after = memberNow(member.id);
expect(after.health, `${member.name} HP before the six-second pulse`).toBe(before.health);
if (member.resourcePools.mana !== undefined) {
expect(after.resourcePools.mana, `${member.name} Mana before the six-second pulse`).toBe(before.mana);
}
}
}
}
describe("native RoM recovery cadence integration", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useDungeonSimulationStore.getState().leave();
usePartyStore.setState({ members: [] });
});
it("uses source recovery points on six-second idle and engaged pulses for the five active RoM NPCs", () => {
const members = configuredNativeFive();
expect(members).toHaveLength(5);
for (const identity of ROM_FIVE_IDENTITIES) {
expect(members.some((member) => member.classId === identity.classId
&& member.name === identity.name
&& member.level === LEVEL
&& member.statRuleset === "rom")).toBe(true);
}
const starting = new Map(members.map((member) => [member.id, {
health: member.health,
mana: member.resourcePools.mana,
healthRecovery: expectedSourceRecovery(member, "healthRecovery"),
manaRecovery: expectedSourceRecovery(member, "manaRecovery"),
usesMana: deriveCharacterStats(
member.classId, member.level, member.gearStats, member.raceId, {},
member.secondaryClassId, member.gender, "rom",
).romNative!.usesMana,
}]));
assertNoRecoveryBeforePulse(members, START_AT);
advancePartyAt(START_AT + 6_000);
for (const member of members) {
const before = starting.get(member.id)!;
const after = memberNow(member.id);
expect(after.health, `${member.name} idle HP pulse`)
.toBeCloseTo(Math.min(member.maxHealth, before.health + before.healthRecovery * 0.66), 6);
if (before.usesMana) {
expect(after.resourcePools.mana, `${member.name} idle Mana pulse`)
.toBeCloseTo(before.mana! + before.manaRecovery * 0.66, 6);
}
}
const mobId = "rom-native-recovery-parity:engaged";
useCombatStore.getState().registerMob(mobId, { name: mobId, maxHealth: 10_000, xpReward: 0 });
useCombatStore.getState().damageMob(mobId, 1, START_AT + 6_000);
const beforeCombatPulse = new Map(members.map((member) => [member.id, {
health: memberNow(member.id).health,
mana: memberNow(member.id).resourcePools.mana,
}]));
for (const elapsed of [6_100, 7_100, 8_100, 9_100, 10_100, 11_100, 11_999]) {
advancePartyAt(START_AT + elapsed);
for (const member of members) {
const before = beforeCombatPulse.get(member.id)!;
const after = memberNow(member.id);
expect(after.health, `${member.name} combat HP before its pulse`).toBe(before.health);
if (member.resourcePools.mana !== undefined) {
expect(after.resourcePools.mana, `${member.name} combat Mana before its pulse`).toBe(before.mana);
}
}
}
advancePartyAt(START_AT + 12_000);
expect(usePartyStore.getState().command).toBe("stop");
for (const member of members) {
const before = beforeCombatPulse.get(member.id)!;
const after = memberNow(member.id);
expect(after.health, `${member.name} engaged HP pulse`).toBe(before.health);
const recovery = starting.get(member.id)!;
if (recovery.usesMana) {
expect(after.resourcePools.mana, `${member.name} engaged Mana pulse`)
.toBeCloseTo(before.mana! + recovery.manaRecovery / 24, 6);
}
}
});
it("regenerates secondary Mana for a Rogue/Priest NPC while its primary Energy pool advances normally", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-priest",
raceId: "rom-human",
gender: "male",
level: LEVEL,
statRuleset: "rom",
});
const rogue = createConfiguredPartyMember({
name: "Rogue/Priest recovery fixture",
classId: "rom-rogue",
secondaryClassId: "rom-priest",
raceId: "rom-human",
gender: "male",
specialization: "Rogue/Priest",
specializationId: "rom-rogue:rom-priest",
talentTreeId: "rom-rogue-rom-priest",
role: "damage",
statRuleset: "rom",
}, LEVEL, 0, "rom-native-recovery-rogue-priest");
const native = deriveCharacterStats(
rogue.classId, rogue.level, rogue.gearStats, rogue.raceId, {},
rogue.secondaryClassId, rogue.gender, "rom",
).romNative!;
expect(rogue.resourceType).toBe("energy");
expect(native.usesMana).toBe(true);
const injured = {
...rogue,
health: Math.round(rogue.maxHealth * 0.4),
resource: 0,
resourcePools: { ...rogue.resourcePools, energy: 0, mana: 0 },
status: "ready" as const,
nextActionAt: Number.MAX_SAFE_INTEGER,
};
usePartyStore.setState({ members: [injured], command: "stop", activeMobId: null, memberTargetIds: {} });
useCombatStore.getState().synchronizeActors();
const synchronized = memberNow(injured.id);
expect(synchronized.resourcePools.mana).toBe(0);
expect(synchronized.resourcePools.energy).toBe(0);
const healthRecovery = expectedSourceRecovery(synchronized, "healthRecovery");
const manaRecovery = expectedSourceRecovery(synchronized, "manaRecovery");
advanceFromStartToPulse(START_AT);
const recovered = memberNow(injured.id);
expect(recovered.health).toBeCloseTo(synchronized.health + healthRecovery * 0.66, 6);
expect(recovered.resourcePools.mana).toBeCloseTo(manaRecovery * 0.66, 6);
expect(recovered.resourcePools.energy).toBeCloseTo(60, 5);
});
it("keeps shared WoW regeneration for WoW-mode RoM players and NPCs, clearing stale native clocks", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-mage",
raceId: "rom-human",
gender: "male",
level: LEVEL,
statRuleset: "wow",
});
const player = useCombatStore.getState();
useCombatStore.setState({
health: Math.round(player.maxHealth * 0.5),
resource: 0,
resourcePools: { ...player.resourcePools, mana: 0 },
romNativeRecoveryClock: { ruleset: "rom", actorKind: "player", elapsedMs: 1_000 },
});
const npcBase = createConfiguredPartyMember({
name: "WoW-mode RoM Mage",
classId: "rom-mage",
raceId: "rom-human",
gender: "male",
specialization: "Primary",
role: "damage",
statRuleset: "wow",
}, LEVEL, 0, "rom-native-recovery-wow-mage");
const npc = {
...npcBase,
health: Math.round(npcBase.maxHealth * 0.5),
resource: 0,
resourcePools: { ...npcBase.resourcePools, mana: 0 },
romNativeRecoveryClock: { ruleset: "rom" as const, actorKind: "party-npc" as const, elapsedMs: 5_000 },
nextActionAt: Number.MAX_SAFE_INTEGER,
};
usePartyStore.setState({ members: [npc], command: "stop", activeMobId: null, memberTargetIds: {} });
useCombatStore.getState().synchronizeActors();
const playerManaBefore = useCombatStore.getState().resourcePools.mana ?? 0;
const npcHealthBefore = memberNow(npc.id).health;
const npcManaBefore = memberNow(npc.id).resourcePools.mana ?? 0;
useCombatStore.getState().tick(1, START_AT + 1_000);
advancePartyAt(START_AT);
advancePartyAt(START_AT + 1_000);
const finalPlayer = useCombatStore.getState();
const finalNpc = memberNow(npc.id);
expect(finalPlayer.statRuleset).toBe("wow");
expect(finalNpc.statRuleset).toBe("wow");
expect(finalPlayer.romNativeRecoveryClock).toBeUndefined();
expect(finalNpc.romNativeRecoveryClock).toBeUndefined();
expect(finalPlayer.resourcePools.mana).toBeGreaterThan(playerManaBefore);
expect(finalNpc.health).toBeGreaterThan(npcHealthBefore);
expect(finalNpc.resourcePools.mana).toBeGreaterThan(npcManaBefore);
});
});
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff. Show more