Update Healer Man server
This commit is contained in:
1 parent
f90ba7d989
commit
189b94d6b7
51 files changed
+3448
-154
No files matched your search
@@ -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
@@ -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: () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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"]);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
: [];
|
||||
}
|
||||
@@ -270,5 +270,5 @@ export function partyAbilityAmount(
|
||||
member.gearStats,
|
||||
member.classId,
|
||||
ability,
|
||||
);
|
||||
) + (effect.kind === "damage" ? effect.bonusFlat ?? 0 : 0);
|
||||
}
|
||||
+143
-18
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user