Update Healer Man server

This commit is contained in:
phenom committed 2026-09-23 16:14:19 -04:00
1 parent f90ba7d989
commit 189b94d6b7
51 files changed
+3448 -154

No files matched your search

+20
View File
@@ -5,6 +5,7 @@ import { installDungeonShellRuntime } from "./dungeonShellRuntime";
import { installManastormShellRuntime } from "./manastormShellRuntime";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
import { DUNGEON_MANIFEST } from "../game/dungeonManifest";
import { dungeonById } from "../game/dungeonCatalog";
import { useGameStore } from "../game/store";
import { useManastormStore } from "../game/manastormStore";
import { createEmptyManastormProgress, recordManastormCheckpoint } from "../game/manastormProgress";
@@ -94,6 +95,7 @@ describe("shell store", () => {
selectedCharacterId: null,
activeCharacter: null,
activeDungeonRole: "healer",
dungeonSelectionId: null,
notice: "",
});
useGameStore.getState().resetAtEntrance();
@@ -135,6 +137,24 @@ describe("shell store", () => {
expect(useGameStore.getState().distanceTravelled).toBe(42);
});
it("opens random full and authored Bite-Sized dungeons in the existing selector", () => {
prepareSelectedCharacter();
useShellStore.getState().openMainMenu();
expect(useShellStore.getState().openDungeons("random")).toBe(true);
const fullDungeon = dungeonById(useShellStore.getState().dungeonSelectionId ?? "");
expect(fullDungeon?.available).toBe(true);
expect(["wow", "rom"]).toContain(fullDungeon?.category);
useShellStore.getState().returnToMainMenu();
expect(useShellStore.getState().openDungeons("random-bite-sized")).toBe(true);
expect(useShellStore.getState().dungeonSelectionId).toBe("bite-sized-stormwind-stockade");
useShellStore.getState().returnToMainMenu();
expect(useShellStore.getState().openDungeons()).toBe(true);
expect(useShellStore.getState().dungeonSelectionId).toBeNull();
});
it("mounts the game and resets it only after choosing the available dungeon", () => {
prepareSelectedCharacter();
useShellStore.getState().openMainMenu();
+16 -3
View File
@@ -38,6 +38,7 @@ import {
import type { ContentCategoryId } from "./contentCategories";
import type { CharacterDraft, CharacterProfile, PlayerSession, ShellPhase } from "./types";
import type { DungeonId } from "../game/dungeonRegistry";
import { randomDungeonByCategory, randomRegularDungeon } from "../game/dungeonCatalog";
import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults";
import type { InventoryItem } from "../game/lootTypes";
import { emptyBossCardCollection, type BossCardCollection } from "../game/bossCards";
@@ -77,6 +78,7 @@ export interface ShellState {
draft: CharacterDraft;
notice: string;
activeDungeonId: DungeonId;
dungeonSelectionId: DungeonId | null;
activeDungeonRole: PartyRole;
combatPresentationSettings: CombatPresentationSettings;
beginSession: (session: PlayerSession) => void;
@@ -90,7 +92,7 @@ export interface ShellState {
createDraftCharacter: () => boolean;
deleteSelectedCharacter: () => void;
openMainMenu: () => boolean;
openDungeons: () => boolean;
openDungeons: (selection?: "random" | "random-bite-sized") => boolean;
openManastorms: () => boolean;
openArenas: () => boolean;
openGmAdmin: () => boolean;
@@ -166,6 +168,7 @@ const restored = restoredState();
export const useShellStore = create<ShellState>((set, get) => ({
...restored,
activeDungeonId: DEFAULT_DUNGEON_ID,
dungeonSelectionId: null,
activeDungeonRole: "healer",
activeCharacter: null,
draft: defaultDraft(),
@@ -319,7 +322,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ phase: "main-menu", activeCharacter: null, notice: "" });
return true;
},
openDungeons: () => {
openDungeons: (selection) => {
const { session, selectedCharacterId, characters } = get();
if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
@@ -330,7 +333,17 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." });
return false;
}
set({ phase: "dungeons", activeCharacter: null, notice: "" });
const dungeon = selection === "random" ? randomRegularDungeon()
: selection === "random-bite-sized" ? randomDungeonByCategory("bite-sized")
: null;
if (selection && !dungeon) {
set({ notice: selection === "random"
? "No regular dungeon is ready to enter."
: "No regular Bite-Sized dungeon is ready to enter." });
return false;
}
set({ phase: "dungeons", activeCharacter: null, dungeonSelectionId: dungeon?.id ?? null,
notice: dungeon ? `Randomly selected ${dungeon.title}.` : "" });
return true;
},
openManastorms: () => {
+1 -1
View File
@@ -157,7 +157,7 @@ export interface AbilityDamageResource {
}
export type AbilityEffect =
| ({ readonly kind: "damage"; readonly coefficient: number } & AbilityAmountScaling & AbilityDamageResource)
| ({ readonly kind: "damage"; readonly coefficient: number; readonly bonusPerSunderStack?: number; readonly bonusFlat?: number } & AbilityAmountScaling & AbilityDamageResource)
| {
readonly kind: "finisher-damage";
readonly baseCoefficient: number;
+6 -1
View File
@@ -14,7 +14,7 @@ const CONE_ABILITIES = new Set([
"cone of cold", "dragon's breath", "dragons breath", "shockwave", "cleave",
"swipe", "swipe (cat)", "whirlwind",
]);
const BEHIND_ABILITIES = new Set(["backstab", "ambush", "garrote", "ravage", "shred", "pounce"]);
const BEHIND_ABILITIES = new Set(["backstab", "ambush", "garrote", "ravage", "shred", "pounce", "blind spot", "sneak attack"]);
const MOVEMENT_CAST_ABILITIES = new Set(["scorch", "steady shot", "spiritwalker's grace"]);
const EMERGENCY_HEAL_ABILITIES = new Set([
"flash heal", "flash of light", "swiftmend", "lay on hands", "nature's swiftness",
@@ -71,6 +71,11 @@ export function canAbilityCastWhileMoving(ability: AbilityDefinition): boolean {
return ability.castRules?.canCastWhileMoving ?? MOVEMENT_CAST_ABILITIES.has(ability.name.toLowerCase());
}
export function abilityRequiresOutOfCombat(ability: AbilityDefinition): boolean {
return ability.source === "runewaker"
&& /\bcannot be used (?:when already )?in combat\b/i.test(ability.description);
}
export function abilityPushbackResistance(ability: AbilityDefinition): number {
if (ability.castRules?.pushbackImmune) return 1;
return Math.max(0, Math.min(1, ability.castRules?.pushbackResistance ?? 0));
+5
View File
@@ -45,6 +45,8 @@ import {
type TalentUiTree,
} from "../talentUiCatalog";
import type { BenchmarkBuildDefinition } from "./benchmarkTypes";
import { romFiveIdentity } from "../romFiveIdentities";
import { ROM_SECONDARY_UNLOCK_LEVEL } from "../../app/romDualClass";
import {
READY_COMPANION_SPECIALIZATION_IDS,
readinessStatusFor,
@@ -401,8 +403,11 @@ function romBuild(
role: PartyRole,
level: number,
): BenchmarkBuildDefinition {
const identity = romFiveIdentity(classId, role);
return Object.freeze({
...commonBuildFields(classId, role, level),
secondaryClassId: identity && level >= ROM_SECONDARY_UNLOCK_LEVEL ? identity.secondaryClassId : null,
...(identity ? { raceId: identity.raceId, characterName: identity.name } : {}),
orientation: "Primary",
talentRanks: EMPTY_TALENT_RANKS,
});
+1
View File
@@ -279,6 +279,7 @@ export async function runCombatBenchmark(
.find((specialization) => specialization.displayName === build.orientation)?.id;
combat.initializeCharacter({
classId: build.classId,
raceId: build.raceId,
secondaryClassId: build.secondaryClassId,
specializationId,
level: build.level,
+2 -1
View File
@@ -1,4 +1,4 @@
import type { ClassId } from "../../app/characterCatalog";
import type { ClassId, RaceId } from "../../app/characterCatalog";
import type { ContentCategoryId } from "../../app/contentCategories";
import type { EquipmentAssignments } from "../equipment";
import type { ItemStats } from "../itemStats";
@@ -47,6 +47,7 @@ export interface BenchmarkBuildDefinition {
readonly id: string;
readonly source: ContentCategoryId;
readonly classId: ClassId;
readonly raceId?: RaceId;
readonly secondaryClassId?: ClassId | null;
readonly role: PartyRole;
readonly level: number;
+2 -2
View File
@@ -24,7 +24,7 @@ const scenario: BenchmarkScenarioDefinition = {
export async function runDungeonBossBenchmark(
build: BenchmarkBuildDefinition, bossId: string, seed: string, mechanics = false,
) {
await preloadAbilityCatalogsForClasses([build.classId]);
await preloadAbilityCatalogsForClasses(build.secondaryClassId ? [build.classId, build.secondaryClassId] : [build.classId]);
const dungeon = requireDungeonDefinition("ragefire-chasm");
const spawn = dungeon.staticSpawns.find((entry) => entry.id === bossId)!;
const entity = dungeon.entities[spawn.entityId];
@@ -56,7 +56,7 @@ export async function runDungeonBossBenchmark(
});
try {
const combat = useCombatStore.getState();
combat.initializeCharacter({ classId: build.classId, secondaryClassId: build.secondaryClassId,
combat.initializeCharacter({ classId: build.classId, raceId: build.raceId, secondaryClassId: build.secondaryClassId,
specializationId: specialization?.id, role: build.role, level: build.level,
talentRanks: build.talentRanks, inventory: build.inventory, equipment: build.equipment });
combat.setPlayerPosition([-2, 0, 0]);
+2 -2
View File
@@ -50,10 +50,10 @@ describe("boss card rewards", () => {
useGameStore.getState().activateDungeon("wailing-caverns");
});
it.each([1, 40, 79, 80])("preserves non-equipment while allowing equipment only at 80 (level %i)", (level) => {
it.each([1, 40, 79, 80])("grants boss items only after leveling (level %i)", (level) => {
const item = { instanceId: "i", itemId: 1, itemKey: "item", name: "Item", quality: "rare" as const, inventorySlot: "waist", armorType: "cloth", weaponType: null, itemLevel: level, sourceItemLevel: 1, stats: EMPTY_ITEM_STATS, quantity: 1, sourceBossKey: "boss", sourceBossName: "Boss", acquiredAt: 1 };
const misc = { ...item, instanceId: "m", inventorySlot: "misc" };
expect(allowedLootDrops([item, misc], level)).toEqual(level === 80 ? [item, misc] : [misc]);
expect(allowedLootDrops([item, misc], level)).toEqual(level === 80 ? [item, misc] : []);
});
});
+10 -1
View File
@@ -142,6 +142,8 @@ export interface AuraDefinition {
/** Null means the aura persists until explicitly removed. */
readonly durationMs: number | null;
readonly maxStacks?: number;
/** Initial stack count for charged buffs such as Inner Fire. */
readonly initialStacks?: number;
readonly stackBehavior?: AuraStackBehavior;
/** Defaults to true. False makes applications from all sources share one aura. */
readonly uniqueBySource?: boolean;
@@ -172,6 +174,8 @@ export interface ActiveAura {
readonly stacks: number;
readonly absorbs: readonly AuraAbsorbState[];
readonly procStates: readonly AuraProcState[];
/** Number of Fire critical hits landed during the current Combustion buff. */
readonly combustionCriticalHits?: number;
}
export interface ApplyAuraRequest {
@@ -319,13 +323,18 @@ export function applyAura(
): AuraApplicationResult {
const { definition } = request;
const maxStacks = boundedPositiveInteger(definition.maxStacks, 1);
const requestedStacks = Math.min(maxStacks, boundedPositiveInteger(request.stacks, 1));
const requestedStacks = Math.min(maxStacks, boundedPositiveInteger(request.stacks ?? definition.initialStacks, 1));
const instanceId = auraIdentity(definition, request.sourceId, request.targetId);
// Mage armors have several DBC effects that coexist until another armor
// replaces them. Other exclusive groups retain their single-aura behavior.
const exclusiveSpellName = definition.name.replace(/, Aura \d+\)$/, ")");
const exclusive = definition.exclusiveGroup
? current.filter((aura) => (
aura.targetId === request.targetId
&& aura.definition.exclusiveGroup === definition.exclusiveGroup
&& aura.instanceId !== instanceId
&& (definition.exclusiveGroup !== "mage:armor"
|| aura.definition.name.replace(/, Aura \d+\)$/, ")") !== exclusiveSpellName)
))
: [];
const exclusiveIds = new Set(exclusive.map((aura) => aura.instanceId));
+14
View File
@@ -140,6 +140,20 @@ describe("combat presentation", () => {
expect(originalCombatPhaseAssets(entry, "release")).toEqual([]);
});
it("plays the original Holy Nova burst once at impact", () => {
const manifest = JSON.parse(readFileSync(
path.resolve("public/assets/game/combat-effects/manifest.json"),
"utf8",
)) as { readonly entries: readonly CombatEffectManifestEntry[] };
for (const spellId of [15237, 15430, 15431, 27799, 27800, 27801, 25331, 48077, 48078]) {
const entry = manifest.entries.find((candidate) => candidate.spellId === spellId && candidate.source === "wow335");
expect(originalCombatPhaseAssets(entry, "cast-start", "wow335-priest-holy-nova")).toEqual([]);
expect(originalCombatPhaseAssets(entry, "release", "wow335-priest-holy-nova")).toEqual([]);
expect(originalCombatPhaseAssets(entry, "impact", "wow335-priest-holy-nova").map((asset) => asset.model))
.toEqual(["spells/holynova_impact_base.mdx"]);
}
});
it("keeps every source-backed instant WoW rank on a renderable phase", () => {
const manifest = JSON.parse(readFileSync(
path.resolve("public/assets/game/combat-effects/manifest.json"),
+12 -2
View File
@@ -247,8 +247,13 @@ export function combatEffectManifestEntry(
export function originalCombatPhaseAssets(
entry: CombatEffectManifestEntry | null | undefined,
phase: CombatPresentationPhase,
abilityId?: string,
): readonly CombatEffectPhaseAsset[] {
return (entry?.phases?.[phase] ?? []).filter((asset) => (
const sourcePhase = abilityId === "wow335-priest-holy-nova" && phase === "impact"
? "cast-start"
: phase;
if (abilityId === "wow335-priest-holy-nova" && phase !== "impact") return [];
return (entry?.phases?.[sourcePhase] ?? []).filter((asset) => (
typeof asset.effectGraphUrl === "string"
&& asset.effectGraphUrl.startsWith("/assets/game/combat-effects/original/")
&& (
@@ -257,15 +262,20 @@ export function originalCombatPhaseAssets(
|| asset.effectKind === "wow-m2-static-mesh"
|| asset.effectKind === "runewaker-particles"
)
&& (abilityId !== "wow335-priest-holy-nova"
|| /(?:^|\/)holynova_impact_base\.mdx$/i.test(asset.model ?? ""))
));
}
export function combatOriginalPhaseAssets(
event: Pick<CombatPresentationEvent, "phase" | "source" | "sourceSpellId">,
event: Pick<CombatPresentationEvent, "phase" | "source" | "sourceSpellId" | "abilityId">,
): readonly CombatEffectPhaseAsset[] {
// The imported Holy Nova instant kit puts the burst alongside two precast
// flashes. Route only that burst to impact so it plays once per cast.
return originalCombatPhaseAssets(
combatEffectManifestEntry(event.sourceSpellId, event.source),
event.phase,
event.abilityId,
);
}
+4 -1
View File
@@ -562,7 +562,10 @@ function scoreAbility(
if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction
&& context.role !== "healer" && profileIndex < 0 && damaging) return null;
if (isComboFinisher(ability) && (context.comboPoints < 4 || context.comboTargetId !== context.hostileTarget?.id)) return null;
if (isComboBuilder(ability) && context.comboPoints >= 5 && context.comboTargetId === context.hostileTarget?.id) return null;
// Shiv may still be needed to add a missing poison dose at five points;
// partyRuntime's dose gate decides whether that maintenance hit is useful.
if (isComboBuilder(ability) && ability.name !== "Shiv"
&& context.comboPoints >= 5 && context.comboTargetId === context.hostileTarget?.id) return null;
let score = hints.priority ?? 0;
let ruleId = "semantic-fallback";
+343 -1
View File
@@ -19,9 +19,11 @@ import {
} from "./combatStore";
import { TALENT_NODES, talentNodesForTree } from "./talentCatalog";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { abilitiesForClass, abilityAtLevel, defaultActionBarForClass } from "./abilityCatalog";
import { abilitiesForClass, abilityAtLevel, abilityTalentNodeId, defaultActionBarForClass } from "./abilityCatalog";
import { defaultActionBindings } from "./actionBindings";
import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
import { applyAura as applyCombatAura } from "./combatAuras";
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
@@ -50,6 +52,13 @@ function registerTarget(
useCombatStore.getState().selectMob(id);
}
function unlockReadySpell(id: string): void {
const ability = useCombatStore.getState().abilities.find((entry) => entry.id === id);
expect(ability, id).toBeDefined();
const nodeId = abilityTalentNodeId(ability!);
if (nodeId) useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, [nodeId]: 1 } }));
}
function setPartyMemberHealth(id: string, health: number): void {
usePartyStore.setState((state) => ({
members: state.members.map((member) => member.id === id
@@ -198,6 +207,34 @@ describe("combat store", () => {
unsubscribe();
});
it("damages nearby enemies and heals nearby allies with one Holy Nova cast", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
registerTarget("nova-near", [3, 0, 0], 100_000, 0);
useCombatStore.getState().registerMob("nova-far", { name: "nova-far", maxHealth: 100_000, xpReward: 0 });
setMobPosition("nova-far", [15, 0, 0]);
const nearbyAlly = preparePartyMember(0, [3, 0, 0], 1);
const start = useCombatStore.getState();
useCombatStore.setState({ health: start.maxHealth - 1_000 });
const events: CombatPresentationEvent[] = [];
const unsubscribe = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.abilityId === "wow335-priest-holy-nova") {
events.push(message.event);
}
});
const result = useCombatStore.getState().castAbility("wow335-priest-holy-nova", [0, 0, 0], 1_000);
unsubscribe();
expect(result).toMatchObject({ ok: true, affectedMobIds: ["nova-near"] });
expect(result.damage).toBeGreaterThan(0);
expect(result.healing).toBeGreaterThan(0);
expect(useCombatStore.getState().mobs["nova-near"].health).toBeLessThan(100_000);
expect(useCombatStore.getState().mobs["nova-far"].health).toBe(100_000);
expect(useCombatStore.getState().health).toBeGreaterThan(start.maxHealth - 1_000);
expect(usePartyStore.getState().members.find((member) => member.id === nearbyAlly)?.health).toBeGreaterThan(1);
expect(events.map((event) => event.phase)).toEqual(["release", "impact"]);
});
it("identifies the player as the target of a self-cast aura presentation", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
const events: CombatPresentationEvent[] = [];
@@ -1734,4 +1771,309 @@ describe("combat store", () => {
expect(useCombatStore.getState().mobs["storm-boss"].pendingLoot).toHaveLength(0);
expect(useCombatStore.getState().mobs["storm-boss"].bossCardDrop?.rarities).toContain("normal");
});
it("stacks Sunder through Devastate and grants Last Stand temporary maximum health", () => {
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80 });
unlockReadySpell("wow335-warrior-devastate");
unlockReadySpell("wow335-warrior-last-stand");
useCombatStore.setState((state) => ({ resource: 100, resourcePools: { ...state.resourcePools, rage: 100 } }));
registerTarget("sunder-target", [2, 0, 0], 100_000, 0);
expect(useCombatStore.getState().castAbility("wow335-warrior-devastate", [0, 0, 0], 1_000).ok).toBe(true);
expect(useCombatStore.getState().auras.find((aura) => aura.targetId === "sunder-target"
&& aura.definition.id === "wow335:warrior:sunder-armor")?.stacks).toBe(1);
expect(useCombatStore.getState().castAbility("wow335-warrior-devastate", [0, 0, 0], 3_000).ok).toBe(true);
expect(useCombatStore.getState().auras.find((aura) => aura.targetId === "sunder-target"
&& aura.definition.id === "wow335:warrior:sunder-armor")?.stacks).toBe(2);
const before = useCombatStore.getState().maxHealth;
expect(useCombatStore.getState().castAbility("wow335-warrior-last-stand", [0, 0, 0], 5_000).ok).toBe(true);
expect(useCombatStore.getState().maxHealth).toBeGreaterThan(before * 1.29);
useCombatStore.getState().tick(0, 24_999);
expect(useCombatStore.getState().maxHealth).toBeGreaterThan(before * 1.29);
useCombatStore.getState().tick(0, 26_000);
expect(useCombatStore.getState().maxHealth).toBe(before);
});
it("requires and consumes Enrage for Enraged Regeneration", () => {
useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 80 });
unlockReadySpell("wow335-warrior-enraged-regeneration");
useCombatStore.setState((state) => ({ resource: 100, resourcePools: { ...state.resourcePools, rage: 100 } }));
expect(useCombatStore.getState().castAbility("wow335-warrior-enraged-regeneration", [0, 0, 0], 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
const enrage = applyCombatAura(useCombatStore.getState().auras, {
definition: { id: "test:enrage", name: "Enrage", disposition: "buff", dispelCategory: "enrage", durationMs: 12_000 },
sourceId: PLAYER_AURA_ENTITY_ID,
targetId: PLAYER_AURA_ENTITY_ID,
now: 1_000,
});
useCombatStore.setState({ auras: enrage.auras });
expect(useCombatStore.getState().castAbility("wow335-warrior-enraged-regeneration", [0, 0, 0], 2_000).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === "test:enrage")).toBe(false);
expect(useCombatStore.getState().effects.some((effect) => effect.kind === "hot"
&& effect.sourceAbilityId === "wow335-warrior-enraged-regeneration")).toBe(true);
});
it("prevents recasting Power Word: Shield until Weakened Soul expires", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
expect(useCombatStore.getState().castAbility("wow335-priest-power-word-shield", [0, 0, 0], 1_000).ok).toBe(true);
expect(useCombatStore.getState().auras.find((aura) => aura.definition.id === "wow335:priest:weakened-soul")?.expiresAt)
.toBe(31_000);
expect(useCombatStore.getState().castAbility("wow335-priest-power-word-shield", [0, 0, 0], 10_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
useCombatStore.getState().tick(0, 31_000);
expect(useCombatStore.getState().castAbility("wow335-priest-power-word-shield", [0, 0, 0], 31_000).ok).toBe(true);
});
it("grants Inner Fire armor for 20 landed physical hits", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
expect(useCombatStore.getState().castAbility("wow335-priest-inner-fire", [0, 0, 0], 1_000).ok).toBe(true);
const charges = () => useCombatStore.getState().auras.find((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.name.startsWith("Inner Fire (DBC ") && aura.definition.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "armor"
)))?.stacks;
expect(charges()).toBe(20);
registerTarget("inner-fire-attacker", [2, 0, 0], 100_000, 0);
useCombatStore.getState().damageMob("inner-fire-attacker", 1, 1_000);
for (let now = 1_450; now < 15_000 && charges() === 20; now += 1_900) {
useCombatStore.getState().advanceMobCombat(now);
}
expect(charges()).toBe(19);
expect(useCombatStore.getState().lastCombatResult?.effectiveArmor).toBeGreaterThan(2_000);
});
it("makes Evasion reduce incoming ranged hit chance", () => {
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 });
expect(useCombatStore.getState().castAbility("wow335-rogue-evasion", [0, 0, 0], 1_000).ok).toBe(true);
useCombatStore.getState().registerMob("evasion-attacker", {
name: "Evasion attacker", level: 80, maxHealth: 100_000, xpReward: 0,
attacks: [{ ...DEFAULT_ENEMY_ATTACK, id: "evasion-arrow", delivery: "projectile", range: 30 }],
});
setMobPosition("evasion-attacker", [8, 0, 0]);
useCombatStore.getState().damageMob("evasion-attacker", 1, 1_000);
useCombatStore.getState().advanceMobCombat(1_450);
expect(useCombatStore.getState().lastCombatResult?.attackTable.missPercent).toBe(30);
});
it("summons one Shadowfiend and restores mana only after its attacks", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
registerTarget("fiend-target", [3, 0, 0], 100_000, 0);
useCombatStore.setState((state) => ({
resource: Math.round(state.maxResource / 2),
resourcePools: { ...state.resourcePools, mana: Math.round(state.maxResource / 2) },
}));
expect(useCombatStore.getState().castAbility("wow335-priest-shadowfiend", [0, 0, 0], 1_000).ok).toBe(true);
expect(useCombatStore.getState().summons.filter((summon) => summon.sourceAbilityId === "wow335-priest-shadowfiend"))
.toHaveLength(1);
const manaBeforeAttack = useCombatStore.getState().resource;
useCombatStore.getState().tick(1, 2_000);
expect(useCombatStore.getState().mobs["fiend-target"].health).toBeLessThan(100_000);
expect(useCombatStore.getState().resource).toBeGreaterThanOrEqual(manaBeforeAttack + Math.floor(useCombatStore.getState().maxResource * 0.01));
});
it("casts Kill Shot only below 20 percent health and resolves one direct hit", () => {
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 });
registerTarget("kill-shot-target", [10, 0, 0], 100_000, 0);
expect(useCombatStore.getState().castAbility("wow335-hunter-kill-shot", [0, 0, 0], 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
useCombatStore.setState((state) => ({ mobs: {
...state.mobs,
"kill-shot-target": { ...state.mobs["kill-shot-target"], health: 20_000 },
} }));
expect(useCombatStore.getState().castAbility("wow335-hunter-kill-shot", [0, 0, 0], 3_000).ok).toBe(true);
expect(useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
&& event.abilityId === "wow335-hunter-kill-shot")).toHaveLength(1);
});
it("summons three Mirror Images and gives Combustion Fire hit stacks", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
unlockReadySpell("wow335-mage-combustion");
registerTarget("fire-target", [8, 0, 0], 100_000, 0);
expect(useCombatStore.getState().castAbility("wow335-mage-mirror-image", [0, 0, 0], 1_000).ok).toBe(true);
expect(useCombatStore.getState().summons.filter((summon) => summon.sourceAbilityId === "wow335-mage-mirror-image"))
.toHaveLength(3);
expect(useCombatStore.getState().castAbility("wow335-mage-combustion", [0, 0, 0], 3_000).ok).toBe(true);
expect(useCombatStore.getState().mobs["fire-target"].health).toBe(100_000);
const combustionId = "wow335:mage:combustion";
expect(useCombatStore.getState().auras.find((aura) => aura.definition.id === combustionId)?.stacks).toBe(1);
const fireBlast = useCombatStore.getState().abilities.find((ability) => ability.name === "Fire Blast")!;
expect(useCombatStore.getState().castAbility(fireBlast.id, [0, 0, 0], 5_000).ok).toBe(true);
expect(useCombatStore.getState().auras.find((aura) => aura.definition.id === combustionId)?.stacks).toBe(2);
useCombatStore.setState((state) => ({ auras: state.auras.map((aura) => aura.definition.id === combustionId
? { ...aura, stacks: 100, combustionCriticalHits: 2 }
: aura) }));
expect(useCombatStore.getState().castAbility(fireBlast.id, [0, 0, 0], 14_000).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === combustionId)).toBe(false);
});
it("scales Rupture duration and ticks with combo points", () => {
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 });
registerTarget("rupture-target", [2, 0, 0], 100_000, 0);
useCombatStore.setState({ comboPoints: 1, comboTargetId: "rupture-target" });
expect(useCombatStore.getState().castAbility("wow335-rogue-rupture", [0, 0, 0], 1_000).ok).toBe(true);
expect(useCombatStore.getState().effects.find((effect) => effect.sourceAbilityId === "wow335-rogue-rupture")?.remainingTicks)
.toBe(4);
const firstAura = useCombatStore.getState().auras.find((aura) => aura.targetId === "rupture-target"
&& aura.definition.name.startsWith("Rupture"));
expect(firstAura?.expiresAt).toBe(9_000);
useCombatStore.setState({ comboPoints: 5, comboTargetId: "rupture-target", resource: 100 });
expect(useCombatStore.getState().castAbility("wow335-rogue-rupture", [0, 0, 0], 3_000).ok).toBe(true);
expect(useCombatStore.getState().effects.find((effect) => effect.sourceAbilityId === "wow335-rogue-rupture")?.remainingTicks)
.toBe(8);
expect(useCombatStore.getState().auras.find((aura) => aura.targetId === "rupture-target"
&& aura.definition.name.startsWith("Rupture"))?.expiresAt).toBe(19_000);
});
it("lets Penance damage an enemy and continue pulsing", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
unlockReadySpell("wow335-priest-penance");
registerTarget("penance-target", [8, 0, 0], 100_000, 0);
expect(useCombatStore.getState().castAbility("wow335-priest-penance", [0, 0, 0], 1_000).ok).toBe(true);
const first = useCombatStore.getState().mobs["penance-target"].health;
expect(first).toBeLessThan(100_000);
useCombatStore.getState().tick(0, 2_000);
expect(useCombatStore.getState().mobs["penance-target"].health).toBeLessThan(first);
useCombatStore.getState().tick(0, 3_000);
expect(useCombatStore.getState().effects.some((effect) => effect.sourceAbilityId === "wow335-priest-penance")).toBe(false);
});
it("lets Penance heal a friendly target immediately and twice afterward", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
unlockReadySpell("wow335-priest-penance");
usePartyStore.getState().selectSelf();
useCombatStore.getState().damagePlayer(2_000);
const before = useCombatStore.getState().health;
expect(useCombatStore.getState().castAbility("wow335-priest-penance", [0, 0, 0], 1_000).ok).toBe(true);
const first = useCombatStore.getState().health;
expect(first).toBeGreaterThan(before);
useCombatStore.getState().tick(0, 2_000);
expect(useCombatStore.getState().health).toBeGreaterThan(first);
useCombatStore.getState().tick(0, 3_000);
expect(useCombatStore.getState().effects.some((effect) => effect.sourceAbilityId === "wow335-priest-penance")).toBe(false);
});
it("selects only the three lowest-health nearby Divine Hymn targets per two-second pulse", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
usePartyStore.getState().initializeParty("hymn-cap-test", 80, useCombatStore.getState().health);
const ids = [0, 1, 2, 3].map((index) => preparePartyMember(index,
[4 + index * 2, 0, 0], index + 1));
const before = usePartyStore.getState().members
.filter((member) => ids.includes(member.id))
.map((member) => ({ id: member.id, health: member.health, fraction: member.health / member.maxHealth }))
.sort((left, right) => left.fraction - right.fraction || left.id.localeCompare(right.id));
expect(useCombatStore.getState().castAbility("wow335-priest-divine-hymn", [0, 0, 0], 1_000).ok).toBe(true);
expect(useCombatStore.getState().activeCast?.totalTicks).toBe(4);
useCombatStore.getState().tick(0, 3_000);
const members = usePartyStore.getState().members;
expect(before.slice(0, 3).every((entry) => members.find((member) => member.id === entry.id)!.health > entry.health)).toBe(true);
expect(members.find((member) => member.id === before[3].id)!.health).toBe(before[3].health);
});
it("refreshes Serpent Sting and triggers its Chimera Shot damage", () => {
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 });
unlockReadySpell("wow335-hunter-chimera-shot");
registerTarget("sting-target", [10, 0, 0], 100_000, 0);
const stingId = useCombatStore.getState().abilities.find((ability) => ability.name === "Serpent Sting")!.id;
expect(useCombatStore.getState().castAbility(stingId, [0, 0, 0], 1_000).ok).toBe(true);
const dotBefore = useCombatStore.getState().effects.find((effect) => effect.sourceAbilityId === stingId);
expect(dotBefore).toBeDefined();
expect(useCombatStore.getState().castAbility("wow335-hunter-chimera-shot", [0, 0, 0], 3_000).ok).toBe(true);
const dotAfter = useCombatStore.getState().effects.find((effect) => effect.sourceAbilityId === stingId);
expect(dotAfter?.nextTickAt).toBe(3_000 + dotBefore!.intervalMs);
expect(dotAfter?.remainingTicks).toBeGreaterThanOrEqual(dotBefore!.remainingTicks);
expect(useCombatStore.getState().combatEvents.filter((event) => event.abilityId === "wow335-hunter-chimera-shot"
&& event.kind === "damage")).toHaveLength(2);
});
it("restores mana from Viper Sting and limits Scorpid Sting disarm to once per minute", () => {
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 });
registerTarget("sting-utility-target", [10, 0, 0], 100_000, 0);
const viperId = useCombatStore.getState().abilities.find((ability) => ability.name === "Viper Sting")!.id;
expect(useCombatStore.getState().castAbility(viperId, [0, 0, 0], 1_000).ok).toBe(true);
useCombatStore.setState((state) => ({
resource: state.maxResource / 2,
resourcePools: { ...state.resourcePools, mana: state.maxResource / 2 },
}));
const manaBefore = useCombatStore.getState().resource;
useCombatStore.getState().triggerChimeraShot(PLAYER_AURA_ENTITY_ID, "sting-utility-target", "wow335-hunter-chimera-shot", 2_000);
expect(useCombatStore.getState().resource).toBeGreaterThan(manaBefore);
useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 });
registerTarget("scorpid-target", [10, 0, 0], 100_000, 0);
const scorpidId = useCombatStore.getState().abilities.find((ability) => ability.name === "Scorpid Sting")!.id;
expect(useCombatStore.getState().castAbility(scorpidId, [0, 0, 0], 1_000).ok).toBe(true);
useCombatStore.getState().triggerChimeraShot(PLAYER_AURA_ENTITY_ID, "scorpid-target", "wow335-hunter-chimera-shot", 2_000);
const disarm = useCombatStore.getState().mobs["scorpid-target"].statuses.find((status) => status.kind === "disarm");
expect(disarm?.endsAt).toBe(12_000);
useCombatStore.getState().triggerChimeraShot(PLAYER_AURA_ENTITY_ID, "scorpid-target", "wow335-hunter-chimera-shot", 3_000);
expect(useCombatStore.getState().mobs["scorpid-target"].statuses.find((status) => status.kind === "disarm")?.endsAt).toBe(12_000);
});
it("explodes Living Bomb onto nearby enemies when its last tick completes", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
unlockReadySpell("wow335-mage-living-bomb");
registerTarget("bomb-target", [5, 0, 0], 100_000, 0);
useCombatStore.getState().registerMob("bomb-neighbor", { name: "bomb-neighbor", maxHealth: 100_000, xpReward: 0 });
setMobPosition("bomb-neighbor", [7, 0, 0]);
expect(useCombatStore.getState().castAbility("wow335-mage-living-bomb", [0, 0, 0], 1_000).ok).toBe(true);
expect(useCombatStore.getState().mobs["bomb-neighbor"].health).toBe(100_000);
useCombatStore.getState().tick(0, 13_000);
expect(useCombatStore.getState().mobs["bomb-neighbor"].health).toBeLessThan(100_000);
});
it("detonates Living Bomb once when its magic aura is dispelled", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
unlockReadySpell("wow335-mage-living-bomb");
registerTarget("dispel-bomb", [5, 0, 0], 100_000, 0);
useCombatStore.getState().registerMob("dispel-neighbor", { name: "dispel-neighbor", maxHealth: 100_000, xpReward: 0 });
setMobPosition("dispel-neighbor", [7, 0, 0]);
expect(useCombatStore.getState().castAbility("wow335-mage-living-bomb", [0, 0, 0], 1_000).ok).toBe(true);
expect(useCombatStore.getState().dispelActor("dispel-bomb", ["magic"], 1, 2_000)).toBe(1);
expect(useCombatStore.getState().mobs["dispel-neighbor"].health).toBeLessThan(100_000);
expect(useCombatStore.getState().effects.some((effect) => effect.targetId === "dispel-bomb"
&& effect.sourceAbilityId === "wow335-mage-living-bomb")).toBe(false);
});
it("casts Molten Armor on self without initial damage", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
registerTarget("armor-target", [8, 0, 0], 100_000, 0);
expect(useCombatStore.getState().castAbility("wow335-mage-molten-armor", [0, 0, 0], 1_000).ok).toBe(true);
expect(useCombatStore.getState().mobs["armor-target"].health).toBe(100_000);
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.name.startsWith("Molten Armor"))).toBe(true);
});
it("applies Molten Armor's Spirit crit conversion and incoming crit reduction", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
registerTarget("molten-crit-target", [8, 0, 0], 100_000, 0);
const fireBlast = useCombatStore.getState().abilities.find((ability) => ability.name === "Fire Blast")!;
expect(useCombatStore.getState().castAbility(fireBlast.id, [0, 0, 0], 3_000).ok).toBe(true);
const baseCrit = useCombatStore.getState().lastCombatResult!.attackTable.criticalPercent;
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
registerTarget("molten-crit-target", [8, 0, 0], 100_000, 0);
expect(useCombatStore.getState().castAbility("wow335-mage-molten-armor", [0, 0, 0], 1_000).ok).toBe(true);
expect(useCombatStore.getState().castAbility(fireBlast.id, [0, 0, 0], 3_000).ok).toBe(true);
expect(useCombatStore.getState().lastCombatResult!.attackTable.criticalPercent).toBeGreaterThan(baseCrit);
useCombatStore.getState().registerMob("molten-attacker", {
name: "Molten attacker", level: 80, maxHealth: 100_000, xpReward: 0,
attacks: [{ ...DEFAULT_ENEMY_ATTACK, id: "molten-arrow", delivery: "projectile", range: 30 }],
});
setMobPosition("molten-attacker", [8, 0, 0]);
useCombatStore.getState().damageMob("molten-attacker", 1, 3_000);
useCombatStore.getState().advanceMobCombat(3_450);
expect(useCombatStore.getState().lastCombatResult?.attackTable.criticalPercent).toBe(0);
});
it("awards Shiv's combo point and consumes Cold Blood on the next strike", () => {
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 });
unlockReadySpell("wow335-rogue-cold-blood");
registerTarget("rogue-target", [2, 0, 0], 100_000, 0);
expect(useCombatStore.getState().castAbility("wow335-rogue-shiv", [0, 0, 0], 1_000).ok).toBe(true);
expect(useCombatStore.getState().comboPoints).toBe(1);
expect(useCombatStore.getState().castAbility("wow335-rogue-cold-blood", [0, 0, 0], 3_000).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("next-offensive-critical"))).toBe(true);
expect(useCombatStore.getState().castAbility("rogue-sinister-strike", [0, 0, 0], 5_000).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("next-offensive-critical"))).toBe(false);
expect(useCombatStore.getState().lastCombatResult?.outcome).toBe("crit");
});
});
+567 -45
View File
File diff suppressed because it is too large. Load diff
+12
View File
@@ -5,6 +5,7 @@ import {
dungeonById,
dungeonsByCategory,
randomDungeonByCategory,
randomRegularDungeon,
randomizedBiteSizedDungeons,
randomRandomizedBiteSizedDungeon,
} from "./dungeonCatalog";
@@ -61,6 +62,17 @@ describe("dungeon catalog", () => {
.not.toBe("stormwind-stockade");
});
it("selects regular full dungeons without entering either Bite-Sized pool", () => {
const first = randomRegularDungeon(undefined, () => 0);
const last = randomRegularDungeon(undefined, () => 0.999999);
for (const dungeon of [first, last]) {
expect(dungeon?.available).toBe(true);
expect(["wow", "rom"]).toContain(dungeon?.category);
}
expect(randomRegularDungeon(first?.id, () => 0)?.id).not.toBe(first?.id);
expect(randomRegularDungeon(undefined, () => 0, () => false)).toBeNull();
});
it("keeps randomized replay in the separate randomized Bite-Sized location pool", () => {
expect(randomizedBiteSizedDungeons().map((dungeon) => dungeon.id))
.toEqual(["randomized-bite-sized-stormwind-stockade"]);
+14
View File
@@ -103,6 +103,20 @@ export function randomDungeonByCategory(
return randomAvailableDungeon(dungeonsByCategory(category), currentDungeonId, random, eligible);
}
/** Selects an enterable full-length WoW or RoM dungeon, excluding both Bite-Sized modes. */
export function randomRegularDungeon(
currentDungeonId?: string,
random: () => number = Math.random,
eligible: (dungeon: DungeonCatalogEntry) => boolean = () => true,
): DungeonCatalogEntry | null {
return randomAvailableDungeon(
DUNGEON_CATALOG.filter((dungeon) => dungeon.category !== "bite-sized"),
currentDungeonId,
random,
eligible,
);
}
/** Selects the next location without crossing from randomized mode into authored Bite-Sized mode. */
export function randomRandomizedBiteSizedDungeon(
currentDungeonId?: string,
@@ -0,0 +1,809 @@
{
"schemaVersion": 1,
"contentStatus": "quest-rewards-confirmed-package-expansion-unverified",
"sourceFiles": {
"questdetailobject.db": "52950d1df2e0094144064298ab36554b2db424acfb23d142cce7d3087e4cfa7f",
"itemobject.db": "00e082b4c18d561d29c893a6400bfe62e3daf90ae539728cb05d1c2893a69880",
"armorobject.db": "a2f0017c4772165d562ebb08ca0ac51625cdc4453e3670a1501df7d741d2bad3",
"suitobject.db": "9c9b0f83f4c6a26fda1bade4c8771f95ec5d6d93da3998d187f14abfb840dbef",
"string_enus.db": "4fce021ba9ed110a213e08d3228b2de57cec66ddbbc8cdcf5753204874d9ab75"
},
"milestones": [
{
"level": 50,
"questId": 424596,
"questName": "Appropriate Support",
"packages": [
{
"armorCategory": "plate",
"packageId": 240384,
"packageName": "Order of Dark Glory Gift (Plate package)",
"weaponPackageId": 240385,
"weaponPackageName": "Plate Weapons Package",
"armorPackageId": 240386,
"armorPackageName": "Plate Armor Package",
"matchingSuitId": 610363,
"matchingSuitName": "Issac's Protection",
"matchingArmorItems": [
{
"itemId": 228366,
"name": "Battle Boots of Earth Exploration",
"requiredLevel": 41
},
{
"itemId": 228370,
"name": "Shoulder Guards of Shadow Lurking",
"requiredLevel": 41
},
{
"itemId": 228589,
"name": "Victory Horn Helmet",
"requiredLevel": 50
},
{
"itemId": 228605,
"name": "Smile of the Dead Plate Pants",
"requiredLevel": 45
},
{
"itemId": 228617,
"name": "Killer Instinct Belt",
"requiredLevel": 43
},
{
"itemId": 228621,
"name": "Adieu Gloves",
"requiredLevel": 48
},
{
"itemId": 228627,
"name": "Reservist's Plate Armor",
"requiredLevel": 50
}
]
},
{
"armorCategory": "chain",
"packageId": 240387,
"packageName": "Order of Dark Glory Gift (Chain package)",
"weaponPackageId": 240388,
"weaponPackageName": "Chain Weapons Package",
"armorPackageId": 240389,
"armorPackageName": "Chain Armor Package",
"matchingSuitId": 610364,
"matchingSuitName": "Issac's Blessing",
"matchingArmorItems": [
{
"itemId": 228365,
"name": "Battle Boots of Crossroad Guidance",
"requiredLevel": 41
},
{
"itemId": 228369,
"name": "Shoulder Guards of Suspicions",
"requiredLevel": 41
},
{
"itemId": 228588,
"name": "Bloodbath Chainmail Helmet",
"requiredLevel": 50
},
{
"itemId": 228604,
"name": "Murmuring Bones Chainmail Pants",
"requiredLevel": 45
},
{
"itemId": 228616,
"name": "Conscripts Belt",
"requiredLevel": 43
},
{
"itemId": 228620,
"name": "Parting Gloves",
"requiredLevel": 48
},
{
"itemId": 228626,
"name": "Messenger's Chainmail",
"requiredLevel": 50
}
]
},
{
"armorCategory": "leather",
"packageId": 240390,
"packageName": "Order of Dark Glory Gift (Leather package)",
"weaponPackageId": 240391,
"weaponPackageName": "Leather Weapons Package",
"armorPackageId": 240392,
"armorPackageName": "Leather Armor Package",
"matchingSuitId": 610365,
"matchingSuitName": "Issac's Power",
"matchingArmorItems": [
{
"itemId": 228364,
"name": "Leather Boots of Path Guiding Wind",
"requiredLevel": 41
},
{
"itemId": 228368,
"name": "Shoulder Guards of Dense Fog",
"requiredLevel": 41
},
{
"itemId": 228587,
"name": "Arrowbreak Tiara",
"requiredLevel": 50
},
{
"itemId": 228603,
"name": "Boneless Leather Pants",
"requiredLevel": 45
},
{
"itemId": 228615,
"name": "Desperate Valor Leather Belt",
"requiredLevel": 43
},
{
"itemId": 228619,
"name": "Entomb Touch",
"requiredLevel": 48
},
{
"itemId": 228625,
"name": "Gardener's Dirty Leather Shirt",
"requiredLevel": 50
}
]
},
{
"armorCategory": "cloth",
"packageId": 240393,
"packageName": "Order of Dark Glory Gift (Cloth package)",
"weaponPackageId": 240394,
"weaponPackageName": "Cloth Weapons Package",
"armorPackageId": 240395,
"armorPackageName": "Cloth Armor Package",
"matchingSuitId": 610366,
"matchingSuitName": "Issac's Prayer",
"matchingArmorItems": [
{
"itemId": 228363,
"name": "Boots of Memory's Road",
"requiredLevel": 41
},
{
"itemId": 228367,
"name": "Shoulder Armor of Dark Shadows",
"requiredLevel": 41
},
{
"itemId": 228586,
"name": "Thinking Cap",
"requiredLevel": 50
},
{
"itemId": 228602,
"name": "Revelation Long Pants",
"requiredLevel": 45
},
{
"itemId": 228614,
"name": "Call of the Frontline Belt",
"requiredLevel": 43
},
{
"itemId": 228618,
"name": "Pacification Gloves",
"requiredLevel": 48
},
{
"itemId": 228624,
"name": "Inferno Gardens Guard Robe",
"requiredLevel": 50
}
]
}
]
},
{
"level": 55,
"questId": 424597,
"questName": "Rewards for Students",
"packages": [
{
"armorCategory": "plate",
"packageId": 240353,
"packageName": "Bodo Treasure (Plate Package)",
"weaponPackageId": 240354,
"weaponPackageName": "Plate Weapons Package",
"armorPackageId": 240355,
"armorPackageName": "Plate Armor Package",
"matchingSuitId": 610395,
"matchingSuitName": "Haz Set",
"matchingArmorItems": [
{
"itemId": 229252,
"name": "Hazka Helmet",
"requiredLevel": 55
},
{
"itemId": 229253,
"name": "Hazka Plate Gauntlets",
"requiredLevel": 55
},
{
"itemId": 229254,
"name": "Hazka Boots",
"requiredLevel": 55
},
{
"itemId": 229255,
"name": "Hazka Plate Armor",
"requiredLevel": 55
},
{
"itemId": 229256,
"name": "Hazka Plate Leggings",
"requiredLevel": 55
},
{
"itemId": 229257,
"name": "Hazka Belt",
"requiredLevel": 55
},
{
"itemId": 229258,
"name": "Hazka Plate Shoulderplates",
"requiredLevel": 55
}
]
},
{
"armorCategory": "chain",
"packageId": 240356,
"packageName": "Bodo Treasure (Chain Package)",
"weaponPackageId": 240357,
"weaponPackageName": "Chain Weapons Package",
"armorPackageId": 240358,
"armorPackageName": "Chain Armor Package",
"matchingSuitId": 610396,
"matchingSuitName": "Eduth Set",
"matchingArmorItems": [
{
"itemId": 229259,
"name": "Eduth Light Helm",
"requiredLevel": 55
},
{
"itemId": 229260,
"name": "Eduth Chain Gloves",
"requiredLevel": 55
},
{
"itemId": 229261,
"name": "Eduth Guard Boots",
"requiredLevel": 55
},
{
"itemId": 229262,
"name": "Eduth Chainmail",
"requiredLevel": 55
},
{
"itemId": 229263,
"name": "Eduth Chainmail Pants",
"requiredLevel": 55
},
{
"itemId": 229264,
"name": "Eduth Belt",
"requiredLevel": 55
},
{
"itemId": 229265,
"name": "Eduth Chain Shoulderplates",
"requiredLevel": 55
}
]
},
{
"armorCategory": "leather",
"packageId": 240359,
"packageName": "Bodo Treasure (Leather Package)",
"weaponPackageId": 240360,
"weaponPackageName": "Leather Weapons Package",
"armorPackageId": 240361,
"armorPackageName": "Leather Armor Package",
"matchingSuitId": 610397,
"matchingSuitName": "Luzan Set",
"matchingArmorItems": [
{
"itemId": 229266,
"name": "Luzan Leather Helmet",
"requiredLevel": 55
},
{
"itemId": 229267,
"name": "Luzan Leather Gloves",
"requiredLevel": 55
},
{
"itemId": 229268,
"name": "Luzan Footwear",
"requiredLevel": 55
},
{
"itemId": 229269,
"name": "Luzan Leather Armor",
"requiredLevel": 55
},
{
"itemId": 229270,
"name": "Luzan Leather Pants",
"requiredLevel": 55
},
{
"itemId": 229271,
"name": "Luzan Belt",
"requiredLevel": 55
},
{
"itemId": 229272,
"name": "Luzan Leather Shoulder Armor",
"requiredLevel": 55
}
]
},
{
"armorCategory": "cloth",
"packageId": 240362,
"packageName": "Bodo Treasure (Cloth Package)",
"weaponPackageId": 240363,
"weaponPackageName": "Cloth Weapons Package",
"armorPackageId": 240364,
"armorPackageName": "Cloth Armor Package",
"matchingSuitId": 610398,
"matchingSuitName": "Rufa Set",
"matchingArmorItems": [
{
"itemId": 229273,
"name": "Rufa Headdress",
"requiredLevel": 55
},
{
"itemId": 229274,
"name": "Rufa Handguards",
"requiredLevel": 55
},
{
"itemId": 229275,
"name": "Rufa Cloth Shoes",
"requiredLevel": 55
},
{
"itemId": 229276,
"name": "Rufa Robes",
"requiredLevel": 55
},
{
"itemId": 229277,
"name": "Rufa Leggings",
"requiredLevel": 55
},
{
"itemId": 229278,
"name": "Rufa Belt",
"requiredLevel": 55
},
{
"itemId": 229279,
"name": "Rufa Cloth Shoulder Guards",
"requiredLevel": 55
}
]
}
]
},
{
"level": 60,
"questId": 424598,
"questName": "Lance's Support",
"packages": [
{
"armorCategory": "plate",
"packageId": 240341,
"packageName": "Jungle Vanquisher (Plate Package)",
"weaponPackageId": 240342,
"weaponPackageName": "Plate Weapons Package",
"armorPackageId": 240343,
"armorPackageName": "Plate Armor Package",
"matchingSuitId": 610391,
"matchingSuitName": "Rh'anka Set",
"matchingArmorItems": [
{
"itemId": 229206,
"name": "Rh'anka Helmet",
"requiredLevel": 60
},
{
"itemId": 229207,
"name": "Rh'anka Plate Gauntlets",
"requiredLevel": 60
},
{
"itemId": 229208,
"name": "Rh'anka Boots",
"requiredLevel": 60
},
{
"itemId": 229209,
"name": "Rh'anka Plate Armor",
"requiredLevel": 60
},
{
"itemId": 229210,
"name": "Rh'anka Plate Leggings",
"requiredLevel": 60
},
{
"itemId": 229211,
"name": "Rh'anka Belt",
"requiredLevel": 60
},
{
"itemId": 229212,
"name": "Rh'anka Plate Shoulderplates",
"requiredLevel": 60
}
]
},
{
"armorCategory": "chain",
"packageId": 240344,
"packageName": "Jungle Vanquisher (Chain Package)",
"weaponPackageId": 240345,
"weaponPackageName": "Chain Weapons Package",
"armorPackageId": 240346,
"armorPackageName": "Chain Armor Package",
"matchingSuitId": 610392,
"matchingSuitName": "Sley Set",
"matchingArmorItems": [
{
"itemId": 229213,
"name": "Sley Light Helm",
"requiredLevel": 60
},
{
"itemId": 229214,
"name": "Sley Chain Gloves",
"requiredLevel": 60
},
{
"itemId": 229215,
"name": "Sley Chain Boots",
"requiredLevel": 60
},
{
"itemId": 229216,
"name": "Sley Chainmail",
"requiredLevel": 60
},
{
"itemId": 229217,
"name": "Sley Chainmail Pants",
"requiredLevel": 60
},
{
"itemId": 229218,
"name": "Sley Belt",
"requiredLevel": 60
},
{
"itemId": 229219,
"name": "Sley Chain Shoulderplates",
"requiredLevel": 60
}
]
},
{
"armorCategory": "leather",
"packageId": 240347,
"packageName": "Jungle Vanquisher (Leather Package)",
"weaponPackageId": 240348,
"weaponPackageName": "Leather Weapons Package",
"armorPackageId": 240349,
"armorPackageName": "Leather Armor Package",
"matchingSuitId": 610393,
"matchingSuitName": "Tempest Set",
"matchingArmorItems": [
{
"itemId": 229220,
"name": "Gale Leather Helmet",
"requiredLevel": 60
},
{
"itemId": 229221,
"name": "Gale Leather Gloves",
"requiredLevel": 60
},
{
"itemId": 229222,
"name": "Gale Footwear",
"requiredLevel": 60
},
{
"itemId": 229223,
"name": "Gale Leather Armor",
"requiredLevel": 60
},
{
"itemId": 229224,
"name": "Gale Leather Pants",
"requiredLevel": 60
},
{
"itemId": 229225,
"name": "Gale Belt",
"requiredLevel": 60
},
{
"itemId": 229226,
"name": "Gale Leather Shoulder Armor",
"requiredLevel": 60
}
]
},
{
"armorCategory": "cloth",
"packageId": 240350,
"packageName": "Jungle Vanquisher (Cloth Package)",
"weaponPackageId": 240351,
"weaponPackageName": "Cloth Weapons Package",
"armorPackageId": 240352,
"armorPackageName": "Cloth Armor Package",
"matchingSuitId": 610394,
"matchingSuitName": "Croso Set",
"matchingArmorItems": [
{
"itemId": 229227,
"name": "Croso Headdress",
"requiredLevel": 60
},
{
"itemId": 229228,
"name": "Croso Handguards",
"requiredLevel": 60
},
{
"itemId": 229229,
"name": "Croso Cloth Shoes",
"requiredLevel": 60
},
{
"itemId": 229230,
"name": "Croso Robes",
"requiredLevel": 60
},
{
"itemId": 229231,
"name": "Croso Leggings",
"requiredLevel": 60
},
{
"itemId": 229232,
"name": "Croso Belt",
"requiredLevel": 60
},
{
"itemId": 229233,
"name": "Croso Cloth Shoulder Guards",
"requiredLevel": 60
}
]
}
]
},
{
"level": 65,
"questId": 424213,
"questName": "Return to Dalanis",
"packages": [
{
"armorCategory": "plate",
"packageId": 240304,
"packageName": "Redhill Mountains Pioneer (Plate package)",
"weaponPackageId": 240302,
"weaponPackageName": "Plate Weapons Package",
"armorPackageId": 240303,
"armorPackageName": "Plate Armor Package",
"matchingSuitId": 610387,
"matchingSuitName": "Forbidden Modification Technique",
"matchingArmorItems": [
{
"itemId": 229140,
"name": "Transformation Helmet",
"requiredLevel": 65
},
{
"itemId": 229141,
"name": "Transformation Gauntlets",
"requiredLevel": 65
},
{
"itemId": 229142,
"name": "Transformation Long Boots",
"requiredLevel": 65
},
{
"itemId": 229143,
"name": "Transformation Plate Armor",
"requiredLevel": 65
},
{
"itemId": 229144,
"name": "Transformation Plate Pants",
"requiredLevel": 65
},
{
"itemId": 229145,
"name": "Transformation Belt",
"requiredLevel": 65
},
{
"itemId": 229146,
"name": "Transformation Shoulder Armor",
"requiredLevel": 65
}
]
},
{
"armorCategory": "chain",
"packageId": 240307,
"packageName": "Redhill Mountains Pioneer (Chain package)",
"weaponPackageId": 240305,
"weaponPackageName": "Chain Weapons Package",
"armorPackageId": 240306,
"armorPackageName": "Chain Armor Package",
"matchingSuitId": 610388,
"matchingSuitName": "Forsaken Wrath",
"matchingArmorItems": [
{
"itemId": 229147,
"name": "Forsaken Light Helm",
"requiredLevel": 65
},
{
"itemId": 229148,
"name": "Forsaken Chain Gloves",
"requiredLevel": 65
},
{
"itemId": 229149,
"name": "Forsaken Foot Guards",
"requiredLevel": 65
},
{
"itemId": 229150,
"name": "Forsaken Chain Armor",
"requiredLevel": 65
},
{
"itemId": 229151,
"name": "Forsaken Chain Pants",
"requiredLevel": 65
},
{
"itemId": 229152,
"name": "Forsaken Belt",
"requiredLevel": 65
},
{
"itemId": 229153,
"name": "Forsaken Chain Shoulders",
"requiredLevel": 65
}
]
},
{
"armorCategory": "leather",
"packageId": 240310,
"packageName": "Redhill Mountains Pioneer (Leather package)",
"weaponPackageId": 240308,
"weaponPackageName": "Leather Weapons Package",
"armorPackageId": 240309,
"armorPackageName": "Leather Armor Package",
"matchingSuitId": 610389,
"matchingSuitName": "Forgotten Loathing",
"matchingArmorItems": [
{
"itemId": 229154,
"name": "Forgotten Leather Helmet",
"requiredLevel": 65
},
{
"itemId": 229155,
"name": "Forgotten Gloves",
"requiredLevel": 65
},
{
"itemId": 229156,
"name": "Forgotten Shoes",
"requiredLevel": 65
},
{
"itemId": 229157,
"name": "Forgotten Leather Coat",
"requiredLevel": 65
},
{
"itemId": 229158,
"name": "Forgotten Leather Leggings",
"requiredLevel": 65
},
{
"itemId": 229159,
"name": "Forgotten Belt",
"requiredLevel": 65
},
{
"itemId": 229160,
"name": "Forgotten Leather Shoulder Armor",
"requiredLevel": 65
}
]
},
{
"armorCategory": "cloth",
"packageId": 240313,
"packageName": "Redhill Mountains Pioneer (Cloth package)",
"weaponPackageId": 240311,
"weaponPackageName": "Cloth Weapons Package",
"armorPackageId": 240312,
"armorPackageName": "Cloth Armor Package",
"matchingSuitId": 610390,
"matchingSuitName": "Lake Energy",
"matchingArmorItems": [
{
"itemId": 229161,
"name": "Headdress of the Lake",
"requiredLevel": 65
},
{
"itemId": 229162,
"name": "Hand Guards of the Lake",
"requiredLevel": 65
},
{
"itemId": 229163,
"name": "Cloth Boots of the Lake",
"requiredLevel": 65
},
{
"itemId": 229164,
"name": "Magic Robe of the Lake",
"requiredLevel": 65
},
{
"itemId": 229165,
"name": "Magic Leggings of the Lake",
"requiredLevel": 65
},
{
"itemId": 229166,
"name": "Belt of the Lake",
"requiredLevel": 65
},
{
"itemId": 229167,
"name": "Cloth Shoulder Guards of the Lake",
"requiredLevel": 65
}
]
}
]
}
]
}
+2 -3
View File
@@ -1,9 +1,8 @@
import { equipmentSlotForItem } from "./equipment";
import type { InventoryItem } from "./lootTypes";
/** Heirlooms cover leveling gear. Keep authored non-equipment rewards at every level. */
/** Leveling dungeon bosses award cards through the separate boss-card path. */
export function allowedLootDrops(drops: readonly InventoryItem[], playerLevel: number): readonly InventoryItem[] {
return Number.isFinite(playerLevel) && playerLevel >= 80
? drops
: drops.filter((item) => equipmentSlotForItem(item) === null);
: [];
}
+1 -1
View File
@@ -270,5 +270,5 @@ export function partyAbilityAmount(
member.gearStats,
member.classId,
ability,
);
) + (effect.kind === "damage" ? effect.bonusFlat ?? 0 : 0);
}
+143 -18
View File
@@ -13,6 +13,7 @@ import {
} from "./abilityCatalog";
import {
aiHintsForAbility,
abilityRequiresOutOfCombat,
canAbilityCastWhileMoving,
targetingRulesForAbility,
} from "./abilityRules";
@@ -21,7 +22,7 @@ import { useCombatStore } from "./combatStore";
import { companionResourceMaximum, memberFromCompanionActor } from "./combatActorAuthority";
import type { MobVector3 } from "./mobPopulation";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { getMobPosition } from "./mobRuntimeRegistry";
import { getMobPosition, getMobRuntimeTransform } from "./mobRuntimeRegistry";
import {
getPartyRuntimePosition,
partyRuntimeIsRejoining,
@@ -44,6 +45,7 @@ import {
type WotlkAttackKind,
} from "./wotlkStats";
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
import {
emitCombatPresentation,
presentationDeliveryForAbility,
@@ -79,6 +81,7 @@ import {
ROGUE_DEADLY_POISON_DOSE_TAG,
ROGUE_ENVENOM_BUFF_TAG,
} from "./roguePoison";
import { romAbilityNeedsShield, romEffectsForCast, romSelfDrainPercent, romTargetBleedCount } from "./romCombatMechanics";
const OUT_OF_COMBAT_REGEN_FRACTION = 0.06;
const ATTACK_ACQUISITION_RANGE = 30;
@@ -753,6 +756,34 @@ function partyAbilityTargetIsValid(
if (castOrigin && !canAbilityCastWhileMoving(ability) && distanceSquared(origin, castOrigin) > 0.04) return false;
const mob = combat.mobs[targetId];
const hostile = Boolean(mob);
if (abilityRequiresOutOfCombat(ability)
&& !(ability.source === "runewaker" && member.classId === "rom-rogue"
&& combat.auras.some((aura) => aura.targetId === member.id
&& 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 (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
&& aura.definition.disposition === "buff" && aura.definition.dispelCategory === "enrage"
&& !aura.definition.name.startsWith("Enraged Regeneration"))) return false;
if (ability.source === "runewaker") {
if (ability.dbcSpellId === 490151 && (member.comboTargetId !== targetId || member.comboPoints <= 0)) return false;
if (ability.dbcSpellId === 490347 && !combat.auras.some((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("rom-poisonous"))) return false;
if (ability.dbcSpellId === 491171 && !combat.auras.some((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("rom-charged"))) return false;
if (ability.dbcSpellId === 491172 && !combat.auras.some((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("rom-static-field-charge"))) return false;
if (ability.dbcSpellId === 490311 || romAbilityNeedsShield(ability)) {
const loadout = resolvedEquipmentLoadout(combat.equipment, combat.inventory, {
ownerId: member.id, ownerKind: "party", classId: member.classId,
specializationId: member.specializationId, role: member.role, level: member.level,
});
if (ability.dbcSpellId === 490311 && loadout["main-hand"]?.weaponType !== "dagger") return false;
if (romAbilityNeedsShield(ability) && loadout["off-hand"]?.armorType !== "shield") return false;
}
}
const targetPosition = hostile ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
if (!targetPosition || mob?.dead) return false;
const targetActor = combat.actors[targetId];
@@ -1100,10 +1131,21 @@ function executePartyAbility(
castStartAlreadyEmitted = false,
): PartyAbilityExecutionResult {
const combat = useCombatStore.getState();
if (ability.name === "Enraged Regeneration" && !combat.consumeActorEnrage(member.id, now)) {
return { member, effectiveAmount: 0 };
}
const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition;
const hostileTargets = combat.mobs[targetId] ? partyHostileTargets(ability, targetId, origin, member.id) : [];
const friendlyTargets = combat.mobs[targetId] ? [] : partyHealingTargets(ability, targetId, members);
const consumesColdBlood = ability.name !== "Cold Blood" && hostileTargets.length > 0
&& 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 = presentationSchoolForAbility(ability);
const usesCombustion = spellSchool === "fire" && hostileTargets.length > 0
&& ability.effects.some((effect) => effect.kind === "damage")
&& combat.auras.some((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("fire-crit-stacking"));
const groundAreaCreated = targetingRulesForAbility(ability).shape === "ground"
? Boolean(useCombatStore.getState().createActorAreaEffect(
member.id,
@@ -1148,12 +1190,41 @@ function executePartyAbility(
}
let runtimeMember = member;
let effectiveAmount = 0;
for (const effect of ability.effects) {
const castEffects = romEffectsForCast(ability, {
sealStacks: member.comboTargetId === targetId ? member.comboPoints : 0,
bleedCount: romTargetBleedCount(combat.effects, member.id, targetId),
charged: combat.auras.some((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("rom-charged")),
mainHandDagger: member.classId === "rom-rogue" && resolvedEquipmentLoadout(combat.equipment, combat.inventory, {
ownerId: member.id, ownerKind: "party", classId: member.classId,
specializationId: member.specializationId, role: member.role, level: member.level,
})["main-hand"]?.weaponType === "dagger",
casterMaxHealth: member.maxHealth,
});
for (const effect of castEffects) {
if (ability.name === "Penance" && combat.mobs[targetId]
&& (effect.kind === "heal" || effect.kind === "hot")) continue;
if (ability.name === "Penance" && !combat.mobs[targetId]
&& (effect.kind === "damage" || effect.kind === "dot")) continue;
if (effect.kind === "damage" || effect.kind === "dot" || effect.kind === "finisher-damage") {
if (effect.kind === "dot" && romSelfDrainPercent(ability.dbcSpellId) > 0) {
combat.applyTimedActorEffect(member.id, member.id, ability, effect, now);
continue;
}
for (const hostileTargetId of hostileTargets) {
if (effect.kind === "dot") {
if (!groundAreaCreated) combat.applyTimedActorEffect(member.id, hostileTargetId, ability, effect, now);
if (!groundAreaCreated) combat.applyTimedActorEffect(
member.id, hostileTargetId, ability,
ability.name === "Rupture"
? { ...effect, ticks: Math.max(4, Math.min(8, 3 + runtimeMember.comboPoints)) }
: effect,
now,
);
} else {
const sunderStacks = effect.kind === "damage" && effect.bonusPerSunderStack
? useCombatStore.getState().auras.find((aura) => aura.targetId === hostileTargetId
&& aura.definition.id === "wow335:warrior:sunder-armor")?.stacks ?? 0
: 0;
const result = combat.damageMob(
hostileTargetId,
partyAbilityAmount(
@@ -1161,10 +1232,16 @@ function executePartyAbility(
ability,
effect,
envenomDosesByTarget.get(hostileTargetId) ?? 0,
),
) + sunderStacks * (effect.kind === "damage" ? effect.bonusPerSunderStack ?? 0 : 0),
now,
{ actorId: member.id, role: member.role, abilityId: ability.id },
spellSchool,
consumesColdBlood || usesCombustion ? {
attackKind: consumesColdBlood ? "melee" : "spell", abilityId: ability.id,
seed: `${member.id}:${ability.id}:${hostileTargetId}:${now}:special-crit`,
criticalChanceBonusPercent: consumesColdBlood ? 100 : 0,
...(ability.name === "Shiv" ? { rules: { canCrit: false } } : {}),
} : undefined,
);
effectiveAmount += result.amount;
if (
@@ -1207,6 +1284,17 @@ function executePartyAbility(
now,
});
effectiveAmount += result.effectiveAmount;
if (ability.name === "Divine Hymn") {
useCombatStore.getState().applyActorAura(member.id, friendlyTargetId, ability, {
kind: "apply-aura", recipient: "ability-target",
aura: {
id: "wow335:priest:divine-hymn-healing", name: "Divine Hymn", disposition: "buff",
durationMs: 8_000, maxStacks: 1, stackBehavior: "refresh",
modifiers: [{ kind: "healing", direction: "taken", operation: "percent", value: 0.03 }],
},
sourceEffectType: 6, sourceAuraType: 118, miscValue: 0, triggerSpellId: 0,
}, now);
}
}
} else if (effect.kind === "hot") {
if (!groundAreaCreated) for (const friendlyTargetId of healingRecipientIds(effect, member.id, targetId, combat.actors,
@@ -1254,10 +1342,11 @@ function executePartyAbility(
}
} else if (effect.kind === "status") {
for (const hostileTargetId of hostileTargets.slice(0, effect.maxTargets)) {
combat.controlMob(hostileTargetId, ability.id, effect.status, effect.durationMs, now);
combat.controlMob(hostileTargetId, ability.id, effect.status, effect.durationMs, now,
effect.breakOnDamage, member.id);
}
} else if (effect.kind === "apply-aura") {
if (ability.name === "Inner Focus") continue;
if (ability.name === "Inner Focus" || ability.name === "Divine Hymn") continue;
const recipients = effect.scope === "party"
? [
...(simulatedPartyExcludesPlayer() ? [] : [PLAYER_AGGRO_ID]),
@@ -1267,7 +1356,9 @@ function executePartyAbility(
? [member.id]
: combat.mobs[targetId] ? hostileTargets : friendlyTargets;
for (const recipientId of recipients) {
const appliedEffect = ability.name === "Envenom"
const appliedEffect = ability.name === "Rupture"
? { ...effect, aura: { ...effect.aura, durationMs: Math.max(4, Math.min(8, 3 + runtimeMember.comboPoints)) * 2_000 } }
: ability.name === "Envenom"
&& effect.aura.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG)
? {
...effect,
@@ -1316,8 +1407,15 @@ function executePartyAbility(
}
}
} else if (effect.kind === "combo") {
if (ability.dbcSpellId === 490151) {
useCombatStore.setState((state) => ({
effects: state.effects.filter((entry) => !(entry.kind === "dot"
&& entry.tag === "rom-holy-seal" && entry.sourceActorId === member.id
&& entry.targetId === targetId)),
}));
}
const current = runtimeMember.comboTargetId === targetId ? runtimeMember.comboPoints : 0;
const comboPoints = Math.max(0, Math.min(5, current + effect.amount));
const comboPoints = Math.max(0, Math.min(member.classId === "rom-knight" ? 4 : 5, current + effect.amount));
runtimeMember = {
...runtimeMember,
comboPoints,
@@ -1345,7 +1443,21 @@ function executePartyAbility(
useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, summonTargetId, now);
} else if (effect.kind === "movement") {
const targetPosition = combat.mobs[targetId] ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
if (effect.toward === "away") {
if (ability.dbcSpellId === 490344) {
const target = getMobRuntimeTransform(targetId);
if (target) updatePartyRuntimePosition(member.id,
target.position[0] - Math.sin(target.yaw) * 1.5,
target.position[1],
target.position[2] - Math.cos(target.yaw) * 1.5);
} else if (ability.dbcSpellId === 490150 && targetPosition) {
const dx = targetPosition[0] - origin[0];
const dz = targetPosition[2] - origin[2];
const length = Math.max(0.001, Math.hypot(dx, dz));
updatePartyRuntimePosition(member.id,
targetPosition[0] - dx / length * 1.5,
targetPosition[1],
targetPosition[2] - dz / length * 1.5);
} else if (effect.toward === "away") {
const danger = Object.values(combat.mobs)
.filter((mob) => mob.engaged && !mob.dead)
.sort((left, right) => (
@@ -1417,6 +1529,12 @@ function executePartyAbility(
}
}
}
if (ability.name === "Chimera Shot" && effectiveAmount > 0 && combat.mobs[targetId]) {
effectiveAmount += useCombatStore.getState().triggerChimeraShot(member.id, targetId, ability.id, now);
}
if (consumesColdBlood) useCombatStore.getState().consumeActorAuraTag(member.id, "next-offensive-critical");
if (ability.dbcSpellId === 491171) useCombatStore.getState().consumeActorAuraTag(member.id, "rom-charged");
if (ability.dbcSpellId === 491172) useCombatStore.getState().consumeActorAuraTag(member.id, "rom-static-field-charge");
emitPartyAbility(member, ability, targetId, "release", now, !castStartAlreadyEmitted);
emitPartyAbility(member, ability, targetId, "impact", now, !castStartAlreadyEmitted);
if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection"].includes(effect.kind))) {
@@ -1602,16 +1720,23 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null, status: "advancing", nextActionAt: now + 250 };
continue;
}
const interval = Math.max(1, cast.originalDurationMs / 5);
const totalTicks = castingAbility.name === "Divine Hymn" ? 4 : 5;
const interval = Math.max(1, cast.originalDurationMs / totalTicks);
let completed = cast.channelTicksCompleted ?? 0;
while (completed < 5 && cast.startedAt + (completed + 1) * interval <= now) {
const effects: AbilityEffect[] = channelAmountEffects(castingAbility, 5).map(({ effect, multiplier }) => ({
kind: effect.kind === "heal" || effect.kind === "hot" ? "heal" : "damage",
coefficient: 0, basePoints: Math.round(partyAbilityAmount(member, castingAbility, effect) * multiplier),
bonusPowerCoefficient: 0, sourceLevel: member.level,
...(effect.kind === "heal" && effect.percentMaxHealth !== undefined
? { percentMaxHealth: effect.percentMaxHealth * multiplier } : {}),
}));
while (completed < totalTicks && cast.startedAt + (completed + 1) * interval <= now) {
const effects: AbilityEffect[] = channelAmountEffects(castingAbility, totalTicks).map(({ effect, multiplier }) => {
const amount = Math.round(partyAbilityAmount(member, castingAbility, effect) * multiplier);
if (effect.kind === "heal" || effect.kind === "hot") return {
kind: "heal", coefficient: 0, basePoints: amount, sourceLevel: member.level,
...("scope" in effect ? {
scope: effect.scope, center: effect.center, radius: effect.radius,
maximumTargets: effect.maximumTargets, recipient: effect.recipient,
} : {}),
...(effect.kind === "heal" && effect.percentMaxHealth !== undefined
? { percentMaxHealth: effect.percentMaxHealth * multiplier } : {}),
};
return { kind: "damage", coefficient: 0, basePoints: amount, sourceLevel: member.level };
});
const execution = executePartyAbility(member, { ...castingAbility, effects }, castTargetId,
cast.startedAt + (completed + 1) * interval, nextMembers, 0, true);
member = execution.member;
+30 -9
View File
@@ -39,6 +39,7 @@ import {
import type { RuntimeTalentRanks as TalentRanks } from "./talentRuntimeCatalog";
import type { EnemyControlMechanic } from "./enemyAttacks";
import type { ContentCategoryId } from "../app/contentCategories";
import { isValidRomClassPair, ROM_SECONDARY_UNLOCK_LEVEL } from "../app/romDualClass";
import type { PartyRole, PartySize } from "./partyRoles";
import {
abilitiesForCharacter,
@@ -47,6 +48,7 @@ import {
type ResourceType,
} from "./abilityCatalog";
import { createDeathKnightRunes, type CombatActorCastState, type DeathKnightRuneState } from "./combatActors";
import { romFiveIdentity } from "./romFiveIdentities";
export {
classCanFillPartyRole,
@@ -102,6 +104,8 @@ export interface PartyMember {
interface PartyTemplate {
readonly name: string;
readonly classId: ClassId;
readonly secondaryClassId?: ClassId | null;
readonly raceId?: RaceId;
readonly specialization: string;
readonly talentTreeId: string;
readonly talentProfileId?: string | null;
@@ -111,6 +115,7 @@ export interface ConfiguredPartyMemberSeed {
readonly name: string;
readonly classId: ClassId;
readonly secondaryClassId?: ClassId | null;
readonly raceId?: RaceId;
readonly specialization: string;
readonly specializationId?: string | null;
readonly talentTreeId?: string;
@@ -167,19 +172,19 @@ function wowTemplates(
const ROM_TANK_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
{ name: "Aldric", classId: "rom-warrior", specialization: "Primary", talentTreeId: "" },
{ ...romFiveIdentity("rom-knight", "tank")!, specialization: "Primary", talentTreeId: "" },
{ name: "Maerwen", classId: "rom-warden", specialization: "Primary", talentTreeId: "" },
{ name: "Bronn", classId: "rom-champion", specialization: "Primary", talentTreeId: "" },
]);
const ROM_HEALER_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
{ name: "Liora", classId: "rom-priest", specialization: "Primary", talentTreeId: "" },
{ ...romFiveIdentity("rom-priest", "healer")!, specialization: "Primary", talentTreeId: "" },
{ name: "Sylva", classId: "rom-druid", specialization: "Primary", talentTreeId: "" },
]);
const ROM_DAMAGE_TEMPLATES: readonly PartyTemplate[] = Object.freeze([
{ name: "Cael", classId: "rom-scout", specialization: "Primary", talentTreeId: "" },
{ name: "Veya", classId: "rom-rogue", specialization: "Primary", talentTreeId: "" },
{ name: "Ilyen", classId: "rom-mage", specialization: "Primary", talentTreeId: "" },
{ ...romFiveIdentity("rom-scout", "damage")!, specialization: "Primary", talentTreeId: "" },
{ ...romFiveIdentity("rom-rogue", "damage")!, specialization: "Primary", talentTreeId: "" },
{ ...romFiveIdentity("rom-mage", "damage")!, specialization: "Primary", talentTreeId: "" },
{ name: "Morgha", classId: "rom-warlock", specialization: "Primary", talentTreeId: "" },
{ name: "Roran", classId: "rom-knight", specialization: "Primary", talentTreeId: "" },
]);
export const TANK_SPECIALIZATIONS = Object.freeze(new Set(
@@ -322,11 +327,19 @@ function createMember(
const identityRandom = {
value: hashSeed(`${seed}:${role}:${index}:${template.classId}:${template.specialization}:appearance-v1`),
};
const eligibleRaces = PLAYABLE_RACES.filter((race) => classesForRace(race.id).some(
(candidate) => candidate.id === template.classId,
const eligibleRaces = PLAYABLE_RACES.filter((race) => (
classesForRace(race.id).some((candidate) => candidate.id === template.classId)
&& (!template.secondaryClassId || isValidRomClassPair(race.id, template.classId, template.secondaryClassId))
));
if (template.secondaryClassId && !eligibleRaces.length) {
throw new Error(`No playable race supports ${template.classId}/${template.secondaryClassId}.`);
}
if (template.raceId && !eligibleRaces.some((race) => race.id === template.raceId)) {
throw new Error(`Race ${template.raceId} cannot play ${template.classId}/${template.secondaryClassId ?? "none"}.`);
}
const racePool = eligibleRaces.length ? eligibleRaces : PLAYABLE_RACES;
const race = racePool[Math.floor(nextRandom(identityRandom) * racePool.length)] ?? racePool[0];
const race = racePool.find((candidate) => candidate.id === template.raceId)
?? racePool[Math.floor(nextRandom(identityRandom) * racePool.length)] ?? racePool[0];
const genders = supportedGenders(race.id);
const gender: GenderId = genders[Math.floor(nextRandom(identityRandom) * genders.length)] ?? genders[0] ?? "male";
const appearance = randomAppearance(race.id, gender, () => nextRandom(identityRandom));
@@ -354,6 +367,7 @@ function createMember(
gender,
appearance,
classId: template.classId,
secondaryClassId: level >= ROM_SECONDARY_UNLOCK_LEVEL ? template.secondaryClassId ?? null : null,
specialization: template.specialization,
specializationId,
talentTreeId: template.talentTreeId,
@@ -398,13 +412,14 @@ export function createConfiguredPartyMember(
const member = createMember({
name: configuration.name,
classId: configuration.classId,
secondaryClassId: configuration.secondaryClassId,
raceId: configuration.raceId,
specialization: configuration.specialization,
talentTreeId: configuration.talentTreeId ?? configuration.specializationId ?? "",
talentProfileId: configuration.talentProfileId ?? null,
}, configuration.role, level, index, seed, configuration.talentRanks);
return Object.freeze(withMemberAbilityResourcePools({
...member,
secondaryClassId: configuration.secondaryClassId ?? null,
specializationId: configuration.specializationId ?? member.specializationId,
talentRanks: configuration.talentRanks ?? member.talentRanks,
}));
@@ -655,6 +670,12 @@ export const usePartyStore = create<PartyState>((set, get) => ({
return withMemberAbilityResourcePools({
...member,
level: safeLevel,
secondaryClassId: isRomClassId(member.classId)
&& romFiveIdentity(member.classId, member.role)?.name === member.name
? (safeLevel >= ROM_SECONDARY_UNLOCK_LEVEL
? romFiveIdentity(member.classId, member.role)?.secondaryClassId ?? null
: null)
: member.secondaryClassId,
health,
maxHealth,
resource,
+115 -3
View File
@@ -506,18 +506,130 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
.filter((effect) => effect.kind === kind)
.sort((left, right) => (strongest ? -1 : 1) * (outputStrength(left) - outputStrength(right)))
.slice(0, 1);
const holySealDot = (strongest: boolean): readonly AbilityEffect[] => oneOutput("dot", strongest).map((effect) => (
effect.kind === "dot" ? { ...effect, tag: "rom-holy-seal" } : effect
));
// These source rows carry alternate/duplicate payloads, while their player
// tooltips specify a single projectile hit. The first attack component is
// the primary shot; executing every component also applies unrelated Shot
// stat modifiers and doubles Throw damage.
if (row.sourceSkillId === 490309 || row.sourceSkillId === 490423) {
return translated.filter((effect) => effect.kind === "damage").slice(0, 1);
}
if (row.sourceSkillId === 490313) return translated.filter((effect) => effect.kind === "damage");
// The source rows include auxiliary spell records alongside the tooltip's
// single hit or periodic heal. They are alternatives, not extra casts.
if (row.sourceSkillId === 491150 || row.sourceSkillId === 490204) {
return translated.filter((effect) => effect.kind === "damage").slice(0, 1);
}
if (row.sourceSkillId === 490252 || row.sourceSkillId === 490244) {
const hit = translated.find((effect) => effect.kind === "damage");
const hitCount = row.sourceSkillId === 490252 ? 5 : 3;
return [
...(hit ? Array.from({ length: hitCount }, () => hit) : []),
...(row.sourceSkillId === 490244
? translated.filter((effect) => effect.kind === "apply-aura")
: []),
];
}
if (row.sourceSkillId === 490256) {
return translated.filter((effect) => effect.kind === "damage");
}
if (row.sourceSkillId === 490269) {
return translated.filter((effect) => effect.kind === "hot").slice(0, 1);
}
if (row.sourceSkillId === 490304 || row.sourceSkillId === 490434) {
return translated.map((effect) => effect.kind === "apply-aura" ? {
...effect,
aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), "rom-self-health-drain"] },
} : effect);
}
if (row.sourceSkillId === 490506 || row.sourceSkillId === 490218) {
return translated.filter((effect) => effect.kind === "apply-aura");
}
if (row.sourceSkillId === 490420) {
return translated.filter((effect) => effect.kind === "damage"
|| (effect.kind === "apply-aura" && !effect.control));
}
if (row.sourceSkillId === 490242) {
return translated.filter((effect) => effect.kind === "damage"
|| (effect.kind === "apply-aura" && effect.control?.kind === "stun"));
}
if (row.sourceSkillId === 490344) {
return [{ kind: "movement", movement: "teleport", toward: "target" }];
}
if (row.sourceSkillId === 490311) {
return translated.filter((effect) => effect.kind === "damage" || effect.kind === "dot");
}
if (row.sourceSkillId === 490192) {
return translated.filter((effect) => effect.kind === "apply-aura").map((effect) => (
effect.kind === "apply-aura" && effect.aura.procs?.length ? {
...effect,
aura: {
...effect.aura,
durationMs: 30_000,
procs: effect.aura.procs.map((proc) => ({
...proc,
action: { kind: "custom" as const, id: "rom-shield-discipline-reflect" },
})),
},
} : effect
));
}
if (row.sourceSkillId === 490347) {
return [{ kind: "status", status: "sleep", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
}
if (row.sourceSkillId === 490454) {
return [{ kind: "status", status: "root", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
}
if (row.sourceSkillId === 490438) {
return [{ kind: "status", status: "stun", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
}
if (row.sourceSkillId === 491172) {
return [{ kind: "status", status: "stun", durationMs: 10_000, maxTargets: 1, breakOnDamage: true }];
}
if (row.sourceSkillId === 490337) {
return translated.filter((effect) => effect.kind === "damage" || effect.kind === "dot"
|| (effect.kind === "apply-aura" && effect.control?.kind === "stun"));
}
if (row.sourceSkillId === 490507) {
const aura = translated.find((effect) => effect.kind === "apply-aura");
return aura?.kind === "apply-aura" ? [{
...aura,
aura: { ...aura.aura, utilityTags: ["next-offensive-critical", "rom-premeditation"] },
}] : translated;
}
if (row.sourceSkillId === 490354) {
return translated.map((effect) => effect.kind === "apply-aura" ? {
...effect,
aura: { ...effect.aura, utilityTags: ["stealth", "rom-vanish"] },
} : effect);
}
if (row.sourceSkillId === 490329) {
const aura = translated.find((effect) => effect.kind === "apply-aura");
return aura?.kind === "apply-aura" ? [{
...aura,
aura: { ...aura.aura, durationMs: 900_000, utilityTags: ["rom-poisonous"] },
}] : translated;
}
// RuneWaker's Knight uses Holy Seal as a target-scoped build/spend resource.
// Model it through the combat engine's combo authority so both players and
// companions build seals before Punishment instead of casting the payoff on
// an unsealed target. Source components contain one alternative payload per
// seal count; executing every branch at once greatly overstates the skills.
if (row.sourceSkillId === 490148) return [
...oneOutput("damage", true), ...oneOutput("dot", false), { kind: "combo", amount: 1 },
...oneOutput("damage", true), ...holySealDot(false), { kind: "combo", amount: 1 },
];
if (row.sourceSkillId === 490149) return [
...oneOutput("damage", true), ...oneOutput("dot", true), { kind: "combo", amount: 4 },
...oneOutput("damage", true), ...holySealDot(true), { kind: "combo", amount: 4 },
];
if (row.sourceSkillId === 490150) return [
{ kind: "movement", movement: "charge", toward: "target" },
...oneOutput("damage", true),
...translated.filter((effect) => effect.kind === "apply-aura" && effect.control?.kind === "stun").slice(0, 1),
...holySealDot(false),
{ kind: "combo", amount: 2 },
];
if (row.sourceSkillId === 490150) return [...translated, { kind: "combo", amount: 2 }];
if (row.sourceSkillId === 490151) return [
...oneOutput("damage", true), ...oneOutput("dot", true), { kind: "combo", amount: -5 },
];
+13 -1
View File
@@ -51,7 +51,19 @@ describe("executable RuneWaker ability mappings", () => {
it("retains every executable part of multi-effect abilities", () => {
const kinds = named("Sneak Attack").effects.map((effect) => effect.kind);
expect(kinds).toEqual(expect.arrayContaining(["damage", "apply-aura", "dot", "scripted"]));
expect(kinds).toEqual(["damage", "apply-aura", "dot"]);
});
it("executes one projectile hit for Rogue Throw and Scout Shot", () => {
for (const name of ["Throw", "Shot"]) {
const ability = named(name);
expect(ability.effects).toHaveLength(1);
expect(ability.effects[0]).toMatchObject({ kind: "damage" });
for (const version of ability.ranks ?? []) {
expect(version.effects).toHaveLength(1);
expect(version.effects[0]).toMatchObject({ kind: "damage" });
}
}
});
it("implements source-authored immunity and absorbs as named auras", () => {
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import { abilityById, type AbilityEffect } from "./abilityCatalog";
import { romEffectsForCast, romTargetBleedCount, type RomCastMechanics } from "./romCombatMechanics";
const empty: RomCastMechanics = {
sealStacks: 0, bleedCount: 0, charged: false, mainHandDagger: false, casterMaxHealth: 2_000,
};
function cast(id: string, overrides: Partial<RomCastMechanics> = {}): readonly AbilityEffect[] {
const ability = abilityById(id);
expect(ability).toBeDefined();
return romEffectsForCast(ability!, { ...empty, ...overrides });
}
function kinds(effects: readonly AbilityEffect[]): readonly string[] {
return effects.map((effect) => effect.kind);
}
describe("RoM primary class conditional combat effects", () => {
it("counts only active Rogue bleeds applied by the caster to that target", () => {
const timed = [
{ kind: "dot", sourceAbilityId: "rom-rogue-primary-1", sourceActorId: "rogue", targetId: "mob", remainingTicks: 3 },
{ kind: "dot", sourceAbilityId: "rom-rogue-general-0", sourceActorId: "rogue", targetId: "mob", remainingTicks: 2 },
{ kind: "dot", sourceAbilityId: "rom-rogue-general-0", sourceActorId: "rogue", targetId: "mob", remainingTicks: 1 },
{ kind: "dot", sourceAbilityId: "rom-rogue-primary-9", sourceActorId: "other", targetId: "mob", remainingTicks: 2 },
{ kind: "dot", sourceAbilityId: "rom-rogue-primary-4", sourceActorId: "rogue", targetId: "other-mob", remainingTicks: 2 },
];
expect(romTargetBleedCount(timed, "rogue", "mob")).toBe(2);
});
it("gives Wound Attack one hit plus one hit per active bleed, without inventing a DoT", () => {
expect(kinds(cast("rom-rogue-primary-2"))).toEqual(["damage"]);
expect(kinds(cast("rom-rogue-primary-2", { bleedCount: 1 }))).toEqual(["damage", "damage"]);
expect(kinds(cast("rom-rogue-primary-2", { bleedCount: 2 }))).toEqual(["damage", "damage", "damage"]);
expect(kinds(cast("rom-rogue-primary-1"))).toEqual(["damage"]);
expect(kinds(cast("rom-rogue-primary-1", { bleedCount: 1 }))).toEqual(["damage", "dot"]);
});
it("selects exactly one Electric Explosion hit for normal and Charged casts", () => {
const normal = cast("rom-mage-primary-54");
const charged = cast("rom-mage-primary-54", { charged: true });
expect(kinds(normal)).toEqual(["damage"]);
expect(kinds(charged)).toEqual(["damage"]);
expect((charged[0] as Extract<AbilityEffect, { kind: "damage" }>).basePoints)
.toBeGreaterThan((normal[0] as Extract<AbilityEffect, { kind: "damage" }>).basePoints ?? 0);
});
it("requires Holy Seals for Punishment and scales its payoff by stack count", () => {
expect(cast("rom-knight-general-0")).toEqual([]);
const one = cast("rom-knight-general-0", { sealStacks: 1 });
const four = cast("rom-knight-general-0", { sealStacks: 4 });
expect(kinds(one)).toEqual(["damage", "dot", "combo"]);
expect((four[0] as Extract<AbilityEffect, { kind: "damage" }>).basePoints)
.toBe(4 * ((one[0] as Extract<AbilityEffect, { kind: "damage" }>).basePoints ?? 0));
});
it("gates Knight seal riders and grants Shield Bash's absorb to the caster", () => {
expect(kinds(cast("rom-knight-primary-7"))).toEqual(["damage", "apply-aura"]);
expect(kinds(cast("rom-knight-primary-7", { sealStacks: 4 })).filter((kind) => kind === "damage")).toHaveLength(2);
expect(kinds(cast("rom-knight-primary-12"))).toEqual(["damage", "apply-aura"]);
expect(kinds(cast("rom-knight-primary-12", { sealStacks: 4 })).filter((kind) => kind === "damage")).toHaveLength(2);
const bash = cast("rom-knight-primary-11", { sealStacks: 3 });
const shield = bash.find((effect) => effect.kind === "apply-aura");
expect(shield?.kind === "apply-aura" && shield.recipient).toBe("caster");
expect(shield?.kind === "apply-aura" && shield.aura.absorbs?.[0]?.amount).toBe(300);
expect(kinds(bash)).toEqual(["damage", "apply-aura", "combo"]);
});
it("requires a dagger for Shadowstab and Sneak Attack bleeds", () => {
expect(kinds(cast("rom-rogue-general-0"))).toEqual(["damage"]);
expect(kinds(cast("rom-rogue-general-0", { mainHandDagger: true }))).toEqual(["damage", "dot"]);
expect(kinds(cast("rom-rogue-primary-9"))).toEqual(["damage", "apply-aura"]);
expect(kinds(cast("rom-rogue-primary-9", { mainHandDagger: true }))).toEqual(["damage", "apply-aura", "dot"]);
});
it("keeps auxiliary source rows from adding extra hits, heals, or false control", () => {
expect(kinds(cast("rom-priest-general-2"))).toEqual(["hot"]);
expect(kinds(cast("rom-mage-primary-1"))).toEqual(["damage"]);
expect(kinds(cast("rom-mage-general-0"))).toEqual(["damage"]);
expect(kinds(cast("rom-rogue-primary-0"))).toEqual(["apply-aura", "apply-aura"]);
expect(kinds(cast("rom-scout-general-3"))).toEqual(["damage", "apply-aura"]);
});
it("executes the source count for multi-hit Mage storms", () => {
expect(kinds(cast("rom-mage-primary-5"))).toEqual(Array(5).fill("damage"));
expect(kinds(cast("rom-mage-primary-12"))).toEqual([
"damage", "damage", "damage", "apply-aura", "apply-aura",
]);
});
it("holds Shield of Discipline for 30 seconds and reflects only on being hit", () => {
const effects = cast("rom-knight-general-7");
expect(kinds(effects)).toEqual(["apply-aura", "apply-aura", "apply-aura"]);
const proc = effects.find((effect) => effect.kind === "apply-aura" && effect.aura.procs?.length);
expect(proc?.kind === "apply-aura" && proc.aura.durationMs).toBe(30_000);
expect(proc?.kind === "apply-aura" && proc.aura.procs?.[0]?.action).toMatchObject({
kind: "custom", id: "rom-shield-discipline-reflect",
});
});
it("uses one breakable control effect for each single-target restraint", () => {
for (const id of ["rom-scout-primary-4", "rom-rogue-primary-5", "rom-mage-primary-56"]) {
const effects = cast(id);
expect(effects).toHaveLength(1);
expect(effects[0]).toMatchObject({ kind: "status", maxTargets: 1, breakOnDamage: true });
}
});
});
+118
View File
@@ -0,0 +1,118 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
const ROGUE_BLEED_ABILITIES = new Set([
"rom-rogue-primary-1", // Low Blow's conditional Grievous Wound
"rom-rogue-primary-4", // Blind Spot
"rom-rogue-primary-9", // Sneak Attack
"rom-rogue-general-0", // Shadowstab
]);
export interface RomCastMechanics {
readonly sealStacks: number;
readonly bleedCount: number;
readonly charged: boolean;
readonly mainHandDagger: boolean;
readonly casterMaxHealth: number;
}
/** Source periodic values for self-sacrifice toggle skills are percentages of HP. */
export function romSelfDrainPercent(spellId: number): number {
if (spellId === 490304) return 6; // Priest Holy Candle
if (spellId === 490434) return 3; // Scout Blood Arrow
return 0;
}
export function romSingleTargetControl(ability: AbilityDefinition | null): boolean {
return ability?.source === "runewaker"
&& [490347, 490454, 491172].includes(ability.dbcSpellId);
}
export function romAbilityNeedsShield(ability: AbilityDefinition): boolean {
return ability.source === "runewaker"
&& [490175, 490186, 490192, 490468].includes(ability.dbcSpellId);
}
export function romTargetBleedCount(
effects: readonly { readonly kind: string; readonly sourceAbilityId: string; readonly sourceActorId: string; readonly targetId: string | null; readonly remainingTicks: number }[],
sourceActorId: string,
targetId: string | null,
): number {
if (!targetId) return 0;
return new Set(effects.filter((effect) => effect.kind === "dot"
&& effect.remainingTicks > 0
&& effect.sourceActorId === sourceActorId
&& effect.targetId === targetId
&& ROGUE_BLEED_ABILITIES.has(effect.sourceAbilityId))
.map((effect) => effect.sourceAbilityId)).size;
}
function scaleOutput(effect: AbilityEffect, factor: number): AbilityEffect {
if (effect.kind !== "damage" && effect.kind !== "dot") return effect;
return {
...effect,
coefficient: effect.coefficient * factor,
...(effect.coefficientPerLevel === undefined ? {} : { coefficientPerLevel: effect.coefficientPerLevel * factor }),
...(effect.basePoints === undefined ? {} : { basePoints: effect.basePoints * factor }),
...(effect.pointsPerLevel === undefined ? {} : { pointsPerLevel: effect.pointsPerLevel * factor }),
...(effect.bonusPowerCoefficient === undefined ? {} : { bonusPowerCoefficient: effect.bonusPowerCoefficient * factor }),
};
}
/** Select source-conditional RoM payloads once for both player and companion casts. */
export function romEffectsForCast(ability: AbilityDefinition, context: RomCastMechanics): readonly AbilityEffect[] {
if (ability.source !== "runewaker") return ability.effects;
const effects = ability.effects;
const damage = effects.filter((effect) => effect.kind === "damage");
const seals = Math.max(0, Math.min(4, context.sealStacks));
switch (ability.dbcSpellId) {
case 490148: // A new Holy Strike increases the target's Holy Seal bleed stack.
case 490150: { // Charge adds two stacks.
const gained = ability.dbcSpellId === 490148 ? 1 : 2;
const nextStacks = Math.min(4, seals + gained);
return effects.map((effect) => effect.kind === "dot" ? scaleOutput(effect, nextStacks) : effect);
}
case 490313: // Wound Attack: one hit plus one rider per self-applied bleed.
return damage.slice(0, 1 + Math.min(2, context.bleedCount));
case 490323: // Low Blow only creates Grievous Wound on a bleeding target.
return context.bleedCount > 0 ? effects : effects.filter((effect) => effect.kind !== "dot");
case 491170: // Electric Explosion has mutually exclusive normal/Charged hits.
return context.charged ? damage.slice(0, 1) : damage.slice(-1);
case 490151: // Punishment spends Holy Seals and scales its one payoff.
return seals > 0 ? effects.map((effect) => scaleOutput(effect, seals / 4)) : [];
case 490153: { // Threaten: target debuffs and extra hit require Holy Seals.
const base = damage[0];
const threat = effects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster");
const debuffs = seals > 0 ? effects.filter((effect) => effect.kind === "apply-aura" && effect.recipient !== "caster") : [];
const bonus = seals > 0 && damage.length > 1 ? scaleOutput(damage[damage.length - 1]!, seals / 4) : null;
return [...(base ? [base] : []), ...(threat ? [threat] : []), ...debuffs, ...(bonus ? [bonus] : [])];
}
case 490154: { // Shock: one hit, one stun, plus a seal-dependent rider.
const stun = effects.find((effect) => effect.kind === "apply-aura" && effect.control?.kind === "stun");
const scaledStun = stun && stun.kind === "apply-aura"
? { ...stun, aura: { ...stun.aura, durationMs: 1_000 * (1 + seals) } }
: null;
const bonus = seals > 0 && damage.length > 1 ? scaleOutput(damage[damage.length - 1]!, seals / 4) : null;
return [...(damage[0] ? [damage[0]] : []), ...(scaledStun ? [scaledStun] : []), ...(bonus ? [bonus] : [])];
}
case 490186: { // Shield Bash shields its caster only when consuming seals.
const absorb = effects.find((effect) => effect.kind === "apply-aura" && effect.aura.absorbs?.length);
const shield = seals > 0 && absorb?.kind === "apply-aura" ? {
...absorb,
recipient: "caster" as const,
aura: {
...absorb.aura,
absorbs: absorb.aura.absorbs?.map((pool) => ({
...pool, amount: Math.max(1, Math.round(context.casterMaxHealth * 0.05 * seals)),
})),
},
} : null;
return [...(damage[0] ? [damage[0]] : []), ...(shield ? [shield, { kind: "combo" as const, amount: -5 }] : [])];
}
case 490306: // Shadowstab's bleed needs a main-hand dagger.
return context.mainHandDagger ? effects : effects.filter((effect) => effect.kind !== "dot");
case 490337: // Sneak Attack always stuns, but its bleed needs a dagger.
return context.mainHandDagger ? effects : effects.filter((effect) => effect.kind !== "dot");
default:
return effects;
}
}
+173 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest";
import { defaultActionBarForCharacter, resourceProfileForClass } from "./abilityCatalog";
import { abilityById, defaultActionBarForCharacter, resourceProfileForClass } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
@@ -9,11 +9,14 @@ import {
import { usePartyStore } from "./partyStore";
import { useCombatStore } from "./combatStore";
import { companionActorFromMember, companionActorWithMemberRuntime } from "./combatActorAuthority";
import { targetingRulesForAbility } from "./abilityRules";
import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks";
describe("RuneWaker combat runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("creates keyed pools for both classes and changes the active resource on orientation swap", () => {
@@ -106,6 +109,96 @@ describe("RuneWaker combat runtime", () => {
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true });
});
it("applies companion Holy Candle HP costs to its actor and party record", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 50 });
usePartyStore.getState().initializeParty("rom-self-drain", 50, useCombatStore.getState().health, "healer", 2, "rom");
const original = usePartyStore.getState().members[0]!;
const member = { ...original, classId: "rom-priest" as const, role: "healer" as const };
usePartyStore.setState((state) => ({ members: [member, ...state.members.slice(1)] }));
useCombatStore.getState().synchronizeActors();
const ability = abilityById("rom-priest-primary-17")!;
const dot = ability.effects.find((effect) => effect.kind === "dot");
const aura = ability.effects.find((effect) => effect.kind === "apply-aura");
expect(dot?.kind).toBe("dot");
expect(aura?.kind).toBe("apply-aura");
expect(useCombatStore.getState().applyTimedActorEffect(member.id, member.id, ability,
dot as Extract<typeof dot, { kind: "dot" }>, 1_000)).toBe(true);
expect(useCombatStore.getState().applyActorAura(member.id, member.id, ability,
aura as Extract<typeof aura, { kind: "apply-aura" }>, 1_000)).toBe(true);
useCombatStore.getState().tick(1, 2_100);
expect(usePartyStore.getState().members[0]!.health).toBeLessThan(member.health);
expect(useCombatStore.getState().actors[member.id]!.health).toBe(usePartyStore.getState().members[0]!.health);
const healthAfterTick = usePartyStore.getState().members[0]!.health;
useCombatStore.getState().consumeActorAuraTag(member.id, "rom-self-health-drain");
useCombatStore.getState().tick(1, 3_100);
expect(usePartyStore.getState().members[0]!.health).toBe(healthAfterTick);
});
it("keeps Scout Lasso on one target and breaks it when attacked", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-scout", raceId: "rom-human", level: 50 });
for (const [id, x] of [["lasso-a", 3], ["lasso-b", 4]] as const) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000 });
setMobPosition(id, [x, 0, 0]);
}
useCombatStore.getState().selectMob("lasso-a");
expect(useCombatStore.getState().castAbility("rom-scout-primary-4", undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 2_100);
expect(useCombatStore.getState().mobs["lasso-a"]!.statuses).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "root", breakOnDamage: true }),
]));
useCombatStore.setState((state) => ({ cooldowns: { ...state.cooldowns, "rom-scout-primary-4": 0 } }));
useCombatStore.getState().selectMob("lasso-b");
expect(useCombatStore.getState().castAbility("rom-scout-primary-4", undefined, 3_600)).toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 4_700);
expect(useCombatStore.getState().mobs["lasso-a"]!.statuses.some((status) => status.kind === "root")).toBe(false);
expect(useCombatStore.getState().mobs["lasso-b"]!.statuses.some((status) => status.kind === "root")).toBe(true);
useCombatStore.getState().damageMob("lasso-b", 10, 4_800);
expect(useCombatStore.getState().mobs["lasso-b"]!.statuses.some((status) => status.kind === "root")).toBe(false);
});
it("moves Rogue Shadow Step behind its target", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 50 });
useCombatStore.getState().registerMob("step-dummy", { name: "Step Dummy", maxHealth: 100_000 });
setMobPosition("step-dummy", [0, 0, 6]);
useCombatStore.getState().selectMob("step-dummy");
expect(useCombatStore.getState().castAbility("rom-rogue-general-7", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().playerPosition).toEqual([0, 0, 4.5]);
});
it("lets Vanish create the out-of-combat opening for Premeditation", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 50 });
useCombatStore.getState().registerMob("vanish-dummy", { name: "Vanish Dummy", maxHealth: 100_000 });
setMobPosition("vanish-dummy", [3, 0, 0]);
useCombatStore.getState().damageMob("vanish-dummy", 1, 1_000);
expect(useCombatStore.getState().castAbility("rom-rogue-primary-8", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility("rom-rogue-primary-11", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().castAbility("rom-rogue-primary-8", undefined, 2_600))
.toMatchObject({ ok: true });
});
it("reflects a spell hit from Shield of Discipline without damaging on cast", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-knight", raceId: "rom-human", level: 50, role: "damage" });
expect(useCombatStore.getState().castAbility("rom-knight-general-7", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
useCombatStore.getState().initializeCharacter({ classId: "rom-knight", raceId: "rom-human", level: 50, role: "tank" });
useCombatStore.getState().registerMob("reflect-mage", {
name: "Reflect Mage", maxHealth: 100_000,
attacks: [{ ...DEFAULT_ENEMY_ATTACK, id: "reflect-fire", school: "fire", delivery: "projectile", range: 20 }],
});
setMobPosition("reflect-mage", [3, 0, 0]);
expect(useCombatStore.getState().castAbility("rom-knight-general-7", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["reflect-mage"]!.health).toBe(100_000);
useCombatStore.getState().damageMob("reflect-mage", 1, 1_000);
useCombatStore.getState().advanceMobCombat(1_450);
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "damage"
&& event.abilityId === "rom-knight-general-7" && event.targetActorId === "reflect-mage")).toBe(true);
});
it("uses source coefficients for direct damage even without equipment", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-warrior", raceId: "rom-human", level: 20 });
useCombatStore.getState().registerMob("coefficient-dummy", { name: "Coefficient Dummy", maxHealth: 1_000 });
@@ -211,6 +304,57 @@ describe("RuneWaker combat runtime", () => {
expect(useCombatStore.getState().resourcePools.rage).toBe(17);
});
it("requires a target's Holy Seals before Punishment and consumes the stacks", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-knight", raceId: "rom-human", level: 50 });
useCombatStore.getState().registerMob("seal-dummy", { name: "Seal Dummy", maxHealth: 100_000 });
setMobPosition("seal-dummy", [3, 0, 0]);
useCombatStore.getState().selectMob("seal-dummy");
expect(useCombatStore.getState().castAbility("rom-knight-general-0", undefined, 1_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().castAbility("rom-knight-general-1", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().comboPoints).toBe(1);
expect(useCombatStore.getState().effects.filter((effect) => effect.tag === "rom-holy-seal")).toHaveLength(1);
expect(useCombatStore.getState().castAbility("rom-knight-general-0", undefined, 2_600))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().comboPoints).toBe(0);
expect(useCombatStore.getState().effects.filter((effect) => effect.tag === "rom-holy-seal")).toHaveLength(0);
});
it("fires one Rogue Wound Attack hit without an existing bleed", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 20 });
useCombatStore.getState().registerMob("wound-dummy", { name: "Wound Dummy", maxHealth: 100_000 });
setMobPosition("wound-dummy", [3, 0, 0]);
useCombatStore.getState().selectMob("wound-dummy");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, energy: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-rogue-primary-2", undefined, 1_000))
.toMatchObject({ ok: true });
expect(useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
&& event.abilityId === "rom-rogue-primary-2")).toHaveLength(1);
expect(useCombatStore.getState().effects.some((effect) => effect.sourceAbilityId === "rom-rogue-primary-2"))
.toBe(false);
});
it.each([
["rom-priest", "rom-priest-primary-17"],
["rom-scout", "rom-scout-general-4"],
] as const)("applies %s self-sacrifice damage on periodic ticks", (classId, abilityId) => {
const ability = abilityById(abilityId);
expect(ability).toBeDefined();
useCombatStore.getState().initializeCharacter({ classId, raceId: "rom-human", level: 50 });
const before = useCombatStore.getState().health;
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().effects.some((effect) => effect.tag === "rom-self-health-drain")).toBe(true);
useCombatStore.getState().tick(1, 2_100);
expect(useCombatStore.getState().health).toBeLessThan(before);
});
it("keeps native summons as temporary allied combat actors", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-warden", raceId: "rom-elf", level: 20 });
const maximumMana = useCombatStore.getState().maxResourcePools.mana!;
@@ -241,6 +385,34 @@ describe("RuneWaker combat runtime", () => {
});
});
it("keeps RoM resurrection and Rogue openers out of combat", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 40 });
usePartyStore.getState().initializeParty("rom-combat-resurrection", 40, useCombatStore.getState().health, "healer", 2, "rom");
const member = usePartyStore.getState().members[0]!;
usePartyStore.getState().damageMember(member.id, member.maxHealth * 10);
registerPartyRuntimePosition(member.id, [3, 0, 0]);
usePartyStore.getState().selectMember(member.id);
useCombatStore.getState().registerMob("rom-combat-target", { name: "Combat target", maxHealth: 1_000 });
setMobPosition("rom-combat-target", [3, 0, 0]);
useCombatStore.getState().damageMob("rom-combat-target", 1, 1_000);
expect(useCombatStore.getState().castAbility("rom-priest-primary-5", undefined, 2_000))
.toMatchObject({ ok: false, reason: "condition-not-met" });
useCombatStore.getState().initializeCharacter({ classId: "rom-rogue", raceId: "rom-human", level: 40 });
useCombatStore.getState().registerMob("rogue-opener-target", { name: "Opener target", maxHealth: 1_000 });
setMobPosition("rogue-opener-target", [3, 0, 0]);
useCombatStore.getState().selectMob("rogue-opener-target");
useCombatStore.getState().damageMob("rogue-opener-target", 1, 1_000);
for (const id of ["rom-rogue-primary-8", "rom-rogue-primary-9"]) {
expect(useCombatStore.getState().castAbility(id, undefined, 2_000), id)
.toMatchObject({ ok: false, reason: "condition-not-met" });
}
const blindSpot = useCombatStore.getState().abilities.find((ability) => ability.id === "rom-rogue-primary-4")!;
const sneakAttack = useCombatStore.getState().abilities.find((ability) => ability.id === "rom-rogue-primary-9")!;
expect(targetingRulesForAbility(blindSpot).requiresBehindTarget).toBe(true);
expect(targetingRulesForAbility(sneakAttack).requiresBehindTarget).toBe(true);
});
it("chains Plasma Arrow, Electric Compression, and Static Field through their source states", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-mage", raceId: "rom-human", level: 20 });
useCombatStore.getState().registerMob("static-dummy", { name: "Static Dummy", maxHealth: 10_000 });
+23
View File
@@ -0,0 +1,23 @@
import type { RomClassId, RomRaceId } from "../app/characterCatalog";
import type { PartyRole } from "./partyRoles";
interface RomFiveIdentity {
readonly name: string;
readonly classId: RomClassId;
readonly secondaryClassId: RomClassId;
readonly raceId: RomRaceId;
readonly role: PartyRole;
}
/** Named, race-legal identities shared by live NPCs and the targeted RoM builds. */
export const ROM_FIVE_IDENTITIES: readonly RomFiveIdentity[] = Object.freeze([
{ name: "Rorik", classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", role: "tank" },
{ name: "Serenna", classId: "rom-priest", secondaryClassId: "rom-knight", raceId: "rom-human", role: "healer" },
{ name: "Veyra", classId: "rom-rogue", secondaryClassId: "rom-warrior", raceId: "rom-human", role: "damage" },
{ name: "Caelan", classId: "rom-scout", secondaryClassId: "rom-rogue", raceId: "rom-elf", role: "damage" },
{ name: "Durnik", classId: "rom-mage", secondaryClassId: "rom-warlock", raceId: "rom-dwarf", role: "damage" },
]);
export function romFiveIdentity(classId: RomClassId, role: PartyRole): RomFiveIdentity | undefined {
return ROM_FIVE_IDENTITIES.find((identity) => identity.classId === classId && identity.role === role);
}
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import { abilitiesForCharacter, learnedAbilityForBuild } from "./abilityCatalog";
import { isValidRomClassPair } from "../app/romDualClass";
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
import { readinessStatusFor } from "./companionSpecReadiness";
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
import { partyRolesForClass } from "./partyRoles";
import { createConfiguredPartyMember, createDungeonParty, createSpectatorDungeonParty, usePartyStore } from "./partyStore";
import { ROM_ORDINARY_ABILITIES_BY_CLASS } from "./romAbilityCatalog";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats";
import { romGearProfile } from "./specializationCatalog";
const CASES = [
{ classId: "rom-priest", role: "healer", layout: "caster" },
{ classId: "rom-knight", role: "tank", layout: "weapon-shield" },
{ classId: "rom-scout", role: "damage", layout: "ranged" },
{ classId: "rom-rogue", role: "damage", layout: "dual-wield" },
{ classId: "rom-mage", role: "damage", layout: "caster" },
] as const;
describe("RoM five class validation scope", () => {
it.each([10, 30, 60, 80])("keeps each secondary class at the primary level for stat growth at level %i", (level) => {
for (const identity of ROM_FIVE_IDENTITIES) {
const primary = romNativeClassBaseStats(identity.classId, level);
const secondary = romNativeClassBaseStats(identity.secondaryClassId, level);
const combined = romDualClassBaseStats(identity.classId, identity.secondaryClassId, level)!;
for (const stat of Object.keys(primary) as (keyof typeof primary)[]) {
expect(combined[stat]).toBeCloseTo(primary[stat] + secondary[stat] * 0.1, 6);
}
}
});
it("builds the requested named class pairs and races without talents", async () => {
const builds = await loadCanonicalBenchmarkBuilds(60, CASES.map((entry) => entry.classId));
const selected = builds.filter((build) => CASES.some((entry) => (
entry.classId === build.classId && entry.role === build.role
)));
expect(selected).toHaveLength(5);
for (const build of selected) {
expect(build.orientation).toBe("Primary");
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === build.classId && entry.role === build.role)!;
expect(build).toMatchObject({
characterName: identity.name,
raceId: identity.raceId,
secondaryClassId: identity.secondaryClassId,
});
expect(isValidRomClassPair(identity.raceId, identity.classId, identity.secondaryClassId)).toBe(true);
expect(build.talentRanks).toEqual({});
expect(readinessStatusFor(romGearProfile(build.classId, build.role).id)).toBe("needs-testing");
}
});
it("uses the same identities for live NPCs and unlocks their secondary class at level 10", () => {
const found = new Map<string, (typeof ROM_FIVE_IDENTITIES)[number]>();
for (let seed = 0; seed < 40; seed += 1) {
for (const member of createSpectatorDungeonParty(`rom-pairs-${seed}`, 60, "rom")) {
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === member.classId && entry.role === member.role);
if (!identity) continue;
expect(member).toMatchObject({
name: identity.name,
raceId: identity.raceId,
secondaryClassId: identity.secondaryClassId,
talentRanks: {},
});
found.set(identity.classId, identity);
}
}
expect(found.size).toBe(5);
expect(new Set(ROM_FIVE_IDENTITIES.map((entry) => entry.name)).size).toBe(5);
usePartyStore.getState().initializeSpectatorParty("rom-unlock", 1, "rom");
expect(usePartyStore.getState().members.every((member) => member.secondaryClassId === null)).toBe(true);
usePartyStore.getState().syncLevel(10);
for (const member of usePartyStore.getState().members) {
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === member.classId && entry.role === member.role);
if (identity) expect(member.secondaryClassId).toBe(identity.secondaryClassId);
}
const mage = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === "rom-mage")!;
expect(createConfiguredPartyMember({
name: mage.name,
classId: mage.classId,
secondaryClassId: mage.secondaryClassId,
raceId: mage.raceId,
specialization: "Primary",
role: mage.role,
}, 60, 4, "rom-manual")).toMatchObject({
name: "Durnik",
raceId: "rom-dwarf",
secondaryClassId: "rom-warlock",
});
});
it("adds general secondary skills to each pair's learned kit", () => {
for (const identity of ROM_FIVE_IDENTITIES) {
const abilities = abilitiesForCharacter(identity.classId, identity.secondaryClassId);
expect(abilities.some((ability) => ability.classId === identity.secondaryClassId
&& ability.romSkillGroup === "general")).toBe(true);
}
});
it("uses ordinary learned abilities with no elite or talent-point gate", () => {
for (const { classId, role } of CASES) {
expect(partyRolesForClass(classId)).toContain(role);
const abilities = abilitiesForCharacter(classId, null);
expect(abilities.map((ability) => ability.id))
.toEqual(ROM_ORDINARY_ABILITIES_BY_CLASS[classId].map((ability) => ability.id));
expect(abilities.every((ability) => !ability.secondaryClassId
&& ability.romSkillGroup !== "elite"
&& !ability.talentNodeId)).toBe(true);
for (const ability of abilities) {
const learned = learnedAbilityForBuild(ability, 80, {});
expect(learned?.id, ability.id).toBe(ability.id);
}
}
});
it("equips the role defining weapons in the manual cohort", () => {
for (const { classId, role, layout } of CASES) {
expect(romGearProfile(classId, role).weaponLayout).toBe(layout);
const loadout = resolvedEquipmentLoadout({}, [], {
ownerId: `rom-five:${classId}`,
ownerKind: "party",
classId,
specializationId: null,
role,
level: 60,
});
if (classId === "rom-knight") expect(loadout["off-hand"]?.armorType).toBe("shield");
if (classId === "rom-scout") expect(loadout.ranged?.weaponType).toBe("bow");
if (classId === "rom-rogue") {
expect(loadout["main-hand"]?.weaponType).toBe("dagger");
expect(loadout["off-hand"]?.weaponType).toBe("dagger");
}
}
});
it("seats Knight only as a tank in RoM generated parties", () => {
const parties = Array.from({ length: 40 }, (_, index) => createDungeonParty(`rom-knight-${index}`, 60, "healer", 5, "rom"));
expect(parties.some((party) => party.some((member) => member.classId === "rom-knight"))).toBe(true);
for (const party of parties) for (const member of party) {
if (member.classId === "rom-knight") expect(member.role).toBe("tank");
}
});
});
+5 -2
View File
@@ -15,7 +15,7 @@ describe("RuneWaker source loot catalog", () => {
it("executes NPC slot and treasure probabilities deterministically", () => {
const sequence = [0, 0, ...Array.from({ length: 28 }, () => 0.999999)];
const drops = rollRunewakerBossLoot("rw-barren-caves-101462", 37, {
const drops = rollRunewakerBossLoot("rw-barren-caves-101462", 80, {
random: () => sequence.shift() ?? 0.999999,
now: 12_345,
});
@@ -23,11 +23,14 @@ describe("RuneWaker source loot catalog", () => {
itemId: 200004,
name: "Healing Sigil",
quantity: 1,
itemLevel: 37,
itemLevel: 80,
sourceBossName: "Anglo",
inventorySlot: "misc",
});
expect(drops[0].stats).toMatchObject({ stamina: 0, armor: 0, meleeWeapon: null });
expect(rollRunewakerBossLoot("rw-barren-caves-101462", 37, {
random: () => 0,
})).toEqual([]);
});
it("keeps no-source tables and unknown bosses fail closed", () => {
+9
View File
@@ -5,6 +5,7 @@ import {
specializationById,
specializationIconUrl,
specializationsForClass,
romGearProfile,
} from "./specializationCatalog";
describe("specialization registry", () => {
@@ -47,4 +48,12 @@ describe("specialization registry", () => {
expect(specializationIconUrl("priest", "mage:frost"))
.toBe("/assets/ui/spells/classicon128_priest.png");
});
it("gives the five RoM primary roles their appropriate weapon layouts", () => {
expect(romGearProfile("rom-priest", "healer").weaponLayout).toBe("caster");
expect(romGearProfile("rom-knight", "tank").weaponLayout).toBe("weapon-shield");
expect(romGearProfile("rom-scout", "damage").weaponLayout).toBe("ranged");
expect(romGearProfile("rom-rogue", "damage").weaponLayout).toBe("dual-wield");
expect(romGearProfile("rom-mage", "damage").weaponLayout).toBe("caster");
});
});
+1 -1
View File
@@ -221,7 +221,7 @@ export function romGearProfile(classId: ClassId, role: PartyRole): Specializatio
: caster ? "spell-dps" : base === "hunter" ? "ranged-dps" : definition.primaryStat === "strength" ? "strength-dps" : "agility-dps";
const weaponLayout: WeaponLayout = role === "tank" && gearArchetype === "block-tank"
? "weapon-shield"
: gearArchetype === "ranged-dps" ? "ranged" : caster ? "caster" : "two-hand";
: gearArchetype === "ranged-dps" ? "ranged" : caster ? "caster" : base === "rogue" ? "dual-wield" : "two-hand";
return Object.freeze({
id: `${classId}:role-${role}`,
classId,
+92 -4
View File
@@ -21,6 +21,22 @@ function effectsOfKind<K extends AbilityEffect["kind"]>(
}
describe("WoW 3.3.5 executable effect mapping", () => {
it("combines each Holy Nova damage rank with its matching healing payload", () => {
const nova = byId("wow335-priest-holy-nova");
expect(nova.ranks).toHaveLength(9);
for (const rank of nova.ranks ?? []) {
expect(effectsOfKind(rank, "damage")).toHaveLength(1);
expect(effectsOfKind(rank, "heal")).toHaveLength(1);
expect(rank.cost.amount).toBeGreaterThan(0);
}
expect(abilityAtLevel(nova, 80)).toMatchObject({
dbcSpellId: 48078,
target: "self",
radius: 10,
castMode: "instant",
});
});
it("maps Envenom to Deadly Poison dose damage and its poison-frequency buff", () => {
const envenom = abilityAtLevel(byId("wow335-rogue-envenom"), 80);
expect(effectsOfKind(envenom, "finisher-damage")).toEqual([
@@ -188,13 +204,85 @@ describe("WoW 3.3.5 executable effect mapping", () => {
]));
});
it("models Penance as the Discipline rotation's friendly heal", () => {
it("models Penance as three healing or damaging pulses", () => {
const penance = byId("wow335-priest-penance");
expect(penance.target).toBe("friendly");
expect(penance.target).toBe("any");
expect(effectsOfKind(penance, "heal")).toEqual([
expect.objectContaining({ coefficient: 1.8 }),
expect.objectContaining({ coefficient: 0.6 }),
]);
expect(effectsOfKind(penance, "damage")).toEqual([]);
expect(effectsOfKind(penance, "hot")).toEqual([
expect.objectContaining({ coefficient: 0.6, ticks: 2, intervalMs: 1_000 }),
]);
expect(effectsOfKind(penance, "damage")).toEqual([
expect.objectContaining({ coefficient: 0.6 }),
]);
expect(effectsOfKind(penance, "dot")).toEqual([
expect.objectContaining({ coefficient: 0.6, ticks: 2, intervalMs: 1_000 }),
]);
});
it("maps the Ready-five missing spell payloads without cast-time damage on self buffs", () => {
const devastate = abilityAtLevel(byId("wow335-warrior-devastate"), 80);
expect(effectsOfKind(devastate, "damage")).toEqual([
expect.objectContaining({ coefficient: 1, bonusPerSunderStack: 202 }),
]);
expect(effectsOfKind(devastate, "apply-aura")).toEqual([
expect.objectContaining({ aura: expect.objectContaining({ id: "wow335:warrior:sunder-armor", maxStacks: 5 }) }),
]);
const lastStand = byId("wow335-warrior-last-stand");
expect(lastStand.target).toBe("self");
expect(effectsOfKind(lastStand, "heal")).toEqual([]);
expect(effectsOfKind(lastStand, "apply-aura")).toEqual([
expect.objectContaining({ aura: expect.objectContaining({
modifiers: [{ kind: "stat", stat: "maximum-health", operation: "percent", value: 0.3 }],
}) }),
]);
expect(effectsOfKind(byId("wow335-priest-divine-hymn"), "heal")).toEqual([
expect.objectContaining({ scope: "party", maximumTargets: 3, radius: 40 }),
]);
expect(effectsOfKind(byId("wow335-hunter-chimera-shot"), "damage")).toEqual([
expect.objectContaining({ coefficient: 1.25 }),
]);
expect(effectsOfKind(abilityAtLevel(byId("wow335-mage-living-bomb"), 80), "dot")).toHaveLength(1);
const moltenArmor = abilityAtLevel(byId("wow335-mage-molten-armor"), 80);
expect(moltenArmor.target).toBe("self");
expect(effectsOfKind(moltenArmor, "damage")).toEqual([]);
expect(effectsOfKind(byId("wow335-rogue-shiv"), "combo")).toEqual([{ kind: "combo", amount: 1 }]);
expect(effectsOfKind(byId("wow335-rogue-cold-blood"), "apply-aura")).toEqual([
expect.objectContaining({ aura: expect.objectContaining({
utilityTags: ["next-offensive-critical"],
modifiers: [{ kind: "stat", stat: "critical-strike", operation: "flat", value: 100 }],
}) }),
]);
});
it("maps the remaining Ready-five cooldowns and defensive modifiers", () => {
expect(effectsOfKind(byId("wow335-warrior-last-stand"), "apply-aura")[0]?.aura.durationMs).toBe(20_000);
expect(byId("wow335-warrior-enraged-regeneration").target).toBe("self");
const innerFire = abilityAtLevel(byId("wow335-priest-inner-fire"), 80);
expect(effectsOfKind(innerFire, "apply-aura")[0]?.aura).toMatchObject({
maxStacks: 20, initialStacks: 20,
modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 2440, perStack: false }],
});
const shadowfiend = byId("wow335-priest-shadowfiend");
expect(effectsOfKind(shadowfiend, "summon")).toHaveLength(1);
expect(effectsOfKind(shadowfiend, "resource")).toHaveLength(0);
expect(effectsOfKind(abilityAtLevel(byId("wow335-hunter-kill-shot"), 80), "damage"))
.toEqual([{ kind: "damage", coefficient: 2, bonusFlat: 325 }]);
const combustion = byId("wow335-mage-combustion");
expect(combustion.target).toBe("self");
expect(effectsOfKind(combustion, "damage")).toHaveLength(0);
expect(effectsOfKind(combustion, "apply-aura")[0]?.aura.utilityTags).toContain("fire-crit-stacking");
expect(effectsOfKind(byId("wow335-mage-mirror-image"), "summon")).toHaveLength(3);
const molten = abilityAtLevel(byId("wow335-mage-molten-armor"), 80);
expect(effectsOfKind(molten, "apply-aura").flatMap((effect) => effect.aura.modifiers ?? []))
.toEqual(expect.arrayContaining([
expect.objectContaining({ stat: "incoming-critical-strike", value: -5 }),
expect.objectContaining({ stat: "spirit-to-spell-crit-rating", value: 0.35 }),
]));
expect(effectsOfKind(abilityAtLevel(byId("wow335-rogue-evasion"), 80), "apply-aura")
.flatMap((effect) => effect.aura.modifiers ?? []))
.toEqual(expect.arrayContaining([expect.objectContaining({ stat: "incoming-ranged-hit", value: -25 })]));
});
it("keeps triggered shield damage children out of learned Shaman ranks", () => {
+105 -19
View File
@@ -171,7 +171,7 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
case 13: return [{ kind: "damage", direction: "dealt", school: schoolFromMask(effect.miscValue), operation: "flat", value: effect.basePoints }];
case 14: return [{ kind: "damage", direction: "taken", school: schoolFromMask(effect.miscValue), operation: "flat", value: effect.basePoints }];
case 20: return flatStat("health-regeneration");
case 22:
case 22: return effect.miscValue === 1 ? flatStat("armor") : flatStat(resistanceStat);
case 83:
case 123:
case 143: return flatStat(resistanceStat);
@@ -210,13 +210,13 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
case 49: return percentStat("dodge");
case 50: return percentStat("critical-healing");
case 51: return percentStat("block");
case 52:
case 187:
case 188:
case 197: return percentStat("critical-strike");
case 54:
case 184:
case 185: return percentStat("hit");
case 52: return percentStat("critical-strike");
case 187: return [{ kind: "stat", stat: "incoming-melee-critical-strike", operation: "flat", value: effect.basePoints }];
case 188: return [{ kind: "stat", stat: "incoming-ranged-critical-strike", operation: "flat", value: effect.basePoints }];
case 197: return [{ kind: "stat", stat: "incoming-critical-strike", operation: "flat", value: effect.basePoints }];
case 54: return percentStat("hit");
case 184: return [{ kind: "stat", stat: "incoming-melee-hit", operation: "flat", value: effect.basePoints }];
case 185: return [{ kind: "stat", stat: "incoming-ranged-hit", operation: "flat", value: effect.basePoints }];
case 55:
case 186:
case 199: return percentStat("spell-hit");
@@ -447,6 +447,14 @@ function namedAuraForEffect(
const school = schoolFromMask(effect.miscValue);
const auraId = `wow335:${rank.spellId}:effect:${effectIndex}:aura:${effect.auraType}`;
const modifiers = [...auraModifiers(effect)];
if (abilityName === "Inner Fire") {
for (const [index, modifier] of modifiers.entries()) modifiers[index] = { ...modifier, perStack: false };
}
if (abilityName === "Molten Armor" && effect.auraType === 220) {
modifiers.splice(0, modifiers.length, {
kind: "stat", stat: "spirit-to-spell-crit-rating", operation: "flat", value: effect.basePoints / 100,
});
}
if (abilityName === "Greater Blessing of Sanctuary" && effect.auraType === 4) {
modifiers.push(
{ kind: "damage", direction: "taken", operation: "percent", value: -0.03 },
@@ -623,8 +631,9 @@ function namedAuraForEffect(
? { hideFromAuraStrip: true }
: {}),
durationMs,
maxStacks: 1,
stackBehavior: "refresh",
maxStacks: abilityName === "Inner Fire" ? 20 : 1,
...(abilityName === "Inner Fire" ? { initialStacks: 20 } : {}),
stackBehavior: abilityName === "Inner Fire" ? "replace" : "refresh",
...(exclusiveGroupForAbility(classId, abilityName) || abilityName === "Light's Beacon"
? { exclusiveGroup: abilityName === "Light's Beacon" ? "paladin:beacon" : exclusiveGroupForAbility(classId, abilityName) }
: {}),
@@ -758,10 +767,36 @@ function classScriptEffects(
case "warrior:Execute": return [{ kind: "execute", coefficient: 1.8 }];
case "warrior:Charge": return [{ kind: "status", status: "stun", durationMs: 1_500 }];
case "warrior:Intercept": return [{ kind: "status", status: "stun", durationMs: 3_000 }];
case "warrior:Devastate": return [
{
kind: "damage",
coefficient: 1,
bonusPerSunderStack: rank.effects.find((effect) => effect.effectType === 121)?.basePoints ?? 0,
},
{
kind: "apply-aura",
aura: {
id: "wow335:warrior:sunder-armor", name: "Sunder Armor", disposition: "debuff", durationMs: 30_000,
maxStacks: 5, stackBehavior: "add",
modifiers: [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.04, perStack: true }],
},
sourceEffectType: 6, sourceAuraType: 4, miscValue: 0, triggerSpellId: 0,
},
];
case "warrior:Last Stand": return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "wow335:warrior:last-stand", name: abilityName, disposition: "buff",
durationMs: 20_000, maxStacks: 1, stackBehavior: "refresh",
modifiers: [{ kind: "stat", stat: "maximum-health", operation: "percent", value: 0.3 }],
},
sourceEffectType: 6, sourceAuraType: 133, miscValue: 0, triggerSpellId: 0,
}];
case "warrior:Sunder Armor": return [{
kind: "apply-aura",
aura: {
id: `wow335:${rank.spellId}:sunder-armor`, name: abilityName, disposition: "debuff", durationMs: 30_000,
id: "wow335:warrior:sunder-armor", name: abilityName, disposition: "debuff", durationMs: 30_000,
maxStacks: 5, stackBehavior: "add",
modifiers: [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: 0.04, perStack: true }],
},
@@ -793,6 +828,11 @@ function classScriptEffects(
case "hunter:Freezing Arrow": return [{ kind: "status", status: "freeze", durationMs: 10_000 }];
case "hunter:Feign Death": return [{ kind: "threat", mode: "percent", amount: -100 }];
case "hunter:Readiness": return [{ kind: "cooldown-reset", mode: "reset" }];
case "hunter:Chimera Shot": return [{ kind: "damage", coefficient: 1.25 }];
case "hunter:Kill Shot": return [{
kind: "damage", coefficient: 2,
bonusFlat: rank.effects.find((effect) => effect.effectType === 17)?.basePoints ?? 0,
}];
// Volley's player-facing spell is a six-second channel aura whose Arcane
// pulses live in same-rank triggered child rows. Keep the channel/cost but
// make the six ground-area pulses executable by players and companions.
@@ -807,7 +847,7 @@ function classScriptEffects(
tag: "volley",
}] : [];
}
case "rogue:Shiv": return [{ kind: "damage", coefficient: 1 }];
case "rogue:Shiv": return [{ kind: "damage", coefficient: 1 }, { kind: "combo", amount: 1 }];
case "rogue:Killing Spree": return [{
kind: "dot", coefficient: 0.5, ticks: 5, intervalMs: 500, tag: "killing-spree",
}];
@@ -843,7 +883,8 @@ function classScriptEffects(
durationMs: rank.durationMs > 0 ? rank.durationMs : 20_000,
maxStacks: 1,
stackBehavior: "refresh",
modifiers: [{ kind: "stat", stat: "critical-strike", operation: "flat", value: 1 }],
modifiers: [{ kind: "stat", stat: "critical-strike", operation: "flat", value: 100 }],
utilityTags: ["next-offensive-critical"],
},
recipient: "caster",
sourceEffectType: 6,
@@ -887,10 +928,17 @@ function classScriptEffects(
// trigger spells. Keep the imported cast/cost/rank data and supply only the
// missing executable payload.
case "priest:Mind Flay": return [{ kind: "damage", coefficient: 0.9 }];
case "priest:Penance": return [{ kind: "heal", coefficient: 1.8 }];
case "priest:Penance": return [
{ kind: "heal", coefficient: 0.6 },
{ kind: "hot", coefficient: 0.6, ticks: 2, intervalMs: 1_000 },
{ kind: "damage", coefficient: 0.6 },
{ kind: "dot", coefficient: 0.6, ticks: 2, intervalMs: 1_000, tag: "penance" },
];
case "priest:Divine Hymn": return [{
kind: "heal", coefficient: 1, scope: "party", center: "caster", radius: 40, maximumTargets: 3,
}];
case "priest:Shadowfiend": return [
{ kind: "summon", creatureId: 19668, coefficient: 0.5, durationMs: 15_000 },
{ kind: "resource", resource: "mana", amount: 30, percentage: true },
];
case "priest:Inner Focus": return [{
kind: "apply-aura",
@@ -971,6 +1019,18 @@ function classScriptEffects(
case "hunter:Misdirection": return [{ kind: "threat", mode: "redirect", amount: 100 }];
case "rogue:Tricks of the Trade": return [{ kind: "threat", mode: "redirect", amount: 100 }];
case "mage:Blink": return [{ kind: "movement", movement: "leap", toward: "away" }];
case "mage:Combustion": return [{
kind: "apply-aura", recipient: "caster",
aura: {
id: "wow335:mage:combustion", name: abilityName, disposition: "buff",
durationMs: null, maxStacks: 100, stackBehavior: "replace",
utilityTags: ["fire-crit-stacking"],
},
sourceEffectType: 6, sourceAuraType: 4, miscValue: 0, triggerSpellId: 0,
}];
case "mage:Mirror Image": return Array.from({ length: 3 }, () => ({
kind: "summon" as const, creatureId: 58836, coefficient: 0.25, durationMs: 30_000,
}));
// Arcane Missiles stores its per-tick payload in a server-triggered child spell
// that is not a player-facing SkillLine entry. Model the channel payload here so
// both players and shared-rotation consumers execute the imported spell instead
@@ -1275,7 +1335,9 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
}
const auraOnlyStance = classId === "death-knight"
&& ["Blood Presence", "Frost Presence", "Unholy Presence"].includes(abilityName);
if (!auraOnlyStance && !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
if (!auraOnlyStance && !(classId === "warrior" && abilityName === "Last Stand")
&& !(classId === "mage" && abilityName === "Molten Armor")
&& !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
if (classId === "death-knight" && abilityName === "Death Coil") {
effects.unshift({ kind: "damage", coefficient: 1 });
} else if (/\b(?:heals?|restore|mend|rejuvenat)\w*\b/i.test(rank.description)) {
@@ -1293,6 +1355,18 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
&& (effect.kind === "damage" || effect.kind === "heal"))
|| (classId === "priest" && abilityName === "Penance"
&& (effect.kind === "damage" || effect.kind === "heal"))
|| (classId === "priest" && abilityName === "Divine Hymn" && effect.kind === "heal")
|| (classId === "warrior" && ["Devastate", "Last Stand"].includes(abilityName)
&& (effect.kind === "damage" || effect.kind === "heal"))
|| (classId === "hunter" && abilityName === "Chimera Shot" && effect.kind === "damage")
|| (classId === "hunter" && abilityName === "Kill Shot" && effect.kind === "damage")
|| (classId === "priest" && abilityName === "Shadowfiend"
&& (effect.kind === "summon" || effect.kind === "trigger-spell"))
|| (classId === "mage" && abilityName === "Combustion"
&& (effect.kind === "damage" || effect.kind === "apply-aura" || effect.kind === "trigger-spell"))
|| (classId === "mage" && abilityName === "Mirror Image"
&& (effect.kind === "summon" || effect.kind === "trigger-spell"))
|| (classId === "rogue" && abilityName === "Cold Blood" && effect.kind === "apply-aura")
|| (classId === "shaman" && abilityName === "Feral Spirit" && effect.kind === "summon")
|| (classId === "warrior" && abilityName === "Bladestorm" && effect.kind === "dot")
|| (classId === "paladin" && abilityName === "Hammer of the Righteous" && effect.kind === "damage")
@@ -1443,9 +1517,20 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
const sourceRanks = chain.ranks.some((rank) => rank.rank > 0)
? chain.ranks.filter((rank) => rank.rank > 0)
: chain.ranks;
const ranks = sourceRanks.map((rank, index) => rankDefinition(classId, resource, rank, index, chain.name));
// Holy Nova's DBC chain stores damage and healing as separate same-rank
// spells. The damage spell is the cast (and pays mana); its healing partner
// is a second payload of that same cast, not another selectable rank.
const castRanks = classId === "priest" && chain.name === "Holy Nova"
? sourceRanks.filter((rank) => rank.effects.some((effect) => effect.effectType === 2))
.map((rank) => {
const healing = sourceRanks.find((candidate) => candidate.rank === rank.rank
&& candidate.effects.some((effect) => effect.effectType === 10));
return healing ? { ...rank, effects: [...rank.effects, ...healing.effects] } : rank;
})
: sourceRanks;
const ranks = castRanks.map((rank, index) => rankDefinition(classId, resource, rank, index, chain.name));
const first = ranks[0];
const firstRaw = sourceRanks[0];
const firstRaw = castRanks[0];
if (!first || !firstRaw) return null;
const target = abilityNameTarget(chain.name) ?? targetForRank(firstRaw, first.effects);
const radius = Math.max(
@@ -1506,7 +1591,8 @@ function abilityNameRadius(abilityName: string): number {
function abilityNameTarget(abilityName: string): AbilityTarget | undefined {
if (["Bladestorm", "Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead", "Fan of Knives"].includes(abilityName)) return "self";
if (abilityName === "Volley") return "hostile";
if (abilityName === "Penance") return "friendly";
if (abilityName === "Penance") return "any";
if (["Last Stand", "Enraged Regeneration", "Molten Armor", "Combustion"].includes(abilityName)) return "self";
if (abilityName === "Death Coil") return "hostile";
return undefined;
}
+5
View File
@@ -153,6 +153,11 @@ describe("combat-effect runtime budget", () => {
], "high");
expect(timed.map(({ event: active }) => active.phase)).toEqual(["release"]);
expect(originalInstantCastEvent(timed[0].event)).toBeNull();
expect(originalInstantCastEvent(event(5, {
phase: "release",
instantCast: true,
abilityId: "wow335-priest-holy-nova",
}))).toBeNull();
});
it("preserves source terminal semantics for instant impact and aura fallbacks", () => {
+2 -1
View File
@@ -152,7 +152,8 @@ export function addOriginalInstantCastEffect(
export function originalInstantCastEvent(
event: CombatPresentationEvent,
): CombatPresentationEvent | null {
if (event.phase !== "release" || !event.instantCast) return null;
if (event.phase !== "release" || !event.instantCast
|| event.abilityId === "wow335-priest-holy-nova") return null;
return {
...event,
id: -event.id,
+1 -1
View File
@@ -183,7 +183,7 @@ export function CharacterCreateScreen() {
<div className="class-summary">
<strong style={{ color: characterClass.color }}>{characterClass.name}</strong>
<span>{characterClass.description}</span>
<small>{characterClass.armor} / {characterClass.resourceSummary} / {characterClass.specializations.length ? characterClass.specializations.join(" · ") : "Automatic skill ranks"}</small>
<small>{characterClass.armor} / {characterClass.resourceSummary} / {characterClass.specializations.length ? characterClass.specializations.join(" · ") : "Skills unlock by level"}</small>
</div>
{specializationOptions.length > 0 && (
<div className="creator-specializations" aria-label="Specialization">
+4 -1
View File
@@ -68,12 +68,15 @@ export function DungeonSelectScreen() {
const enterDungeon = useShellStore((state) => state.enterDungeon);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
const notice = useShellStore((state) => state.notice);
const dungeonSelectionId = useShellStore((state) => state.dungeonSelectionId);
const session = useShellStore((state) => state.session);
const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group);
const updateOnlineMember = useOnlineGroupStore((state) => state.updateMember);
const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity);
const character = activeCharacter ?? rosterCharacter;
const firstAvailable = DUNGEON_CATALOG.find((dungeon) => dungeon.available) ?? DUNGEON_CATALOG[0];
const firstAvailable = DUNGEON_CATALOG.find((dungeon) => dungeon.id === dungeonSelectionId)
?? DUNGEON_CATALOG.find((dungeon) => dungeon.available)
?? DUNGEON_CATALOG[0];
const [selectedCategory, setSelectedCategory] = useState<DungeonCategoryId>(firstAvailable?.category ?? "wow");
const [selectedDungeonId, setSelectedDungeonId] = useState<DungeonId | "">(firstAvailable?.id ?? "");
const [rolePickerOpen, setRolePickerOpen] = useState(false);
+3 -3
View File
@@ -83,9 +83,9 @@ export function GameplayPanels() {
known: !abilityTalentNodeId(catalogAbility) || Boolean(learnedTalentRank),
talentLocked: Boolean(abilityTalentNodeId(catalogAbility)),
passive: ability.passive,
rank: activeRank?.rank,
rankCount: catalogAbility.ranks?.length,
ranks: catalogAbility.ranks?.map((rank) => ({
rank: ability.source === "runewaker" ? undefined : activeRank?.rank,
rankCount: ability.source === "runewaker" ? undefined : catalogAbility.ranks?.length,
ranks: ability.source === "runewaker" ? undefined : catalogAbility.ranks?.map((rank) => ({
rank: rank.rank,
level: rank.level,
spellId: rank.spellId,
+43 -16
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useMemo, useState } from "react";
import { lazy, Suspense, useCallback, useMemo, useState } from "react";
import { classById } from "../app/characterCatalog";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
@@ -28,8 +28,22 @@ const GAME_MODES = [
available: true,
},
{
id: "randomized-bite-sized",
id: "random-dungeon",
number: "02",
name: "Random Dungeon",
description: "Let fate choose an available full dungeon from WoW or RoM.",
available: true,
},
{
id: "random-bite-sized",
number: "03",
name: "Random Regular Bite-Sized Dungeon",
description: "Choose a short authored dungeon with its regular enemies and boss rules.",
available: true,
},
{
id: "randomized-bite-sized",
number: "04",
name: "Randomized Bite-Sized",
description: "Run a short dungeon with every mob and the final boss rerolled.",
available: true,
@@ -114,6 +128,8 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
const activeCharacter = useShellStore((state) => state.activeCharacter);
const rosterCharacter = useShellStore(selectedCharacter);
const openDungeons = useShellStore((state) => state.openDungeons);
const openRandomDungeon = useCallback(() => openDungeons("random"), [openDungeons]);
const openRandomBiteSizedDungeon = useCallback(() => openDungeons("random-bite-sized"), [openDungeons]);
const openManastorms = useShellStore((state) => state.openManastorms);
const openArenas = useShellStore((state) => state.openArenas);
const openClassHall = useShellStore((state) => state.openClassHall);
@@ -137,12 +153,22 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
{
id: "mode-dungeons",
run: openDungeons,
neighbors: { up: "back-to-roster", right: "mode-randomized-bite-sized" },
neighbors: { up: "back-to-roster", right: "mode-random-dungeon", down: "mode-random-bite-sized" },
},
{
id: "mode-random-dungeon",
run: openRandomDungeon,
neighbors: { up: "back-to-roster", left: "mode-dungeons", down: "mode-randomized-bite-sized" },
},
{
id: "mode-random-bite-sized",
run: openRandomBiteSizedDungeon,
neighbors: { up: "mode-dungeons", right: "mode-randomized-bite-sized" },
},
{
id: "mode-randomized-bite-sized",
run: onOpenRandomizedBiteSized,
neighbors: { up: "back-to-roster", left: "mode-dungeons" },
neighbors: { up: "mode-random-dungeon", left: "mode-random-bite-sized" },
},
...(character ? [
{ id: "mode-class-hall", run: openClassHall },
@@ -158,7 +184,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
] : []),
{ id: "mode-downloads", run: onOpenDownloads },
{ id: "back-to-roster", run: returnToCharacters, neighbors: { down: "mode-dungeons" } },
], [character, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBalanceTuning, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
], [character, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBalanceTuning, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, openRandomBiteSizedDungeon, openRandomDungeon, returnToCharacters]);
const controller = useMenuController(actions, {
columns: 2,
initialId: gmMenuOpen ? (isGm ? "gm-menu-gm-tools" : "gm-menu-boss-testing") : "mode-dungeons",
@@ -168,6 +194,15 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
const characterClass = character ? classById(character.classId) : null;
const specialization = specializationById(character?.specializationId);
const secondaryClass = character?.secondaryClassId ? classById(character.secondaryClassId) : null;
const gameModeAction = (id: string) => {
if (id === "manastorms") return openManastorms;
if (id === "arenas") return openArenas;
if (id === "downloads") return onOpenDownloads;
if (id === "random-dungeon") return openRandomDungeon;
if (id === "random-bite-sized") return openRandomBiteSizedDungeon;
if (id === "randomized-bite-sized") return onOpenRandomizedBiteSized;
return () => openDungeons();
};
const renderGameMode = (mode: (typeof GAME_MODES)[number]) => mode.available ? (
<ControllerButton
key={mode.id}
@@ -176,15 +211,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
select={controller.select}
className="front-button main-menu-mode main-menu-mode--available"
type="button"
onClick={mode.id === "manastorms"
? openManastorms
: mode.id === "arenas"
? openArenas
: mode.id === "downloads"
? onOpenDownloads
: mode.id === "randomized-bite-sized"
? onOpenRandomizedBiteSized
: openDungeons}
onClick={gameModeAction(mode.id)}
>
<b aria-hidden="true">{mode.number}</b>
<span><strong>{mode.name}</strong><small>{mode.description}</small></span>
@@ -247,7 +274,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
<section className="main-menu-card main-menu-card--adventures" aria-labelledby="main-menu-adventures-title">
<header><span>01</span><strong id="main-menu-adventures-title">Adventures</strong></header>
<div className="main-menu-card__grid">
{GAME_MODES.filter((mode) => mode.id === "dungeons" || mode.id === "randomized-bite-sized").map(renderGameMode)}
{GAME_MODES.filter((mode) => ["dungeons", "random-dungeon", "random-bite-sized", "randomized-bite-sized"].includes(mode.id)).map(renderGameMode)}
</div>
</section>
@@ -280,7 +307,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--gm-menu" type="button" onClick={() => setGmMenuOpen(true)}>
<b aria-hidden="true">GM</b><span><strong>GM Menu</strong><small>Testing, readiness, reports, and balance tools.</small></span><em>Open</em>
</ControllerButton>
{GAME_MODES.filter((mode) => mode.id !== "dungeons" && mode.id !== "randomized-bite-sized").map(renderGameMode)}
{GAME_MODES.filter((mode) => !["dungeons", "random-dungeon", "random-bite-sized", "randomized-bite-sized"].includes(mode.id)).map(renderGameMode)}
</div>
</section>
</nav>
+4 -4
View File
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { classById } from "../app/characterCatalog";
import { abilityAtLevel, abilityRankForLevel, type AbilityDefinition } from "../game/abilityCatalog";
import { abilityAtLevel, type AbilityDefinition } from "../game/abilityCatalog";
import { useCombatStore } from "../game/combatStore";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
@@ -41,11 +41,11 @@ export function RomSkillsPanel({ open, onClose }: { open?: boolean; onClose?: ()
<div className="modal-backdrop game-panel-backdrop" role="presentation">
<section className="game-panel rom-skills-panel" role="dialog" aria-modal="true" aria-labelledby="rom-skills-title">
<header className="game-panel__header"><div><p>Runes of Magic / Automatic progression</p><h2 id="rom-skills-title">Skills</h2></div><ControllerButton controlId="rom-skills-close" selectedId={controller.selectedId} select={controller.select} type="button" onClick={close}>Close</ControllerButton></header>
<p className="rom-skills-panel__intro"><strong>{primary.name}{secondary ? ` / ${secondary.name}` : ""}</strong><span>Ranks automatically use the highest source rank available at level {level}. No training-point allocation is required.</span></p>
<p className="rom-skills-panel__intro"><strong>{primary.name}{secondary ? ` / ${secondary.name}` : ""}</strong><span>Skills unlock automatically as you level. There are no talent points or spell ranks to assign.</span></p>
<div className="rom-skills-groups">
{GROUPS.map((group) => {
{GROUPS.filter((group) => abilities.some((ability) => visibleGroup(ability, classId) === group.id)).map((group) => {
const entries = abilities.filter((ability) => visibleGroup(ability, classId) === group.id);
return <section key={group.id}><header><h3>{group.label}</h3><span>{entries.filter((ability) => level >= ability.unlockLevel).length} / {entries.length}</span></header><div>{entries.map((catalogAbility) => { const ability = abilityAtLevel(catalogAbility, level); const rank = abilityRankForLevel(catalogAbility, level); const locked = level < catalogAbility.unlockLevel; return <ControllerButton key={ability.id} controlId={`rom-skill-${ability.id}`} selectedId={controller.selectedId} select={controller.select} className={`rom-skill-card ${locked ? "is-locked" : ""}`} type="button"><img src={ability.icon} alt=""/><span><strong>{ability.name}</strong><small>{locked ? `Unlocks at level ${ability.unlockLevel}` : `Rank ${rank?.rank ?? 1}${ability.secondaryClassId ? ` / ${secondary?.name ?? "Elite"}` : ""}`}</small></span><em>{ability.passive ? "Passive" : ability.cost.amount ? `${ability.cost.amount} ${ability.cost.resource}` : "No cost"}</em></ControllerButton>; })}</div></section>;
return <section key={group.id}><header><h3>{group.label}</h3><span>{entries.filter((ability) => level >= ability.unlockLevel).length} / {entries.length}</span></header><div>{entries.map((catalogAbility) => { const ability = abilityAtLevel(catalogAbility, level); const locked = level < catalogAbility.unlockLevel; return <ControllerButton key={ability.id} controlId={`rom-skill-${ability.id}`} selectedId={controller.selectedId} select={controller.select} className={`rom-skill-card ${locked ? "is-locked" : ""}`} type="button"><img src={ability.icon} alt=""/><span><strong>{ability.name}</strong><small>{locked ? `Unlocks at level ${ability.unlockLevel}` : ability.secondaryClassId ? `Elite / ${secondary?.name ?? "Secondary class"}` : "Learned"}</small></span><em>{ability.passive ? "Passive" : ability.cost.amount ? `${ability.cost.amount} ${ability.cost.resource}` : "No cost"}</em></ControllerButton>; })}</div></section>;
})}
</div>
</section>
+1 -1
View File
@@ -59,7 +59,7 @@ describe("gameplay panel loading integration", () => {
it("groups primary adventures separately and keeps testing utilities in the GM menu", () => {
expect(mainMenuSource).toContain('className="main-menu-card main-menu-card--adventures"');
expect(mainMenuSource).toContain('mode.id === "dungeons" || mode.id === "randomized-bite-sized"');
expect(mainMenuSource).toContain('["dungeons", "random-dungeon", "random-bite-sized", "randomized-bite-sized"]');
expect(mainMenuSource).toContain('controlId="mode-gm-menu"');
for (const controlId of [
"gm-menu-gm-tools",