Release Healer Man 0.1.6

This commit is contained in:
phenom committed 2026-08-20 14:05:50 -04:00
1 parent 55cfd43d66
commit b12fb84859
96 files changed
+10874 -609

No files matched your search

+18 -5
View File
@@ -6,6 +6,10 @@ import { useGameStore } from "./store";
import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout";
import { useManastormStore } from "./manastormStore";
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
import {
onlineSessionIsActive,
onlineSessionOwnsWorldSimulation,
} from "./onlineSessionRuntime";
/**
* Connects the pure combat runtime to the active character and persistent
@@ -21,6 +25,7 @@ export function CombatBridge() {
useEffect(() => {
if (!character) return;
useCombatStore.getState().initializeCharacter(character);
useCombatStore.getState().synchronizeActors();
if (gameMode === "manastorm") {
activateManastormActiveSpellLoadout(
useManastormStore.getState().activeSpellLoadout,
@@ -108,17 +113,25 @@ export function CombatBridge() {
const delta = Math.min(0.25, Math.max(0, (now - previous) / 1_000));
previous = now;
const game = useGameStore.getState();
if (!game.paused && !game.companionOpen) {
const sharedOnline = onlineSessionIsActive();
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
const epochNow = Date.now();
const combat = useCombatStore.getState();
combat.setPlayerPosition(game.playerPosition);
combat.tick(delta, epochNow);
if (game.gameMode === "dungeon") {
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
useCombatStore.getState().engageNearbyMobs(epochNow, game.playerPosition);
}
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
advanceDungeonPartyCombat(epochNow);
if (game.gameMode === "dungeon" && game.activeDungeonId === "wailing-caverns") {
if (onlineSessionOwnsWorldSimulation()) {
useCombatStore.getState().advanceMobCombat(epochNow, game.playerPosition);
advanceDungeonPartyCombat(epochNow);
}
useCombatStore.getState().synchronizeActors();
if (
onlineSessionOwnsWorldSimulation()
&& game.gameMode === "dungeon"
&& game.activeDungeonId === "wailing-caverns"
) {
useWailingEncounterStore.getState().advance(
useCombatStore.getState().mobs,
epochNow,
+11 -2
View File
@@ -14,6 +14,7 @@ import { getManastormCatalog, manastormEncounterById } from "./manastorm";
import { synchronizeManastormProgress } from "./manastormProgressSync";
import { synchronizeManastormAffixes } from "./manastormAffixRuntime";
import { preloadManastormStage } from "./manastormStageLoader";
import { onlineSessionIsActive, onlineSessionOwnsWorldSimulation } from "./onlineSessionRuntime";
/** Connects staged combat, party sizing, and shared resurrections to Manastorm state. */
export function ManastormBridge() {
@@ -30,11 +31,12 @@ export function ManastormBridge() {
useEffect(() => {
if (gameMode !== "manastorm") return;
if (!onlineSessionOwnsWorldSimulation()) return;
synchronizeManastormPartySize();
}, [gameMode, partyMemberCount, partySize]);
useEffect(() => {
if (gameMode !== "manastorm" || status !== "entering" || !encounter) return;
if (gameMode !== "manastorm" || status !== "entering" || !encounter || !onlineSessionOwnsWorldSimulation()) return;
useManastormStore.getState().enterStage();
}, [encounter, gameMode, status]);
@@ -42,12 +44,14 @@ export function ManastormBridge() {
if (
gameMode !== "manastorm"
|| !encounter
|| !onlineSessionOwnsWorldSimulation()
) return;
synchronizeManastormCombat();
}, [encounter, gameMode, mobs, status]);
useEffect(() => {
if (gameMode !== "manastorm") return;
if (!onlineSessionOwnsWorldSimulation()) return;
const timer = window.setInterval(() => synchronizeManastormAffixes(Date.now()), 250);
return () => window.clearInterval(timer);
}, [gameMode]);
@@ -71,6 +75,7 @@ export function ManastormBridge() {
|| previous.health <= 0
|| useGameStore.getState().gameMode !== "manastorm"
|| resolvingDefeatRef.current
|| onlineSessionIsActive()
) return;
resolvingDefeatRef.current = true;
try {
@@ -82,7 +87,11 @@ export function ManastormBridge() {
}), []);
useEffect(() => usePartyStore.subscribe((state, previous) => {
if (useGameStore.getState().gameMode !== "manastorm" || resolvingDefeatRef.current) return;
if (
useGameStore.getState().gameMode !== "manastorm"
|| resolvingDefeatRef.current
|| !onlineSessionOwnsWorldSimulation()
) return;
const previousById = new Map(previous.members.map((member) => [member.id, member]));
const downedMemberIds = state.members.filter((member) => (
member.health <= 0 && (previousById.get(member.id)?.health ?? 0) > 0
+319
View File
@@ -0,0 +1,319 @@
import { useEffect, useRef } from "react";
import { synchronizeOnlineSession } from "../app/onlineAccountClient";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import type {
OnlineRelayEvent,
OnlineRelayEventInput,
OnlineSessionSnapshot,
OnlineSharedWorld,
} from "../app/onlineSessionTypes";
import { useShellStore } from "../app/shellStore";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
import { getMobRuntimeTransform } from "./mobRuntimeRegistry";
import {
configureOnlineSessionRuntime,
drainOnlineInteractions,
isOnlinePlayerActorId,
onlineAccountIdFromActor,
onlinePlayerActorId,
} from "./onlineSessionRuntime";
import {
getPartyRuntimePosition,
removePartyRuntimePosition,
updatePartyRuntimePosition,
} from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import { useManastormStore } from "./manastormStore";
import { advanceManastormSession } from "./manastormSession";
import { useWailingEncounterStore } from "./wailingCavernsEncounter";
const SYNC_INTERVAL_MS = 100;
const WORLD_INTERVAL_MS = 200;
function serializableState<T extends object>(state: T): Readonly<Record<string, unknown>> {
return JSON.parse(JSON.stringify(state)) as Readonly<Record<string, unknown>>;
}
function sharedWorld(): OnlineSharedWorld {
const combat = useCombatStore.getState();
const party = usePartyStore.getState();
const game = useGameStore.getState();
const mobTransforms = Object.fromEntries(
Object.keys(combat.mobs).flatMap((id) => {
const transform = getMobRuntimeTransform(id);
return transform ? [[id, transform]] : [];
}),
);
const partyPositions = Object.fromEntries(
party.members.flatMap((member) => {
const position = getPartyRuntimePosition(member.id);
return position ? [[member.id, position]] : [];
}),
);
return {
mobs: combat.mobs,
mobTransforms,
partyMembers: party.members,
partyPositions,
partyRuntime: {
command: party.command,
activeMobId: party.activeMobId,
objectiveBossId: party.objectiveBossId,
memberTargetIds: party.memberTargetIds,
},
wailing: game.gameMode === "dungeon"
? serializableState(useWailingEncounterStore.getState())
: null,
manastorm: game.gameMode === "manastorm"
? serializableState(useManastormStore.getState())
: null,
};
}
function applySharedWorld(world: OnlineSharedWorld): void {
useCombatStore.setState({ mobs: world.mobs });
usePartyStore.setState({
members: world.partyMembers,
command: world.partyRuntime.command as ReturnType<typeof usePartyStore.getState>["command"],
activeMobId: world.partyRuntime.activeMobId,
objectiveBossId: world.partyRuntime.objectiveBossId,
memberTargetIds: world.partyRuntime.memberTargetIds,
});
for (const [id, position] of Object.entries(world.partyPositions)) {
updatePartyRuntimePosition(id, position[0], position[1], position[2]);
}
if (world.wailing) useWailingEncounterStore.setState(world.wailing as never);
if (world.manastorm) useManastormStore.setState(world.manastorm as never);
}
function eventForRelay(
event: ReturnType<typeof useCombatStore.getState>["combatEvents"][number],
localAccountId: string,
authority: boolean,
): OnlineRelayEventInput | null {
if (!["damage", "healing", "resurrection"].includes(event.kind)) return null;
const combat = useCombatStore.getState();
const playerOwnedSummon = combat.summons.some((summon) => (
summon.id === event.sourceActorId && summon.ownerActorId === PLAYER_AGGRO_ID
));
const sourceIsLocal = event.sourceActorId === PLAYER_AGGRO_ID || playerOwnedSummon;
const targetsRemotePlayer = isOnlinePlayerActorId(event.targetActorId);
if (!sourceIsLocal && !(authority && targetsRemotePlayer)) return null;
const localActorId = onlinePlayerActorId(localAccountId);
const targetActorId = event.targetActorId === PLAYER_AGGRO_ID
? localActorId
: event.targetActorId;
if (sourceIsLocal && targetActorId === localActorId) return null;
return {
clientEventId: `combat:${localAccountId}:${event.occurredAt}:${event.id}:${event.kind}`,
kind: event.kind as "damage" | "healing" | "resurrection",
sourceActorId: sourceIsLocal ? localActorId : event.sourceActorId,
targetActorId,
abilityId: event.abilityId,
school: event.school,
rawAmount: event.rawAmount,
effectiveAmount: event.effectiveAmount,
critical: event.critical,
occurredAt: event.occurredAt,
};
}
function applyRelayEvent(
event: OnlineRelayEvent,
snapshot: OnlineSessionSnapshot,
applyingRef: React.MutableRefObject<boolean>,
): void {
if (event.sourceAccountId === snapshot.localAccountId) return;
const localActorId = onlinePlayerActorId(snapshot.localAccountId);
applyingRef.current = true;
try {
if (event.kind === "portal" && snapshot.authority) {
advanceManastormSession();
return;
}
if (event.kind === "loot" && snapshot.authority && event.targetActorId) {
useCombatStore.getState().lootMob(event.targetActorId, event.occurredAt || Date.now());
return;
}
if (event.targetActorId === localActorId) {
if (event.kind === "damage") {
const sourceLevel = useCombatStore.getState().mobs[event.sourceActorId]?.level;
useCombatStore.getState().damagePlayer(
event.rawAmount ?? event.effectiveAmount ?? 0,
event.school ?? "physical",
sourceLevel,
);
} else if (event.kind === "healing") {
useCombatStore.getState().healActor(PLAYER_AGGRO_ID, event.effectiveAmount ?? 0, {
sourceActorId: event.sourceActorId,
abilityId: event.abilityId ?? undefined,
school: event.school ?? undefined,
now: event.occurredAt,
amountAlreadyModified: true,
});
} else if (event.kind === "resurrection") {
const combat = useCombatStore.getState();
combat.revivePlayer((event.effectiveAmount ?? 1) / Math.max(1, combat.maxHealth));
}
return;
}
if (!snapshot.authority) return;
const partyMember = usePartyStore.getState().members.find((member) => member.id === event.targetActorId);
if (partyMember) {
if (event.kind === "healing") {
useCombatStore.getState().healActor(event.targetActorId, event.effectiveAmount ?? 0, {
sourceActorId: event.sourceActorId,
abilityId: event.abilityId ?? undefined,
school: event.school ?? undefined,
now: event.occurredAt,
amountAlreadyModified: true,
});
} else if (event.kind === "resurrection") {
useCombatStore.getState().reviveActor(
event.targetActorId,
(event.effectiveAmount ?? 1) / Math.max(1, partyMember.maxHealth),
event.sourceActorId,
event.abilityId ?? undefined,
event.occurredAt,
);
}
return;
}
const mob = useCombatStore.getState().mobs[event.targetActorId];
if (mob && event.kind === "damage") {
const sourceMember = snapshot.players.find((player) => player.accountId === event.sourceAccountId);
useCombatStore.getState().damageMob(
mob.id,
event.rawAmount ?? event.effectiveAmount ?? 0,
event.occurredAt,
{
actorId: event.sourceActorId,
role: sourceMember?.role ?? "damage",
abilityId: event.abilityId ?? undefined,
},
event.school ?? "physical",
);
}
} finally {
applyingRef.current = false;
}
}
export function OnlineSessionBridge() {
const session = useShellStore((state) => state.session);
const activeActivity = useOnlineGroupStore((state) => state.activeActivity);
const group = useOnlineGroupStore((state) => state.snapshot.group);
const accept = useOnlineSessionStore((state) => state.accept);
const fail = useOnlineSessionStore((state) => state.fail);
const reset = useOnlineSessionStore((state) => state.reset);
const pendingEventsRef = useRef<OnlineRelayEventInput[]>([]);
const applyingRef = useRef(false);
useEffect(() => {
if (!session?.accessToken || !activeActivity || !group) {
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
reset();
return undefined;
}
const localAccountId = session.ownerId;
const immediateAuthority = group.leaderAccountId === localAccountId;
configureOnlineSessionRuntime({
active: true,
authority: immediateAuthority,
localAccountId,
});
let disposed = false;
let syncing = false;
let latestEventId = 0;
let lastWorldSentAt = 0;
const remotePositionIds = new Set<string>();
const unsubscribeCombat = useCombatStore.subscribe((state, previous) => {
if (applyingRef.current || state.combatEvents === previous.combatEvents) return;
const previousKeys = new Set(previous.combatEvents.map((event) => `${event.id}:${event.occurredAt}:${event.kind}`));
for (const event of state.combatEvents) {
if (previousKeys.has(`${event.id}:${event.occurredAt}:${event.kind}`)) continue;
const relay = eventForRelay(event, localAccountId, group.leaderAccountId === localAccountId);
if (relay) pendingEventsRef.current.push(relay);
}
});
const synchronize = async () => {
if (disposed || syncing) return;
syncing = true;
const combat = useCombatStore.getState();
const game = useGameStore.getState();
const outgoingEvents = [...pendingEventsRef.current.splice(0), ...drainOnlineInteractions()];
const now = Date.now();
const publishWorld = group.leaderAccountId === localAccountId && now - lastWorldSentAt >= WORLD_INTERVAL_MS;
if (publishWorld) lastWorldSentAt = now;
try {
const snapshot = await synchronizeOnlineSession(session, {
afterEventId: latestEventId,
presence: {
position: game.playerPosition,
yaw: game.cameraYaw,
grounded: game.playerGrounded,
health: combat.health,
maxHealth: combat.maxHealth,
resource: combat.resource,
maxResource: combat.maxResource,
resourceName: combat.resourceName,
selectedTargetId: combat.selectedTargetId,
activeCast: combat.activeCast,
animationEvent: combat.playerAnimationEvent,
equipment: equipmentItemsForOwner(
combat.equipment,
combat.inventory,
PLAYER_EQUIPMENT_OWNER_ID,
),
},
...(publishWorld ? { world: sharedWorld() } : {}),
...(outgoingEvents.length ? { events: outgoingEvents } : {}),
});
if (disposed) return;
latestEventId = Math.max(latestEventId, snapshot.latestEventId);
configureOnlineSessionRuntime({
active: true,
authority: snapshot.authority,
localAccountId,
players: snapshot.players,
mobTransforms: snapshot.world?.mobTransforms,
});
for (const player of snapshot.players) {
if (player.accountId === localAccountId) continue;
const actorId = onlinePlayerActorId(player.accountId);
remotePositionIds.add(actorId);
updatePartyRuntimePosition(actorId, player.position[0], player.position[1], player.position[2]);
}
if (!snapshot.authority && snapshot.world) applySharedWorld(snapshot.world);
for (const event of snapshot.events) applyRelayEvent(event, snapshot, applyingRef);
useCombatStore.getState().synchronizeActors();
accept(snapshot);
} catch (error) {
if (outgoingEvents.length) pendingEventsRef.current.unshift(...outgoingEvents);
if (!disposed) fail(error instanceof Error ? error.message : "The shared activity lost connection.");
} finally {
syncing = false;
}
};
void synchronize();
const timer = window.setInterval(() => void synchronize(), SYNC_INTERVAL_MS);
return () => {
disposed = true;
window.clearInterval(timer);
unsubscribeCombat();
for (const id of remotePositionIds) removePartyRuntimePosition(id);
pendingEventsRef.current = [];
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
reset();
};
}, [accept, activeActivity?.id, fail, group?.id, group?.leaderAccountId, reset, session?.accessToken, session?.ownerId]);
return null;
}
+9 -2
View File
@@ -5,6 +5,7 @@ import { resetPartyRuntimeClock } from "./partyRuntime";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import { useManastormStore } from "./manastormStore";
import { onlineGroupAiPartySize, useOnlineGroupStore } from "../app/onlineGroupStore";
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
export function PartyBridge() {
@@ -12,6 +13,7 @@ export function PartyBridge() {
const playerRole = useShellStore((state) => state.activeDungeonRole);
const gameMode = useGameStore((state) => state.gameMode);
const manastormPartySize = useManastormStore((state) => state.partySize);
const onlineActivity = useOnlineGroupStore((state) => state.activeActivity);
const level = useCombatStore((state) => state.level);
const resetRevision = useGameStore((state) => state.resetRevision);
const mountedResetRevision = useRef(resetRevision);
@@ -20,17 +22,22 @@ export function PartyBridge() {
if (!character) return;
const combat = useCombatStore.getState();
const party = usePartyStore.getState();
const requestedPartySize = gameMode === "manastorm" ? manastormPartySize : 5;
const aiPartySize = onlineActivity
&& onlineActivity.type === (gameMode === "manastorm" ? "manastorm" : "dungeon")
? onlineGroupAiPartySize(onlineActivity.partySize)
: requestedPartySize;
party.initializeParty(
character.id,
combat.level,
combat.health,
playerRole,
gameMode === "manastorm" ? manastormPartySize : 5,
aiPartySize,
character.categoryId ?? "wow",
);
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
resetPartyRuntimeClock();
}, [character?.id, gameMode, manastormPartySize, playerRole]);
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]);
useEffect(() => {
usePartyStore.getState().syncLevel(level);
+14 -1
View File
@@ -38,7 +38,20 @@ describe("baseline ability catalog", () => {
const iconPath = path.join(process.cwd(), "public", ability.icon.slice(1));
expect(existsSync(iconPath), `${ability.name} icon should exist at ${iconPath}`).toBe(true);
expect(statSync(iconPath).size, `${ability.name} icon should not be empty`).toBeGreaterThan(0);
expect(ability.cost.resource).toBe(resource.type);
if (characterClass.mode !== "classic" || ability.cost.amount > 0) {
const classicResources: Partial<Record<typeof characterClass.id, readonly string[]>> = {
warrior: ["rage", "health"],
rogue: ["energy"],
"death-knight": ["runic-power", "health"],
druid: ["mana", "rage", "energy"],
warlock: ["mana", "health"],
};
expect(
characterClass.mode === "classic"
? classicResources[characterClass.id] ?? [resource.type]
: ["mana", "rage", "energy", "runic-power", "focus", "health"],
).toContain(ability.cost.resource);
}
expect(ability.cost.amount).toBeGreaterThanOrEqual(0);
expect(ability.effects.length).toBeGreaterThan(0);
expect(allIds.has(ability.id)).toBe(false);
+86 -6
View File
@@ -6,11 +6,68 @@ import {
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES, ROM_ORDINARY_ABILITIES_BY_CLASS, type RomSkillGroup } from "./romAbilityCatalog";
import { registerAbilityAnimationLookup } from "./abilityAnimationLookup";
import type { AuraDefinition, DispelCategory } from "./combatAuras";
import type { AuraDefinition, DamageSchool, DispelCategory } from "./combatAuras";
export type ResourceType = "mana" | "rage" | "energy" | "runic-power" | "focus" | "nature-power" | "psi" | "health";
export type AbilityTarget = "hostile" | "friendly" | "self";
export type ResourceType =
| "mana"
| "rage"
| "energy"
| "runic-power"
| "focus"
| "nature-power"
| "psi"
| "health"
| "holy-power"
| "chi"
| "soul-power"
| `custom:${string}`;
export type AbilityTarget = "hostile" | "friendly" | "any" | "self";
export type AbilityCastMode = "instant" | "cast" | "channel";
export type AbilityTargetShape = "unit" | "circle" | "cone" | "chain" | "ground";
export interface AbilityTargetingRules {
readonly shape: AbilityTargetShape;
readonly requiresLineOfSight?: boolean;
readonly requiresFacing?: boolean;
readonly requiresBehindTarget?: boolean;
readonly attackArcRadians?: number;
readonly radius?: number;
readonly maximumTargets?: number;
readonly jumpRange?: number;
readonly groundRange?: number;
}
export interface AbilityCastRules {
readonly school?: DamageSchool;
readonly canCastWhileMoving?: boolean;
readonly pushbackImmune?: boolean;
/** A fraction from zero to one, applied independently to each pushback hit. */
readonly pushbackResistance?: number;
}
export interface AbilityRuneCost {
readonly blood?: number;
readonly frost?: number;
readonly unholy?: number;
}
export interface AbilityAiHints {
readonly categories: readonly (
| "damage"
| "execute"
| "heal"
| "emergency-heal"
| "area-heal"
| "mitigation"
| "taunt"
| "interrupt"
| "dispel"
| "resurrection"
| "control"
| "debuff"
)[];
readonly priority?: number;
}
export type MobStatusKind =
| "slow"
| "stun"
@@ -63,6 +120,8 @@ export interface AbilityAmountScaling {
readonly basePoints?: number;
readonly dieSides?: number;
readonly pointsPerLevel?: number;
/** Additional power coefficient gained per source skill level. */
readonly coefficientPerLevel?: number;
readonly bonusPowerCoefficient?: number;
readonly sourceLevel?: number;
readonly maxScalingLevel?: number;
@@ -77,15 +136,17 @@ export type AbilityEffect =
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number } & AbilityAmountScaling)
| { readonly kind: "resurrection"; readonly percentMaxHealth: number }
| { readonly kind: "summon"; readonly creatureId: number; readonly coefficient: number; readonly durationMs: number }
| { readonly kind: "resource"; readonly amount: number; readonly resource?: ResourceType }
| { readonly kind: "resource"; readonly amount: number; readonly resource?: ResourceType; readonly percentage?: boolean }
| { readonly kind: "combo"; readonly amount: number }
| { readonly kind: "taunt"; readonly durationMs: number; readonly maxTargets?: number }
| { readonly kind: "status"; readonly status: MobStatusKind; readonly durationMs: number; readonly magnitude?: number; readonly maxTargets?: number }
| { readonly kind: "status"; readonly status: MobStatusKind; readonly durationMs: number; readonly magnitude?: number; readonly maxTargets?: number; readonly breakOnDamage?: boolean }
| {
readonly kind: "apply-aura";
readonly aura: AuraDefinition;
/** Some hostile spells apply their beneficial rider to the caster. */
readonly recipient?: "ability-target" | "caster";
/** Party/raid area auras are applied to the player and every active party member. */
readonly scope?: "target" | "party";
readonly control?: AbilityAuraControl;
readonly sourceEffectType: number;
readonly sourceAuraType: number;
@@ -109,6 +170,7 @@ export type AbilityEffect =
readonly kind: "threat";
readonly mode: "flat" | "percent" | "redirect";
readonly amount: number;
readonly recipient?: "caster" | "ability-target";
}
| {
readonly kind: "movement";
@@ -119,7 +181,14 @@ export type AbilityEffect =
| { readonly kind: "pet"; readonly action: "summon" | "call" | "recall" | "dismiss" | "resurrect" | "feed" | "tame"; readonly creatureId?: number }
| { readonly kind: "totem"; readonly action: "destroy-all" }
| { readonly kind: "rune"; readonly action: "activate"; readonly count: number }
| { readonly kind: "cooldown-reset"; readonly mode: "reset" | "reduce"; readonly abilityName?: string; readonly amountMs?: number }
| {
readonly kind: "cooldown-reset";
readonly mode: "reset" | "reduce";
readonly abilityName?: string;
readonly amountMs?: number;
/** Fraction of the target ability's remaining cooldown removed at execution time. */
readonly amountPercent?: number;
}
| { readonly kind: "trigger-spell"; readonly spellId: number; readonly withValue?: number }
| {
/** A known combat-side DBC script hook that requires a spell-specific handler. */
@@ -140,6 +209,7 @@ export type AbilityEffect =
}
| {
readonly kind: "utility";
readonly action?: "create-item" | "open-lock" | "portal" | "weapon-enchant" | "pickpocket" | "farsight" | "class-script";
readonly effectType: number;
readonly auraType: number;
readonly miscValue: number;
@@ -165,6 +235,9 @@ export interface AbilityRankDefinition {
readonly cost: AbilityCost;
readonly costs?: readonly AbilityCost[];
readonly effects: readonly AbilityEffect[];
readonly targeting?: AbilityTargetingRules;
readonly castRules?: AbilityCastRules;
readonly runeCost?: AbilityRuneCost;
}
export interface AbilityDefinition {
@@ -188,6 +261,10 @@ export interface AbilityDefinition {
readonly cost: AbilityCost;
readonly costs?: readonly AbilityCost[];
readonly effects: readonly AbilityEffect[];
readonly targeting?: AbilityTargetingRules;
readonly castRules?: AbilityCastRules;
readonly runeCost?: AbilityRuneCost;
readonly ai?: AbilityAiHints;
readonly passive?: boolean;
readonly source?: "core" | "wow335-progression" | "ascension-progression" | "coa-starter" | "coa-progression" | "coa-tree" | "runewaker";
readonly romSkillGroup?: RomSkillGroup;
@@ -793,6 +870,9 @@ export function abilityAtLevel(
cost: rank.cost,
costs: rank.costs ?? [rank.cost],
effects: rank.effects,
targeting: rank.targeting ?? ability.targeting,
castRules: rank.castRules ?? ability.castRules,
runeCost: rank.runeCost ?? ability.runeCost,
};
}
+104
View File
@@ -0,0 +1,104 @@
import type {
AbilityAiHints,
AbilityDefinition,
AbilityRuneCost,
AbilityTargetingRules,
} from "./abilityCatalog";
const CHAIN_ABILITIES = new Set(["chain heal", "chain lightning", "prayer of mending"]);
const GROUND_ABILITIES = new Set([
"blizzard", "rain of fire", "flamestrike", "death and decay", "consecration",
"volley", "hurricane", "earthquake", "meteor", "healing rain",
]);
const CONE_ABILITIES = new Set([
"cone of cold", "dragon's breath", "dragons breath", "shockwave", "cleave",
"swipe", "swipe (cat)", "fan of knives", "whirlwind",
]);
const BEHIND_ABILITIES = new Set(["backstab", "ambush", "garrote", "ravage", "shred", "pounce"]);
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",
]);
const RUNE_COSTS_BY_SPELL_ID: Readonly<Record<number, AbilityRuneCost>> = Object.freeze({
49909: { frost: 1 }, 45524: { frost: 1 }, 49184: { frost: 1 }, 47528: { frost: 1 },
49921: { unholy: 1 }, 55271: { unholy: 1 }, 49222: { unholy: 1 }, 51052: { unholy: 1 },
49930: { blood: 1 }, 55262: { blood: 1 }, 49941: { blood: 1 }, 50842: { blood: 1 },
48982: { blood: 1 }, 49005: { blood: 1 },
49924: { frost: 1, unholy: 1 }, 51425: { frost: 1, unholy: 1 },
49938: { blood: 1, frost: 1, unholy: 1 },
});
const RUNE_COSTS_BY_NAME: Readonly<Record<string, AbilityRuneCost>> = Object.freeze({
"icy touch": { frost: 1 }, "chains of ice": { frost: 1 }, "howling blast": { frost: 1 }, "mind freeze": { frost: 1 },
"plague strike": { unholy: 1 }, "scourge strike": { unholy: 1 }, "bone shield": { unholy: 1 }, "anti-magic zone": { unholy: 1 },
"blood strike": { blood: 1 }, "heart strike": { blood: 1 }, "blood boil": { blood: 1 }, "pestilence": { blood: 1 },
"rune tap": { blood: 1 }, "mark of blood": { blood: 1 },
"death strike": { frost: 1, unholy: 1 }, "obliterate": { frost: 1, unholy: 1 },
"death and decay": { blood: 1, frost: 1, unholy: 1 },
});
export function targetingRulesForAbility(ability: AbilityDefinition): AbilityTargetingRules {
if (ability.targeting) return ability.targeting;
const name = ability.name.toLowerCase();
const shape = CHAIN_ABILITIES.has(name)
? "chain"
: GROUND_ABILITIES.has(name) || name.endsWith(" trap")
? "ground"
: CONE_ABILITIES.has(name) || name.endsWith(" breath")
? "cone"
: ability.radius && ability.radius > 0
? "circle"
: "unit";
const hostile = ability.target === "hostile" || ability.target === "any";
return Object.freeze({
shape,
requiresLineOfSight: ability.target !== "self",
// WoW friendly spells are deliberately facing independent.
requiresFacing: hostile && shape !== "circle" && shape !== "ground",
requiresBehindTarget: hostile && BEHIND_ABILITIES.has(name),
attackArcRadians: shape === "cone" ? Math.PI / 2 : Math.PI,
...(ability.radius ? { radius: ability.radius } : {}),
...(shape === "chain" ? { maximumTargets: 3, jumpRange: 12 } : {}),
...(shape === "ground" ? { groundRange: ability.range.max, radius: ability.radius ?? 8 } : {}),
});
}
export function canAbilityCastWhileMoving(ability: AbilityDefinition): boolean {
return ability.castRules?.canCastWhileMoving ?? MOVEMENT_CAST_ABILITIES.has(ability.name.toLowerCase());
}
export function abilityPushbackResistance(ability: AbilityDefinition): number {
if (ability.castRules?.pushbackImmune) return 1;
return Math.max(0, Math.min(1, ability.castRules?.pushbackResistance ?? 0));
}
/** WotLK 3.3.5 rune costs for the active DK toolkit imported by the game. */
export function runeCostForAbility(ability: AbilityDefinition): AbilityRuneCost | null {
if (ability.runeCost) return ability.runeCost;
return RUNE_COSTS_BY_SPELL_ID[ability.dbcSpellId]
?? RUNE_COSTS_BY_NAME[ability.name.toLowerCase()]
?? null;
}
export function aiHintsForAbility(ability: AbilityDefinition): AbilityAiHints {
if (ability.ai) return ability.ai;
const name = ability.name.toLowerCase();
const categories = new Set<AbilityAiHints["categories"][number]>();
for (const effect of ability.effects) {
if (["damage", "dot", "finisher-damage"].includes(effect.kind)) categories.add("damage");
if (effect.kind === "execute") categories.add("execute");
if (effect.kind === "heal" || effect.kind === "hot") categories.add("heal");
if (effect.kind === "shield") categories.add("mitigation");
if (effect.kind === "taunt") categories.add("taunt");
if (effect.kind === "interrupt") categories.add("interrupt");
if (effect.kind === "dispel" || effect.kind === "dispel-mechanic") categories.add("dispel");
if (effect.kind === "resurrection") categories.add("resurrection");
if (effect.kind === "status") {
categories.add(effect.status === "interrupt" ? "interrupt" : "control");
}
if (effect.kind === "dot" || (effect.kind === "apply-aura" && effect.aura.disposition === "debuff")) categories.add("debuff");
}
if (EMERGENCY_HEAL_ABILITIES.has(name)) categories.add("emergency-heal");
if ((ability.radius ?? 0) > 0 && categories.has("heal")) categories.add("area-heal");
return Object.freeze({ categories: Object.freeze([...categories]) });
}
+188
View File
@@ -0,0 +1,188 @@
import { describe, expect, it } from "vitest";
import { COA_CLASS_IDS, type CoaClassId } from "../app/characterCatalog";
import type { AbilityDefinition, AbilityEffect, AbilityRankDefinition } from "./abilityCatalog";
import {
COA_ABILITIES_BY_CLASS,
COA_CLASS_RESOURCES,
COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
} from "./coaAbilityCatalog";
import {
COA_PROGRESSION_CHAINS,
COA_TREE_ENTRIES,
coaClassIdFromDbcId,
coaSpellById,
} from "./coaLiveCatalog";
const abilities = Object.values(COA_ABILITIES_BY_CLASS).flat();
const ranksFor = (ability: AbilityDefinition): readonly AbilityRankDefinition[] => ability.ranks ?? [{
rank: 1,
spellId: ability.dbcSpellId,
level: ability.unlockLevel,
description: ability.description,
castMode: ability.castMode,
castTimeMs: ability.castTimeMs,
cooldownMs: ability.cooldownMs,
gcdMs: ability.gcdMs,
cost: ability.cost,
effects: ability.effects,
}];
const ranks = abilities.flatMap((ability) => ranksFor(ability).map((rank) => ({ ability, rank })));
const byName = (classId: CoaClassId, name: string): AbilityDefinition => {
const ability = COA_ABILITIES_BY_CLASS[classId].find((candidate) => candidate.name === name);
if (!ability) throw new Error(`Missing ${classId} ability ${name}`);
return ability;
};
const rankEffectsForSpell = (spellId: number): readonly AbilityEffect[] => ranks
.find(({ rank }) => rank.spellId === spellId)?.rank.effects ?? [];
describe("Ascension Conquest ability catalog", () => {
it("covers every class, source family, and playable rank", () => {
expect(Object.keys(COA_ABILITIES_BY_CLASS).sort()).toEqual([...COA_CLASS_IDS].sort());
expect(Object.keys(COA_CLASS_RESOURCES).sort()).toEqual([...COA_CLASS_IDS].sort());
expect(abilities).toHaveLength(1_275);
expect(ranks).toHaveLength(3_394);
expect(new Set(ranks.map(({ rank }) => rank.spellId)).size).toBe(3_347);
const runtimeSpellIds = new Set(ranks.map(({ rank }) => rank.spellId));
for (const chain of COA_PROGRESSION_CHAINS) {
const classId = coaClassIdFromDbcId(chain.classId);
expect(classId, chain.name).not.toBeNull();
for (const rank of chain.ranks) {
expect(runtimeSpellIds.has(rank.spellId), `${classId}: ${chain.name} rank ${rank.rank}`).toBe(true);
}
}
for (const entry of COA_TREE_ENTRIES.filter((candidate) => candidate.entryType === "Ability")) {
const classId = coaClassIdFromDbcId(entry.classId);
if (!classId) continue;
for (const rank of entry.rankDescriptions) {
expect(runtimeSpellIds.has(rank.spellId), `${classId}: ${entry.name} rank ${rank.rank}`).toBe(true);
}
}
});
it("gives every family and rank finite, executable runtime data", () => {
expect(new Set(abilities.map((ability) => ability.id)).size).toBe(abilities.length);
for (const ability of abilities) {
expect(ability.classId, ability.id).toBeTruthy();
expect(ability.name.length, ability.id).toBeGreaterThan(0);
expect(ability.icon.startsWith("/assets/ui/spells/"), ability.id).toBe(true);
expect(Number.isFinite(ability.range.min), ability.id).toBe(true);
expect(Number.isFinite(ability.range.max), ability.id).toBe(true);
expect(ability.range.max, ability.id).toBeGreaterThanOrEqual(ability.range.min);
expect(ability.cooldownMs, ability.id).toBeGreaterThanOrEqual(0);
for (const rank of ranksFor(ability)) {
const label = `${ability.id} rank ${rank.rank} (${rank.spellId})`;
expect(rank.spellId, label).toBeGreaterThan(0);
expect(rank.level, label).toBeGreaterThan(0);
expect(rank.description.length, label).toBeGreaterThan(0);
expect(rank.castTimeMs, label).toBeGreaterThanOrEqual(0);
expect(rank.cooldownMs, label).toBeGreaterThanOrEqual(0);
expect(rank.gcdMs, label).toBeGreaterThanOrEqual(0);
expect(Number.isFinite(rank.cost.amount), label).toBe(true);
expect(rank.cost.amount, label).toBeGreaterThanOrEqual(0);
expect(rank.effects.length, label).toBeGreaterThan(0);
expect(rank.effects.some((effect) => effect.kind === "scripted" || effect.kind === "unsupported"), label).toBe(false);
if (!ability.passive) {
expect(rank.effects.some((effect) => effect.kind !== "utility" || effect.action !== "class-script"), label).toBe(true);
}
}
}
});
it("translates every standard DBC combat effect into its executable semantic", () => {
const applyAuraTypes = new Set([0, 3, 6, 27, 35, 65, 119, 128, 129, 143, 190]);
const triggerTypes = new Set([32, 64, 140, 141, 142, 148, 151, 160, 165, 169, 175, 178, 183]);
const movementTypes = new Set([5, 29, 41, 42, 43, 79, 96, 98, 138, 144, 149]);
for (const { ability, rank } of ranks) {
const dbc = coaSpellById(rank.spellId)?.dbc;
if (!dbc) continue;
const kinds = new Set(rank.effects.map((effect) => effect.kind));
for (const effect of dbc.effects) {
const label = `${ability.classId}: ${ability.name} rank ${rank.rank}, DBC ${effect.effectType}/${effect.auraType}`;
if (effect.effectType === 2 || [17, 31, 58, 121].includes(effect.effectType)) expect(kinds.has("damage"), label).toBe(true);
if ([10, 67, 136].includes(effect.effectType)) expect(kinds.has("heal"), label).toBe(true);
if (applyAuraTypes.has(effect.effectType) && effect.auraType > 0) expect(kinds.has("apply-aura"), label).toBe(true);
if (effect.auraType === 3 && effect.periodMs > 0) expect(kinds.has("dot"), label).toBe(true);
if ([8, 20].includes(effect.auraType) && effect.periodMs > 0) expect(kinds.has("hot"), label).toBe(true);
if (triggerTypes.has(effect.effectType) && effect.triggerSpellId > 0) expect(kinds.has("trigger-spell"), label).toBe(true);
if (movementTypes.has(effect.effectType)) expect(kinds.has("movement"), label).toBe(true);
if (effect.effectType === 38) expect(kinds.has("dispel"), label).toBe(true);
if (effect.effectType === 68) expect(kinds.has("interrupt"), label).toBe(true);
if ([18, 94, 113, 117].includes(effect.effectType)) expect(kinds.has("resurrection"), label).toBe(true);
if ([192, 195].includes(effect.effectType)) expect(kinds.has("cooldown-reset"), label).toBe(true);
}
}
});
it("preserves percentages, actual power pools, and shared cooldown groups", () => {
const percentageCosts = ranks.filter(({ rank }) => rank.cost.percentage);
expect(percentageCosts.length).toBeGreaterThan(1_500);
expect(ranks.some(({ rank }) => rank.cost.resource === "health")).toBe(true);
expect(ranks.some(({ rank }) => rank.cost.resource === "energy")).toBe(true);
expect(ranks.some(({ rank }) => rank.cost.resource === "rage")).toBe(true);
expect(ranks.some(({ rank }) => rank.cost.resource === "runic-power")).toBe(true);
const ascension = byName("sun-cleric", "Solar Invocation: Ascension");
const revelation = byName("sun-cleric", "Solar Invocation: Revelation");
expect(ascension.cooldownGroup).toBe("coa:solar-invocation-spells");
expect(revelation.cooldownGroup).toBe(ascension.cooldownGroup);
expect(revelation.target).toBe("any");
const profound = byName("templar", "Profound Enlightenment");
expect(profound.effects.filter((effect) => effect.kind === "cooldown-reset")).toEqual([
expect.objectContaining({ mode: "reduce", amountPercent: 0.5 }),
expect.objectContaining({ mode: "reduce", amountPercent: 0.5 }),
expect.objectContaining({ mode: "reduce", amountPercent: 0.5 }),
]);
});
it("keeps representative healing, protection, control, proc, trigger, and reset spells functional", () => {
expect(byName("sun-cleric", "Solar Invocation: Ascension")).toMatchObject({ target: "self", radius: 40 });
expect(byName("guardian", "Feats of Strength")).toMatchObject({ target: "friendly" });
expect(byName("knight-of-xoroth", "Black Shield").ranks?.every((rank) => rank.effects.some((effect) => effect.kind === "shield"))).toBe(true);
expect(byName("witch-hunter", "Brand of the Unworthy").effects).toContainEqual(expect.objectContaining({ kind: "status", status: "sleep" }));
expect(byName("necromancer", "Plaguestorm").ranks?.every((rank) => rank.effects.some((effect) => effect.kind === "dot"))).toBe(true);
expect(byName("templar", "Profound Enlightenment").effects.filter((effect) => effect.kind === "cooldown-reset")).toHaveLength(3);
expect(byName("sun-cleric", "New Day").effects.filter((effect) => effect.kind === "cooldown-reset")).toHaveLength(3);
const gaze = byName("cultist", "Gaze of C'Thun");
expect(gaze.target).toBe("any");
expect(gaze.effects.some((effect) => effect.kind === "trigger-spell")).toBe(true);
expect(gaze.effects.some((effect) => effect.kind === "damage")).toBe(true);
expect(gaze.effects.some((effect) => effect.kind === "heal")).toBe(true);
const razorice = byName("necromancer", "Razorice");
const procs = razorice.effects
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
.flatMap((effect) => effect.aura.procs ?? []);
expect(procs.length).toBeGreaterThan(0);
expect(procs.every((proc) => proc.action.kind === "damage" || proc.action.kind === "custom")).toBe(true);
});
it("keeps every rank spell lookup executable, including recursively expanded trigger spells", () => {
for (const { ability, rank } of ranks) {
expect(rankEffectsForSpell(rank.spellId).length, `${ability.name} ${rank.spellId}`).toBeGreaterThan(0);
}
const gaze = byName("cultist", "Gaze of C'Thun");
expect(gaze.effects.filter((effect) => effect.kind === "trigger-spell")).toEqual(expect.arrayContaining([
expect.objectContaining({ spellId: 520334 }),
expect.objectContaining({ spellId: 520335 }),
]));
});
it("ships executable hidden payloads for proc-triggered DBC spell IDs", () => {
expect(Object.keys(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)).toHaveLength(585);
expect(COA_TRIGGERED_ABILITIES_BY_SPELL_ID[800951]).toMatchObject({
name: "Battle Rhythm",
effects: expect.arrayContaining([{ kind: "resource", amount: 5 }]),
});
expect(Object.values(COA_TRIGGERED_ABILITIES_BY_SPELL_ID).filter((ability) => (
ability.effects.some((effect) => effect.kind !== "utility")
))).toHaveLength(547);
for (const [spellId, ability] of Object.entries(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)) {
expect(ability.dbcSpellId, spellId).toBe(Number(spellId));
expect(ability.effects.length, spellId).toBeGreaterThan(0);
expect(ability.effects.some((effect) => effect.kind === "scripted" || effect.kind === "unsupported"), spellId).toBe(false);
}
});
});
File diff suppressed because it is too large. Load diff
+5
View File
@@ -6,10 +6,15 @@ interface CoaAbilityRuntimeSnapshot {
readonly schemaVersion: 1;
readonly abilitiesByClass: Readonly<Record<CoaClassId, readonly AbilityDefinition[]>>;
readonly resourcesByClass: Readonly<Record<CoaClassId, ClassResourceProfile>>;
readonly triggeredAbilitiesBySpellId: Readonly<Record<number, AbilityDefinition>>;
}
const snapshot = generated as unknown as CoaAbilityRuntimeSnapshot;
export const COA_RUNTIME_ABILITIES_BY_CLASS = snapshot.abilitiesByClass;
export const COA_RUNTIME_CLASS_RESOURCES = snapshot.resourcesByClass;
export const COA_RUNTIME_TRIGGERED_ABILITIES_BY_SPELL_ID = snapshot.triggeredAbilitiesBySpellId;
export function coaTriggeredAbilityBySpellId(spellId: number): AbilityDefinition | null {
return COA_RUNTIME_TRIGGERED_ABILITIES_BY_SPELL_ID[spellId] ?? null;
}
+55
View File
@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it } from "vitest";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
describe("Ascension Conquest combat runtime", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
});
it("heals a selected ally with an enemy-or-ally spell", () => {
useCombatStore.getState().initializeCharacter({ classId: "cultist", raceId: "human", level: 20 });
usePartyStore.getState().initializeParty("coa-any-friendly", 20, useCombatStore.getState().health, "healer", 2, "coa");
const member = usePartyStore.getState().members[0]!;
usePartyStore.getState().damageMember(member.id, 500);
registerPartyRuntimePosition(member.id, [3, 0, 0]);
usePartyStore.getState().selectMember(member.id);
const injuredHealth = usePartyStore.getState().members[0]!.health;
expect(useCombatStore.getState().castAbility("cultist-gaze-of-cthun", undefined, 1_000)).toMatchObject({ ok: true });
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(injuredHealth);
});
it("damages a selected enemy with the same enemy-or-ally spell", () => {
useCombatStore.getState().initializeCharacter({ classId: "cultist", raceId: "human", level: 20 });
usePartyStore.getState().clearSelection();
useCombatStore.getState().registerMob("gaze-dummy", { name: "Gaze Dummy", maxHealth: 5_000 });
setMobPosition("gaze-dummy", [3, 0, 0]);
useCombatStore.getState().selectMob("gaze-dummy");
expect(useCombatStore.getState().castAbility("cultist-gaze-of-cthun", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["gaze-dummy"]!.health).toBeLessThan(5_000);
});
it("executes a hidden DBC payload when a passive proc fires", () => {
useCombatStore.getState().initializeCharacter({ classId: "pyromancer", raceId: "human", level: 20 });
usePartyStore.getState().initializeParty("coa-hidden-proc", 20, useCombatStore.getState().health, "healer", 2, "coa");
const member = usePartyStore.getState().members[0]!;
usePartyStore.getState().damageMember(member.id, 500);
registerPartyRuntimePosition(member.id, [3, 0, 0]);
usePartyStore.getState().selectMember(member.id);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.name.includes("Phoenix Handler"))).toBe(true);
expect(useCombatStore.getState().castAbility("coa-24-cinderheart-476-502044", undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 4_100);
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.id === "coa:704865:effect:0:aura:23"
))).toBe(true);
});
});
+105
View File
@@ -0,0 +1,105 @@
import { resourceProfileForClass, type ResourceType } from "./abilityCatalog";
import {
normalizeActorResourcePool,
type CombatActorState,
} from "./combatActors";
import type { PartyMember } from "./partyStore";
/**
* Creates the authoritative companion actor. Existing actor values win over
* the party-frame compatibility record once the actor has entered combat.
*/
export function companionActorFromMember(
member: PartyMember,
absorb = 0,
existing?: CombatActorState | null,
): CombatActorState {
const profile = resourceProfileForClass(member.classId);
const resources = existing?.kind === "companion"
? existing.resources
: Object.fromEntries(Object.entries(member.resourcePools).map(([id, current]) => [id,
normalizeActorResourcePool(
id,
current ?? 0,
id === member.resourceType ? member.maxResource : 100,
id === member.resourceType ? profile.regenerationPerSecond : 0,
member.lastResourceSpentAt[id as ResourceType] ?? 0,
),
]));
const actor = existing?.kind === "companion" ? existing : null;
return Object.freeze({
id: member.id,
threatActorId: member.id,
kind: "companion" as const,
classId: member.classId,
level: member.level,
health: actor?.health ?? member.health,
maxHealth: actor?.maxHealth ?? member.maxHealth,
absorb,
resources,
cooldowns: actor?.cooldowns ?? member.cooldowns,
globalCooldownEndsAt: actor?.globalCooldownEndsAt ?? member.globalCooldownEndsAt,
cast: actor?.cast ?? member.activeCast,
controlledUntil: actor?.controlledUntil ?? member.controlledUntil,
schoolLockedUntil: actor?.schoolLockedUntil ?? member.schoolLockedUntil,
comboPoints: actor?.comboPoints ?? 0,
comboTargetId: actor?.comboTargetId ?? null,
runes: actor?.runes ?? member.runes,
abilityIds: actor?.abilityIds,
alive: (actor?.health ?? member.health) > 0,
});
}
/** Party and scene consumers receive a read-compatible mirror of actor state. */
export function memberFromCompanionActor(member: PartyMember, actor?: CombatActorState | null): PartyMember {
if (!actor || actor.kind !== "companion") return member;
const resourcePools = Object.fromEntries(Object.entries(actor.resources).map(([id, pool]) => [id, pool.current])) as Partial<Record<ResourceType, number>>;
const lastResourceSpentAt = Object.fromEntries(Object.entries(actor.resources).map(([id, pool]) => [id, pool.lastSpentAt])) as Partial<Record<ResourceType, number>>;
const primary = actor.resources[member.resourceType];
return {
...member,
health: actor.health,
maxHealth: actor.maxHealth,
resource: primary?.current ?? member.resource,
maxResource: primary?.maximum ?? member.maxResource,
resourcePools,
lastResourceSpentAt,
cooldowns: actor.cooldowns,
globalCooldownEndsAt: actor.globalCooldownEndsAt,
activeCast: actor.cast,
activeCastTargetId: actor.cast?.targetId ?? member.activeCastTargetId,
schoolLockedUntil: actor.schoolLockedUntil,
runes: actor.runes,
controlledUntil: actor.controlledUntil,
status: actor.health <= 0 ? "down" : member.status,
};
}
export function companionActorWithMemberRuntime(
actor: CombatActorState,
member: PartyMember,
): CombatActorState {
const profile = resourceProfileForClass(member.classId);
const resources = Object.fromEntries(Object.entries(member.resourcePools).map(([id, current]) => [id,
normalizeActorResourcePool(
id,
current ?? 0,
id === member.resourceType ? member.maxResource : actor.resources[id]?.maximum ?? 100,
id === member.resourceType ? profile.regenerationPerSecond : actor.resources[id]?.regenerationPerSecond ?? 0,
member.lastResourceSpentAt[id as ResourceType] ?? actor.resources[id]?.lastSpentAt ?? 0,
),
]));
return Object.freeze({
...actor,
health: member.health,
maxHealth: member.maxHealth,
resources,
cooldowns: member.cooldowns,
globalCooldownEndsAt: member.globalCooldownEndsAt,
cast: member.activeCast ? { ...member.activeCast, targetId: member.activeCastTargetId } : null,
controlledUntil: member.controlledUntil,
schoolLockedUntil: member.schoolLockedUntil,
runes: member.runes,
alive: member.health > 0,
});
}
+117
View File
@@ -0,0 +1,117 @@
import type { ClassId } from "../app/characterCatalog";
import type { DamageSchool } from "./combatAuras";
export type CombatActorKind = "player" | "companion" | "enemy" | "pet" | "totem" | "summon";
export type CombatResourceId = string;
export type DeathKnightRuneType = "blood" | "frost" | "unholy" | "death";
export type CombatActorPosition = readonly [number, number, number];
export interface CombatResourcePoolState {
readonly id: CombatResourceId;
readonly current: number;
readonly maximum: number;
readonly regenerationPerSecond: number;
/** Mana regeneration observes the five-second rule from this timestamp. */
readonly lastSpentAt: number;
}
export interface DeathKnightRuneState {
readonly slot: number;
readonly baseType: Exclude<DeathKnightRuneType, "death">;
readonly type: DeathKnightRuneType;
readonly readyAt: number;
}
export interface CombatActorCastState {
readonly abilityId: string;
readonly school: DamageSchool;
readonly mode: "cast" | "channel";
readonly startedAt: number;
readonly completesAt: number;
readonly originalDurationMs: number;
readonly pushbackHits: number;
/** Simulation-space origin used for movement cancellation and completion validation. */
readonly origin?: CombatActorPosition;
readonly targetId?: string | null;
}
/**
* Renderer-free snapshot shared by every combatant. Specialized player, party,
* and mob records may retain presentation fields, but combat rules consume
* this common contract.
*/
export interface CombatActorState {
readonly id: string;
/** Stable id used by threat tables even when a renderer entity is replaced. */
readonly threatActorId?: string;
readonly kind: CombatActorKind;
readonly ownerId?: string;
readonly classId?: ClassId;
readonly level: number;
readonly health: number;
readonly maxHealth: number;
readonly absorb: number;
readonly resources: Readonly<Record<CombatResourceId, CombatResourcePoolState>>;
readonly cooldowns: Readonly<Record<string, number>>;
readonly globalCooldownEndsAt: number;
readonly cast: CombatActorCastState | null;
readonly controlledUntil: number;
readonly schoolLockedUntil: Readonly<Partial<Record<DamageSchool, number>>>;
readonly comboPoints: number;
readonly comboTargetId: string | null;
readonly runes: readonly DeathKnightRuneState[];
/** Executable catalog abilities owned by pets, summons, and companions. */
readonly abilityIds?: readonly string[];
readonly alive: boolean;
}
export function createDeathKnightRunes(now = 0): readonly DeathKnightRuneState[] {
const types = ["blood", "blood", "frost", "frost", "unholy", "unholy"] as const;
return Object.freeze(types.map((type, slot) => Object.freeze({
slot,
baseType: type,
type,
readyAt: Math.max(0, now),
})));
}
export function normalizeActorResourcePool(
id: CombatResourceId,
current: number,
maximum: number,
regenerationPerSecond = 0,
lastSpentAt = 0,
): CombatResourcePoolState {
const safeMaximum = Math.max(0, Number.isFinite(maximum) ? maximum : 0);
return Object.freeze({
id,
current: Math.max(0, Math.min(safeMaximum, Number.isFinite(current) ? current : 0)),
maximum: safeMaximum,
regenerationPerSecond: Math.max(0, Number.isFinite(regenerationPerSecond) ? regenerationPerSecond : 0),
lastSpentAt: Math.max(0, Number.isFinite(lastSpentAt) ? lastSpentAt : 0),
});
}
export function spendActorResource(
pool: CombatResourcePoolState,
amount: number,
now: number,
): CombatResourcePoolState | null {
const requested = Math.max(0, Number.isFinite(amount) ? amount : 0);
if (pool.current + Number.EPSILON < requested) return null;
return Object.freeze({
...pool,
current: Math.max(0, pool.current - requested),
lastSpentAt: Math.max(pool.lastSpentAt, Number.isFinite(now) ? now : 0),
});
}
export function restoreActorResource(
pool: CombatResourcePoolState,
amount: number,
): CombatResourcePoolState {
return Object.freeze({
...pool,
current: Math.min(pool.maximum, pool.current + Math.max(0, Number.isFinite(amount) ? amount : 0)),
});
}
+25 -4
View File
@@ -143,6 +143,16 @@ export interface AuraDefinition {
readonly stackBehavior?: AuraStackBehavior;
/** Defaults to true. False makes applications from all sources share one aura. */
readonly uniqueBySource?: boolean;
/** A target can have only one aura from an exclusive stance/form/seal/etc. group. */
readonly exclusiveGroup?: string;
/** Explicit non-numeric gameplay state such as stealth, tracking, flight, or water walking. */
readonly utilityTags?: readonly string[];
/** Enemy control mechanics ignored while this aura is active. */
readonly controlImmunities?: readonly ("fear" | "sleep" | "root" | "stun" | "silence")[];
/** Chance from 0 through 1 to reflect an incoming spell. */
readonly reflectSpellChance?: number;
/** Portion of incoming target damage transferred to the aura source. */
readonly redirectDamagePercent?: number;
readonly dispelCategory?: DispelCategory;
readonly dispelPriority?: number;
readonly modifiers?: readonly AuraModifier[];
@@ -309,14 +319,25 @@ export function applyAura(
const maxStacks = boundedPositiveInteger(definition.maxStacks, 1);
const requestedStacks = Math.min(maxStacks, boundedPositiveInteger(request.stacks, 1));
const instanceId = auraIdentity(definition, request.sourceId, request.targetId);
const existingIndex = current.findIndex((aura) => aura.instanceId === instanceId);
const exclusive = definition.exclusiveGroup
? current.filter((aura) => (
aura.targetId === request.targetId
&& aura.definition.exclusiveGroup === definition.exclusiveGroup
&& aura.instanceId !== instanceId
))
: [];
const exclusiveIds = new Set(exclusive.map((aura) => aura.instanceId));
const base = exclusiveIds.size > 0
? current.filter((aura) => !exclusiveIds.has(aura.instanceId))
: current;
const existingIndex = base.findIndex((aura) => aura.instanceId === instanceId);
if (existingIndex < 0) {
const aura = makeAura(request, requestedStacks);
return { auras: [...current, aura], aura };
return { auras: [...base, aura], aura, ...(exclusive[0] ? { replaced: exclusive[0] } : {}) };
}
const existing = current[existingIndex];
const existing = base[existingIndex];
const behavior = definition.stackBehavior ?? "refresh";
const stacks = behavior === "add"
? Math.min(maxStacks, existing.stacks + requestedStacks)
@@ -333,7 +354,7 @@ export function applyAura(
procStates: existing.procStates,
}
: replacement;
const auras = [...current];
const auras = [...base];
auras[existingIndex] = aura;
return { auras, aura, replaced: existing };
}
+49
View File
@@ -0,0 +1,49 @@
import type { DamageSchool } from "./combatAuras";
export type StructuredCombatEventKind =
| "damage"
| "healing"
| "absorb"
| "resource"
| "aura"
| "cast"
| "interrupt"
| "death"
| "resurrection";
export interface StructuredCombatEvent {
readonly id: number;
readonly occurredAt: number;
readonly kind: StructuredCombatEventKind;
readonly sourceActorId: string;
readonly targetActorId: string;
readonly abilityId?: string;
readonly school?: DamageSchool;
readonly rawAmount: number;
readonly effectiveAmount: number;
readonly overheal: number;
readonly absorbed: number;
readonly critical: boolean;
readonly outcome?: string;
}
export type CombatEventInput = Omit<StructuredCombatEvent, "id">;
export const MAX_COMBAT_EVENT_HISTORY = 200;
export function appendCombatEvent(
events: readonly StructuredCombatEvent[],
event: CombatEventInput,
id: number,
maximum = MAX_COMBAT_EVENT_HISTORY,
): readonly StructuredCombatEvent[] {
const next = Object.freeze({
...event,
id,
occurredAt: Math.max(0, Number.isFinite(event.occurredAt) ? event.occurredAt : 0),
rawAmount: Math.max(0, Number.isFinite(event.rawAmount) ? event.rawAmount : 0),
effectiveAmount: Math.max(0, Number.isFinite(event.effectiveAmount) ? event.effectiveAmount : 0),
overheal: Math.max(0, Number.isFinite(event.overheal) ? event.overheal : 0),
absorbed: Math.max(0, Number.isFinite(event.absorbed) ? event.absorbed : 0),
});
return Object.freeze([...events, next].slice(-Math.max(1, Math.trunc(maximum))));
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { resolveHealing } from "./combatHealing";
describe("combat healing resolution", () => {
it("reports critical raw healing, effective healing, and overheal independently", () => {
expect(resolveHealing(100, 950, 1_000, 0.25, 0.1)).toEqual({
rawAmount: 150,
effectiveAmount: 50,
overheal: 100,
critical: true,
});
});
it("does not resurrect a zero-health actor implicitly", () => {
const result = resolveHealing(100, 0, 1_000, 0, 1);
expect(result).toMatchObject({ rawAmount: 100, effectiveAmount: 100, overheal: 0, critical: false });
});
});
+28
View File
@@ -0,0 +1,28 @@
export interface HealingResolution {
readonly rawAmount: number;
readonly effectiveAmount: number;
readonly overheal: number;
readonly critical: boolean;
}
export function resolveHealing(
amount: number,
currentHealth: number,
maximumHealth: number,
criticalChance = 0,
roll = 1,
criticalMultiplier = 1.5,
): HealingResolution {
const critical = Number.isFinite(roll) && roll < Math.max(0, Math.min(1, criticalChance));
const rawAmount = Math.max(0, Math.round(
(Number.isFinite(amount) ? amount : 0) * (critical ? Math.max(1, criticalMultiplier) : 1),
));
const missing = Math.max(0, (Number.isFinite(maximumHealth) ? maximumHealth : 0)
- (Number.isFinite(currentHealth) ? currentHealth : 0));
const effectiveAmount = Math.min(rawAmount, missing);
return Object.freeze({
rawAmount,
effectiveAmount,
overheal: rawAmount - effectiveAmount,
critical,
});
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { createDeathKnightRunes, normalizeActorResourcePool } from "./combatActors";
import {
activateDeathKnightRunes,
advanceCombatResourcePool,
spendDeathKnightRunes,
} from "./combatResources";
describe("authentic combat resources", () => {
it("suppresses spirit mana regeneration for five seconds after spending", () => {
const mana = normalizeActorResourcePool("mana", 50, 100, 12, 1_000);
const insideRule = advanceCombatResourcePool(mana, {
inCombat: true,
actorLevel: 80,
now: 5_999,
deltaSeconds: 1,
manaSpiritRegenerationPerSecond: 8,
});
const outsideRule = advanceCombatResourcePool(mana, {
inCombat: true,
actorLevel: 80,
now: 6_000,
deltaSeconds: 1,
manaSpiritRegenerationPerSecond: 8,
});
expect(insideRule.current).toBe(54);
expect(outsideRule.current).toBe(62);
});
it("decays rage and runic power only out of combat", () => {
const rage = normalizeActorResourcePool("rage", 20, 100);
const runic = normalizeActorResourcePool("runic-power", 20, 100);
expect(advanceCombatResourcePool(rage, { inCombat: false, actorLevel: 80, now: 1, deltaSeconds: 1 }).current).toBe(17);
expect(advanceCombatResourcePool(runic, { inCombat: false, actorLevel: 80, now: 1, deltaSeconds: 1 }).current).toBe(17.5);
expect(advanceCombatResourcePool(rage, { inCombat: true, actorLevel: 80, now: 1, deltaSeconds: 1 }).current).toBe(20);
});
it("recharges six Death Knight runes independently", () => {
const initial = createDeathKnightRunes();
const first = spendDeathKnightRunes(initial, { frost: 1, unholy: 1 }, 1_000)!;
expect(first).toHaveLength(6);
expect(first.filter((rune) => rune.readyAt === 11_000)).toHaveLength(2);
expect(spendDeathKnightRunes(first, { frost: 2 }, 1_001)).toBeNull();
const activated = activateDeathKnightRunes(first, 1);
expect(activated.filter((rune) => rune.readyAt === 0)).toHaveLength(5);
});
});
+89
View File
@@ -0,0 +1,89 @@
import {
createDeathKnightRunes,
type CombatResourcePoolState,
type DeathKnightRuneState,
type DeathKnightRuneType,
} from "./combatActors";
export const MANA_FIVE_SECOND_RULE_MS = 5_000;
export const DEATH_KNIGHT_RUNE_RECHARGE_MS = 10_000;
export interface CombatResourceAdvanceRules {
readonly inCombat: boolean;
readonly actorLevel: number;
readonly now: number;
readonly deltaSeconds: number;
readonly manaSpiritRegenerationPerSecond?: number;
}
export function advanceCombatResourcePool(
pool: CombatResourcePoolState,
rules: CombatResourceAdvanceRules,
): CombatResourcePoolState {
const delta = Math.max(0, Math.min(1, Number.isFinite(rules.deltaSeconds) ? rules.deltaSeconds : 0));
let rate = pool.regenerationPerSecond;
if (pool.id === "mana" && rules.now - pool.lastSpentAt < MANA_FIVE_SECOND_RULE_MS) {
rate = Math.max(0, rate - Math.max(0, rules.manaSpiritRegenerationPerSecond ?? 0));
}
if ((pool.id === "rage" || pool.id === "runic-power") && !rules.inCombat) {
rate = pool.id === "rage" ? -3 : -2.5;
}
return Object.freeze({
...pool,
current: Math.max(0, Math.min(pool.maximum, pool.current + rate * delta)),
});
}
export interface RuneCost {
readonly blood?: number;
readonly frost?: number;
readonly unholy?: number;
}
export function availableDeathKnightRunes(
runes: readonly DeathKnightRuneState[],
now: number,
): readonly DeathKnightRuneState[] {
return runes.filter((rune) => rune.readyAt <= now);
}
function runeMatches(rune: DeathKnightRuneState, type: Exclude<DeathKnightRuneType, "death">): boolean {
return rune.type === type || rune.type === "death";
}
export function spendDeathKnightRunes(
runes: readonly DeathKnightRuneState[] | undefined,
cost: RuneCost,
now: number,
rechargeMs = DEATH_KNIGHT_RUNE_RECHARGE_MS,
): readonly DeathKnightRuneState[] | null {
const current = runes?.length === 6 ? [...runes] : [...createDeathKnightRunes(now)];
const spent = new Set<number>();
for (const type of ["blood", "frost", "unholy"] as const) {
const count = Math.max(0, Math.trunc(cost[type] ?? 0));
for (let index = 0; index < count; index += 1) {
const candidate = current.find((rune) => (
!spent.has(rune.slot) && rune.readyAt <= now && runeMatches(rune, type)
));
if (!candidate) return null;
spent.add(candidate.slot);
}
}
return Object.freeze(current.map((rune) => spent.has(rune.slot)
? Object.freeze({ ...rune, readyAt: now + Math.max(1, rechargeMs) })
: rune));
}
export function activateDeathKnightRunes(
runes: readonly DeathKnightRuneState[],
count: number,
): readonly DeathKnightRuneState[] {
let remaining = Math.max(0, Math.trunc(count));
return Object.freeze([...runes]
.sort((left, right) => right.readyAt - left.readyAt || left.slot - right.slot)
.map((rune) => {
if (remaining <= 0 || rune.readyAt <= 0) return rune;
remaining -= 1;
return Object.freeze({ ...rune, readyAt: 0 });
})
.sort((left, right) => left.slot - right.slot));
}
+7 -1
View File
@@ -1,9 +1,14 @@
import { describe, expect, it } from "vitest";
import { CLASSES, type ClassId } from "../app/characterCatalog";
import { COA_ABILITIES_BY_CLASS, COA_CLASS_RESOURCES } from "./coaAbilityCatalog";
import {
COA_ABILITIES_BY_CLASS,
COA_CLASS_RESOURCES,
COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
} from "./coaAbilityCatalog";
import {
COA_RUNTIME_ABILITIES_BY_CLASS,
COA_RUNTIME_CLASS_RESOURCES,
COA_RUNTIME_TRIGGERED_ABILITIES_BY_SPELL_ID,
} from "./coaAbilityRuntimeCatalog";
import generatedTalentRuntime from "./generated/talentRuntimeCatalog.json";
import {
@@ -21,6 +26,7 @@ describe("compact combat runtime catalogs", () => {
it("preserves every derived COA ability and resource profile", () => {
expect(COA_RUNTIME_ABILITIES_BY_CLASS).toEqual(COA_ABILITIES_BY_CLASS);
expect(COA_RUNTIME_CLASS_RESOURCES).toEqual(COA_CLASS_RESOURCES);
expect(COA_RUNTIME_TRIGGERED_ABILITIES_BY_SPELL_ID).toEqual(COA_TRIGGERED_ABILITIES_BY_SPELL_ID);
});
it("preserves every talent field used by runtime allocation and modifiers", () => {
+33
View File
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it } from "vitest";
import {
combatHasLineOfSight,
isPositionInFacingArc,
isSourceBehindTarget,
projectCombatGroundPoint,
registerCombatSpatialProvider,
resetCombatSpatialProvider,
} from "./combatSpatial";
describe("renderer-independent combat spatial queries", () => {
afterEach(resetCombatSpatialProvider);
it("validates facing and behind arcs", () => {
const actor = { id: "actor", position: [0, 0, 0] as const, yaw: 0 };
expect(isPositionInFacingArc(actor, [0, 0, 5])).toBe(true);
expect(isPositionInFacingArc(actor, [0, 0, -5])).toBe(false);
expect(isSourceBehindTarget([0, 0, -2], actor)).toBe(true);
expect(isSourceBehindTarget([0, 0, 2], actor)).toBe(false);
});
it("delegates line of sight and collision-safe ground projection", () => {
registerCombatSpatialProvider({
actor: () => null,
hasLineOfSight: (_source, target) => target[0] >= 0,
projectGroundPoint: (requested) => requested[1] > 5 ? null : [requested[0], 0, requested[2]],
});
expect(combatHasLineOfSight([0, 0, 0], [2, 0, 0])).toBe(true);
expect(combatHasLineOfSight([0, 0, 0], [-2, 0, 0])).toBe(false);
expect(projectCombatGroundPoint([2, 3, 4], 10)).toEqual([2, 0, 4]);
expect(projectCombatGroundPoint([2, 8, 4], 10)).toBeNull();
});
});
+86
View File
@@ -0,0 +1,86 @@
export type CombatSpatialPosition = readonly [x: number, y: number, z: number];
export interface CombatSpatialActorSnapshot {
readonly id: string;
readonly position: CombatSpatialPosition;
/** World-space yaw in radians; +Z is forward. */
readonly yaw: number;
}
export interface CombatSpatialProvider {
readonly actor: (actorId: string) => CombatSpatialActorSnapshot | null;
readonly hasLineOfSight: (
source: CombatSpatialPosition,
target: CombatSpatialPosition,
) => boolean;
readonly projectGroundPoint: (
requested: CombatSpatialPosition,
maximumDistance: number,
origin?: CombatSpatialPosition,
) => CombatSpatialPosition | null;
}
let provider: CombatSpatialProvider | null = null;
/** Registers the loaded dungeon's read-only spatial query seam. */
export function registerCombatSpatialProvider(next: CombatSpatialProvider): () => void {
provider = next;
return () => {
if (provider === next) provider = null;
};
}
export function resetCombatSpatialProvider(): void {
provider = null;
}
export function combatSpatialActor(actorId: string): CombatSpatialActorSnapshot | null {
return provider?.actor(actorId) ?? null;
}
export function combatHasLineOfSight(
source: CombatSpatialPosition,
target: CombatSpatialPosition,
): boolean {
return provider?.hasLineOfSight(source, target) ?? true;
}
export function projectCombatGroundPoint(
requested: CombatSpatialPosition,
maximumDistance: number,
origin?: CombatSpatialPosition,
): CombatSpatialPosition | null {
if (!requested.every(Number.isFinite) || !Number.isFinite(maximumDistance) || maximumDistance < 0) return null;
return provider ? provider.projectGroundPoint(requested, maximumDistance, origin) : requested;
}
function normalizedPlanarDirection(
from: CombatSpatialPosition,
to: CombatSpatialPosition,
): readonly [number, number] | null {
const dx = to[0] - from[0];
const dz = to[2] - from[2];
const length = Math.hypot(dx, dz);
return length > 1e-5 ? [dx / length, dz / length] : null;
}
export function isPositionInFacingArc(
source: CombatSpatialActorSnapshot,
target: CombatSpatialPosition,
arcRadians = Math.PI,
): boolean {
const direction = normalizedPlanarDirection(source.position, target);
if (!direction) return true;
const forwardX = Math.sin(source.yaw);
const forwardZ = Math.cos(source.yaw);
const cosine = forwardX * direction[0] + forwardZ * direction[1];
return cosine >= Math.cos(Math.max(0, Math.min(Math.PI * 2, arcRadians)) / 2);
}
export function isSourceBehindTarget(
source: CombatSpatialPosition,
target: CombatSpatialActorSnapshot,
rearArcRadians = Math.PI,
): boolean {
return !isPositionInFacingArc(target, source, Math.PI * 2 - rearArcRadians);
}
+151 -2
View File
@@ -12,6 +12,7 @@ import {
EMPTY_CORPSE_DESPAWN_MS,
MINIMUM_CORPSE_VISIBLE_MS,
MOB_WOUND_ANIMATION_COOLDOWN_MS,
PLAYER_AURA_ENTITY_ID,
selectedMob,
useCombatStore,
} from "./combatStore";
@@ -90,7 +91,7 @@ describe("combat store", () => {
expect(useCombatStore.getState().mobs.ravager.health).toBe(100);
useCombatStore.getState().tick(0.1, 2500);
expect(useCombatStore.getState().mobs.ravager.health).toBeLessThan(100);
expect(useCombatStore.getState().resource).toBe(startingResource - 14);
expect(useCombatStore.getState().resource).toBe(startingResource - Math.round(startingResource * 0.14));
expect(useCombatStore.getState().activeCast).toBeNull();
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
});
@@ -950,7 +951,7 @@ describe("combat store", () => {
expect(useCombatStore.getState().castSlot(0, undefined, 10_000).ok).toBe(true);
const boltCompletion = useCombatStore.getState().activeCast!.completesAt;
useCombatStore.getState().tick(0.1, boltCompletion);
expect(useCombatStore.getState().resource).toBe(startingMana - 9);
expect(useCombatStore.getState().resource).toBe(startingMana - Math.round(startingMana * 0.1 * 0.85));
});
it("routes a cast-time direct heal to the selected living party member", () => {
@@ -972,6 +973,154 @@ describe("combat store", () => {
expect(useCombatStore.getState().selectedTargetId).toBe("hostile-selection");
});
it("fully heals the selected ally with Lay on Hands and starts its cooldown", () => {
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 10 });
usePartyStore.getState().initializeParty("lay-on-hands-test", 10, useCombatStore.getState().health);
const memberId = preparePartyMember(0, [10, 0, 0], 1, true);
const memberBefore = usePartyStore.getState().members.find((member) => member.id === memberId)!;
expect(useCombatStore.getState().castAbility(
"wow335-paladin-lay-on-hands",
[0, 0, 0],
1_000,
)).toMatchObject({
ok: true,
healing: memberBefore.maxHealth - 1,
});
expect(usePartyStore.getState().members.find((member) => member.id === memberId)?.health)
.toBe(memberBefore.maxHealth);
expect(useCombatStore.getState().cooldowns["wow335-paladin-lay-on-hands"])
.toBe(1_201_000);
expect(useCombatStore.getState().castAbility(
"wow335-paladin-lay-on-hands",
[0, 0, 0],
2_500,
)).toMatchObject({ ok: false, reason: "cooldown" });
});
it("makes the selected ally immune to physical attacks with Hand of Protection", () => {
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 10 });
usePartyStore.getState().initializeParty("hand-of-protection-test", 10, useCombatStore.getState().health);
const memberId = preparePartyMember(0, [0, 0, 0], 1, true);
const memberBefore = usePartyStore.getState().members.find((member) => member.id === memberId)!;
expect(useCombatStore.getState().castAbility(
"wow335-paladin-hand-of-protection",
[0, 0, 0],
1_000,
)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
targetId: memberId,
expiresAt: 7_000,
definition: expect.objectContaining({ name: expect.stringContaining("Hand of Protection") }),
}));
expect(useCombatStore.getState().cooldowns["wow335-paladin-hand-of-protection"])
.toBe(301_000);
useCombatStore.getState().registerMob("party-victim", {
name: "Party victim",
maxHealth: 200,
xpReward: 0,
});
setMobPosition("party-victim", [2, 0, 0]);
useCombatStore.getState().damageMob("party-victim", 5, 1_000, {
actorId: memberId,
role: "tank",
});
expect(useCombatStore.getState().advanceMobCombat(1_450)).toBe(0);
expect(usePartyStore.getState().members.find((member) => member.id === memberId)?.health)
.toBe(memberBefore.health);
});
it("clears roots with Hand of Freedom and replaces mutually exclusive seals", () => {
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
usePartyStore.getState().initializeParty("paladin-aura-test", 80, useCombatStore.getState().health);
const memberId = preparePartyMember(0, [0, 0, 0], Number.MAX_SAFE_INTEGER, true);
usePartyStore.getState().controlMember(memberId, "root", 30_000);
expect(useCombatStore.getState().castAbility("wow335-paladin-hand-of-freedom", [0, 0, 0], 1_000).ok).toBe(true);
expect(usePartyStore.getState().members.find((member) => member.id === memberId)).toMatchObject({
controlledUntil: 0,
controlMechanic: null,
});
usePartyStore.getState().selectSelf();
expect(useCombatStore.getState().castAbility("wow335-paladin-seal-of-righteousness", [0, 0, 0], 3_000).ok).toBe(true);
expect(useCombatStore.getState().castAbility("wow335-paladin-seal-of-wisdom", [0, 0, 0], 5_000).ok).toBe(true);
const seals = useCombatStore.getState().auras.filter((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID && aura.definition.exclusiveGroup === "paladin:seal"
));
expect(seals).toHaveLength(1);
expect(seals[0].definition.name).toContain("Seal of Wisdom");
});
it("redirects Hand of Sacrifice damage to the paladin", () => {
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
usePartyStore.getState().initializeParty("hand-of-sacrifice-test", 80, useCombatStore.getState().health);
const memberId = preparePartyMember(0, [0, 0, 0], Number.MAX_SAFE_INTEGER, true);
const memberBefore = usePartyStore.getState().members.find((member) => member.id === memberId)!;
const playerBefore = useCombatStore.getState().health;
expect(useCombatStore.getState().castAbility("wow335-paladin-hand-of-sacrifice", [0, 0, 0], 1_000).ok).toBe(true);
useCombatStore.getState().registerMob("sacrifice-attacker", { name: "Sacrifice attacker", maxHealth: 200, xpReward: 0 });
setMobPosition("sacrifice-attacker", [2, 0, 0]);
useCombatStore.getState().damageMob("sacrifice-attacker", 5, 1_000, { actorId: memberId, role: "tank" });
expect(useCombatStore.getState().advanceMobCombat(1_450)).toBeGreaterThan(0);
const memberLoss = memberBefore.health - usePartyStore.getState().members.find((member) => member.id === memberId)!.health;
const playerLoss = playerBefore - useCombatStore.getState().health;
expect(memberLoss).toBeGreaterThan(0);
expect(playerLoss).toBeGreaterThan(0);
expect(playerLoss).toBeLessThan(memberLoss);
});
it("taunts up to three attackers off an ally with Righteous Defense", () => {
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
usePartyStore.getState().initializeParty("righteous-defense-test", 80, useCombatStore.getState().health);
const memberId = preparePartyMember(0, [0, 0, 0], Number.MAX_SAFE_INTEGER, true);
for (const id of ["attacker-a", "attacker-b", "attacker-c", "attacker-d"]) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100, xpReward: 0 });
setMobPosition(id, [2, 0, 0]);
}
useCombatStore.setState((state) => ({
mobs: Object.fromEntries(Object.entries(state.mobs).map(([id, mob]) => [id, {
...mob,
engaged: true,
targetActorId: memberId,
threatByActor: { [memberId]: 10 },
}])),
}));
expect(useCombatStore.getState().castAbility("wow335-paladin-righteous-defense", [0, 0, 0], 1_000).ok).toBe(true);
expect(Object.values(useCombatStore.getState().mobs).filter((mob) => mob.forcedTarget?.actorId === PLAYER_AURA_ENTITY_ID)).toHaveLength(3);
});
it("channels party-wide classic healing without requiring an enemy target", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
usePartyStore.getState().initializeParty("divine-hymn-test", 80, useCombatStore.getState().health);
const memberId = preparePartyMember(0, [4, 0, 0], 1);
useCombatStore.getState().damagePlayer(500);
const playerBefore = useCombatStore.getState().health;
expect(useCombatStore.getState().castAbility("wow335-priest-divine-hymn", [0, 0, 0], 1_000).ok).toBe(true);
const completion = useCombatStore.getState().activeCast?.completesAt;
if (completion) useCombatStore.getState().tick(1, completion);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(useCombatStore.getState().health).toBeGreaterThan(playerBefore);
expect(usePartyStore.getState().members.find((member) => member.id === memberId)!.health).toBeGreaterThan(1);
});
it("executes intentional classic utility spells instead of rejecting them", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
useCombatStore.getState().damagePlayer(500);
const before = useCombatStore.getState().health;
expect(useCombatStore.getState().castAbility("wow335-mage-conjure-food", [0, 0, 0], 1_000).ok).toBe(true);
const completion = useCombatStore.getState().activeCast?.completesAt;
if (completion) useCombatStore.getState().tick(1, completion);
expect(useCombatStore.getState().health).toBeGreaterThan(before);
});
it("falls back to the player and snapshots an explicit null friendly target", () => {
registerTarget("enemy", [10, 0, 0], 100, 0);
const memberId = preparePartyMember(0, [8, 0, 0], 1);
+2850 -245
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -24,7 +24,7 @@ describe("death respawn", () => {
useCombatStore.getState().selectMob("respawn-pack:a");
useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000);
expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true);
expect(useCombatStore.getState().resource).toBe(startingResource - 15);
expect(useCombatStore.getState().resource).toBe(startingResource - Math.round(startingResource * 0.15));
const revisionBefore = useGameStore.getState().resetRevision;
useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 });
+105
View File
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
function registerTarget(id = "target", position: readonly [number, number, number] = [0, 0, 10]) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
setMobPosition(id, position);
useCombatStore.getState().selectMob(id);
}
describe("dungeon combat fidelity integration", () => {
beforeEach(() => {
resetCombatSpatialProvider();
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 10 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("fidelity", 10, useCombatStore.getState().health);
});
afterEach(resetCombatSpatialProvider);
it("rejects obstructed and rear-arc hostile casts while friendly healing ignores facing", () => {
registerTarget();
let blocked = true;
registerCombatSpatialProvider({
actor: (id) => id === PLAYER_AGGRO_ID
? { id, position: [0, 0, 0], yaw: Math.PI }
: { id, position: getMobPosition(id) ?? [0, 0, 0], yaw: 0 },
hasLineOfSight: () => !blocked,
projectGroundPoint: (point) => point,
});
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 })).toMatchObject({ ok: false, reason: "line-of-sight" });
blocked = false;
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 })).toMatchObject({ ok: false, reason: "not-facing" });
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 10 });
const ally = usePartyStore.getState().members[0];
registerPartyRuntimePosition(ally.id, [0, 0, 10]);
usePartyStore.getState().selectMember(ally.id);
usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === ally.id ? { ...member, health: 1 } : member) }));
const friendlyResult = useCombatStore.getState().castAbility("wow335-priest-lesser-heal", { now: 2_000 });
expect(friendlyResult.reason).toBeUndefined();
});
it("applies only the first two direct-damage pushbacks and ignores fully absorbed hits", () => {
registerTarget();
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 }).ok).toBe(true);
const original = useCombatStore.getState().activeCast!.completesAt;
useCombatStore.getState().damagePlayer(1);
useCombatStore.getState().damagePlayer(1);
useCombatStore.getState().damagePlayer(1);
expect(useCombatStore.getState().activeCast).toMatchObject({ completesAt: original + 1_000, pushbackHits: 2 });
useCombatStore.getState().cancelCast();
useCombatStore.setState({ globalCooldownEndsAt: 0, shield: 10_000, shieldExpiresAt: Number.MAX_SAFE_INTEGER });
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 5_000 }).ok).toBe(true);
const absorbedOriginal = useCombatStore.getState().activeCast!.completesAt;
useCombatStore.getState().damagePlayer(100);
expect(useCombatStore.getState().activeCast!.completesAt).toBe(absorbedOriginal);
});
it("creates persistent collision-safe ground effects and dynamically ticks actors in their radius", () => {
useCombatStore.getState().initializeCharacter({ classId: "paladin", level: 80 });
registerTarget("late-target", [20, 0, 0]);
registerCombatSpatialProvider({
actor: (id) => ({ id, position: id === PLAYER_AGGRO_ID ? [0, 0, 0] : getMobPosition(id) ?? [0, 0, 0], yaw: 0 }),
hasLineOfSight: () => true,
projectGroundPoint: (point) => [point[0], 0, point[2]],
});
const groundResult = useCombatStore.getState().castAbility("paladin-consecration", { now: 1_000, groundPosition: [0, 3, 0] });
expect(groundResult.reason).toBeUndefined();
expect(useCombatStore.getState().areaEffects).toHaveLength(1);
setMobPosition("late-target", [2, 0, 0]);
const before = useCombatStore.getState().mobs["late-target"].health;
useCombatStore.getState().tick(1, 2_000);
expect(useCombatStore.getState().mobs["late-target"].health).toBeLessThan(before);
});
it("spends independent Death Knight runes and exposes every combatant through actor snapshots", () => {
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 10 });
registerTarget("rune-target", [0, 0, 10]);
expect(useCombatStore.getState().castAbility("death-knight-icy-touch", { now: 1_000 }).ok).toBe(true);
expect(useCombatStore.getState().deathKnightRunes.filter((rune) => rune.readyAt > 1_000)).toHaveLength(1);
useCombatStore.getState().synchronizeActors();
const actors = useCombatStore.getState().actors;
expect(actors[PLAYER_AGGRO_ID].runes).toHaveLength(6);
expect(actors["rune-target"].kind).toBe("enemy");
for (const member of usePartyStore.getState().members) expect(actors[member.id].kind).toBe("companion");
});
it("emits structured effective damage and healing records", () => {
registerTarget("event-target", [0, 0, 10]);
useCombatStore.getState().damageMob("event-target", 25, 1_000);
useCombatStore.getState().damagePlayer(10);
useCombatStore.getState().healPlayer(100);
const events = useCombatStore.getState().combatEvents;
expect(events.some((event) => event.kind === "damage" && event.targetActorId === "event-target" && event.effectiveAmount > 0)).toBe(true);
expect(events.some((event) => event.kind === "healing" && event.overheal >= 0)).toBe(true);
expect(events.every((event) => event.rawAmount >= event.effectiveAmount)).toBe(true);
});
});
@@ -0,0 +1,195 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { abilitiesForClass, abilityAtLevel, abilityById, type AbilityDefinition } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
import { targetingRulesForAbility } from "./abilityRules";
import { clearMobRuntimeRegistry, getMobPosition, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
function registerMob(id: string, position: readonly [number, number, number], boss = false): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 10_000, xpReward: 0, boss });
setMobPosition(id, position);
}
describe("extended dungeon combat fidelity", () => {
beforeEach(() => {
resetCombatSpatialProvider();
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("actor-authority", 80, useCombatStore.getState().health);
useCombatStore.getState().synchronizeActors();
});
afterEach(resetCombatSpatialProvider);
it("keeps companion vitals authoritative when compatibility party records are stale", () => {
const member = usePartyStore.getState().members[0];
const actor = useCombatStore.getState().actors[member.id];
useCombatStore.setState((state) => ({
actors: { ...state.actors, [member.id]: { ...actor, health: 7, alive: true } },
}));
usePartyStore.setState((state) => ({
members: state.members.map((candidate) => candidate.id === member.id
? { ...candidate, health: candidate.maxHealth }
: candidate),
}));
useCombatStore.getState().synchronizeActors();
expect(useCombatStore.getState().actors[member.id].health).toBe(7);
expect(usePartyStore.getState().members.find((candidate) => candidate.id === member.id)?.health).toBe(7);
});
it("binds combo points to the enemy that generated them", () => {
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 20 });
registerMob("combo-a", [0, 0, 3]);
registerMob("combo-b", [1, 0, 2]);
useCombatStore.getState().selectMob("combo-a");
expect(useCombatStore.getState().castAbility("rogue-sinister-strike", { now: 1_000 }).ok).toBe(true);
expect(useCombatStore.getState()).toMatchObject({ comboPoints: 1, comboTargetId: "combo-a" });
useCombatStore.setState((state) => ({
globalCooldownEndsAt: 0,
cooldowns: {},
resource: state.maxResource,
resourcePools: { ...state.resourcePools, energy: state.maxResource },
}));
useCombatStore.getState().selectMob("combo-b");
const secondStrike = useCombatStore.getState().castAbility("rogue-sinister-strike", { now: 2_000 });
expect(secondStrike.ok, secondStrike.reason).toBe(true);
expect(useCombatStore.getState()).toMatchObject({ comboPoints: 1, comboTargetId: "combo-b" });
});
it("applies boss control and taunt diminishing returns through immunity", () => {
registerMob("dr-boss", [0, 0, 3], true);
const durations: number[] = [];
for (const now of [1_000, 2_000, 3_000]) {
expect(useCombatStore.getState().controlMob("dr-boss", `stun-${now}`, "stun", 8_000, now)).toBe(true);
const status = useCombatStore.getState().mobs["dr-boss"].statuses.find((entry) => entry.kind === "stun")!;
durations.push(status.endsAt - now);
}
expect(durations).toEqual([3_000, 1_500, 750]);
expect(useCombatStore.getState().controlMob("dr-boss", "stun-immune", "stun", 8_000, 4_000)).toBe(false);
const tauntDurations: number[] = [];
for (const now of [5_000, 6_000, 7_000]) {
expect(useCombatStore.getState().tauntMob("dr-boss", { actorId: PLAYER_AGGRO_ID, role: "tank" }, 3_000, now)).toBe(true);
tauntDurations.push(useCombatStore.getState().mobs["dr-boss"].forcedTarget!.endsAt - now);
}
expect(tauntDurations).toEqual([3_000, 1_950, 1_268]);
expect(useCombatStore.getState().tauntMob("dr-boss", { actorId: PLAYER_AGGRO_ID, role: "tank" }, 3_000, 8_000)).toBe(false);
});
it("moves and confirms a collision-projected ground reticle", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
const groundAbility = abilitiesForClass("mage")
.map((ability) => abilityAtLevel(ability, 80))
.find((ability) => targetingRulesForAbility(ability).shape === "ground" && ability.range.max > 1)!;
expect(groundAbility).toBeDefined();
registerCombatSpatialProvider({
actor: (id) => ({ id, position: [0, 0, 0], yaw: 0 }),
hasLineOfSight: () => true,
projectGroundPoint: (point, maximumDistance, origin = [0, 0, 0]) => {
const dx = point[0] - origin[0];
const dz = point[2] - origin[2];
const distance = Math.hypot(dx, dz);
const scale = distance > maximumDistance ? maximumDistance / distance : 1;
return [origin[0] + dx * scale, 0, origin[2] + dz * scale];
},
});
expect(useCombatStore.getState().beginGroundTarget(groundAbility.id)).toBe(true);
const before = useCombatStore.getState().pendingGroundTarget!;
expect(before.valid).toBe(true);
useCombatStore.getState().moveGroundTarget(1, 0, 0, 0.1);
const moved = useCombatStore.getState().pendingGroundTarget!;
expect(moved.position).not.toEqual(before.position);
expect(Math.hypot(moved.position[0], moved.position[2])).toBeLessThanOrEqual(moved.maximumRange);
expect(useCombatStore.getState().confirmGroundTarget(undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().pendingGroundTarget).toBeNull();
const activeCast = useCombatStore.getState().activeCast;
if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt);
expect(useCombatStore.getState().areaEffects).toHaveLength(1);
});
it("keeps summons targetable, credits their owner, replaces totems, and expires them", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-warden", raceId: "rom-elf", level: 20 });
useCombatStore.getState().synchronizeActors();
registerMob("summon-target", [0, 0, 3]);
useCombatStore.getState().selectMob("summon-target");
expect(useCombatStore.getState().castAbility("rom-warden-primary-1", { now: 1_000 }).ok).toBe(true);
useCombatStore.getState().tick(0.1, 5_000);
const summon = useCombatStore.getState().summons[0];
expect(summon).toBeDefined();
expect(useCombatStore.getState().actors[summon.id]).toMatchObject({ kind: "summon", ownerId: PLAYER_AGGRO_ID, alive: true });
expect(useCombatStore.getState().selectFriendlyActor(summon.id)).toBe(true);
const beforeDamage = summon.health;
expect(useCombatStore.getState().damageSummon(summon.id, 5)).toBe(5);
expect(useCombatStore.getState().actors[summon.id].health).toBe(beforeDamage - 5);
expect(useCombatStore.getState().healActor(summon.id, 5, { sourceActorId: PLAYER_AGGRO_ID }).effectiveAmount).toBe(5);
const baseAbility = abilityById("rom-warden-primary-1")!;
const combatSummonAbility: AbilityDefinition = { ...baseAbility, id: "test-guardian", name: "Test Guardian" };
useCombatStore.getState().summonActor(
PLAYER_AGGRO_ID,
combatSummonAbility,
{ kind: "summon", creatureId: 2, coefficient: 0.5, durationMs: 10_000 },
[0, 0, 0],
"summon-target",
6_000,
);
useCombatStore.getState().tick(1, 7_500);
expect(useCombatStore.getState().mobs["summon-target"].threatByActor[PLAYER_AGGRO_ID]).toBeGreaterThan(0);
expect(useCombatStore.getState().mobs["summon-target"].threatByActor[summon.id] ?? 0).toBe(0);
const totemAbility: AbilityDefinition = { ...baseAbility, id: "test-totem", name: "Test Totem" };
const totemEffect = { kind: "summon" as const, creatureId: 1, coefficient: 0.2, durationMs: 10_000 };
useCombatStore.getState().summonActor(PLAYER_AGGRO_ID, totemAbility, totemEffect, [0, 0, 0], null, 8_000);
useCombatStore.getState().summonActor(PLAYER_AGGRO_ID, totemAbility, totemEffect, [1, 0, 0], null, 8_100);
expect(useCombatStore.getState().summons.filter((candidate) => candidate.exclusiveGroup === `${PLAYER_AGGRO_ID}:totem`)).toHaveLength(1);
useCombatStore.getState().tick(0.1, 20_001);
expect(useCombatStore.getState().summons.some((candidate) => candidate.id === summon.id)).toBe(false);
expect(useCombatStore.getState().actors[summon.id]).toBeUndefined();
});
it("executes damaging abilities from Classic, RuneWaker, and CoA catalogs through one runtime", () => {
const cases = [
{ classId: "mage" as const, level: 20, source: "classic" },
{ classId: "rom-mage" as const, level: 20, source: "runewaker" },
{ classId: "stormbringer" as const, level: 60, source: "coa" },
];
registerCombatSpatialProvider({
actor: (id) => ({ id, position: id === PLAYER_AGGRO_ID ? [0, 0, 0] : getMobPosition(id) ?? [0, 0, 0], yaw: 0 }),
hasLineOfSight: () => true,
projectGroundPoint: (point) => point,
});
for (const entry of cases) {
useCombatStore.getState().initializeCharacter({ classId: entry.classId, level: entry.level });
const ability = abilitiesForClass(entry.classId)
.map((candidate) => abilityAtLevel(candidate, entry.level))
.find((candidate) => (
!candidate.passive
&& candidate.target === "hostile"
&& candidate.talentEntryId === undefined
&& candidate.effects.some((effect) => effect.kind === "damage")
));
expect(ability, `${entry.source} executable damage ability`).toBeDefined();
const id = `${entry.source}-target`;
const range = Math.max(1, Math.min(10, ability!.range.max));
registerMob(id, [0, 0, range]);
useCombatStore.getState().selectMob(id);
const before = useCombatStore.getState().mobs[id].health;
const cast = useCombatStore.getState().castAbility(ability!.id, { now: 1_000 });
expect(cast.ok, `${entry.source} cast: ${cast.reason ?? "ok"}`).toBe(true);
const activeCast = useCombatStore.getState().activeCast;
if (activeCast) useCombatStore.getState().tick(0.1, activeCast.completesAt);
expect(useCombatStore.getState().mobs[id].health, `${entry.source} damage`).toBeLessThan(before);
clearMobRuntimeRegistry();
}
});
});
+1 -1
View File
@@ -15,7 +15,7 @@ export type EnemyDamageSchool =
| "shadow"
| "holy";
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun";
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun" | "silence";
export type EnemyAbilityEffect =
| { readonly kind: "damage"; readonly multiplier: number }
@@ -1 +1 @@
{"schemaVersion":1,"abilitiesByClass":{"barbarian":[{"id":"coa-tree-30762","classId":"barbarian","dbcSpellId":500918,"name":"Ancestral Roar","unlockLevel":1,"icon":"/assets/ui/spells/_d3threateningshout.png","sigil":"AR","description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","target":"friendly","range":{"min":0,"max":0},"radius":0,"castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0},{"kind":"utility","effectType":0,"auraType":0,"miscValue":0,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500918,"level":1,"description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0},{"kind":"utility","effectType":0,"auraType":0,"miscValue":0,"triggerSpellId":0,"durationMs":0}]}],"talentEntryId":30762},{"id":"coa-12-ancestral-spirit-47-705153","classId":"barbarian","dbcSpellId":705153,"name":"Ancestral Spirit","unlockLevel":1,"icon":"/assets/ui/spells/inv_misc_questionmark.png","sigil":"AS","description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","target":"self","range":{"min":0,"max":0},"radius":0,"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"utility","effectType":6,"auraType":108,"miscValue":14,"triggerSpellId":0,"durationMs":-1},{"kind":"utility","effectType":6,"auraType":108,"miscValue":1,"triggerSpellId":0,"durationMs":-1},{"kind":"utility","effectType":0,"auraType":0,"miscValue":0,"triggerSpellId":0,"durationMs":-1}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705153,"level":1,"description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"utility","effectType":6,"auraType":108,"miscValue":14,"triggerSpellId":0,"durationMs":-1},{"kind":"utility","effectType":6,"auraType":108,"miscValue":1,"triggerSpellId":0,"durationMs":-1},{"kind":"utility","effectType":0,"auraType":0,"miscValue":0,"triggerSpellId":0,"durationMs":-1}]},{"rank":2,"spellId":705154,"level":1,"description":"Ancestral SpiritRank 2Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 100%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"utility","effectType":6,"auraType":108,"miscValue":14,"triggerSpellId":0,"durationMs":-1},{"kind":"utility","effectType":6,"auraType":108,"miscValue":1,"triggerSpellId":0,"durationMs":-1},{"kind":"utility","effectType":0,"auraType":0,"miscValue":0,"triggerSpellId":0,"durationMs":-1}]}]},{"id":"coa-tree-34279","classId":"barbarian","dbcSpellId":560456,"name":"Athleticism","unlockLevel":1,"icon":"/assets/ui/spells/5_run_border.png","sigil":"AT","description":"Increases your Energy regeneration by 25%.","target":"self","range":{"min":0,"max":0},"radius":0,"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"utility","effectType":6,"auraType":110,"miscValue":3,"triggerSpellId":0,"durationMs":-1},{"kind":"utility","effectType":0,"auraType":0,"miscValue":0,"triggerSpellId":0,"durationMs":-1}],"passive":true,"source":"coa-tree","ranks":[{"rank":1,"spellId":560456,"level":1,"description":"Increases your Energy regeneration by 25%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"utility","effectType":6,"auraType":110,"miscValue":3,"triggerSpellId":0,"durationMs":-1},{"kind":"utility","effectType":0,"auraType":0,"miscValue":0,"triggerSpellId":0,"durationMs":-1}]}],"talentEntryId":34279},{"id":"barbarian-barbaric-strike","classId":"barbarian","dbcSpellId":801787,"name":"Barbaric Strike","unlockLevel":1,"icon":"/assets/ui/spells/warrior_wild_strike.png","sigil":"BS","description":"Barbaric StrikeRank 160 Energy Instant6 sec cooldownRequires Weapon \"Strikes an enemy for Weapon Damage pLine truncated
{"schemaVersion":1,"abilitiesByClass":{"barbarian":[{"id":"coa-tree-30762","classId":"barbarian","dbcSpellId":500918,"name":"Ancestral Roar","unlockLevel":1,"icon":"/assets/ui/spells/_d3threateningshout.png","sigil":"AR","description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}],"passive":false,"source":"coa-tree","ranks":[{"rank":1,"spellId":500918,"level":1,"description":"Ancestral RoarRank 1Instant cast25 sec cooldownRequires Enraged Roar with ancestral fervor, healing you for 100+0+AP*0.5.","castMode":"instant","castTimeMs":0,"cooldownMs":25000,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"heal","coefficient":1.6580645243326824,"basePoints":100,"dieSides":7,"pointsPerLevel":3.200000047683716,"bonusPowerCoefficient":0,"sourceLevel":16,"maxScalingLevel":22},{"kind":"utility","action":"class-script","effectType":3,"auraType":0,"miscValue":3,"triggerSpellId":0,"durationMs":0}]}],"talentEntryId":30762},{"id":"coa-12-ancestral-spirit-47-705153","classId":"barbarian","dbcSpellId":705153,"name":"Ancestral Spirit","unlockLevel":1,"icon":"/assets/ui/spells/inv_misc_questionmark.png","sigil":"AS","description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","target":"self","range":{"min":0,"max":0},"castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}],"passive":true,"source":"coa-progression","ranks":[{"rank":1,"spellId":705153,"level":1,"description":"Ancestral SpiritRank 1Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 50%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705153:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705153:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":0.5}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]},{"rank":2,"spellId":705154,"level":1,"description":"Ancestral SpiritRank 2Reduces the Energy cost of your Spirit spells and increases the duration of your Shouts by 100%.","castMode":"instant","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"cost":{"resource":"energy","amount":0},"effects":[{"kind":"apply-aura","aura":{"id":"coa:705154:effect:0:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":-1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":14,"triggerSpellId":0},{"kind":"apply-aura","aura":{"id":"coa:705154:effect:1:aura:108","name":"Ancestral Spirit (Aura 108)","disposition":"buff","durationMs":null,"maxStacks":1,"stackBehavior":"refresh","modifiers":[{"kind":"stat","stat":"ranged-attack-power","operation":"percent","value":1}]},"recipient":"caster","sourceEffectType":6,"sourceAuraType":108,"miscValue":1,"triggerSpellId":0}]}]},{"id":"coa-tree-34279","classId":"barbarian","dbcSpellId":560456,"name":"Athleticism","unlockLevel":1,"icon":"/assets/ui/spells/5_run_border.png","sigil":"AT","descriptionLine truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"spells":[{"id":500917,"name":"Add Carnage","rankText":"","description":"Being torn to shreds.","categoryId":60,"dispelType":0,"mechanic":0,"attributes":[2216951808,0,0,0,1048576,0,8388608,33554432],"castTimeIndex":1,"castTimeMs":0,"recoveryTimeMs":0,"categoryRecoveryTimeMs":0,"interruptFlags":8,"auraInterruptFlags":0,"channelInterruptFlags":0,"procFlags":0,"procChance":100,"procCharges":0,"maxLevel":0,"baseLevel":4,"spellLevel":4,"durationIndex":0,"durationMs":0,"powerType":0,"powerCost":0,"powerCostPerLevel":0,"powerCostPerSecond":0,"powerCostPerSecondPerLevel":0,"rangeIndex":6,"rangeMin":0,"rangeMax":100,"projectileSpeed":0,"stackAmount":20,"equippedItemClass":0,"equippedItemSubclassMask":-1,"equippedItemInventoryMask":0,"iconId":1531,"powerCostPercentage":0,"startRecoveryCategory":0,"startRecoveryTimeMs":0,"maximumTargets":0,"damageClass":0,"preventionType":0,"schoolMask":1,"runeCostId":0,"powerDisplayId":0,"effects":[{"index":0,"effectType":3,"auraType":0,"basePoints":0,"dieSides":1,"pointsPerLevel":0,"mechanic":0,"implicitTargetA":6,"implicitTargetB":0,"radiusIndex":0,"radius":0,"periodMs":0,"valueMultiplier":0,"chainTargets":0,"itemType":0,"miscValue":0,"miscValueB":1,"triggerSpellId":0,"pointsPerCombo":0,"coefficient":0},{"index":1,"effectType":0,"auraType":0,"basePoints":0,"dieSides":1,"pointsPerLevel":0,"mechanic":0,"implicitTargetA":0,"implicitTargetB":0,"radiusIndex":0,"radius":0,"periodMs":0,"valueMultiplier":0,"chainTargets":0,"itemType":0,"miscValue":0,"miscValueB":0,"triggerSpellId":0,"pointsPerCombo":0,"coefficient":0},{"index":2,"effectType":0,"auraType":0,"basePoints":0,"dieSides":1,"pointsPerLevel":0,"mechanic":0,"implicitTargetA":0,"implicitTargetB":0,"radiusIndex":0,"radius":0,"periodMs":0,"valueMultiplier":0,"chainTargets":0,"itemType":0,"miscValue":0,"miscValueB":0,"triggerSpellId":0,"pointsPerCombo":0,"coefficient":0}]},{"id":800951,"name":"Battle Rhythm","rankText":"","description":"Grants 5 Energy.","categoryId":0,"dispelType":0,"mechanic":0,"attributes":[65536,0,0,0,0,0,0,0],"castTimeIndex":1,"castTimeMs":0,"recoveryTimeMs":8000,"categoryRecoveryTimeMs":0,"interruptFlags":15,"auraInterruptFlags":0,"channelInterruptFlags":0,"procFlags":0,"procChance":101,"procCharges":0,"maxLevel":0,"baseLevel":1,"spellLevel":1,"durationIndex":0,"durationMs":0,"powerType":0,"powerCost":0,"powerCostPerLevel":0,"powerCostPerSecond":0,"powerCostPerSecondPerLevel":0,"rangeIndex":5,"rangeMin":0,"rangeMax":40,"projectileSpeed":0,"stackAmount":0,"equippedItemClass":0,"equippedItemSubclassMask":-1,"equippedItemInventoryMask":0,"iconId":2025,"powerCostPercentage":13,"startRecoveryCategory":133,"startRecoveryTimeMs":1500,"maximumTargets":0,"damageClass":1,"preventionType":1,"schoolMask":1,"runeCostId":0,"powerDisplayId":0,"effects":[{"index":0,"effectType":30,"auraType":0,"basePoints":5,"dieSides":1,"pointsPerLevel":0,"mechanic":0,"implicitTargetA":1,"implicitTargetB":0,"radiusIndex":0,"radius":0,"periodMs":0,"valueMultiplier":0,"chainTargets":0,"itemType":0,"miscValue":3,"miscValueB":0,"triggerSpellId":0,"pointsPerCombo":0,"coefficient":0},{"index":1,"effectType":3,"auraType":0,"basePoints":-1000,"dieSides":1,"pointsPerLevel":0,"mechanic":0,"implicitTargetA":1,"implicitTargetB":0,"radiusIndex":0,"radius":0,"periodMs":0,"valueMultiplier":0,"chainTargets":0,"itemType":0,"miscValue":0,"miscValueB":0,"triggerSpellId":0,"pointsPerCombo":0,"coefficient":0},{"index":2,"effectType":0,"auraType":0,"basePoints":0,"dieSides":1,"pointsPerLevel":0,"mechanic":0,"implicitTargetA":0,"implicitTargetB":0,"radiusIndex":0,"radius":0,"periodMs":0,"valueMultiplier":0,"chainTargets":0,"itemType":0,"miscValue":0,"miscValueB":0,"triggerSpellId":0,"pointsPerCombo":0,"coefficient":0}]},{"id":806960,"name":"Berserker Axe","rankText":"Damage","description":"|cffffffffRequires Enraged|r |cffffffff3 Charges, 8 sec recharge|r Hurl two axes at an enemy, each dealing ${$m1+0+$RAP*0.35} Physical Damage and reducing enemy armor by ${-($m3)} for $d. @ext:Shares a cooldown with |cffffffffAxe Volley|r.:ext@","categoryId":0,"dispelType":0,"mechanic":0,"attributes":[65552,0,1073872896,0,262144,0,0,0],"castTimeIndex":1,"castTimeMs":0,"recoveryTimeMs":0,"categoryRecoveryTimeMs":0,"interruptFlags":15,"auraInterruptFlags":0,"channelInterruptFlags":0,"procFlags":0,"procChance":0,"procCharges":0,"maxLevel":0,"baseLevel":11,"spellLevel":11,"durationIndex":0,"durationMs":0,"powerType":3,"powerCost":0,"powerCostPerLevel":0,"powerCostPerSecond":0,"powerCostPerSecondPerLevel":0,"rangeIndex":4,"rangeMin":0,"rangeMax":30,"projectileSpeed":30,"stackAmount":0,"equippedItemClass":0,"equippedItemSubclassMask":2,"equippedItemInventoryMask":65536,"iconId":8547,"powerCostPercentage":0,"startRecoveryCategory":0,"startRecoveryTimeMs":0,"maximumTargets":0,"damageClass":3,"preventionType":2,"schoolMask":1,"runeCostId":0,"powerDisplayId":0,"effects":[{"index":0,"effectType":2,"auraType":0,"basePoints":1,"dieSides":1,"pointsPerLevel":0,"mechanic":0,"imLine truncated
+21
View File
@@ -48,6 +48,9 @@ function inputActionSpies(blocked = false): InputActions {
setPartyCommand: vi.fn(),
recallParty: vi.fn(),
clearTarget: vi.fn(),
groundTargetActive: vi.fn(() => false),
confirmGroundTarget: vi.fn(),
cancelGroundTarget: vi.fn(),
gameplayActionsBlocked: vi.fn(() => blocked),
setCameraLookActive: vi.fn(),
setInputMode: vi.fn(),
@@ -90,6 +93,24 @@ describe("gameplay input bindings", () => {
dispose();
});
it("confirms and cancels an active ground reticle before face-button casts", () => {
stubInputEventTargets();
const nextActions = inputActionSpies();
(nextActions.groundTargetActive as ReturnType<typeof vi.fn>).mockReturnValue(true);
const dispose = installInput(nextActions);
emitControllerToken({ token: "Button0", repeat: false, pressed: true });
emitControllerToken({ token: "Button0", repeat: false, pressed: false });
emitControllerToken({ token: "Button1", repeat: false, pressed: true });
expect(nextActions.confirmGroundTarget).toHaveBeenCalledTimes(1);
expect(nextActions.cancelGroundTarget).toHaveBeenCalledTimes(1);
expect(nextActions.lootActiveBoss).not.toHaveBeenCalled();
expect(nextActions.castActionBinding).not.toHaveBeenCalled();
dispose();
});
it("maps Thor controls without stealing Select from display routing", () => {
expect(controllerCommandForToken("Button9")).toBe("pause");
expect(controllerCommandForToken("Button11")).toBe("target-next");
+20
View File
@@ -34,6 +34,9 @@ export interface InputActions {
setPartyCommand: (command: PartyCommand) => void;
recallParty: () => void;
clearTarget: () => void;
groundTargetActive: () => boolean;
confirmGroundTarget: () => void;
cancelGroundTarget: () => void;
gameplayActionsBlocked: () => boolean;
setCameraLookActive: (active: boolean) => void;
setInputMode: (mode: "keyboard" | "gamepad") => void;
@@ -246,6 +249,16 @@ export function installInput(nextActions: InputActions): () => void {
if (!isPressed) return;
nextActions.setInputMode("gamepad");
if (repeat) return;
if (nextActions.groundTargetActive()) {
if (token === "Button0") {
nextActions.confirmGroundTarget();
return;
}
if (token === "Button1" || token === CONTROLLER_SYSTEM_BACK_TOKEN) {
nextActions.cancelGroundTarget();
return;
}
}
if (
token === "Button0"
&& !nextActions.gameplayActionsBlocked()
@@ -292,6 +305,13 @@ export function installInput(nextActions: InputActions): () => void {
actions?.setInputMode("keyboard");
}
if (event.repeat) return;
if (nextActions.groundTargetActive() && (event.code === "Escape" || event.code === "Enter")) {
event.preventDefault();
nextActions.setInputMode("keyboard");
if (event.code === "Enter") nextActions.confirmGroundTarget();
else nextActions.cancelGroundTarget();
return;
}
const actionControl = actionControlForKeyboardCode(event.code);
if (actionControl) {
if (nextActions.gameplayActionsBlocked()) return;
+2
View File
@@ -3,6 +3,7 @@ import { LOOT_CATALOG, rollBossLoot } from "./lootCatalog";
import { useManastormStore } from "./manastormStore";
import { useGameStore } from "./store";
import { getManastormCatalog, manastormScaling } from "./manastorm";
import { requestOnlineInteraction } from "./onlineSessionRuntime";
/** Shared keyboard, controller, touch, and HUD interaction for the active corpse. */
export function lootCurrentManastormBoss(now = Date.now()): boolean {
@@ -11,6 +12,7 @@ export function lootCurrentManastormBoss(now = Date.now()): boolean {
if (game.gameMode !== "manastorm" || manastorm.status !== "awaiting-loot") return false;
const bossId = manastorm.currentEncounter?.bossRuntimeId;
if (!bossId) return false;
if (requestOnlineInteraction("loot", bossId)) return true;
return useCombatStore.getState().lootMob(bossId, now);
}
+102
View File
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, registerMobRuntime } from "./mobRuntimeRegistry";
import {
configureOnlineSessionRuntime,
onlinePlayerActorId,
} from "./onlineSessionRuntime";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
} from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
function remotePlayer(health = 1): OnlineSessionPlayer {
return {
accountId: "remote",
username: "Remote",
character: {
id: "remote-character",
name: "Bulwarka",
classId: "warrior",
categoryId: "wow",
raceId: "human",
gender: "female",
level: 20,
},
role: "tank",
position: [8, 0, 0],
yaw: 0,
grounded: true,
health,
maxHealth: 1_000,
resource: 0,
maxResource: 100,
resourceName: "Rage",
selectedTargetId: null,
activeCast: null,
animationEvent: null,
equipment: [],
updatedAt: 1,
};
}
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 20 });
useCombatStore.getState().resetEncounter();
usePartyStore.setState({ members: [], selectedMemberId: null, selfSelected: false });
});
afterEach(() => {
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
});
describe("shared online combat actors", () => {
it("casts a normal player heal on a remote online player", () => {
const player = remotePlayer();
const actorId = onlinePlayerActorId(player.accountId);
configureOnlineSessionRuntime({
active: true,
authority: true,
localAccountId: "local",
players: [player],
});
registerPartyRuntimePosition(actorId, player.position);
useCombatStore.getState().synchronizeActors();
expect(useCombatStore.getState().selectFriendlyActor(actorId)).toBe(true);
expect(useCombatStore.getState().castAbility("wow335-priest-lesser-heal", [0, 0, 0], 1_000).ok).toBe(true);
const completion = useCombatStore.getState().activeCast?.completesAt ?? 1_000;
useCombatStore.getState().tick(0.1, completion);
expect(useCombatStore.getState().actors[actorId]?.health).toBeGreaterThan(1);
expect(useCombatStore.getState().combatEvents).toContainEqual(
expect.objectContaining({ kind: "healing", targetActorId: actorId }),
);
});
it("lets the leader's enemies acquire a nearby remote player", () => {
const player = { ...remotePlayer(1_000), position: [1, 0, 0] as const };
const actorId = onlinePlayerActorId(player.accountId);
configureOnlineSessionRuntime({
active: true,
authority: true,
localAccountId: "local",
players: [player],
});
registerPartyRuntimePosition(actorId, player.position);
registerMobRuntime("shared-enemy", [0, 0, 0]);
useCombatStore.getState().setPlayerPosition([100, 0, 100]);
useCombatStore.getState().registerMob("shared-enemy", {
name: "Shared enemy",
maxHealth: 100,
aggroRange: 5,
leashRange: 20,
});
expect(useCombatStore.getState().engageNearbyMobs(1_000)).toBe(1);
expect(useCombatStore.getState().mobs["shared-enemy"].targetActorId).toBe(actorId);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it } from "vitest";
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
import {
configureOnlineSessionRuntime,
drainOnlineInteractions,
onlinePlayerActorId,
onlinePlayerRuntimeActor,
onlineSessionIsActive,
onlineSessionOwnsWorldSimulation,
onlineSynchronizedMobTransform,
requestOnlineInteraction,
} from "./onlineSessionRuntime";
const remotePlayer: OnlineSessionPlayer = {
accountId: "remote",
username: "Remote",
character: {
id: "remote-character",
name: "Mendara",
classId: "priest",
categoryId: "wow",
raceId: "human",
gender: "female",
level: 20,
},
role: "healer",
position: [4, 0, 6],
yaw: 1,
grounded: true,
health: 800,
maxHealth: 1_000,
resource: 400,
maxResource: 500,
resourceName: "Mana",
selectedTargetId: null,
activeCast: null,
animationEvent: null,
equipment: [],
updatedAt: 1,
};
afterEach(() => configureOnlineSessionRuntime({
active: false,
authority: true,
localAccountId: null,
}));
describe("online session runtime", () => {
it("exposes remote players as live combat actors", () => {
configureOnlineSessionRuntime({
active: true,
authority: true,
localAccountId: "local",
players: [remotePlayer],
});
const actor = onlinePlayerRuntimeActor(onlinePlayerActorId("remote"));
expect(actor).toMatchObject({ health: 800, maxHealth: 1_000, alive: true, kind: "companion" });
expect(onlineSessionIsActive()).toBe(true);
expect(onlineSessionOwnsWorldSimulation()).toBe(true);
});
it("uses leader transforms and queues follower interactions", () => {
configureOnlineSessionRuntime({
active: true,
authority: false,
localAccountId: "local",
players: [remotePlayer],
mobTransforms: { boss: { position: [7, 0, 9], yaw: 0.75 } },
});
expect(onlineSessionOwnsWorldSimulation()).toBe(false);
expect(onlineSynchronizedMobTransform("boss")?.position).toEqual([7, 0, 9]);
expect(requestOnlineInteraction("loot", "boss")).toBe(true);
expect(drainOnlineInteractions()).toEqual([
expect.objectContaining({ kind: "loot", targetActorId: "boss", sourceActorId: onlinePlayerActorId("local") }),
]);
expect(drainOnlineInteractions()).toEqual([]);
});
});
+137
View File
@@ -0,0 +1,137 @@
import type { ClassId } from "../app/characterCatalog";
import type {
OnlineMobTransform,
OnlineRelayEventInput,
OnlineSessionPlayer,
} from "../app/onlineSessionTypes";
import {
normalizeActorResourcePool,
type CombatActorState,
} from "./combatActors";
const ONLINE_PLAYER_PREFIX = "online-player:";
let active = false;
let authority = true;
let localAccountId: string | null = null;
let playerActors = new Map<string, CombatActorState>();
let players = new Map<string, OnlineSessionPlayer>();
let mobTransforms = new Map<string, OnlineMobTransform>();
let interactionSequence = 0;
let interactions: OnlineRelayEventInput[] = [];
export function onlinePlayerActorId(accountId: string): string {
return `${ONLINE_PLAYER_PREFIX}${accountId}`;
}
export function onlineAccountIdFromActor(actorId: string): string | null {
return actorId.startsWith(ONLINE_PLAYER_PREFIX)
? actorId.slice(ONLINE_PLAYER_PREFIX.length)
: null;
}
export function isOnlinePlayerActorId(actorId: string): boolean {
return actorId.startsWith(ONLINE_PLAYER_PREFIX);
}
export function configureOnlineSessionRuntime(input: {
readonly active: boolean;
readonly authority: boolean;
readonly localAccountId: string | null;
readonly players?: readonly OnlineSessionPlayer[];
readonly mobTransforms?: Readonly<Record<string, OnlineMobTransform>>;
}): void {
active = input.active;
authority = !input.active || input.authority;
localAccountId = input.localAccountId;
players = new Map((input.players ?? []).map((player) => [player.accountId, player]));
mobTransforms = new Map(Object.entries(input.mobTransforms ?? {}));
const nextActors = new Map<string, CombatActorState>();
for (const player of input.players ?? []) {
if (player.accountId === input.localAccountId || !player.character) continue;
const id = onlinePlayerActorId(player.accountId);
const maximum = Math.max(0, player.maxResource);
const resourceId = player.resourceName.toLowerCase() || "resource";
nextActors.set(id, {
id,
threatActorId: id,
kind: "companion",
ownerId: player.accountId,
classId: player.character.classId as ClassId,
level: player.character.level,
health: player.health,
maxHealth: player.maxHealth,
absorb: 0,
resources: maximum > 0
? { [resourceId]: normalizeActorResourcePool(resourceId, player.resource, maximum) }
: {},
cooldowns: {},
globalCooldownEndsAt: 0,
cast: player.activeCast ? {
abilityId: player.activeCast.abilityId,
school: player.activeCast.school,
mode: player.activeCast.mode,
startedAt: player.activeCast.startedAt,
completesAt: player.activeCast.completesAt,
originalDurationMs: player.activeCast.originalDurationMs,
pushbackHits: player.activeCast.pushbackHits,
origin: player.activeCast.origin,
targetId: player.activeCast.targetId,
} : null,
controlledUntil: 0,
schoolLockedUntil: {},
comboPoints: 0,
comboTargetId: null,
runes: [],
alive: player.health > 0,
});
}
playerActors = nextActors;
if (!input.active) interactions = [];
}
export function onlineSessionIsActive(): boolean {
return active;
}
export function onlineSessionOwnsWorldSimulation(): boolean {
return !active || authority;
}
export function onlineSessionLocalAccountId(): string | null {
return localAccountId;
}
export function onlinePlayerRuntimeActors(): readonly CombatActorState[] {
return [...playerActors.values()];
}
export function onlinePlayerRuntimeActor(actorId: string): CombatActorState | null {
return playerActors.get(actorId) ?? null;
}
export function onlineSessionPlayer(accountId: string): OnlineSessionPlayer | null {
return players.get(accountId) ?? null;
}
export function onlineSynchronizedMobTransform(id: string): OnlineMobTransform | null {
return active && !authority ? mobTransforms.get(id) ?? null : null;
}
export function requestOnlineInteraction(kind: "loot" | "portal", targetActorId = ""): boolean {
if (!active || authority) return false;
interactionSequence += 1;
interactions.push({
clientEventId: `interaction:${kind}:${Date.now()}:${interactionSequence}`,
kind,
sourceActorId: localAccountId ? onlinePlayerActorId(localAccountId) : "",
targetActorId,
occurredAt: Date.now(),
});
return true;
}
export function drainOnlineInteractions(): readonly OnlineRelayEventInput[] {
const pending = interactions;
interactions = [];
return pending;
}
+82
View File
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it } from "vitest";
import { abilitiesForClass, abilityAtLevel, resourceProfileForClass } from "./abilityCatalog";
import { aiHintsForAbility } from "./abilityRules";
import { useCombatStore } from "./combatStore";
import { commitPartyAbilityUse, choosePartyAbility, partyAbilityIsReady } from "./partyAbilityAi";
import { usePartyStore, type PartyMember } from "./partyStore";
function memberFor(classId: PartyMember["classId"], role: PartyMember["role"]): PartyMember {
const base = usePartyStore.getState().members[0];
const profile = resourceProfileForClass(classId);
return {
...base,
classId,
role,
level: 80,
resourceType: profile.type,
resourceName: profile.name,
resource: profile.maximum,
maxResource: profile.maximum,
resourcePools: { [profile.type]: profile.maximum },
cooldowns: {},
globalCooldownEndsAt: 0,
nextActionAt: 0,
tauntReadyAt: 0,
};
}
describe("catalog-driven party rotations", () => {
beforeEach(() => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
usePartyStore.getState().initializeParty("party-ai", 80, useCombatStore.getState().health);
useCombatStore.getState().registerMob("caster", { name: "Caster", maxHealth: 1_000, xpReward: 0 });
useCombatStore.setState((state) => ({
mobs: {
...state.mobs,
caster: {
...state.mobs.caster,
activeCast: {
attackId: "test-cast",
name: "Test Cast",
school: "shadow",
interruptible: true,
targetActorId: "__player__",
startedAt: 1_000,
completesAt: 5_000,
},
},
},
}));
});
it("prioritizes an interrupt over ordinary damage deterministically", () => {
const member = memberFor("rogue", "damage");
const context = { now: 2_000, target: useCombatStore.getState().mobs.caster };
const first = choosePartyAbility(member, context);
const second = choosePartyAbility(member, context);
expect(first?.id).toBe(second?.id);
expect(first && aiHintsForAbility(first).categories).toContain("interrupt");
});
it("selects a real healer catalog spell and spends its resource/GCD", () => {
const member = memberFor("priest", "healer");
const ability = choosePartyAbility(member, {
now: 2_000,
target: null,
injuredAllies: 1,
lowestHealthFraction: 0.2,
});
expect(ability && aiHintsForAbility(ability).categories).toContain("heal");
const committed = commitPartyAbilityUse(member, ability!, 2_000)!;
expect(committed.resource).toBeLessThan(member.resource);
expect(committed.globalCooldownEndsAt).toBeGreaterThan(2_000);
});
it("will not cast a mana spell after mana exhaustion", () => {
const member = { ...memberFor("priest", "healer"), resource: 0, resourcePools: { mana: 0 } };
const heal = abilitiesForClass("priest")
.map((ability) => abilityAtLevel(ability, 80))
.find((ability) => aiHintsForAbility(ability).categories.includes("heal") && ability.cost.amount > 0)!;
expect(partyAbilityIsReady(member, heal, 2_000)).toBe(false);
});
});
+150
View File
@@ -0,0 +1,150 @@
import {
abilitiesForClass,
abilityAtLevel,
isAbilityUnlocked,
type AbilityDefinition,
type AbilityEffect,
type ResourceType,
} from "./abilityCatalog";
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
import { spendDeathKnightRunes } from "./combatResources";
import type { MobCombatState } from "./combatStore";
import type { PartyMember } from "./partyStore";
export interface PartyAbilityContext {
readonly now: number;
readonly target: MobCombatState | null;
readonly targetIsLoose?: boolean;
readonly requiredControl?: boolean;
readonly allyNeedsDispel?: boolean;
readonly fallenAlly?: boolean;
readonly injuredAllies?: number;
readonly lowestHealthFraction?: number;
}
function currentResource(member: PartyMember, type: ResourceType): number {
return member.resourcePools[type] ?? (type === member.resourceType ? member.resource : 0);
}
function abilityCosts(ability: AbilityDefinition): readonly { resource: ResourceType; amount: number }[] {
return (ability.costs?.length ? ability.costs : [ability.cost]).map((cost) => ({
resource: cost.resource,
amount: cost.percentage ? cost.amount / 100 : cost.amount,
}));
}
export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinition, now: number): boolean {
if (ability.passive || !isAbilityUnlocked(ability, member.level)) return false;
if (member.globalCooldownEndsAt > now || (member.cooldowns[ability.id] ?? 0) > now) return false;
if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
if (abilityCosts(ability).some((cost) => {
const amount = cost.amount <= 1 && ability.cost.percentage
? (cost.resource === member.resourceType ? member.maxResource : 100) * cost.amount
: cost.amount;
return currentResource(member, cost.resource) < amount;
})) return false;
const runeCost = member.classId === "death-knight" ? runeCostForAbility(ability) : null;
return !runeCost || spendDeathKnightRunes(member.runes, runeCost, now) !== null;
}
function candidateAbilities(member: PartyMember, now: number): readonly AbilityDefinition[] {
return abilitiesForClass(member.classId)
.filter((ability) => partyAbilityIsReady(member, abilityAtLevel(ability, member.level), now))
.map((ability) => abilityAtLevel(ability, member.level));
}
function scoreAbility(member: PartyMember, ability: AbilityDefinition, context: PartyAbilityContext): number {
const categories = new Set(aiHintsForAbility(ability).categories);
let score = ability.ai?.priority ?? 0;
if (member.role === "tank") {
if (context.target?.activeCast && categories.has("interrupt")) score += 1_000;
if (context.targetIsLoose && categories.has("taunt")) score += 900;
if (categories.has("mitigation") && member.health / member.maxHealth < 0.55) score += 760;
if (categories.has("control") && context.requiredControl) score += 700;
if (categories.has("debuff")) score += 300;
if (categories.has("damage")) score += 180;
} else if (member.role === "healer") {
if (context.fallenAlly && categories.has("resurrection")) score += 1_200;
if (context.allyNeedsDispel && categories.has("dispel")) score += 1_050;
if ((context.lowestHealthFraction ?? 1) < 0.3 && categories.has("emergency-heal")) score += 950;
if ((context.injuredAllies ?? 0) >= 3 && categories.has("area-heal")) score += 850;
if ((context.lowestHealthFraction ?? 1) < 0.9 && categories.has("heal")) score += 600;
if (categories.has("mitigation")) score += 350;
if (context.target?.activeCast && categories.has("interrupt")) score += 250;
if (categories.has("damage")) score += 80;
} else {
if (context.target?.activeCast && categories.has("interrupt")) score += 1_000;
if (context.requiredControl && categories.has("control")) score += 900;
if ((context.target?.health ?? 1) / Math.max(1, context.target?.maxHealth ?? 1) <= 0.2 && categories.has("execute")) score += 800;
if (categories.has("debuff")) score += 520;
if (categories.has("damage")) score += 300;
}
if (ability.castTimeMs > 0 && context.target?.activeCast && categories.has("interrupt")) score -= 800;
return score;
}
export function choosePartyAbility(
member: PartyMember,
context: PartyAbilityContext,
): AbilityDefinition | null {
return candidateAbilities(member, context.now)
.map((ability) => ({ ability, score: scoreAbility(member, ability, context) }))
.filter((candidate) => candidate.score > 0)
.sort((left, right) => right.score - left.score
|| left.ability.cooldownMs - right.ability.cooldownMs
|| left.ability.id.localeCompare(right.ability.id))[0]?.ability ?? null;
}
export function commitPartyAbilityUse(
member: PartyMember,
ability: AbilityDefinition,
now: number,
): PartyMember | null {
if (!partyAbilityIsReady(member, ability, now)) return null;
const resourcePools = { ...member.resourcePools };
const lastResourceSpentAt = { ...member.lastResourceSpentAt };
let resource = member.resource;
for (const cost of abilityCosts(ability)) {
const maximum = cost.resource === member.resourceType ? member.maxResource : 100;
const amount = cost.amount <= 1 && ability.cost.percentage ? maximum * cost.amount : cost.amount;
const current = currentResource(member, cost.resource);
resourcePools[cost.resource] = Math.max(0, current - amount);
if (amount > 0) lastResourceSpentAt[cost.resource] = now;
if (cost.resource === member.resourceType) resource = resourcePools[cost.resource] ?? resource;
}
const runeCost = member.classId === "death-knight" ? runeCostForAbility(ability) : null;
const runes = runeCost ? spendDeathKnightRunes(member.runes, runeCost, now) : member.runes;
if (!runes) return null;
return {
...member,
resource,
resourcePools,
lastResourceSpentAt,
runes,
cooldowns: ability.cooldownMs > 0
? { ...member.cooldowns, [ability.id]: now + ability.cooldownMs }
: member.cooldowns,
globalCooldownEndsAt: now + ability.gcdMs,
tauntReadyAt: aiHintsForAbility(ability).categories.includes("taunt")
? now + Math.max(8_000, ability.cooldownMs)
: member.tauntReadyAt,
};
}
export function partyAbilityAmount(
member: PartyMember,
ability: AbilityDefinition,
effect: AbilityEffect,
): number {
const coefficient = "coefficient" in effect
? effect.coefficient
: effect.kind === "finisher-damage"
? effect.baseCoefficient + effect.perComboCoefficient * 5
: 0;
const power = member.level * 4
+ member.gearStats.spellPower
+ member.gearStats.attackPower * 0.35
+ member.gearStats.rangedAttackPower * 0.35;
return Math.max(1, Math.round(Math.max(0.1, coefficient) * Math.max(8, power)));
}
+727 -133
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -108,6 +108,11 @@ export function updatePartyRuntimePosition(
}
}
export function removePartyRuntimePosition(id: string): void {
positions.delete(id);
targetVisibility.delete(id);
}
export function getPartyRuntimePosition(id: string): PartyWorldPosition | null {
const position = positions.get(id);
return position ? [...position] as MutablePartyWorldPosition : null;
+62
View File
@@ -29,6 +29,8 @@ import type { RuntimeTalentRanks as TalentRanks } from "./talentRuntimeCatalog";
import type { EnemyControlMechanic } from "./enemyAttacks";
import type { ContentCategoryId } from "../app/contentCategories";
import type { PartyRole, PartySize } from "./partyRoles";
import { resourceProfileForClass, type ResourceType } from "./abilityCatalog";
import { createDeathKnightRunes, type CombatActorCastState, type DeathKnightRuneState } from "./combatActors";
export {
classCanFillPartyRole,
@@ -56,6 +58,18 @@ export interface PartyMember {
readonly level: number;
readonly health: number;
readonly maxHealth: number;
readonly resourceType: ResourceType;
readonly resourceName: string;
readonly resource: number;
readonly maxResource: number;
readonly resourcePools: Readonly<Partial<Record<ResourceType, number>>>;
readonly lastResourceSpentAt: Readonly<Partial<Record<ResourceType, number>>>;
readonly cooldowns: Readonly<Record<string, number>>;
readonly globalCooldownEndsAt: number;
readonly activeCast: CombatActorCastState | null;
readonly activeCastTargetId: string | null;
readonly schoolLockedUntil: Readonly<Partial<Record<DamageSchool, number>>>;
readonly runes: readonly DeathKnightRuneState[];
readonly gearStats: ItemStats;
readonly status: PartyMemberStatus;
readonly nextActionAt: number;
@@ -214,6 +228,9 @@ function createMember(
const gender: GenderId = genders[Math.floor(nextRandom(identityRandom) * genders.length)] ?? genders[0] ?? "male";
const appearance = randomAppearance(race.id, gender, () => nextRandom(identityRandom));
const maxHealth = memberMaxHealth(level, role, EMPTY_ITEM_STATS, template.classId, race.id);
const resourceProfile = resourceProfileForClass(template.classId);
const maxResource = resourceProfile.maximum;
const resource = resourceProfile.initial;
return {
id: `party-${role}-${index}-${template.classId}`,
name: template.name,
@@ -230,6 +247,18 @@ function createMember(
level: Math.max(1, Math.trunc(level)),
health: maxHealth,
maxHealth,
resourceType: resourceProfile.type,
resourceName: resourceProfile.name,
resource,
maxResource,
resourcePools: Object.freeze({ [resourceProfile.type]: resource }),
lastResourceSpentAt: Object.freeze({}),
cooldowns: Object.freeze({}),
globalCooldownEndsAt: 0,
activeCast: null,
activeCastTargetId: null,
schoolLockedUntil: Object.freeze({}),
runes: template.classId === "death-knight" ? createDeathKnightRunes() : Object.freeze([]),
gearStats: EMPTY_ITEM_STATS,
status: "ready",
nextActionAt: 0,
@@ -321,6 +350,27 @@ export interface PartyState {
cycleMember: (direction?: 1 | -1) => string | null;
clearSelection: () => void;
healMember: (id: string, amount: number) => number;
updateMemberCombat: (
id: string,
patch: Partial<Pick<PartyMember,
| "resource"
| "maxResource"
| "resourcePools"
| "lastResourceSpentAt"
| "cooldowns"
| "globalCooldownEndsAt"
| "activeCast"
| "activeCastTargetId"
| "schoolLockedUntil"
| "runes"
| "health"
| "maxHealth"
| "controlledUntil"
| "controlMechanic"
| "nextActionAt"
| "status"
>>,
) => boolean;
reviveMember: (id: string, percentMaxHealth?: number) => number;
damageMember: (id: string, amount: number, school?: DamageSchool, attackerLevel?: number) => number;
/** Applies damage that has already passed a discrete combat-result roll. */
@@ -479,6 +529,18 @@ export const usePartyStore = create<PartyState>((set, get) => ({
return healed;
},
updateMemberCombat: (id, patch) => {
let updated = false;
set((state) => ({
members: state.members.map((member) => {
if (member.id !== id) return member;
updated = true;
return { ...member, ...patch };
}),
}));
return updated;
},
reviveMember: (id, percentMaxHealth = 0.35) => {
let restored = 0;
set((state) => ({
+276 -28
View File
@@ -12,6 +12,7 @@ import {
translateRomAbilitySemantics,
type RomAbilitySemantic,
type RomGeneratedAbilityLike,
type RomSourceComponent,
} from "./romAbilitySemantics";
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
@@ -71,9 +72,9 @@ interface GeneratedSkill {
const generated = rawCatalog.skills as unknown as readonly GeneratedSkill[];
function resource(value: string): ResourceType {
return ["mana", "rage", "energy", "runic-power", "focus", "health"].includes(value)
return ["mana", "rage", "energy", "runic-power", "focus", "nature-power", "psi", "health"].includes(value)
? value as ResourceType
: "mana";
: `custom:${slug(value)}`;
}
function costs(rows: readonly GeneratedCost[]): readonly AbilityCost[] {
@@ -109,10 +110,10 @@ function knownResource(value: string): ResourceType | undefined {
if (value === "mp") return "mana";
if (value === "nature's power") return "nature-power";
if (value === "psi point" || value === "psi points") return "psi";
if (value === "soul point" || value === "soul points") return "focus";
if (value === "soul point" || value === "soul points") return "soul-power";
return ["mana", "rage", "energy", "runic-power", "focus", "nature-power", "psi", "health"].includes(value)
? value as ResourceType
: undefined;
: value ? `custom:${slug(value)}` : undefined;
}
function auraRecipient(row: GeneratedSemanticRow, semantic: Extract<RomAbilitySemantic, { kind: "aura" | "absorb" | "proc" }>): "ability-target" | "caster" {
@@ -123,36 +124,119 @@ function auraRecipient(row: GeneratedSemanticRow, semantic: Extract<RomAbilitySe
return "ability-target";
}
function auraModifier(semantic: Extract<RomAbilitySemantic, { kind: "aura" }>): AuraModifier | null {
const MAGIC_SCHOOLS: readonly DamageSchool[] = ["arcane", "fire", "frost", "holy", "nature", "shadow"];
function percent(value: number): number {
return value / 100;
}
function magicDamageTaken(value: number): readonly AuraModifier[] {
return MAGIC_SCHOOLS.map((school) => ({
kind: "damage" as const,
direction: "taken" as const,
school,
operation: "percent" as const,
value,
}));
}
function auraModifiers(semantic: Extract<RomAbilitySemantic, { kind: "aura" }>): readonly AuraModifier[] {
const value = semantic.value ?? 0;
if (semantic.effect === "damage-immunity") {
return { kind: "damage", direction: "taken", operation: "percent", value: -100 };
return [{ kind: "damage", direction: "taken", operation: "percent", value: -1 }];
}
if (semantic.effect === "physical-damage-immunity") {
return { kind: "damage", direction: "taken", school: "physical", operation: "percent", value: -100 };
return [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: -1 }];
}
if (semantic.effect === "magic-damage-immunity") {
return { kind: "stat", stat: "magic-damage-immunity", operation: "flat", value: 1 };
return magicDamageTaken(-1);
}
if (semantic.effect === "poison-immunity") {
return { kind: "stat", stat: "poison-immunity", operation: "flat", value: 1 };
return [{ kind: "stat", stat: "poison-immunity", operation: "flat", value: 1 }];
}
if (semantic.effect === "maximum-mana") {
return { kind: "resource", resource: "mana", property: "maximum", operation: "flat", value };
return [{ kind: "resource", resource: "mana", property: "maximum", operation: "flat", value }];
}
if (semantic.effect === "mana-regeneration") {
return { kind: "resource", resource: "mana", property: "regeneration", operation: "flat", value };
return [{ kind: "resource", resource: "mana", property: "regeneration", operation: "flat", value }];
}
if (semantic.effect === "health-regeneration") {
return { kind: "resource", resource: "health", property: "regeneration", operation: "flat", value };
return [{ kind: "stat", stat: "health-regeneration", operation: "flat", value }];
}
if (["physical-attack", "magical-attack", "weapon-damage"].includes(semantic.effect)) {
return { kind: "stat", stat: semantic.effect, operation: "flat", value };
const type = semantic.modifierType;
if (type === null) return semantic.value === null
? []
: [{ kind: "stat", stat: semantic.effect, operation: "flat", value }];
if (type >= 2 && type <= 22) {
return [{ kind: "stat", stat: semantic.effect, operation: "flat", value }];
}
if (semantic.modifierType !== null || semantic.value !== null) {
return { kind: "stat", stat: semantic.effect, operation: "flat", value };
if (type === 23) return [{ kind: "stat", stat: "attack-speed", operation: "flat", value: -value }];
if (type === 24) return [{ kind: "stat", stat: "movement-speed", operation: "percent", value: percent(value) }];
if (type === 25) return [
{ kind: "damage", direction: "dealt", attackKind: "melee", operation: "flat", value },
{ kind: "damage", direction: "dealt", attackKind: "ranged", operation: "flat", value },
];
if (type >= 26 && type <= 32) {
const school = ({ 27: "nature", 28: "frost", 29: "fire", 30: "nature", 31: "holy", 32: "shadow" } as Partial<Record<number, DamageSchool>>)[type];
return [{ kind: "stat", stat: school ? `resistance:${school}` : "all-resistance", operation: "flat", value }];
}
return null;
if (type === 33) return [{ kind: "resource", resource: "mana", property: "cost", operation: "percent", value: -percent(value) }];
if (type === 37 || (type >= 56 && type <= 67) || type === 173) {
return [{ kind: "damage", direction: "dealt", attackKind: "melee", operation: "percent", value: percent(value) }];
}
if (type >= 38 && type <= 43) return [{ kind: "stat", stat: "armor", operation: "percent", value: percent(value) }];
if (type === 44) return [{ kind: "damage", direction: "dealt", attackKind: "spell", operation: "percent", value: percent(value) }];
if (type >= 45 && type <= 50) {
const school = (["nature", "frost", "fire", "nature", "holy", "shadow"] as const)[type - 45];
return [{ kind: "damage", direction: "dealt", school, operation: "percent", value: percent(value) }];
}
if (type === 51) return [{ kind: "stat", stat: "spell-haste", operation: "flat", value: -value }];
if (type >= 52 && type <= 55) return [{ kind: "damage", direction: "dealt", attackKind: "ranged", operation: "percent", value: percent(value) }];
if (type >= 68 && type <= 71) return [{ kind: "stat", stat: "ranged-haste", operation: "flat", value: -value }];
if (type >= 72 && type <= 83) return [{ kind: "stat", stat: "melee-haste", operation: "flat", value: -value }];
if (type === 134) return [{ kind: "stat", stat: "attack-power", operation: "percent", value: percent(value) }];
if (type === 135) return [{ kind: "stat", stat: "armor", operation: "percent", value: percent(value) }];
if (type === 138) return [{ kind: "stat", stat: "threat", operation: "percent", value: percent(value) }];
if (type >= 139 && type <= 141) {
const resource = (["rage", "focus", "energy"] as const)[type - 139];
return [{ kind: "resource", resource, property: "regeneration", operation: "percent", value: percent(value) }];
}
if (type === 142) return magicDamageTaken(-percent(value));
if (type === 143) return [{ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: -percent(value) }];
if (type === 144) return [{ kind: "healing", direction: "taken", operation: "percent", value: -percent(value) }];
if (type === 148) return [{ kind: "damage", direction: "dealt", attackKind: "spell", operation: "flat", value }];
if (type === 149) return [{ kind: "healing", direction: "done", operation: "percent", value: percent(value) }];
if (type === 150) return [{ kind: "healing", direction: "done", operation: "flat", value }];
if (type >= 161 && type <= 166) {
const stat = (["strength", "stamina", "intellect", "wisdom", "dexterity", "all-primary-attributes"] as const)[type - 161];
return [{ kind: "stat", stat, operation: "percent", value: percent(value) }];
}
if (type === 167) return [{ kind: "stat", stat: "maximum-health", operation: "percent", value: percent(value) }];
if (type === 168) return [{ kind: "resource", resource: "mana", property: "maximum", operation: "percent", value: percent(value) }];
if (type === 170) return [{ kind: "stat", stat: "magical-defense", operation: "percent", value: percent(value) }];
if (type === 171) return [{ kind: "stat", stat: "magical-attack", operation: "percent", value: percent(value) }];
if (type === 184 || type === 185 || type === 186 || type === 187 || type === 188 || type === 189) {
const school = (["nature", "frost", "fire", "nature", "holy", "shadow"] as const)[type - 184];
return [{ kind: "damage", direction: "dealt", school, operation: "flat", value }];
}
if (type === 191) return [{ kind: "damage", direction: "dealt", attackKind: "spell", operation: "flat", value }];
if (type === 192 || type === 208) return [{ kind: "damage", direction: "dealt", attackKind: "spell", operation: "percent", value: percent(value) }];
if (type === 195 || type === 199) return [{ kind: "stat", stat: "magical-hit", operation: "flat", value }];
if (type === 197) return [{ kind: "stat", stat: "physical-hit", operation: "flat", value }];
if (type === 198) return [{ kind: "stat", stat: "dodge", operation: "flat", value }];
if (type === 204 || type === 205) return [{ kind: "damage", direction: "dealt", operation: "percent", value: percent(value) }];
if (type === 206 || type === 207 || type === 209) return [{ kind: "damage", direction: "taken", operation: "percent", value: percent(value) }];
if (type >= 214 && type <= 216) {
const resource = (["rage", "energy", "focus"] as const)[type - 214];
return [{ kind: "resource", resource, property: "cost", operation: "percent", value: -percent(value) }];
}
if (type >= 220 && type <= 226) {
const school = ({ 221: "nature", 222: "frost", 223: "fire", 224: "nature", 225: "holy", 226: "shadow" } as Partial<Record<number, DamageSchool>>)[type];
return [{ kind: "stat", stat: school ? `resistance:${school}` : "all-resistance", operation: "percent", value: percent(value) }];
}
if (type === 227) return [{ kind: "stat", stat: "parry", operation: "percent", value: percent(value) }];
return [{ kind: "stat", stat: semantic.effect, operation: "flat", value }];
}
function controlKind(effect: string): Extract<AbilityEffect, { kind: "apply-aura" }>["control"] {
@@ -173,11 +257,30 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
const componentKey = semantic.sourceComponentIds.join("-") || "tooltip";
const effectId = `${row.sourceSkillId}-${componentKey}-${index}`;
if (semantic.kind === "damage" || semantic.kind === "heal") {
const rawPower = Math.max(0, semantic.power);
const rawFixed = Math.max(0, semantic.fixedValue);
const coefficientCalculation = semantic.calculationType === null
? rawPower > 0 && rawPower <= 4
: [0, 2, 3, 10].includes(semantic.calculationType);
const powerCoefficient = coefficientCalculation ? rawPower : 0;
const fixedCoefficient = semantic.fixedType > 0 ? rawFixed : 0;
const coefficientBase = powerCoefficient + fixedCoefficient;
const coefficientPerLevel = powerCoefficient * Math.max(0, semantic.powerPerSkillLevel) / 100
+ fixedCoefficient * Math.max(0, semantic.fixedPerSkillLevel) / 100;
const coefficient = coefficientBase + coefficientPerLevel;
const sourcePoints = coefficientCalculation ? 0 : rawPower;
const pointsPerLevel = sourcePoints * Math.max(0, semantic.powerPerSkillLevel) / 100;
const basePoints = sourcePoints + pointsPerLevel;
return {
kind: semantic.kind,
coefficient: Math.max(0, semantic.power),
basePoints: Math.max(0, semantic.fixedValue),
pointsPerLevel: Math.max(0, semantic.fixedPerSkillLevel),
coefficient,
...(coefficientPerLevel > 0 ? { coefficientPerLevel, sourceLevel: 1 } : {}),
...(basePoints > 0 ? {
basePoints,
pointsPerLevel,
sourceLevel: 1,
...(coefficient > 0 ? { bonusPowerCoefficient: coefficient } : {}),
} : {}),
...(semantic.kind === "heal" && semantic.percentMaxHealth !== undefined
? { percentMaxHealth: semantic.percentMaxHealth }
: {}),
@@ -189,8 +292,9 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
return {
kind: semantic.kind,
coefficient: 0,
basePoints: Math.max(0, semantic.basePerTick),
pointsPerLevel: Math.max(0, semantic.perSkillLevel),
basePoints: Math.max(0, semantic.basePerTick) * (1 + Math.max(0, semantic.perSkillLevel) / 100),
pointsPerLevel: Math.max(0, semantic.basePerTick) * Math.max(0, semantic.perSkillLevel) / 100,
sourceLevel: 1,
ticks,
intervalMs,
...(semantic.kind === "dot" ? { tag: `rom-${effectId}` } : {}),
@@ -214,24 +318,30 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
id: `rom-control-${effectId}-${slug(semantic.effect)}`,
name: `${row.name}: ${semantic.effect}`,
disposition: "debuff",
durationMs: durationMs(semantic.durationSeconds),
durationMs: Math.max(1_000, durationMs(semantic.durationSeconds)),
dispelCategory: "magic",
};
return { kind: "apply-aura", aura, control, sourceEffectType: -1, sourceAuraType: -1, miscValue: 0, triggerSpellId: 0 };
}
if (semantic.kind === "interrupt") return { kind: "interrupt", lockoutMs: durationMs(semantic.lockoutSeconds || 4) };
if (semantic.kind === "interrupt") return { kind: "interrupt", lockoutMs: Math.max(1_000, durationMs(semantic.lockoutSeconds || 4)) };
if (semantic.kind === "aura") {
const recipient = auraRecipient(row, semantic);
const modifier = auraModifier(semantic);
const modifiers = auraModifiers(semantic);
const partyScope = /\b(?:all )?party members?\b/i.test(row.description);
const aura: AuraDefinition = {
id: `rom-aura-${effectId}-${slug(semantic.effect)}`,
name: `${row.name}: ${semantic.effect}`,
disposition: recipient === "caster" || row.target !== "hostile" ? "buff" : "debuff",
durationMs: semantic.durationSeconds > 0 ? durationMs(semantic.durationSeconds) : null,
dispelCategory: "magic",
...(modifier ? { modifiers: [modifier] } : {}),
...(modifiers.length ? { modifiers } : {}),
...(semantic.effect === "poison-immunity"
? { utilityTags: ["poison-immunity"] }
: semantic.effect === "stealth"
? { utilityTags: ["stealth"] }
: {}),
};
return { kind: "apply-aura", aura, recipient, sourceEffectType: -1, sourceAuraType: semantic.modifierType ?? -1, miscValue: semantic.value ?? 0, triggerSpellId: 0 };
return { kind: "apply-aura", aura, recipient, ...(partyScope ? { scope: "party" as const } : {}), sourceEffectType: -1, sourceAuraType: semantic.modifierType ?? -1, miscValue: semantic.value ?? 0, triggerSpellId: 0 };
}
if (semantic.kind === "absorb") {
if (semantic.amount <= 0 && semantic.perSkillLevel <= 0) {
@@ -260,6 +370,7 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
id: `rom-proc-trigger-${effectId}`,
triggers: procTrigger(semantic.trigger),
chance,
...(semantic.intervalSeconds && semantic.intervalSeconds > 0 ? { internalCooldownMs: durationMs(semantic.intervalSeconds) } : {}),
action: { kind: "custom", id: "rom-trigger-skill", data: { spellId: semantic.triggerSkillId, rank: semantic.rank ?? 0 } },
}],
};
@@ -267,6 +378,23 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
}
if (semantic.kind === "resource") {
const resourceType = knownResource(semantic.resource);
if (semantic.intervalSeconds && semantic.intervalSeconds > 0 && resourceType) {
const amountPerSecond = semantic.amount / semantic.intervalSeconds;
const aura: AuraDefinition = {
id: `rom-resource-over-time-${effectId}`,
name: row.name,
disposition: semantic.operation === "drain" ? "debuff" : "buff",
durationMs: semantic.durationSeconds && semantic.durationSeconds > 0 ? durationMs(semantic.durationSeconds) : null,
modifiers: [{
kind: "resource",
resource: resourceType,
property: "regeneration",
operation: "flat",
value: semantic.operation === "drain" ? -amountPerSecond : amountPerSecond,
}],
};
return { kind: "apply-aura", aura, recipient: row.target === "hostile" ? "ability-target" : "caster", sourceEffectType: -1, sourceAuraType: -1, miscValue: semantic.amount, triggerSpellId: 0 };
}
if (semantic.operation === "drain") return {
kind: "resource-drain",
amount: semantic.amount,
@@ -288,13 +416,97 @@ function semanticEffect(row: GeneratedSemanticRow, semantic: RomAbilitySemantic,
if (semantic.kind === "trigger-spell") return { kind: "trigger-spell", spellId: semantic.spellId };
if (semantic.kind === "pet") return { kind: "pet", action: semantic.action };
if (semantic.kind === "custom") {
if (semantic.mechanicId === "threat-reset") {
return { kind: "threat", mode: "percent", amount: -100 };
}
return { kind: "scripted", effectType: -1, auraType: -1, miscValue: 0, triggerSpellId: 0, label: `RoM ${semantic.mechanicId}: ${semantic.summary}` };
}
return { kind: "scripted", effectType: -1, auraType: -1, miscValue: 0, triggerSpellId: 0, label: `RoM source mechanic` };
}
function stateAuraEffect(
row: GeneratedSemanticRow,
stateId: string,
name: string,
duration: number | null,
options: Pick<AuraDefinition, "maxStacks" | "stackBehavior" | "procs"> = {},
): AbilityEffect {
return {
kind: "apply-aura",
recipient: "caster",
aura: {
id: `rom-state-${stateId}`,
name,
disposition: "buff",
durationMs: duration,
utilityTags: [`rom-${stateId}`],
...options,
},
sourceEffectType: -1,
sourceAuraType: -1,
miscValue: 0,
triggerSpellId: row.sourceSkillId,
};
}
function specializedEffects(row: GeneratedSemanticRow): readonly AbilityEffect[] | null {
if (row.sourceSkillId === 491163) {
return [stateAuraEffect(row, "frost-arrow", "Frost Arrow", 600_000, {
procs: [{
id: "rom-frost-arrow-wind-arrow",
triggers: "hit",
chance: 0.5,
action: { kind: "custom", id: "rom-frost-arrow" },
}],
})];
}
if (row.sourceSkillId === 491171) {
return [stateAuraEffect(row, "static-field-charge", "Static Field Charge", null)];
}
if (row.sourceSkillId === 494038) {
return [stateAuraEffect(row, "entling-offering", "Entling Offering", 900_000, {
procs: [{
id: "rom-entling-offering-shot",
triggers: "hit",
chance: 1,
action: { kind: "custom", id: "rom-entling-offering" },
}],
})];
}
if (row.sourceSkillId === 495296) {
return [stateAuraEffect(row, "holy-salvation-candle", "Holy Salvation Candle", 20_000)];
}
if (row.sourceSkillId === 498571) {
return [stateAuraEffect(row, "remodeled-body", "Remodeled Body", 8_000)];
}
if (row.sourceSkillId === 499542) {
return [{
kind: "utility",
action: "class-script",
effectType: -1,
auraType: -1,
miscValue: 499542,
triggerSpellId: 623078,
durationMs: 0,
}];
}
return null;
}
function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
return translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index));
const specialized = specializedEffects(row);
if (specialized) return specialized;
const translated = translateRomAbilitySemantics(row).map((semantic, index) => semanticEffect(row, semantic, index));
if (row.sourceSkillId === 491169) {
return [...translated, stateAuraEffect(row, "charged", "Charged", 6_000)];
}
return translated;
}
function areaRadius(row: Pick<GeneratedSkill, "description" | "range">): number | undefined {
if (!/\b(?:multiple targets|all targets|surrounding (?:enemies|targets)|party members within|friendly targets within|targets within (?:a )?range)\b/i.test(row.description)) return undefined;
const sourceRange = row.description.match(/\b(?:range|radius) of\s+(\d+(?:\.\d+)?)/i)?.[1];
return Math.max(3, Math.min(40, sourceRange ? Number(sourceRange) / 10 : row.range || 8));
}
function rank(row: GeneratedRank, parent: GeneratedSkill): AbilityRankDefinition {
@@ -337,6 +549,7 @@ function ability(row: GeneratedSkill): AbilityDefinition {
description: row.description,
target: row.target,
range: { min: 0, max: row.range },
...(areaRadius(row) !== undefined ? { radius: areaRadius(row) } : {}),
castMode: row.castTimeMs > 0 ? "cast" : "instant",
castTimeMs: row.castTimeMs,
cooldownMs: row.cooldownMs,
@@ -354,6 +567,41 @@ function ability(row: GeneratedSkill): AbilityDefinition {
export const ROM_ALL_ABILITIES = Object.freeze(generated.map(ability));
const romAbilityByGeneratedId = new Map(ROM_ALL_ABILITIES.map((entry) => [entry.id, entry]));
const triggeredComponentOwners = new Map<number, { readonly parent: GeneratedSkill; readonly component: RomSourceComponent }>();
for (const parent of generated) {
const componentSets = [parent.source?.components ?? [], ...parent.ranks.map((entry) => entry.source?.components ?? [])];
for (const components of componentSets) for (const component of components) {
if (!triggeredComponentOwners.has(component.id)) triggeredComponentOwners.set(component.id, { parent, component });
}
}
/** Resolve source-triggered MagicObject rows that are not player-facing skills. */
export function romTriggeredAbilityBySpellId(spellId: number): AbilityDefinition | null {
const owner = triggeredComponentOwners.get(spellId);
if (!owner) return null;
const parentAbility = romAbilityByGeneratedId.get(owner.parent.id);
if (!parentAbility) return null;
const triggeredEffects = effects({
sourceSkillId: spellId,
name: `${owner.parent.name} trigger`,
description: "",
target: owner.parent.target,
passive: false,
source: { componentIds: [spellId], components: [owner.component] },
}).filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported");
if (!triggeredEffects.length) return null;
return {
...parentAbility,
id: `rom-triggered-${spellId}`,
dbcSpellId: spellId,
name: `${owner.parent.name} trigger`,
passive: false,
effects: triggeredEffects,
ranks: undefined,
};
}
export const ROM_ORDINARY_ABILITIES_BY_CLASS: Readonly<Record<RomClassId, readonly AbilityDefinition[]>> = Object.freeze(
Object.fromEntries((rawCatalog.canonicalClasses as readonly { id: RomClassId }[]).map(({ id }) => [
id,
+81 -8
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import type { AbilityEffect } from "./abilityCatalog";
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
import generated from "./romPlayerCatalog.generated.json";
@@ -8,6 +9,33 @@ function named(name: string) {
return ability;
}
function isMeaningfulEffect(effect: AbilityEffect): boolean {
if (effect.kind === "scripted" || effect.kind === "unsupported") return false;
if (["damage", "heal", "dot", "hot", "shield"].includes(effect.kind)) {
const output = effect as Extract<AbilityEffect, { kind: "damage" | "heal" | "dot" | "hot" | "shield" }>;
return (
output.coefficient > 0
|| (output.coefficientPerLevel ?? 0) > 0
|| (output.basePoints ?? 0) > 0
|| (output.pointsPerLevel ?? 0) > 0
|| (output.kind === "heal" && (output.percentMaxHealth ?? 0) > 0)
);
}
if (effect.kind === "apply-aura") {
return Boolean(
effect.control
|| effect.aura.modifiers?.length
|| effect.aura.absorbs?.length
|| effect.aura.procs?.length
|| effect.aura.utilityTags?.length
|| effect.aura.controlImmunities?.length
|| effect.aura.reflectSpellChance
|| effect.aura.redirectDamagePercent,
);
}
return true;
}
describe("executable RuneWaker ability mappings", () => {
it("maps Taunt, Shout, and Cleanse to their real combat mechanics", () => {
expect(named("Taunt").effects).toEqual(expect.arrayContaining([
@@ -31,7 +59,7 @@ describe("executable RuneWaker ability mappings", () => {
expect(holyAura).toMatchObject({
aura: {
disposition: "buff",
modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: -100 }],
modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: -1 }],
},
});
const electrostatic = named("Electrostatic Charge").effects.find((effect) => (
@@ -63,16 +91,61 @@ describe("executable RuneWaker ability mappings", () => {
expect(named("Spreading Pain").effects.filter((effect) => effect.kind === "trigger-spell")).toHaveLength(3);
});
it("leaves only genuinely stateful combo transformations as script-only active skills", () => {
it("gives every active base skill and playable rank a concrete runtime effect", () => {
const versions = ROM_ALL_ABILITIES.flatMap((ability) => [
{ name: ability.name, rank: 0, passive: ability.passive, effects: ability.effects },
...(ability.ranks ?? []).map((rank) => ({
name: ability.name,
rank: rank.rank,
passive: ability.passive,
effects: rank.effects,
})),
]);
expect(versions).toHaveLength(1_863);
const scriptOnly = ROM_ALL_ABILITIES
.filter((ability) => !ability.passive && ability.effects.every((effect) => effect.kind === "scripted"))
.map((ability) => ability.name)
.sort();
expect(scriptOnly).toEqual([
"Electric Compression",
"Entling Offering",
"Frost Arrow",
"Holy Salvation Candle",
]);
expect(scriptOnly).toEqual([]);
for (const version of versions.filter((entry) => !entry.passive)) {
expect(
version.effects.some(isMeaningfulEffect),
`${version.name} rank ${version.rank}`,
).toBe(true);
}
});
it("implements source state for RuneWaker's active transformations", () => {
expect(named("Frost Arrow").effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
utilityTags: ["rom-frost-arrow"],
procs: [expect.objectContaining({ action: { kind: "custom", id: "rom-frost-arrow" } })],
}),
}),
]));
expect(named("Electric Compression").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", aura: expect.objectContaining({ utilityTags: ["rom-static-field-charge"] }) }),
]));
expect(named("Entling Offering").effects).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: "apply-aura",
aura: expect.objectContaining({
utilityTags: ["rom-entling-offering"],
procs: [expect.objectContaining({ action: { kind: "custom", id: "rom-entling-offering" } })],
}),
}),
]));
expect(named("Holy Salvation Candle").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", aura: expect.objectContaining({ utilityTags: ["rom-holy-salvation-candle"] }) }),
]));
expect(named("Remodeled Body").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "apply-aura", aura: expect.objectContaining({ utilityTags: ["rom-remodeled-body"] }) }),
]));
expect(named("Revival").effects).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "utility", action: "class-script" }),
]));
});
});
+70 -3
View File
@@ -63,6 +63,7 @@ export interface RomSourceComponent {
};
readonly attack?: {
readonly type: number;
readonly calculationType?: number;
readonly damagePower: number;
readonly damagePowerSkillLevelArg: number;
readonly fixedValue: number;
@@ -121,6 +122,7 @@ export type RomAbilitySemantic =
readonly fixedValue: number;
readonly fixedType: number;
readonly fixedPerSkillLevel: number;
readonly calculationType: number | null;
readonly percentMaxHealth?: number;
})
| (RomSemanticBase & {
@@ -170,6 +172,7 @@ export type RomAbilitySemantic =
readonly operation: "restore" | "drain" | "modify";
readonly amount: number;
readonly intervalSeconds: number | null;
readonly durationSeconds?: number;
readonly percentage?: boolean;
})
| (RomSemanticBase & {
@@ -225,9 +228,9 @@ const MODIFIER_NAMES: Readonly<Record<number, string>> = Object.freeze({
15: "magical-attack",
16: "physical-hit",
17: "dodge",
18: "physical-critical-rate",
18: "physical-critical-strike",
19: "physical-critical-power",
20: "magical-critical-rate",
20: "magical-critical-strike",
21: "magical-critical-power",
22: "parry",
23: "attack-speed",
@@ -240,6 +243,66 @@ const MODIFIER_NAMES: Readonly<Record<number, string>> = Object.freeze({
30: "wind-resistance",
31: "light-resistance",
32: "dark-resistance",
33: "mana-cost",
37: "off-hand-damage",
44: "all-magic-damage",
45: "earth-damage",
46: "water-damage",
47: "fire-damage",
48: "wind-damage",
49: "light-damage",
50: "dark-damage",
51: "spell-speed",
52: "ranged-damage",
56: "melee-damage",
68: "ranged-attack-speed",
72: "melee-attack-speed",
134: "physical-attack-percent",
135: "physical-defense-percent",
138: "threat-percent",
139: "rage-regeneration-percent",
140: "focus-regeneration-percent",
141: "energy-regeneration-percent",
142: "magic-damage-absorption-percent",
143: "physical-damage-absorption-percent",
144: "healing-absorption-percent",
148: "magical-damage-points",
149: "healing-power-percent",
150: "healing-points",
161: "strength-percent",
162: "stamina-percent",
163: "intellect-percent",
164: "wisdom-percent",
165: "dexterity-percent",
166: "all-primary-attributes-percent",
167: "maximum-health-percent",
168: "maximum-mana-percent",
170: "magical-defense-percent",
171: "magical-attack-percent",
173: "weapon-damage-percent",
191: "magical-damage",
192: "magical-damage-percent",
195: "magical-hit",
197: "physical-hit-percent",
198: "physical-dodge-percent",
199: "magical-hit-percent",
204: "player-damage-percent",
205: "npc-damage-percent",
206: "player-damage-taken-percent",
207: "npc-damage-taken-percent",
208: "area-magic-damage-percent",
209: "area-magic-damage-taken-percent",
214: "rage-cost-percent",
215: "energy-cost-percent",
216: "focus-cost-percent",
220: "all-resistance-percent",
221: "earth-resistance-percent",
222: "water-resistance-percent",
223: "fire-resistance-percent",
224: "wind-resistance-percent",
225: "light-resistance-percent",
226: "dark-resistance-percent",
227: "parry-percent",
});
const MAGIC_SCHOOLS = ["earth", "water", "fire", "wind", "light", "dark"] as const;
@@ -481,6 +544,7 @@ function componentSemantics(component: RomSourceComponent, target: RomAbilityTar
operation: dot.base > 0 ? "restore" : "drain",
amount: Math.abs(dot.base),
intervalSeconds: dot.timeSeconds,
durationSeconds: positiveDuration(component),
...componentSource(component),
}, String(component.id));
}
@@ -498,6 +562,8 @@ function componentSemantics(component: RomSourceComponent, target: RomAbilityTar
fixedValue: Math.abs(attack.fixedValue),
fixedType: attack.fixedType,
fixedPerSkillLevel: Math.abs(attack.fixedDamageSkillLevelArg),
calculationType: attack.calculationType ?? null,
...(healing && attack.calculationType === 8 ? { percentMaxHealth: Math.abs(attack.damagePower) / 100 } : {}),
...componentSource(component),
}, String(component.id));
} else {
@@ -712,7 +778,7 @@ function tooltipSemantics(ability: RomGeneratedAbilityLike, rows: RomAbilitySema
});
}
if (/(?:deals?|inflicts?|causes?)\b[^.]*\b(?:physical|magical|earth|water|fire|wind|light|dark)?\s*damage/.test(lower) && !hasKind(rows, "damage")) {
rows.push({ kind: "damage", school: "tooltip-defined", power: 0, powerPerSkillLevel: 0, fixedValue: 0, fixedType: 0, fixedPerSkillLevel: 0, ...tooltipSource() });
rows.push({ kind: "damage", school: "tooltip-defined", power: 0, powerPerSkillLevel: 0, fixedValue: 0, fixedType: 0, fixedPerSkillLevel: 0, calculationType: null, ...tooltipSource() });
}
if (/(?:heals?|restores?|recovers?)\b[^.]*\b(?:hp|health)/.test(lower) && !hasKind(rows, "heal") && !hasKind(rows, "hot")) {
const percentMaxHealth = firstNumber(lower, /(\d+(?:\.\d+)?)%[^.]*?(?:hp|health)/);
@@ -724,6 +790,7 @@ function tooltipSemantics(ability: RomGeneratedAbilityLike, rows: RomAbilitySema
fixedValue: 0,
fixedType: 0,
fixedPerSkillLevel: 0,
calculationType: null,
...(percentMaxHealth === null ? {} : { percentMaxHealth: percentMaxHealth / 100 }),
...tooltipSource(),
});
+85
View File
@@ -79,6 +79,36 @@ describe("RuneWaker combat runtime", () => {
expect(useCombatStore.getState().castAbility(abilityId, undefined, 1_000)).toMatchObject({ ok: 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 });
setMobPosition("coefficient-dummy", [3, 0, 0]);
useCombatStore.getState().selectMob("coefficient-dummy");
useCombatStore.setState((state) => ({
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
}));
expect(useCombatStore.getState().castAbility("rom-warrior-primary-1", undefined, 1_000)).toMatchObject({ ok: true });
expect(useCombatStore.getState().mobs["coefficient-dummy"]!.health).toBeLessThan(1_000);
});
it("finishes a RuneWaker direct heal on the selected party member", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level: 20 });
usePartyStore.getState().initializeParty("rom-urgent-heal", 20, useCombatStore.getState().health, "healer", 2, "rom");
const member = usePartyStore.getState().members[0]!;
usePartyStore.getState().damageMember(member.id, 500);
registerPartyRuntimePosition(member.id, [3, 0, 0]);
usePartyStore.getState().selectMember(member.id);
const injuredHealth = usePartyStore.getState().members[0]!.health;
expect(useCombatStore.getState().castAbility("rom-priest-general-0", undefined, 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 2_100);
expect(useCombatStore.getState().activeCast).toBeNull();
expect(usePartyStore.getState().members[0]!.health).toBeGreaterThan(injuredHealth);
});
it("uses native RoM Rage gains from auto attacks and health damage", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-warrior",
@@ -183,4 +213,59 @@ describe("RuneWaker combat runtime", () => {
status: "ready",
});
});
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 });
setMobPosition("static-dummy", [3, 0, 0]);
useCombatStore.getState().selectMob("static-dummy");
expect(useCombatStore.getState().castAbility("rom-mage-primary-55", undefined, 1_000)).toMatchObject({
ok: false,
reason: "condition-not-met",
});
expect(useCombatStore.getState().castAbility("rom-mage-primary-52", undefined, 2_000)).toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 4_100);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-charged"))).toBe(true);
expect(useCombatStore.getState().castAbility("rom-mage-primary-55", undefined, 4_200)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-charged"))).toBe(false);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-static-field-charge"))).toBe(true);
expect(useCombatStore.getState().castAbility("rom-mage-primary-56", undefined, 5_800)).toMatchObject({ ok: true });
useCombatStore.getState().tick(0.1, 6_900);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-static-field-charge"))).toBe(false);
});
it("lets Remodeled Body prevent lethal damage once", () => {
useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 80 });
const now = Date.now();
expect(useCombatStore.getState().castAbility("rom-champion-primary-16", undefined, now)).toMatchObject({ ok: true });
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-remodeled-body"))).toBe(true);
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth * 10);
expect(useCombatStore.getState().health).toBe(useCombatStore.getState().maxHealth);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-remodeled-body"))).toBe(false);
});
it("builds and consumes Revival charges from damage taken", () => {
useCombatStore.getState().initializeCharacter({
classId: "rom-rogue",
secondaryClassId: "rom-warden",
raceId: "rom-elf",
level: 80,
});
useCombatStore.getState().damagePlayer(100);
useCombatStore.getState().damagePlayer(100);
useCombatStore.getState().damagePlayer(100);
const revival = useCombatStore.getState().auras.find((aura) => aura.definition.utilityTags?.includes("rom-revival-charge"));
expect(revival?.stacks).toBe(3);
const injuredHealth = useCombatStore.getState().health;
expect(useCombatStore.getState().castAbility("rom-rogue-rom-warden-elite-60", undefined, Date.now())).toMatchObject({ ok: true });
expect(useCombatStore.getState().health).toBeGreaterThan(injuredHealth);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.utilityTags?.includes("rom-revival-charge"))).toBe(false);
});
});
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"source":{"rootHint":"RuneWaker source snapshot","hashes":{"learnMagic":"6148199bcd1484ba1a122a6db5e2b3c2f2a161402bfe67fc67eb53f55fc6cfa3","magicCollect":"4aae19865cb07b30f99945aa7319a0096db244ff5e5b4b5d902fc6889474f501","magicObject":"52af13a2d8cfa9a66d4caa2d3c38832edc232f058995a64ef8135a6b82e43691","imageObject":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9","vocationTable":"f06cdaf60c54415dcbea47e9fa0e77b7527035a2aec73d3289370dc39751e76c","strings":"0c20d43660cb2ee042a78f7a8c18b4e104a263d54dd8c4bc98a54155e8adb3b0","eliteLua":"ee9e337548abd7fc555d85bf2f96813dfd77416c3e65d4cadf74a80838da1486","roleAttr":"8143a90ce826bf3152551a4508d212f186012cbc054e505e426551cb62369c1f","objectStruct":"708f2d087f2654b97d149ab7124d9cdffc915cf8bf18108feff3f1a45f124c20","interfaceFdb":"51bbb5a9d6a19a254b302000d3dd6e3a796f4f759015f3352f576a1055496e30"}},"canonicalClasses":[{"id":"rom-warrior","sourceClassId":1,"name":"Warrior","resource":"rage"},{"id":"rom-scout","sourceClassId":2,"name":"Scout","resource":"focus"},{"id":"rom-rogue","sourceClassId":3,"name":"Rogue","resource":"energy"},{"id":"rom-mage","sourceClassId":4,"name":"Mage","resource":"mana"},{"id":"rom-priest","sourceClassId":5,"name":"Priest","resource":"mana"},{"id":"rom-knight","sourceClassId":6,"name":"Knight","resource":"mana"},{"id":"rom-warden","sourceClassId":7,"name":"Warden","resource":"mana"},{"id":"rom-druid","sourceClassId":8,"name":"Druid","resource":"mana"},{"id":"rom-warlock","sourceClassId":9,"name":"Warlock","resource":"focus"},{"id":"rom-champion","sourceClassId":10,"name":"Champion","resource":"rage"}],"baseStatGrowth":{"rom-warrior":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"intellect":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-scout":{"strength":{"levelOne":11,"flatGrowth":3.85,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.65,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-rogue":{"strength":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"intellect":{"levelOne":7,"flatGrowth":2.6,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-mage":{"strength":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-priest":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-knight":{"strength":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"stamina":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGrowth":3.75,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-warden":{"strength":{"levelOne":9,"flatGrowth":3.25,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"wisdom":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-druid":{"strength":{"levelOne":12,"flatGrowth":4.35,"percentGrowth":0.02},"stamina":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGLine truncated
{"schemaVersion":1,"source":{"rootHint":"RuneWaker source snapshot","hashes":{"learnMagic":"6148199bcd1484ba1a122a6db5e2b3c2f2a161402bfe67fc67eb53f55fc6cfa3","magicCollect":"4aae19865cb07b30f99945aa7319a0096db244ff5e5b4b5d902fc6889474f501","magicObject":"52af13a2d8cfa9a66d4caa2d3c38832edc232f058995a64ef8135a6b82e43691","imageObject":"dd5ffcfd74b253dcecaa2f41a931a634dd45ef5f7c13e849d539ddc47fdee1f9","vocationTable":"f06cdaf60c54415dcbea47e9fa0e77b7527035a2aec73d3289370dc39751e76c","strings":"0c20d43660cb2ee042a78f7a8c18b4e104a263d54dd8c4bc98a54155e8adb3b0","eliteLua":"ee9e337548abd7fc555d85bf2f96813dfd77416c3e65d4cadf74a80838da1486","roleAttr":"8143a90ce826bf3152551a4508d212f186012cbc054e505e426551cb62369c1f","objectStruct":"708f2d087f2654b97d149ab7124d9cdffc915cf8bf18108feff3f1a45f124c20","interfaceFdb":"51bbb5a9d6a19a254b302000d3dd6e3a796f4f759015f3352f576a1055496e30"}},"canonicalClasses":[{"id":"rom-warrior","sourceClassId":1,"name":"Warrior","resource":"rage"},{"id":"rom-scout","sourceClassId":2,"name":"Scout","resource":"focus"},{"id":"rom-rogue","sourceClassId":3,"name":"Rogue","resource":"energy"},{"id":"rom-mage","sourceClassId":4,"name":"Mage","resource":"mana"},{"id":"rom-priest","sourceClassId":5,"name":"Priest","resource":"mana"},{"id":"rom-knight","sourceClassId":6,"name":"Knight","resource":"mana"},{"id":"rom-warden","sourceClassId":7,"name":"Warden","resource":"mana"},{"id":"rom-druid","sourceClassId":8,"name":"Druid","resource":"mana"},{"id":"rom-warlock","sourceClassId":9,"name":"Warlock","resource":"focus"},{"id":"rom-champion","sourceClassId":10,"name":"Champion","resource":"rage"}],"baseStatGrowth":{"rom-warrior":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"intellect":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-scout":{"strength":{"levelOne":11,"flatGrowth":3.85,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.65,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-rogue":{"strength":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"intellect":{"levelOne":7,"flatGrowth":2.6,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-mage":{"strength":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-priest":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-knight":{"strength":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"stamina":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGrowth":3.75,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-warden":{"strength":{"levelOne":9,"flatGrowth":3.25,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"wisdom":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-druid":{"strength":{"levelOne":12,"flatGrowth":4.35,"percentGrowth":0.02},"stamina":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGLine truncated
+23
View File
@@ -85,6 +85,29 @@ describe("generated RuneWaker player catalog", () => {
expect(abilityAtLevel(ranked, 80).dbcSpellId).toBe(eligible.spellId);
});
it("preserves every source rank and its full flat or percentage cost", () => {
const normalizedCosts = (rows: readonly { resource: string; amount: number; percentage?: boolean }[]) => rows.map((cost) => ({
resource: cost.resource,
amount: cost.amount,
...(cost.percentage ? { percentage: true } : {}),
}));
const generatedRanks = generated.skills.reduce((sum, ability) => sum + ability.ranks.length, 0);
expect(generatedRanks).toBe(934);
expect(Math.max(...generated.skills.flatMap((ability) => (
ability.costs.filter((cost) => !cost.percentage).map((cost) => cost.amount)
)))).toBe(400);
for (const source of generated.skills) {
const runtime = ROM_ALL_ABILITIES.find((ability) => ability.id === source.id)!;
expect(runtime.costs, `${source.name} base costs`).toEqual(normalizedCosts(source.costs));
expect(runtime.ranks).toHaveLength(source.ranks.length);
for (const [index, sourceRank] of source.ranks.entries()) {
expect(runtime.ranks?.[index]?.spellId, `${source.name} rank ${sourceRank.rank}`).toBe(sourceRank.sourceSkillId);
expect(runtime.ranks?.[index]?.costs, `${source.name} rank ${sourceRank.rank} costs`).toEqual(normalizedCosts(sourceRank.costs));
}
}
});
it("adds exactly ten percent of the secondary class base stats", () => {
const primary = romNativeClassBaseStats("rom-warrior", 40);
const secondary = romNativeClassBaseStats("rom-scout", 40);
+72 -17
View File
@@ -20,26 +20,59 @@ function effectsOfKind<K extends AbilityEffect["kind"]>(
}
describe("WoW 3.3.5 executable effect mapping", () => {
it("never reduces an imported DBC effect to generic utility", () => {
it("gives every imported classic rank executable or explicit utility semantics", () => {
const effects = Object.values(WOW335_ABILITIES_BY_CLASS)
.flat()
.flatMap((ability) => ability.ranks?.flatMap((rank) => rank.effects) ?? ability.effects);
expect(effects.length).toBeGreaterThan(1_000);
expect(effects.some((effect) => effect.kind === "utility")).toBe(false);
expect([
...new Set(effectsOfKind(
{ effects },
"scripted",
).map((effect) => effect.effectType)),
].sort((left, right) => left - right)).toEqual([3, 77]);
for (const effect of effectsOfKind(
{ effects },
"unsupported",
)) {
expect(effect.combat).toBe(false);
expect(effect.reason).toMatch(/^Noncombat DBC effect \d+$/);
expect(effectsOfKind({ effects }, "scripted")).toEqual([]);
expect(effectsOfKind({ effects }, "unsupported")).toEqual([]);
for (const effect of effectsOfKind({ effects }, "utility")) {
expect(effect.action).toMatch(/^(?:create-item|open-lock|portal|weapon-enchant|pickpocket|farsight|class-script)$/);
}
for (const ability of Object.values(WOW335_ABILITIES_BY_CLASS).flat()) {
if (ability.passive) continue;
for (const rank of ability.ranks ?? []) {
expect(rank.effects.length, `${ability.name} rank ${rank.rank}`).toBeGreaterThan(0);
expect(rank.effects.some((effect) => {
if (["trigger-spell", "scripted", "unsupported"].includes(effect.kind)) return false;
if (effect.kind !== "apply-aura") return true;
return Boolean(
effect.control
|| effect.aura.modifiers?.length
|| effect.aura.absorbs?.length
|| effect.aura.procs?.length
|| effect.aura.utilityTags?.length
|| effect.aura.controlImmunities?.length
|| effect.aura.reflectSpellChance
|| effect.aura.redirectDamagePercent,
);
}), `${ability.name} rank ${rank.rank}`).toBe(true);
}
}
});
it("does not promote triggered rank-zero child spells into player ranks", () => {
expect(byId("wow335-warrior-execute").ranks?.map((rank) => rank.spellId)).not.toContain(20647);
expect(byId("wow335-priest-divine-hymn").ranks?.map((rank) => rank.spellId)).toEqual([64843]);
expect(byId("wow335-death-knight-death-coil").ranks?.map((rank) => rank.spellId)).not.toContain(47632);
});
it("keeps percentage costs and form resources source-correct", () => {
expect(byId("wow335-mage-frostbolt").cost).toMatchObject({ resource: "mana", amount: 14, percentage: true });
expect(byId("wow335-druid-claw").cost).toMatchObject({ resource: "energy", amount: 45 });
expect(byId("wow335-druid-maul").cost).toMatchObject({ resource: "rage", amount: 15 });
});
it("provides mechanics for previously silent class scripts and hidden proc spells", () => {
expect(effectsOfKind(byId("wow335-warrior-slam"), "damage")).not.toEqual([]);
expect(effectsOfKind(byId("wow335-death-knight-death-grip"), "pull")).not.toEqual([]);
expect(effectsOfKind(byId("wow335-shaman-windfury-weapon"), "apply-aura")[0]?.aura.procs).not.toEqual([]);
expect(effectsOfKind(byId("wow335-shaman-lightning-shield"), "apply-aura")[0]?.aura.procs).toEqual(expect.arrayContaining([
expect.objectContaining({ triggers: "damage-taken", charges: 3 }),
]));
});
it("creates source-named stat and avoidance auras", () => {
@@ -77,6 +110,28 @@ describe("WoW 3.3.5 executable effect mapping", () => {
expect(shield.effects.some((effect) => effect.kind === "shield")).toBe(false);
});
it("maps Paladin emergency cooldowns to their source-backed mechanics", () => {
const layOnHands = byId("wow335-paladin-lay-on-hands");
expect(effectsOfKind(layOnHands, "heal")).toContainEqual(expect.objectContaining({
coefficient: 0,
percentMaxHealth: 1,
}));
const handOfProtection = byId("wow335-paladin-hand-of-protection");
const immunity = effectsOfKind(handOfProtection, "apply-aura")
.find((effect) => effect.sourceAuraType === 39);
expect(immunity?.aura).toMatchObject({
durationMs: 6_000,
modifiers: [{
kind: "damage",
direction: "taken",
school: "physical",
operation: "percent",
value: -1,
}],
});
});
it("keeps periodic damage and healing auras out of the generic HUD strip", () => {
const pain = byId("wow335-priest-shadow-word-pain");
const painAura = effectsOfKind(pain, "apply-aura")
@@ -123,11 +178,11 @@ describe("WoW 3.3.5 executable effect mapping", () => {
.find((effect) => effect.sourceAuraType === 42);
expect(seal?.aura.procs).toEqual([
expect.objectContaining({
triggers: expect.arrayContaining(["hit"]),
triggers: "hit",
action: {
kind: "custom",
id: "wow335-trigger-spell",
data: { spellId: 20170 },
id: "classic-status",
data: { status: "stun", durationMs: 2_000 },
},
}),
]);
+560 -27
View File
@@ -125,6 +125,11 @@ function effectTargetsHostile(effect: Wow335Effect): boolean {
}
function schoolFromMask(mask: number): DamageSchool | undefined {
const matches = schoolsFromMask(mask);
return matches.length === 1 ? matches[0] : undefined;
}
function schoolsFromMask(mask: number): readonly DamageSchool[] {
const schools: readonly [number, DamageSchool][] = [
[1, "physical"],
[2, "holy"],
@@ -134,8 +139,7 @@ function schoolFromMask(mask: number): DamageSchool | undefined {
[32, "shadow"],
[64, "arcane"],
];
const matches = schools.filter(([bit]) => (mask & bit) !== 0);
return matches.length === 1 ? matches[0]?.[1] : undefined;
return schools.filter(([bit]) => (mask & bit) !== 0).map(([, school]) => school);
}
function percentValue(effect: Wow335Effect): number {
@@ -158,6 +162,7 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
case 221: return percentStat("threat");
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 83:
case 123:
@@ -182,6 +187,17 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
case 34:
case 250: return flatStat("maximum-health");
case 35: return [{ kind: "resource", resource: "power", property: "maximum", operation: "flat", value: effect.basePoints }];
// SPELL_AURA_SCHOOL_IMMUNITY. Multi-school masks are common (Divine
// Shield, Ice Block, Cyclone); retain every bit instead of dropping the
// aura unless exactly one school was selected.
case 39:
case 267: return schoolsFromMask(effect.miscValue).map((school) => ({
kind: "damage" as const,
direction: "taken" as const,
school,
operation: "percent" as const,
value: -1,
}));
case 47: return percentStat("parry");
case 49: return percentStat("dodge");
case 50: return percentStat("critical-healing");
@@ -200,6 +216,7 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
case 71:
case 179: return percentStat("spell-critical-strike");
case 59: return [{ kind: "damage", direction: "dealt", operation: "flat", value: effect.basePoints }];
case 61: return percentStat("haste");
case 65:
case 138:
case 140:
@@ -260,6 +277,9 @@ function auraModifiers(effect: Wow335Effect): readonly AuraModifier[] {
case 189:
case 220: return flatStat(`combat-rating:${effect.miscValue}`);
case 240: return flatStat("expertise");
case 230: return flatStat("maximum-health");
case 271: return [{ kind: "damage", direction: "taken", operation: "percent", value: percentValue(effect) }];
case 280: return percentStat("armor-penetration");
case 248: return percentStat("combat-result");
case 251: return percentStat("enemy-dodge");
case 253: return percentStat("block-critical-strike");
@@ -279,11 +299,56 @@ function controlFromAura(auraType: number): AbilityAuraControl | undefined {
case 27: return { kind: "silence" };
case 60: return { kind: "silence" };
case 67:
case 254: return { kind: "disarm" };
case 254:
case 278: return { kind: "disarm" };
case 56: return { kind: "confuse" };
case 92: return { kind: "root" };
default: return undefined;
}
}
function exclusiveGroupForAbility(classId: BaseClassId, abilityName: string): string | undefined {
if (/\bStance$/i.test(abilityName)) return `${classId}:stance`;
if (/^Seal of\b/i.test(abilityName)) return "paladin:seal";
if (/\bAura$/i.test(abilityName)) return `${classId}:aura`;
if (/^(?:Greater )?Blessing of\b/i.test(abilityName)) return "paladin:blessing";
if (/^Aspect of\b/i.test(abilityName)) return "hunter:aspect";
if (/^Track\b|^Sense (?:Undead|Demons)$/i.test(abilityName)) return `${classId}:tracking`;
if (/\b(?:Bear|Cat|Travel|Aquatic|Flight|Tree of Life) Form$/i.test(abilityName)) return "druid:form";
if (/\bArmor$/i.test(abilityName)) return `${classId}:armor`;
if (/\bPresence$/i.test(abilityName)) return "death-knight:presence";
if (/ Weapon$/i.test(abilityName)) return "shaman:weapon-enchant";
return undefined;
}
function utilityTagsForAura(auraType: number, abilityName: string): readonly string[] {
const tags: string[] = [];
if (auraType === 16 || /\bStealth|Prowl\b/i.test(abilityName)) tags.push("stealth");
if ([17, 19, 44, 151].includes(auraType)) tags.push("tracking");
if (auraType === 36) tags.push("shapeshift");
if (auraType === 66) tags.push("feign-death");
if (auraType === 78) tags.push("mounted");
if (auraType === 82) tags.push("water-breathing");
if (auraType === 104) tags.push("water-walking");
if (auraType === 105) tags.push("slow-fall");
if ([1, 4, 68, 121, 128, 226, 292].includes(auraType)) tags.push(`classic-aura:${auraType}`);
if (auraType === 120) tags.push("untrackable");
if (auraType === 149) tags.push("pushback-resistance");
if (auraType === 25) tags.push("pacified");
if (auraType === 41) tags.push("reveal-stealth");
if (auraType === 91) tags.push("reduced-aggro-radius");
return tags;
}
function controlImmunitiesForAura(auraType: number, miscValue: number): AuraDefinition["controlImmunities"] {
if (auraType !== 77) return undefined;
if (miscValue === 5) return ["fear"];
if (miscValue === 7 || miscValue === 11) return ["root"];
if (miscValue === 10) return ["sleep"];
if (miscValue === 12) return ["stun"];
return undefined;
}
function inferredProcTriggers(description: string, auraType: number): readonly ProcTrigger[] {
if (auraType === 23 || auraType === 48 || auraType === 226 || auraType === 227) return ["periodic"];
const triggers: ProcTrigger[] = [];
@@ -355,16 +420,176 @@ function dispelCategoryForAura(
}
function namedAuraForEffect(
classId: BaseClassId,
effect: Wow335Effect,
rank: Wow335Rank,
abilityName: string,
effectIndex: number,
): AuraDefinition {
const durationMs = rank.durationMs > 0 ? rank.durationMs : null;
const proc = procForAura(effect, rank, rank.description);
const hiddenProcOverrides = new Set([
"Seal of Justice", "Lightning Shield", "Water Shield", "Frost Armor", "Ice Armor", "Molten Armor", "Nature's Grasp",
]);
const proc = hiddenProcOverrides.has(abilityName) ? undefined : procForAura(effect, rank, rank.description);
const school = schoolFromMask(effect.miscValue);
const auraId = `wow335:${rank.spellId}:effect:${effectIndex}:aura:${effect.auraType}`;
const modifiers = [...auraModifiers(effect)];
if (abilityName === "Greater Blessing of Sanctuary" && effect.auraType === 4) {
modifiers.push(
{ kind: "damage", direction: "taken", operation: "percent", value: -0.03 },
{ kind: "stat", stat: "stamina", operation: "percent", value: 0.1 },
);
}
if (abilityName === "Ice Block" && effect.auraType === 39) {
modifiers.push({ kind: "damage", direction: "taken", school: "physical", operation: "percent", value: -1 });
}
if (effect.auraType === 36 && ["Bear Form", "Dire Bear Form"].includes(abilityName)) {
modifiers.push(
{ kind: "stat", stat: "armor", operation: "percent", value: abilityName === "Dire Bear Form" ? 3.7 : 1.8 },
{ kind: "stat", stat: "stamina", operation: "percent", value: abilityName === "Dire Bear Form" ? 0.25 : 0.1 },
);
}
if (effect.auraType === 36 && abilityName === "Cat Form") {
modifiers.push(
{ kind: "stat", stat: "attack-power", operation: "percent", value: 0.1 },
{ kind: "stat", stat: "movement-speed", operation: "percent", value: 0.3 },
);
}
if (effect.auraType === 36 && ["Travel Form", "Ghost Wolf"].includes(abilityName)) {
modifiers.push({ kind: "stat", stat: "movement-speed", operation: "percent", value: 0.4 });
}
if (effect.auraType === 36 && ["Flight Form", "Swift Flight Form"].includes(abilityName)) {
modifiers.push({ kind: "stat", stat: "movement-speed", operation: "percent", value: abilityName === "Swift Flight Form" ? 2.8 : 0.6 });
}
if (effect.auraType === 36 && abilityName === "Tree of Life") {
modifiers.push({ kind: "healing", direction: "done", operation: "percent", value: 0.06 });
}
if (effect.auraType === 36 && abilityName === "Defensive Stance") {
modifiers.push(
{ kind: "damage", direction: "taken", operation: "percent", value: -0.1 },
{ kind: "stat", stat: "threat", operation: "percent", value: 0.3 },
);
}
if (effect.auraType === 36 && abilityName === "Berserker Stance") {
modifiers.push(
{ kind: "stat", stat: "critical-strike", operation: "percent", value: 0.03 },
{ kind: "damage", direction: "taken", operation: "percent", value: 0.05 },
);
}
if (effect.auraType === 21 && abilityName === "Aspect of the Viper") {
modifiers.push({
kind: "resource",
resource: "mana",
property: "regeneration",
operation: "flat",
value: Math.max(1, Math.abs(effect.basePoints)),
});
}
const specialProcs: AuraProcDefinition[] = [];
const procAmount = Math.max(1, Math.round(playerCombatPower(Math.max(1, rank.sourceLevel)) * 0.12));
if (["Seal of Righteousness", "Seal of Vengeance", "Seal of Corruption"].includes(abilityName) && effectIndex === 0) {
specialProcs.push({
id: `${auraId}:seal-damage`,
triggers: "hit",
chance: 1,
action: { kind: "damage", amount: procAmount, school: "holy", target: "event-other" },
});
} else if (abilityName === "Seal of Light" && effectIndex === 0) {
specialProcs.push({
id: `${auraId}:seal-heal`,
triggers: "hit",
chance: 1,
action: { kind: "heal", amount: procAmount, target: "aura-target" },
});
} else if (abilityName === "Seal of Wisdom" && effectIndex === 0) {
specialProcs.push({
id: `${auraId}:seal-mana`,
triggers: "hit",
chance: 1,
action: { kind: "resource", resource: "mana", amount: Math.max(1, Math.round(procAmount / 3)), target: "aura-target" },
});
}
if (abilityName === "Holy Shield" && effect.auraType === 43) {
specialProcs.push({
id: `${auraId}:holy-shield`,
triggers: "block",
chance: 1,
action: { kind: "damage", amount: procAmount, school: "holy", target: "event-other" },
});
}
if (abilityName === "Retaliation" && effect.auraType === 4) {
specialProcs.push({
id: `${auraId}:retaliation`, triggers: "damage-taken", chance: 1, charges: 30,
action: { kind: "damage", amount: Math.max(1, Math.round(procAmount * 1.5)), school: "physical", target: "event-other" },
});
}
if (abilityName === "Judgement of Light" && effect.auraType === 4) {
specialProcs.push({
id: `${auraId}:judgement-light`, triggers: "damage-taken", chance: 1,
action: { kind: "heal", amount: procAmount, target: "event-other" },
});
}
if (abilityName === "Judgement of Wisdom" && effect.auraType === 4) {
specialProcs.push({
id: `${auraId}:judgement-wisdom`, triggers: "damage-taken", chance: 1,
action: { kind: "resource", resource: "mana", amount: Math.max(1, Math.round(procAmount / 3)), target: "event-other" },
});
}
if (abilityName === "Seal of Justice" && effect.auraType === 42) {
specialProcs.push({
id: `${auraId}:seal-justice`, triggers: "hit", chance: 0.2,
action: { kind: "custom", id: "classic-status", data: { status: "stun", durationMs: 2_000 } },
});
}
if (abilityName === "Lightning Shield" && effect.auraType === 42) {
specialProcs.push({
id: `${auraId}:lightning-shield`, triggers: "damage-taken", chance: 1, charges: 3,
action: { kind: "damage", amount: Math.max(1, effect.basePoints), school: "nature", target: "event-other" },
});
}
if (abilityName === "Water Shield" && effect.auraType === 42) {
specialProcs.push({
id: `${auraId}:water-shield`, triggers: "damage-taken", chance: 1, charges: 3,
action: { kind: "resource", resource: "mana", amount: Math.max(1, effect.basePoints), target: "aura-target" },
});
}
if (["Frost Armor", "Ice Armor"].includes(abilityName) && effect.auraType === 42) {
specialProcs.push({
id: `${auraId}:frost-slow`, triggers: "damage-taken", chance: 1,
action: { kind: "custom", id: "classic-status", data: { status: "slow", durationMs: 5_000, magnitude: 0.3 } },
});
}
if (abilityName === "Molten Armor" && effect.auraType === 42) {
specialProcs.push({
id: `${auraId}:molten-retaliation`, triggers: "damage-taken", chance: 1,
action: { kind: "damage", amount: procAmount, school: "fire", target: "event-other" },
});
}
if (abilityName === "Nature's Grasp" && effect.auraType === 42) {
specialProcs.push({
id: `${auraId}:natures-grasp`, triggers: "damage-taken", chance: 1, charges: 1,
action: { kind: "custom", id: "classic-status", data: { status: "root", durationMs: 10_000 } },
});
}
if (abilityName === "Recklessness" && effect.auraType === 107) {
modifiers.push({ kind: "stat", stat: "critical-strike", operation: "percent", value: 1 });
}
const sacredShield = abilityName === "Sacred Shield" && effect.auraType === 4;
if (sacredShield) {
specialProcs.push({
id: `${auraId}:sacred-shield-refresh`,
triggers: "damage-taken",
chance: 1,
internalCooldownMs: 6_000,
action: { kind: "apply-aura", auraId, target: "aura-target" },
});
}
const utilityTags = [
...utilityTagsForAura(effect.auraType, abilityName),
...(abilityName === "Light's Beacon" ? ["beacon-of-light"] : []),
];
return {
id: `wow335:${rank.spellId}:effect:${effectIndex}:aura:${effect.auraType}`,
id: auraId,
name: `${abilityName} (DBC ${rank.spellId}, Aura ${effect.auraType})`,
disposition: effectTargetsHostile(effect) ? "debuff" : "buff",
...([3, 8].includes(effect.auraType) && effect.periodMs > 0
@@ -373,21 +598,37 @@ function namedAuraForEffect(
durationMs,
maxStacks: 1,
stackBehavior: "refresh",
...(exclusiveGroupForAbility(classId, abilityName) || abilityName === "Light's Beacon"
? { exclusiveGroup: abilityName === "Light's Beacon" ? "paladin:beacon" : exclusiveGroupForAbility(classId, abilityName) }
: {}),
...(utilityTags.length ? { utilityTags } : {}),
...(controlImmunitiesForAura(effect.auraType, effect.miscValue)
? { controlImmunities: controlImmunitiesForAura(effect.auraType, effect.miscValue) }
: {}),
...(effect.auraType === 28
? { reflectSpellChance: Math.min(1, Math.max(0, Math.abs(effect.basePoints) / 100)) }
: {}),
...(effect.auraType === 74
? { reflectSpellChance: Math.min(1, Math.max(0, Math.abs(effect.basePoints) / 100)) }
: {}),
...(effect.auraType === 81
? { redirectDamagePercent: Math.min(1, Math.max(0, Math.abs(effect.basePoints) / 100)) }
: {}),
...(dispelCategoryForAura(abilityName, rank.description, rank.durationMs) !== undefined
? { dispelCategory: dispelCategoryForAura(abilityName, rank.description, rank.durationMs) }
: {}),
...(auraModifiers(effect).length ? { modifiers: auraModifiers(effect) } : {}),
...([69, 97].includes(effect.auraType)
...(modifiers.length ? { modifiers } : {}),
...([69, 97].includes(effect.auraType) || sacredShield
? {
absorbs: [{
id: `wow335:${rank.spellId}:absorb:${effectIndex}`,
amount: Math.max(0, effect.basePoints),
amount: sacredShield ? Math.max(1, Math.round(playerCombatPower(Math.max(1, rank.sourceLevel)) * 0.75)) : Math.max(0, effect.basePoints),
...(school !== undefined ? { school } : {}),
perStack: false,
}],
}
: {}),
...(proc ? { procs: [proc] } : {}),
...(proc || specialProcs.length ? { procs: [...(proc ? [proc] : []), ...specialProcs] } : {}),
};
}
@@ -437,16 +678,183 @@ function fallbackCoefficient(effect: Wow335Effect, rank: Wow335Rank): number {
return Math.max(0.08, Math.min(4, average / playerCombatPower(Math.max(1, rank.sourceLevel || rank.level))));
}
function effectsForRank(rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] {
function utilityAction(effectType: number): Extract<AbilityEffect, { kind: "utility" }>["action"] {
if (effectType === 24) return "create-item";
if (effectType === 33) return "open-lock";
if (effectType === 50) return "portal";
if (effectType === 54) return "weapon-enchant";
if (effectType === 71) return "pickpocket";
if (effectType === 72) return "farsight";
return "class-script";
}
function classScriptEffects(
classId: BaseClassId,
abilityName: string,
rank: Wow335Rank,
): readonly AbilityEffect[] {
const power = playerCombatPower(Math.max(1, rank.sourceLevel || rank.level));
const aura = (
id: string,
modifiers: readonly AuraModifier[],
procs: readonly AuraProcDefinition[] = [],
): AbilityEffect => ({
kind: "apply-aura",
aura: {
id: `wow335:${rank.spellId}:${id}`,
name: abilityName,
disposition: "buff",
durationMs: rank.durationMs > 0 ? rank.durationMs : null,
maxStacks: 1,
stackBehavior: "refresh",
exclusiveGroup: exclusiveGroupForAbility(classId, abilityName),
...(modifiers.length ? { modifiers } : {}),
...(procs.length ? { procs } : {}),
utilityTags: ["weapon-enchant"],
},
sourceEffectType: 3,
sourceAuraType: 4,
miscValue: 0,
triggerSpellId: 0,
});
switch (`${classId}:${abilityName}`) {
case "warrior:Slam": return [{ kind: "damage", coefficient: 1.5 }];
case "warrior:Charge": return [{ kind: "status", status: "stun", durationMs: 1_500 }];
case "warrior:Intercept": return [{ kind: "status", status: "stun", durationMs: 3_000 }];
case "warrior:Sunder Armor": return [{
kind: "apply-aura",
aura: {
id: `wow335:${rank.spellId}: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 }],
},
sourceEffectType: 6, sourceAuraType: 4, miscValue: 0, triggerSpellId: 0,
}];
case "warrior:Shattering Throw": return [
{ kind: "damage", coefficient: 1 },
{ kind: "status", status: "marked", durationMs: 10_000, magnitude: 0.2 },
];
case "paladin:Judgement of Justice": return [{ kind: "status", status: "root", durationMs: 10_000 }];
case "paladin:Righteous Defense": return [{ kind: "taunt", durationMs: 3_000, maxTargets: 3 }];
case "paladin:Hand of Salvation": return [{ kind: "threat", mode: "percent", amount: -20, recipient: "ability-target" }];
case "paladin:Divine Intervention": return [{
kind: "apply-aura",
aura: {
id: `wow335:${rank.spellId}:divine-intervention`, name: abilityName, disposition: "buff", durationMs: 180_000,
modifiers: schoolsFromMask(127).map((school) => ({
kind: "damage" as const, direction: "taken" as const, school, operation: "percent" as const, value: -1,
})),
},
sourceEffectType: 64, sourceAuraType: 39, miscValue: 127, triggerSpellId: 0,
}, {
kind: "utility", action: "class-script", effectType: 77, auraType: 0, miscValue: 0, triggerSpellId: 0, durationMs: 0,
}];
case "hunter:Disengage": return [{ kind: "movement", movement: "leap", toward: "away" }];
case "hunter:Freezing Arrow": return [{ kind: "status", status: "freeze", durationMs: 10_000 }];
case "hunter:Feign Death": return [{ kind: "threat", mode: "percent", amount: -100 }];
case "rogue:Shiv": return [{ kind: "damage", coefficient: 1 }];
case "rogue:Garrote": return [{ kind: "status", status: "silence", durationMs: 3_000 }];
case "rogue:Cloak of Shadows": return [{
kind: "dispel", mode: "cleanse", relationship: "friendly", categories: ["magic"], maxCount: 10,
}];
case "priest:Shadowfiend": return [{ kind: "summon", creatureId: 19668, coefficient: 0.5, durationMs: 15_000 }];
case "priest:Prayer of Mending": return [{
kind: "apply-aura",
aura: {
id: `wow335:${rank.spellId}:prayer-of-mending`,
name: abilityName,
disposition: "buff",
durationMs: 30_000,
maxStacks: 1,
stackBehavior: "refresh",
procs: [{
id: `wow335:${rank.spellId}:prayer-of-mending-proc`,
triggers: "damage-taken",
chance: 1,
charges: 5,
action: { kind: "heal", amount: Math.max(1, Math.round(power * 0.8)), target: "aura-target" },
}],
},
sourceEffectType: 64,
sourceAuraType: 42,
miscValue: 0,
triggerSpellId: 0,
}];
case "death-knight:Death Grip": return [{ kind: "pull", toward: "caster" }];
case "death-knight:Blood Tap": return [{ kind: "rune", action: "activate", count: 1 }];
case "death-knight:Chains of Ice": return [
{ kind: "status", status: "root", durationMs: 3_000 },
{ kind: "status", status: "slow", durationMs: 10_000, magnitude: 0.95 },
];
case "death-knight:Icy Touch": return [{ kind: "dot", coefficient: 0.22, ticks: 5, intervalMs: 3_000, tag: "frost-fever" }];
case "death-knight:Plague Strike": return [{ kind: "dot", coefficient: 0.22, ticks: 5, intervalMs: 3_000, tag: "blood-plague" }];
case "death-knight:Pestilence": return [{ kind: "damage", coefficient: 0.35 }];
case "death-knight:Raise Dead": return [{ kind: "summon", creatureId: 26125, coefficient: 0.45, durationMs: 60_000 }];
case "death-knight:Army of the Dead": return Array.from({ length: 8 }, () => ({
kind: "summon" as const, creatureId: 24207, coefficient: 0.2, durationMs: 40_000,
}));
case "shaman:Rockbiter Weapon": return [aura("rockbiter", [
{ kind: "damage", direction: "dealt", school: "physical", operation: "flat", value: Math.max(1, Math.round(power * 0.12)) },
])];
case "shaman:Frostbrand Weapon": return [aura("frostbrand", [], [{
id: `wow335:${rank.spellId}:frostbrand-proc`, triggers: "hit", chance: 0.2,
action: { kind: "damage", amount: Math.max(1, Math.round(power * 0.3)), school: "frost", target: "event-other" },
}])];
case "shaman:Flametongue Weapon": return [aura("flametongue", [], [{
id: `wow335:${rank.spellId}:flametongue-proc`, triggers: "hit", chance: 1,
action: { kind: "damage", amount: Math.max(1, Math.round(power * 0.18)), school: "fire", target: "event-other" },
}])];
case "shaman:Windfury Weapon": return [aura("windfury", [], [{
id: `wow335:${rank.spellId}:windfury-proc`, triggers: "hit", chance: 0.2,
action: { kind: "damage", amount: Math.max(1, Math.round(power * 0.75)), school: "physical", target: "event-other" },
}])];
case "warlock:Soulshatter": return [{ kind: "threat", mode: "percent", amount: -90 }];
case "warlock:Demonic Circle: Summon": return [{ kind: "summon", creatureId: 19175, coefficient: 0, durationMs: 360_000 }];
case "warlock:Demonic Circle: Teleport": return [{ kind: "movement", movement: "teleport", toward: "away" }];
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:Arcane Blast": return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "wow335:arcane-blast-stack",
name: "Arcane Blast",
disposition: "debuff",
durationMs: 6_000,
maxStacks: 4,
stackBehavior: "add",
modifiers: [{ kind: "resource", resource: "mana", property: "cost", operation: "percent", value: 0.15 }],
},
sourceEffectType: 64,
sourceAuraType: 108,
miscValue: 0,
triggerSpellId: 0,
}];
case "warlock:Inferno": return [{ kind: "summon", creatureId: 89, coefficient: 0.8, durationMs: 60_000 }];
case "druid:Pounce": return [{ kind: "dot", coefficient: 0.18, ticks: 6, intervalMs: 3_000, tag: "pounce" }];
default: return [];
}
}
const COMBO_FINISHERS = new Set([
"Eviscerate", "Slice and Dice", "Expose Armor", "Rupture", "Kidney Shot", "Envenom", "Deadly Throw",
"Rip", "Ferocious Bite", "Maim", "Savage Roar",
]);
const DIRECT_COMBO_FINISHERS = new Set(["Eviscerate", "Envenom", "Deadly Throw", "Ferocious Bite"]);
function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] {
const effects: AbilityEffect[] = [];
for (const [effectIndex, effect] of rank.effects.entries()) {
const coefficient = fallbackCoefficient(effect, rank);
const scaling = amountScaling(effect, rank);
if (APPLY_AURA_EFFECTS.has(effect.effectType) && effect.auraType > 0) {
const aura = namedAuraForEffect(effect, rank, abilityName, effectIndex);
const aura = namedAuraForEffect(classId, effect, rank, abilityName, effectIndex);
effects.push({
kind: "apply-aura",
aura,
...([35, 65].includes(effect.effectType) ? { scope: "party" as const } : {}),
...(controlFromAura(effect.auraType) ? { control: controlFromAura(effect.auraType) } : {}),
sourceEffectType: effect.effectType,
sourceAuraType: effect.auraType,
@@ -461,6 +869,42 @@ function effectsForRank(rank: Wow335Rank, abilityName: string): readonly Ability
effects.push({ kind: "hot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs });
} else if (effect.auraType === 11) {
effects.push({ kind: "taunt", durationMs: Math.max(3_000, rank.durationMs) });
} else if (effect.auraType === 20 && effect.periodMs > 0) {
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
effects.push({ kind: "hot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs });
} else if (effect.auraType === 53 && effect.periodMs > 0) {
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
effects.push({ kind: "dot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs, tag: `spell-${rank.spellId}` });
effects.push({
kind: "hot",
coefficient: abilityName === "Devouring Plague" ? coefficient * 0.15 : coefficient,
...scaling,
ticks,
intervalMs: effect.periodMs,
});
} else if (effect.auraType === 64 && effect.periodMs > 0) {
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
effects.push({ kind: "resource-drain", amount: Math.abs(effect.basePoints) * ticks, burn: false, resource: "mana", percentage: true });
} else if (effect.auraType === 24) {
const ticks = effect.periodMs > 0
? Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs))
: 1;
effects.push({
kind: "resource",
resource: abilityName === "Enrage" ? "rage" : "mana",
amount: abilityName === "Innervate" ? 100 : Math.abs(effect.basePoints) * ticks,
...(abilityName === "Innervate" ? { percentage: true } : {}),
});
} else if (effect.auraType === 21 && abilityName === "Divine Plea") {
effects.push({ kind: "resource", resource: "mana", amount: 25, percentage: true });
} else if (effect.auraType === 226 && abilityName === "Death and Decay" && effect.periodMs > 0) {
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
effects.push({ kind: "dot", coefficient, ...scaling, ticks, intervalMs: effect.periodMs, tag: `spell-${rank.spellId}` });
} else if (effect.auraType === 226 && abilityName === "Frenzied Regeneration" && effect.periodMs > 0) {
const ticks = Math.max(1, Math.round(Math.max(effect.periodMs, rank.durationMs) / effect.periodMs));
effects.push({ kind: "hot", coefficient: 0.3, ticks, intervalMs: effect.periodMs });
} else if (effect.auraType === 226 && abilityName === "Mirror Image") {
effects.push({ kind: "summon", creatureId: effect.triggerSpellId || 58836, coefficient: 0.25, durationMs: Math.max(1_000, rank.durationMs) });
}
continue;
}
@@ -492,8 +936,18 @@ function effectsForRank(rank: Wow335Rank, abilityName: string): readonly Ability
effects.push({ kind: "damage", coefficient: weaponCoefficient });
continue;
}
if (effect.effectType === 10 || effect.effectType === 67 || effect.effectType === 136) {
effects.push({ kind: "heal", coefficient, ...scaling });
if (effect.effectType === 67) {
// SPELL_EFFECT_HEAL_MAX_HEALTH is used by Lay on Hands. Its DBC amount
// fields are intentionally zero because the target's maximum health is
// the amount; treating it as an ordinary coefficient heal makes the
// spell appear to do nothing.
effects.push({ kind: "heal", coefficient: 0, percentMaxHealth: 1, ...scaling });
continue;
}
if (effect.effectType === 10 || effect.effectType === 136) {
effects.push(effect.effectType === 136
? { kind: "heal", coefficient: 0, percentMaxHealth: Math.max(0, Math.abs(effect.basePoints) / 100), ...scaling }
: { kind: "heal", coefficient, ...scaling });
continue;
}
if (effect.effectType === 30) {
@@ -501,7 +955,7 @@ function effectsForRank(rank: Wow335Rank, abilityName: string): readonly Ability
continue;
}
if (effect.effectType === 137) {
effects.push({ kind: "resource", amount: Math.abs(effect.basePoints) });
effects.push({ kind: "resource", amount: Math.abs(effect.basePoints), percentage: true });
continue;
}
if (effect.effectType === 63 || effect.effectType === 91 || effect.effectType === 125 || effect.effectType === 130) {
@@ -634,11 +1088,13 @@ function effectsForRank(rank: Wow335Rank, abilityName: string): readonly Ability
if (effect.effectType === 0 && effect.auraType === 0) continue;
if (NONCOMBAT_EFFECTS.has(effect.effectType)) {
effects.push({
kind: "unsupported",
combat: false,
kind: "utility",
action: utilityAction(effect.effectType),
effectType: effect.effectType,
auraType: effect.auraType,
reason: `Noncombat DBC effect ${effect.effectType}`,
miscValue: effect.miscValue,
triggerSpellId: effect.triggerSpellId,
durationMs: rank.durationMs,
});
continue;
}
@@ -658,15 +1114,40 @@ function effectsForRank(rank: Wow335Rank, abilityName: string): readonly Ability
effects.unshift({ kind: "damage", coefficient: 1 });
}
}
return effects.length
? effects
const scriptedFallbacks = classScriptEffects(classId, abilityName, rank);
const executable = effects
.filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported")
.map((effect) => effect.kind === "utility" ? effect : effect);
const result = scriptedFallbacks.length
? [...executable, ...scriptedFallbacks]
: executable;
let finalized = result;
if (DIRECT_COMBO_FINISHERS.has(abilityName)) {
finalized = [
...result.filter((effect) => effect.kind !== "damage" && effect.kind !== "finisher-damage"),
{ kind: "finisher-damage", baseCoefficient: 0.55, perComboCoefficient: 0.45 },
];
}
if (COMBO_FINISHERS.has(abilityName) && !finalized.some((effect) => effect.kind === "finisher-damage")) {
finalized = [...finalized, { kind: "combo", amount: -5 }];
}
if (
classId === "death-knight"
&& rank.powerType === 5
&& finalized.some((effect) => ["damage", "dot", "status", "pull", "taunt"].includes(effect.kind))
) {
finalized = [...finalized, { kind: "resource", resource: "runic-power", amount: 10 }];
}
return finalized.length
? finalized
: [{
kind: "scripted",
kind: "utility",
action: "class-script",
effectType: 0,
auraType: 0,
miscValue: 0,
triggerSpellId: 0,
label: "Description-backed spell script",
durationMs: rank.durationMs,
}];
}
@@ -689,6 +1170,20 @@ function costForRank(resource: ResourceType, rank: Wow335Rank): number {
return rank.powerCost;
}
function resourceForRank(fallback: ResourceType, rank: Wow335Rank): ResourceType {
switch (rank.powerType) {
case -2: return "health";
case 0: return "mana";
case 1: return "rage";
case 2: return "focus";
case 3: return "energy";
case 6: return "runic-power";
// Runes (power type 5) are represented by the existing rune effects;
// their DBC powerCost is zero, so keep the class pool for display.
default: return fallback;
}
}
function rankDefinition(
classId: BaseClassId,
resource: ResourceType,
@@ -696,8 +1191,9 @@ function rankDefinition(
index: number,
abilityName: string,
): AbilityRankDefinition {
const effects = effectsForRank(rank, abilityName);
const effects = effectsForRank(classId, rank, abilityName);
const channeling = rank.channelDurationMs > 0;
const rankResource = resourceForRank(resource, rank);
return {
rank: rank.rank || index + 1,
spellId: rank.spellId,
@@ -707,7 +1203,11 @@ function rankDefinition(
castTimeMs: channeling ? Math.max(rank.castTimeMs, rank.channelDurationMs) : rank.castTimeMs,
cooldownMs: rank.cooldownMs,
gcdMs: rank.gcdMs,
cost: { resource, amount: costForRank(resource, rank) },
cost: {
resource: rankResource,
amount: costForRank(rankResource, rank),
...(rank.powerCostPercentage > 0 ? { percentage: true } : {}),
},
effects,
};
}
@@ -724,12 +1224,22 @@ function sigil(name: string): string {
function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDefinition | null {
const resource = RESOURCE_BY_CLASS[classId];
const ranks = chain.ranks.map((rank, index) => rankDefinition(classId, resource, rank, index, chain.name));
// Some SpellChain rows include rank-zero triggered children or cosmetic
// variants after the actual learnable ranks (Execute's damage child,
// Divine Hymn's tick, Death Coil's split target spells, polymorph models).
// They are executable sub-spells, not a higher player rank.
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));
const first = ranks[0];
const firstRaw = chain.ranks[0];
const firstRaw = sourceRanks[0];
if (!first || !firstRaw) return null;
const target = targetForRank(firstRaw, first.effects);
const radius = Math.max(0, ...firstRaw.effects.map((effect) => effect.radius));
const target = abilityNameTarget(chain.name) ?? targetForRank(firstRaw, first.effects);
const radius = Math.max(
abilityNameRadius(chain.name),
...firstRaw.effects.map((effect) => effect.radius),
);
const range = target === "self"
? { min: 0, max: 0 }
: {
@@ -752,6 +1262,9 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
castMode: first.castMode,
castTimeMs: first.castTimeMs,
cooldownMs: first.cooldownMs,
...(classicCooldownGroup(classId, chain.name)
? { cooldownGroup: classicCooldownGroup(classId, chain.name) }
: {}),
gcdMs: first.gcdMs,
cost: first.cost,
effects: first.effects,
@@ -761,6 +1274,26 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
};
}
function abilityNameRadius(abilityName: string): number {
if (abilityName === "Hellfire") return 10;
if (abilityName === "Mind Sear") return 10;
if (abilityName === "Pestilence") return 10;
if (abilityName === "Divine Hymn") return 40;
return 0;
}
function abilityNameTarget(abilityName: string): AbilityTarget | undefined {
if (["Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead"].includes(abilityName)) return "self";
return undefined;
}
function classicCooldownGroup(classId: BaseClassId, abilityName: string): string | undefined {
if (classId === "shaman" && / Shock$/i.test(abilityName)) return "shaman-shocks";
if (classId === "hunter" && / Trap$/i.test(abilityName)) return "hunter-traps";
if (classId === "mage" && /^(?:Fire|Frost) Ward$/i.test(abilityName)) return "mage-wards";
return undefined;
}
export const WOW335_ABILITIES_BY_CLASS: Readonly<Record<BaseClassId, readonly AbilityDefinition[]>> =
Object.freeze(Object.fromEntries(BASE_CLASS_IDS.map((classId) => [
classId,