Update Healer Man server
This commit is contained in:
1 parent
4fb7a0b9c8
commit
4c768fbec6
24 files changed
+654
-22
No files matched your search
@@ -39,6 +39,7 @@ import {
|
||||
} from "../game/manastormProgress";
|
||||
import { abilitiesForClass } from "../game/abilityCatalog";
|
||||
import { DUNGEON_RUN_CHECKPOINT_VERSION } from "../game/dungeonRunCheckpoint";
|
||||
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "../game/wow335GlyphCatalog";
|
||||
|
||||
class MemoryStorage implements StorageLike {
|
||||
private values = new Map<string, string>();
|
||||
@@ -48,6 +49,43 @@ class MemoryStorage implements StorageLike {
|
||||
}
|
||||
|
||||
describe("local profile repository", () => {
|
||||
it("defaults new glyph loadouts to empty and persists explicit glyph choices", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const character = createCharacter({
|
||||
ownerId: "offline-roster",
|
||||
name: "Leafkeeper",
|
||||
categoryId: "wow",
|
||||
raceId: "night-elf",
|
||||
classId: "druid",
|
||||
specializationId: "druid:restoration",
|
||||
gender: "female",
|
||||
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
|
||||
}, storage).character!;
|
||||
|
||||
expect(character.glyphIds).toEqual([]);
|
||||
|
||||
const legacyProfile = { ...character };
|
||||
delete (legacyProfile as Partial<typeof character>).glyphIds;
|
||||
const [migrated] = replaceCharactersForOwner("offline-roster", [legacyProfile], storage);
|
||||
expect(migrated?.glyphIds).toEqual([]);
|
||||
|
||||
const updated = updateCharacterProgression("offline-roster", migrated.id, {
|
||||
level: migrated.level,
|
||||
experience: migrated.experience,
|
||||
talentRanks: migrated.talentRanks,
|
||||
glyphIds: [WOW335_GLYPH_OF_SWIFTMEND_ID, WOW335_GLYPH_OF_SWIFTMEND_ID, " "],
|
||||
}, storage)!;
|
||||
expect(updated.glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
|
||||
expect(listCharacters("offline-roster", storage)[0]?.glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
|
||||
|
||||
const progressionOnly = updateCharacterProgression("offline-roster", migrated.id, {
|
||||
level: updated.level,
|
||||
experience: updated.experience,
|
||||
talentRanks: updated.talentRanks,
|
||||
}, storage)!;
|
||||
expect(progressionOnly.glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
|
||||
});
|
||||
|
||||
it("persists a safe dungeon checkpoint per character", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const character = createCharacter({
|
||||
|
||||
@@ -129,6 +129,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
actionBindings?: unknown;
|
||||
experience?: unknown;
|
||||
talentRanks?: unknown;
|
||||
glyphIds?: unknown;
|
||||
level?: unknown;
|
||||
inventory?: unknown;
|
||||
equipment?: unknown;
|
||||
@@ -150,6 +151,11 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
.filter(([, rank]) => typeof rank === "number" && Number.isFinite(rank) && rank > 0)
|
||||
.map(([id, rank]) => [id, Math.floor(rank as number)]))
|
||||
: {};
|
||||
const glyphIds = Array.isArray(legacy.glyphIds)
|
||||
? [...new Set(legacy.glyphIds.filter((glyphId): glyphId is string => (
|
||||
typeof glyphId === "string" && glyphId.trim().length > 0
|
||||
)))]
|
||||
: [];
|
||||
const actionBindings = legacy.actionBindings && typeof legacy.actionBindings === "object" && !Array.isArray(legacy.actionBindings)
|
||||
? Object.fromEntries(Object.entries(legacy.actionBindings as Record<string, unknown>)
|
||||
.filter(([, abilityId]) => abilityId === null || typeof abilityId === "string")
|
||||
@@ -188,6 +194,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
level,
|
||||
experience,
|
||||
talentRanks,
|
||||
glyphIds,
|
||||
actionBindings,
|
||||
categoryId,
|
||||
specializationId,
|
||||
@@ -513,7 +520,7 @@ export function replaceCharactersForOwner(
|
||||
}
|
||||
|
||||
export function createCharacter(
|
||||
profile: Omit<CharacterProfile, "id" | "createdAt" | "lastPlayedAt" | "level" | "experience" | "talentRanks" | "actionBindings" | "inventory" | "equipment" | "manastormProgress" | "location" | "specializationId"> & {
|
||||
profile: Omit<CharacterProfile, "id" | "createdAt" | "lastPlayedAt" | "level" | "experience" | "talentRanks" | "glyphIds" | "actionBindings" | "inventory" | "equipment" | "manastormProgress" | "location" | "specializationId"> & {
|
||||
specializationId?: string | null;
|
||||
},
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
@@ -532,6 +539,7 @@ export function createCharacter(
|
||||
level: 1,
|
||||
experience: 0,
|
||||
talentRanks: {},
|
||||
glyphIds: [],
|
||||
actionBindings: {},
|
||||
inventory: [],
|
||||
equipment: {},
|
||||
@@ -565,7 +573,7 @@ export function touchCharacter(
|
||||
export function updateCharacterProgression(
|
||||
ownerId: string,
|
||||
characterId: string,
|
||||
progression: Pick<CharacterProfile, "level" | "experience" | "talentRanks">,
|
||||
progression: Pick<CharacterProfile, "level" | "experience" | "talentRanks"> & Partial<Pick<CharacterProfile, "glyphIds">>,
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): CharacterProfile | null {
|
||||
if (!storage) return null;
|
||||
@@ -578,6 +586,9 @@ export function updateCharacterProgression(
|
||||
level: progression.level,
|
||||
experience: progression.experience,
|
||||
talentRanks: { ...progression.talentRanks },
|
||||
glyphIds: progression.glyphIds === undefined
|
||||
? [...(character.glyphIds ?? [])]
|
||||
: [...progression.glyphIds],
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { beginMainMenuCharacterEditing } from "./mainMenuCharacterEditing";
|
||||
import { selectedCharacter, useShellStore } from "./shellStore";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { talentNodesForTree } from "../game/talentCatalog";
|
||||
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "../game/wow335GlyphCatalog";
|
||||
|
||||
class MemoryStorage implements StorageLike {
|
||||
values = new Map<string, string>();
|
||||
@@ -65,6 +66,33 @@ describe("main-menu character editing", () => {
|
||||
expect(listCharacters("offline-roster", storage)[0]).toMatchObject({ level: 17, experience: 123 });
|
||||
});
|
||||
|
||||
it("persists a glyph toggle and reloads it when the editor is reopened", () => {
|
||||
const druid = createCharacter({
|
||||
ownerId: "offline-roster", name: "Leafkeeper", classId: "druid",
|
||||
raceId: "night-elf", gender: "female", appearance: createDefaultAppearance(),
|
||||
specializationId: "druid:restoration",
|
||||
}, storage).character!;
|
||||
useShellStore.setState({
|
||||
selectedCharacterId: druid.id,
|
||||
characters: [druid],
|
||||
});
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
|
||||
expect(useCombatStore.getState().glyphIds).not.toContain(WOW335_GLYPH_OF_SWIFTMEND_ID);
|
||||
expect(useCombatStore.getState().classId).toBe("druid");
|
||||
expect(useCombatStore.getState().setGlyphActive(WOW335_GLYPH_OF_SWIFTMEND_ID, true)).toBe(true);
|
||||
expect(useCombatStore.getState().glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
|
||||
expect(listCharacters("offline-roster", storage).find((character) => character.id === druid.id)?.glyphIds)
|
||||
.toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
|
||||
expect(selectedCharacter(useShellStore.getState())?.glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
|
||||
|
||||
stop!();
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
expect(useCombatStore.getState().glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
|
||||
useCombatStore.getState().setGlyphActive(WOW335_GLYPH_OF_SWIFTMEND_ID, false);
|
||||
expect(listCharacters("offline-roster", storage).find((character) => character.id === druid.id)?.glyphIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists controller assignments and reloads them without touching progression or equipment", () => {
|
||||
const profile = selectedCharacter(useShellStore.getState())!;
|
||||
stop = beginMainMenuCharacterEditing(storage);
|
||||
|
||||
@@ -24,16 +24,19 @@ export function beginMainMenuCharacterEditing(storage?: StorageLike): (() => voi
|
||||
const profile = selectedCharacter(current);
|
||||
if (current.phase !== "main-menu" || current.session?.ownerId !== ownerId
|
||||
|| profile?.id !== characterId || profile.ownerId !== ownerId) return;
|
||||
if (combat.talentRanks === previous.talentRanks && combat.actionBindings === previous.actionBindings) return;
|
||||
if (combat.talentRanks === previous.talentRanks
|
||||
&& combat.glyphIds === previous.glyphIds
|
||||
&& combat.actionBindings === previous.actionBindings) return;
|
||||
|
||||
try {
|
||||
let updated = profile;
|
||||
if (combat.talentRanks !== previous.talentRanks) {
|
||||
if (combat.talentRanks !== previous.talentRanks || combat.glyphIds !== previous.glyphIds) {
|
||||
const saved = updateCharacterProgression(ownerId, characterId, {
|
||||
// Menu edits cannot award experience or alter the character's level.
|
||||
level: profile.level,
|
||||
experience: profile.experience,
|
||||
talentRanks: combat.talentRanks,
|
||||
glyphIds: combat.glyphIds,
|
||||
}, storage);
|
||||
if (!saved) throw new Error("Character storage is unavailable.");
|
||||
updated = saved;
|
||||
|
||||
@@ -114,7 +114,7 @@ export interface ShellState {
|
||||
/** Compatibility alias for the original single-dungeon entry point. */
|
||||
enterWorld: () => boolean;
|
||||
returnToCharacters: () => void;
|
||||
saveActiveProgression: (level: number, experience: number, talentRanks: Record<string, number>) => void;
|
||||
saveActiveProgression: (level: number, experience: number, talentRanks: Record<string, number>, glyphIds?: readonly string[]) => void;
|
||||
saveActiveActionBindings: (actionBindings: Readonly<Record<string, string | null>>) => void;
|
||||
saveActiveInventory: (inventory: readonly InventoryItem[]) => void;
|
||||
saveAccountBossCards: (bossCards: BossCardCollection) => void;
|
||||
@@ -533,13 +533,14 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
ownerId: session.ownerId, characterId: selectedCharacterId, phase: "characters",
|
||||
});
|
||||
},
|
||||
saveActiveProgression: (level, experience, talentRanks) => {
|
||||
saveActiveProgression: (level, experience, talentRanks, glyphIds) => {
|
||||
const { session, activeCharacter } = get();
|
||||
if (!session || !activeCharacter) return;
|
||||
const updated = updateCharacterProgression(session.ownerId, activeCharacter.id, {
|
||||
level,
|
||||
experience,
|
||||
talentRanks,
|
||||
glyphIds,
|
||||
});
|
||||
if (!updated) return;
|
||||
set((state) => ({
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface CharacterProfile {
|
||||
level: number;
|
||||
experience: number;
|
||||
talentRanks: Record<string, number>;
|
||||
/** Active WoW glyph IDs in the character's saved build; normalized legacy profiles may omit this field. */
|
||||
glyphIds?: readonly string[];
|
||||
actionBindings: Record<string, string | null>;
|
||||
/** Bindings keyed by ordered `primary:secondary` class pair. */
|
||||
actionLoadouts?: Record<string, Record<string, string | null>>;
|
||||
|
||||
@@ -93,11 +93,12 @@ export function CombatBridge() {
|
||||
state.level !== previousState.level
|
||||
|| state.xp !== previousState.xp
|
||||
|| state.talentRanks !== previousState.talentRanks
|
||||
|| state.glyphIds !== previousState.glyphIds
|
||||
) {
|
||||
// Kills and talent changes are infrequent, so persist immediately. This
|
||||
// also guarantees a fast return to Character Select cannot outrun a
|
||||
// deferred save and lose the last level or talent point.
|
||||
shell.saveActiveProgression(state.level, state.xp, state.talentRanks);
|
||||
shell.saveActiveProgression(state.level, state.xp, state.talentRanks, state.glyphIds);
|
||||
if (state.level !== previousState.level) useCombatStore.getState().refreshResolvedGear();
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ export interface AbilityDamageResource {
|
||||
}
|
||||
|
||||
export type AbilityEffect =
|
||||
| ({ readonly kind: "damage"; readonly coefficient: number; readonly school?: DamageSchool; readonly falloffPerJump?: number; readonly bonusPerSunderStack?: number; readonly bonusFlat?: number } & AbilityAmountScaling & AbilityDamageResource)
|
||||
| ({ readonly kind: "damage"; readonly coefficient: number; readonly school?: DamageSchool; readonly weaponHand?: "main-hand" | "off-hand"; readonly falloffPerJump?: number; readonly bonusPerSunderStack?: number; readonly bonusFlat?: number } & AbilityAmountScaling & AbilityDamageResource)
|
||||
| {
|
||||
readonly kind: "finisher-damage";
|
||||
readonly baseCoefficient: number;
|
||||
|
||||
+60
-7
@@ -157,6 +157,7 @@ import {
|
||||
wow335DruidFeralInstinctEnrageAura,
|
||||
wow335DruidFeralInstinctTigerAura,
|
||||
} from "./wow335DruidTalentRuntime";
|
||||
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
|
||||
import {
|
||||
WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG,
|
||||
WOW335_WARLOCK_METAMORPHOSIS_IMMOLATION_TAG,
|
||||
@@ -922,6 +923,7 @@ export interface CharacterCombatSeed {
|
||||
/** Backward-compatible profile field accepted by the integration bridge. */
|
||||
readonly experience?: number;
|
||||
readonly talentRanks?: TalentRanks;
|
||||
readonly glyphIds?: readonly string[];
|
||||
readonly actionBindings?: unknown;
|
||||
readonly inventory?: unknown;
|
||||
readonly bossCards?: unknown;
|
||||
@@ -1084,6 +1086,7 @@ export interface CombatState {
|
||||
lastCombatResult: CombatResult | null;
|
||||
bossLootRewards: readonly BossLootReward[];
|
||||
talentRanks: TalentRanks;
|
||||
glyphIds: readonly string[];
|
||||
talentModifiers: readonly TalentModifier[];
|
||||
actionBindings: ActionBindings;
|
||||
inventory: readonly InventoryItem[];
|
||||
@@ -1181,6 +1184,7 @@ export interface CombatState {
|
||||
readonly preserveDefeatedIds?: readonly string[];
|
||||
}) => void;
|
||||
allocateTalent: (treeId: string) => boolean;
|
||||
setGlyphActive: (glyphId: string, active: boolean) => boolean;
|
||||
resetTalents: () => void;
|
||||
setActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, abilityId: string | null) => boolean;
|
||||
resetActionBindingLayer: (layer: ActionBindingLayer) => void;
|
||||
@@ -1763,6 +1767,7 @@ function talentAdjustedMaxHealth(
|
||||
|
||||
function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkAttackKind {
|
||||
if (romWarriorPhysicalWeaponTooltip(ability)) return "melee";
|
||||
if (ability.effects.some((effect) => effect.kind === "damage" && effect.weaponHand)) return "melee";
|
||||
if (ability.source === "runewaker") {
|
||||
const sourceHit = ability.effects.find((effect) => effect.kind === "damage" || effect.kind === "dot");
|
||||
if (sourceHit && (sourceHit.kind === "damage" || sourceHit.kind === "dot") && sourceHit.school && sourceHit.school !== "physical") {
|
||||
@@ -7818,6 +7823,7 @@ function initialState() {
|
||||
lastCombatResult: null,
|
||||
bossLootRewards: [] as readonly BossLootReward[],
|
||||
talentRanks,
|
||||
glyphIds: [],
|
||||
talentModifiers,
|
||||
actionBindings: defaultActionBindings(defaultActionBarForClass(classId)),
|
||||
inventory: [] as readonly InventoryItem[],
|
||||
@@ -7843,6 +7849,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const progression = normalizeProgression(profile.level ?? 1, profile.xp ?? profile.experience ?? 0);
|
||||
const resource = resourceProfileForClass(classId);
|
||||
const talentRanks = normalizeTalentRanks(classId, progression.level, profile.talentRanks ?? {});
|
||||
const glyphIds = classId === "druid" && profile.glyphIds?.includes(WOW335_GLYPH_OF_SWIFTMEND_ID)
|
||||
? [WOW335_GLYPH_OF_SWIFTMEND_ID]
|
||||
: [];
|
||||
const talentModifiers = characterStatModifiers(classId, progression.level, talentRanks);
|
||||
const abilities = abilitiesForCharacter(classId, secondaryClassId);
|
||||
const inventory = normalizeInventory(profile.inventory);
|
||||
@@ -7898,6 +7907,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
deathKnightRunes: classId === "death-knight" ? createDeathKnightRunes() : [],
|
||||
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, combatNow()),
|
||||
talentRanks,
|
||||
glyphIds,
|
||||
talentModifiers,
|
||||
actionBindings: withoutUnlearnedTalentBindings(
|
||||
normalizeActionBindings(profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level)),
|
||||
@@ -10243,6 +10253,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const shamanLavaLashOffhandSpeedMs = state.classId === "shaman" && ability.name === "Lava Lash"
|
||||
? playerEquippedMeleeWeapon(state, "off-hand")?.speedMs
|
||||
: undefined;
|
||||
const shamanStormstrikeOffhandWeapon = state.classId === "shaman" && ability.name === "Stormstrike"
|
||||
? playerEquippedMeleeWeapon(state, "off-hand")
|
||||
: null;
|
||||
let shamanTidalWavesProcAttempted = false;
|
||||
const combatEventIdBeforeAbilityEffects = work.combatEvents.at(-1)?.id ?? 0;
|
||||
let suddenDoomTrigger: ReturnType<typeof wow335DeathKnightSuddenDoomTriggerForLandedHit> = null;
|
||||
@@ -10267,6 +10280,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
)
|
||||
) continue;
|
||||
if (effect.kind === "damage") {
|
||||
const weaponHitProfile = effect.weaponHand === "main-hand"
|
||||
? playerAutoAttackProfile(
|
||||
state,
|
||||
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID),
|
||||
playerEquippedMeleeWeapon(state, "main-hand") ?? undefined,
|
||||
)
|
||||
: effect.weaponHand === "off-hand" && shamanStormstrikeOffhandWeapon
|
||||
? playerOffhandAutoAttackProfile(
|
||||
state,
|
||||
shamanStormstrikeOffhandWeapon,
|
||||
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID),
|
||||
)
|
||||
: null;
|
||||
if (effect.weaponHand && !weaponHitProfile) continue;
|
||||
const amount = talentAdjustedAmount(
|
||||
(() => {
|
||||
const conflagrateDot = state.classId === "warlock" && ability.name === "Conflagrate"
|
||||
@@ -10274,7 +10301,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
: null;
|
||||
const sourceAbility = conflagrateDot ? abilityById(conflagrateDot.sourceAbilityId) : null;
|
||||
const burstSeconds = sourceAbility?.name === "Immolate" ? 12 : 8;
|
||||
const baseDamage = conflagrateDot && sourceAbility
|
||||
const baseDamage = weaponHitProfile
|
||||
? weaponHitProfile.damage * effect.coefficient
|
||||
: conflagrateDot && sourceAbility
|
||||
? (conflagrateDot.fixedAmount ?? equipmentAdjustedCombatAmount(
|
||||
conflagrateDot,
|
||||
work.progression.level,
|
||||
@@ -10326,13 +10355,19 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
castThreatSource,
|
||||
effectSchool === "physical" ? attackKind : "spell",
|
||||
effectSchool,
|
||||
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct`,
|
||||
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct${effect.weaponHand ? `:${effect.weaponHand}` : ""}`,
|
||||
ability,
|
||||
false,
|
||||
ability.name === "Shiv" || (consumesSurgeOfLight && ability.name === "Smite")
|
||||
? { canCrit: false } : {},
|
||||
mageArcanePotencyCriticalChanceBonusPercent,
|
||||
shamanLavaLashOffhandSpeedMs,
|
||||
effect.weaponHand === "main-hand"
|
||||
? playerEquippedMeleeWeapon(state, "main-hand")?.speedMs
|
||||
?? work.gearStats.meleeWeapon?.speedMs
|
||||
?? WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS
|
||||
: effect.weaponHand === "off-hand"
|
||||
? shamanStormstrikeOffhandWeapon?.speedMs
|
||||
: shamanLavaLashOffhandSpeedMs,
|
||||
);
|
||||
if (state.classId === "death-knight"
|
||||
&& effect.kind === "damage"
|
||||
@@ -10678,7 +10713,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
healingTargetHealth,
|
||||
healingTargetMaxHealth,
|
||||
);
|
||||
if (swiftmendHot) {
|
||||
if (swiftmendHot && !state.glyphIds.includes(WOW335_GLYPH_OF_SWIFTMEND_ID)) {
|
||||
work.effects = work.effects.filter((candidate) => candidate.id !== swiftmendHot.id);
|
||||
}
|
||||
const riptideAdjustedAmount = shamanRiptideHot
|
||||
@@ -16405,10 +16440,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
&& (!autoAttackSource || isPositionInFacingArc(autoAttackSource, autoAttackTargetPosition ?? state.playerPosition, Math.PI))
|
||||
);
|
||||
if (canAutoAttack) {
|
||||
const mainHandWeapon = state.classId === "rogue"
|
||||
const supportsPlayerOffhandSwings = state.classId === "rogue" || state.classId === "shaman";
|
||||
const mainHandWeapon = supportsPlayerOffhandSwings
|
||||
? playerEquippedMeleeWeapon(state, "main-hand")
|
||||
: null;
|
||||
const offHandWeapon = state.classId === "rogue"
|
||||
const offHandWeapon = supportsPlayerOffhandSwings
|
||||
? playerEquippedMeleeWeapon(state, "off-hand")
|
||||
: null;
|
||||
const profile = playerAutoAttackProfile(
|
||||
@@ -16433,6 +16469,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
if (now >= nextAutoAttackAt) {
|
||||
const isDualWielding = Boolean(offHandWeapon);
|
||||
const mainHandSpeedMs = mainHandWeapon?.speedMs
|
||||
?? state.gearStats.meleeWeapon?.speedMs
|
||||
?? WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS;
|
||||
const autoAttackSchool = mainHandWeapon?.damageSchool
|
||||
?? (offHandWeapon ? "physical" : state.gearStats.meleeWeapon?.damageSchool)
|
||||
?? "physical";
|
||||
@@ -16449,6 +16488,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
undefined,
|
||||
false,
|
||||
{ isAutoAttack: true, isDualWielding },
|
||||
0,
|
||||
mainHandSpeedMs,
|
||||
);
|
||||
let autoAttackKilled = result.killed;
|
||||
const enchantedThrowActive = work.auras.some((aura) => (
|
||||
@@ -16542,7 +16583,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
nextOffhandAutoAttackAt = 0;
|
||||
}
|
||||
}
|
||||
if (state.classId !== "rogue" || !offHandWeapon || !autoAttackTargetId) {
|
||||
if (!supportsPlayerOffhandSwings || !offHandWeapon || !autoAttackTargetId) {
|
||||
nextOffhandAutoAttackAt = 0;
|
||||
} else if (now >= nextOffhandAutoAttackAt) {
|
||||
const offhandProfile = playerOffhandAutoAttackProfile(
|
||||
@@ -16565,6 +16606,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
undefined,
|
||||
false,
|
||||
{ isAutoAttack: true, isDualWielding: true },
|
||||
0,
|
||||
offHandWeapon.speedMs,
|
||||
);
|
||||
if (result.combatResult?.landed) {
|
||||
maybeProcRogueCombatPotencyInWork(work, offhandSeed, now);
|
||||
@@ -17910,6 +17953,16 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
};
|
||||
}),
|
||||
|
||||
setGlyphActive: (glyphId, active) => {
|
||||
const state = get();
|
||||
if (state.classId !== "druid" || glyphId !== WOW335_GLYPH_OF_SWIFTMEND_ID) return false;
|
||||
const glyphIds = active
|
||||
? [WOW335_GLYPH_OF_SWIFTMEND_ID]
|
||||
: state.glyphIds.filter((id) => id !== WOW335_GLYPH_OF_SWIFTMEND_ID);
|
||||
if (glyphIds.length !== state.glyphIds.length) set({ glyphIds });
|
||||
return true;
|
||||
},
|
||||
|
||||
allocateTalent: (treeId) => {
|
||||
const state = get();
|
||||
const result = allocateTalentPoint(state.talentRanks, state.classId, treeId, state.level);
|
||||
|
||||
@@ -23,10 +23,11 @@ import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||
import { abilitiesForClass, abilityAtLevel } from "./abilityCatalog";
|
||||
import { abilitiesForClass, abilityAtLevel, abilityTalentNodeId } from "./abilityCatalog";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
import { channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
|
||||
import { baseClassFor } from "../app/characterCatalog";
|
||||
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
|
||||
|
||||
function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
|
||||
@@ -130,6 +131,72 @@ describe("party combat runtime", () => {
|
||||
expect(effects.find(effect => effect.targetId === first.targetId)?.nextTickAt).toBe(first.nextTickAt);
|
||||
});
|
||||
|
||||
it.each([false, true])("companion Swiftmend preserves the HoT only with the configured glyph (%s)", (glyphActive) => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const druidAbilities = abilitiesForClass("druid");
|
||||
const swiftmendBase = druidAbilities.find((ability) => ability.name === "Swiftmend")!;
|
||||
const swiftmendNodeId = abilityTalentNodeId(swiftmendBase)!;
|
||||
const swiftmend = abilityAtLevel(swiftmendBase, 80);
|
||||
const rejuvenation = abilityAtLevel(druidAbilities.find((ability) => ability.name === "Rejuvenation")!, 80);
|
||||
const rejuvenationHot = rejuvenation.effects.find((effect) => effect.kind === "hot")!;
|
||||
const druid = createConfiguredPartyMember({
|
||||
name: "Configured Druid",
|
||||
classId: "druid",
|
||||
specialization: "Restoration",
|
||||
specializationId: "druid:restoration",
|
||||
talentTreeId: "druid-restoration",
|
||||
talentRanks: { [swiftmendNodeId]: 1 },
|
||||
glyphIds: glyphActive ? [WOW335_GLYPH_OF_SWIFTMEND_ID] : [],
|
||||
role: "healer",
|
||||
}, 80, 0, "party-swiftmend-glyph");
|
||||
const target = createConfiguredPartyMember({
|
||||
name: "Injured Tank",
|
||||
classId: "warrior",
|
||||
specialization: "Protection",
|
||||
specializationId: "warrior:protection",
|
||||
talentTreeId: "warrior-protection",
|
||||
role: "tank",
|
||||
}, 80, 1, "party-swiftmend-glyph");
|
||||
const startedAt = 1_000;
|
||||
usePartyStore.getState().initializeParty("party-swiftmend-glyph", 80,
|
||||
useCombatStore.getState().health, "damage");
|
||||
usePartyStore.setState({ members: [
|
||||
{ ...target, health: 100 },
|
||||
{
|
||||
...druid,
|
||||
activeCast: {
|
||||
abilityId: swiftmend.id,
|
||||
school: "nature",
|
||||
mode: "cast",
|
||||
startedAt,
|
||||
completesAt: startedAt,
|
||||
originalDurationMs: 0,
|
||||
pushbackHits: 0,
|
||||
origin: [0, 0, 0],
|
||||
targetId: target.id,
|
||||
},
|
||||
activeCastTargetId: target.id,
|
||||
status: "healing",
|
||||
},
|
||||
] });
|
||||
registerPartyRuntimePosition(druid.id, [0, 0, 0]);
|
||||
registerPartyRuntimePosition(target.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
expect(useCombatStore.getState().applyTimedActorEffect(
|
||||
druid.id, target.id, rejuvenation, rejuvenationHot, startedAt,
|
||||
)).toBe(true);
|
||||
const originalHot = useCombatStore.getState().effects.find((effect) => (
|
||||
effect.kind === "hot" && effect.sourceActorId === druid.id && effect.targetId === target.id
|
||||
))!;
|
||||
|
||||
advanceDungeonPartyCombat(startedAt);
|
||||
|
||||
expect(useCombatStore.getState().actors[target.id]!.health).toBeGreaterThan(100);
|
||||
expect(useCombatStore.getState().effects.some((effect) => effect.id === originalHot.id)).toBe(glyphActive);
|
||||
expect(usePartyStore.getState().members.find((member) => member.id === druid.id)?.glyphIds)
|
||||
.toEqual(glyphActive ? [WOW335_GLYPH_OF_SWIFTMEND_ID] : []);
|
||||
});
|
||||
|
||||
it("refreshes a companion Priest's Shadow Word: Pain from its own completed Mind Blast", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const priest = createConfiguredPartyMember({
|
||||
|
||||
@@ -37,6 +37,7 @@ import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore
|
||||
import { baseClassFor, classById } from "../app/characterCatalog";
|
||||
import { useGameStore } from "./store";
|
||||
import { useManastormStore } from "./manastormStore";
|
||||
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
|
||||
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
|
||||
import { PLAYER_AGGRO_ID, type ThreatSource } from "./aggro";
|
||||
import {
|
||||
@@ -296,6 +297,24 @@ function partyShamanRiptideHotOnTarget(memberId: string, targetId: string, now:
|
||||
&& effect.nextTickAt + Math.max(0, effect.remainingTicks - 1) * effect.intervalMs > now);
|
||||
}
|
||||
|
||||
function partySwiftmendHotOnTarget(memberId: string, targetId: string, now: number) {
|
||||
const candidates = useCombatStore.getState().effects.filter((effect) => {
|
||||
if (effect.kind !== "hot" || (effect.targetId ?? PLAYER_AGGRO_ID) !== targetId
|
||||
|| effect.remainingTicks <= 0
|
||||
|| effect.nextTickAt + Math.max(0, effect.remainingTicks - 1) * effect.intervalMs <= now) return false;
|
||||
const name = abilityById(effect.sourceAbilityId)?.name;
|
||||
return name === "Rejuvenation" || name === "Regrowth";
|
||||
});
|
||||
const own = candidates.filter((effect) => effect.sourceActorId === memberId);
|
||||
const pool = own.length ? own : candidates;
|
||||
return pool.reduce<typeof pool[number] | undefined>((shortest, candidate) => {
|
||||
if (!shortest) return candidate;
|
||||
const expiry = candidate.nextTickAt + Math.max(0, candidate.remainingTicks - 1) * candidate.intervalMs;
|
||||
const shortestExpiry = shortest.nextTickAt + Math.max(0, shortest.remainingTicks - 1) * shortest.intervalMs;
|
||||
return expiry < shortestExpiry ? candidate : shortest;
|
||||
}, undefined);
|
||||
}
|
||||
|
||||
function maybeProcPartyShamanImprovedWaterShield(
|
||||
member: PartyMember,
|
||||
ability: AbilityDefinition,
|
||||
@@ -1349,6 +1368,8 @@ export function partyAbilityTargetIsValid(
|
||||
.flatMap((aura) => aura.definition.utilityTags ?? []);
|
||||
if (!romPriestWarriorStanceUseAllowed(ability, activeAuraTags)) return false;
|
||||
}
|
||||
if (member.classId === "druid" && ability.name === "Swiftmend"
|
||||
&& !partySwiftmendHotOnTarget(member.id, targetId, now)) return false;
|
||||
if (member.classId === "warlock") {
|
||||
const metamorphosisActive = combat.auras.some((aura) => aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes(WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG)
|
||||
@@ -2186,6 +2207,18 @@ function executePartyAbility(
|
||||
&& targetIndex === 0
|
||||
? partyShamanRiptideHotOnTarget(member.id, friendlyTargetId, now)
|
||||
: undefined;
|
||||
const swiftmendHot = member.classId === "druid" && ability.name === "Swiftmend"
|
||||
? partySwiftmendHotOnTarget(member.id, friendlyTargetId, now)
|
||||
: undefined;
|
||||
const swiftmendHotAbility = swiftmendHot ? abilityById(swiftmendHot.sourceAbilityId) : null;
|
||||
const swiftmendHotEffect = swiftmendHotAbility?.effects.find((candidate) => candidate.kind === "hot");
|
||||
const swiftmendTickCount = swiftmendHotAbility?.name === "Rejuvenation" ? 4
|
||||
: swiftmendHotAbility?.name === "Regrowth" ? 6
|
||||
: 0;
|
||||
const swiftmendAmount = swiftmendHot && swiftmendHotAbility && swiftmendHotEffect?.kind === "hot"
|
||||
? (swiftmendHot.fixedAmount ?? partyAbilityAmount(runtimeMember, swiftmendHotAbility, swiftmendHotEffect))
|
||||
* swiftmendTickCount
|
||||
: null;
|
||||
if (riptideHot) {
|
||||
useCombatStore.setState((state) => ({
|
||||
effects: state.effects.filter((candidate) => candidate.id !== riptideHot.id),
|
||||
@@ -2196,7 +2229,7 @@ function executePartyAbility(
|
||||
const healingSchool = member.classId === "shaman"
|
||||
? abilityDamageSchool("shaman", ability)
|
||||
: spellSchool;
|
||||
const requested = Math.round((partyAbilityAmount(runtimeMember, ability, effect)
|
||||
const requested = Math.round(((swiftmendAmount ?? partyAbilityAmount(runtimeMember, ability, effect))
|
||||
+ tidalWavesBonus
|
||||
+ Math.round(actor.maxHealth * (effect.percentMaxHealth ?? 0)))
|
||||
* (riptideHot ? PARTY_SHAMAN_RIPTIDE_CHAIN_HEAL_MULTIPLIER : 1));
|
||||
@@ -2208,6 +2241,11 @@ function executePartyAbility(
|
||||
seed: `${member.id}:${ability.id}:${friendlyTargetId}:${now}:heal`,
|
||||
now,
|
||||
});
|
||||
if (swiftmendHot && !runtimeMember.glyphIds?.includes(WOW335_GLYPH_OF_SWIFTMEND_ID)) {
|
||||
useCombatStore.setState((state) => ({
|
||||
effects: state.effects.filter((candidate) => candidate.id !== swiftmendHot.id),
|
||||
}));
|
||||
}
|
||||
effectiveAmount += result.effectiveAmount;
|
||||
directHealingRawAmount += result.rawAmount;
|
||||
runtimeMember = maybeProcPartyShamanImprovedWaterShield(
|
||||
|
||||
@@ -17,6 +17,7 @@ import { spentTalentPoints, talentPointsForLevel, talentTreeById } from "./talen
|
||||
import type { InventoryItem } from "./lootTypes";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { DEFAULT_COMPANION_SPEC_READINESS_POLICY } from "./companionSpecReadiness";
|
||||
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
|
||||
|
||||
describe("dungeon party composition", () => {
|
||||
beforeEach(() => usePartyStore.setState({
|
||||
@@ -183,6 +184,30 @@ describe("dungeon party composition", () => {
|
||||
expect(cat.resourcePools).toMatchObject({ mana: cat.maxResource, rage: 0, energy: 100 });
|
||||
});
|
||||
|
||||
it("keeps generated companions unglyphed unless a configured Druid has Swiftmend glyph", () => {
|
||||
const generated = createDungeonParty("unglyphed-defaults", 80);
|
||||
expect(generated.every((member) => !member.glyphIds?.length)).toBe(true);
|
||||
|
||||
const configuredDruid = createConfiguredPartyMember({
|
||||
name: "Glyphkeeper",
|
||||
classId: "druid",
|
||||
specialization: "Restoration",
|
||||
specializationId: "druid:restoration",
|
||||
role: "healer",
|
||||
glyphIds: [WOW335_GLYPH_OF_SWIFTMEND_ID, WOW335_GLYPH_OF_SWIFTMEND_ID, "not-a-glyph"],
|
||||
}, 80, 0, "configured-glyphs");
|
||||
const unconfiguredDruid = createConfiguredPartyMember({
|
||||
name: "Plainleaf",
|
||||
classId: "druid",
|
||||
specialization: "Restoration",
|
||||
specializationId: "druid:restoration",
|
||||
role: "healer",
|
||||
}, 80, 1, "configured-glyphs");
|
||||
|
||||
expect(configuredDruid.glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
|
||||
expect(unconfiguredDruid.glyphIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("is deterministic per character and scales every ally to the player level", () => {
|
||||
const first = createDungeonParty("character-42", 18);
|
||||
const second = createDungeonParty("character-42", 18);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type DamageSchool,
|
||||
type ItemStats,
|
||||
} from "./itemStats";
|
||||
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
|
||||
import {
|
||||
damageAfterEquipmentMitigation,
|
||||
deriveCharacterStats,
|
||||
@@ -87,6 +88,8 @@ export interface PartyMember {
|
||||
readonly talentTreeId: string;
|
||||
readonly talentProfileId: string | null;
|
||||
readonly talentRanks: TalentRanks;
|
||||
/** Explicitly configured WoW glyphs; generated companions have none by default. */
|
||||
readonly glyphIds?: readonly string[];
|
||||
readonly role: PartyRole;
|
||||
readonly level: number;
|
||||
readonly health: number;
|
||||
@@ -134,6 +137,7 @@ export interface ConfiguredPartyMemberSeed {
|
||||
readonly talentTreeId?: string;
|
||||
readonly talentProfileId?: string | null;
|
||||
readonly talentRanks?: TalentRanks;
|
||||
readonly glyphIds?: readonly string[];
|
||||
readonly role: PartyRole;
|
||||
}
|
||||
|
||||
@@ -438,6 +442,9 @@ export function createConfiguredPartyMember(
|
||||
...member,
|
||||
specializationId: configuration.specializationId ?? member.specializationId,
|
||||
talentRanks: configuration.talentRanks ?? member.talentRanks,
|
||||
glyphIds: configuration.classId === "druid"
|
||||
? [...new Set((configuration.glyphIds ?? []).filter((glyphId) => glyphId === WOW335_GLYPH_OF_SWIFTMEND_ID))]
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,35 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
||||
expect(byId("wow335-priest-power-word-shield").gcdMs).toBe(1_500);
|
||||
});
|
||||
|
||||
it("maps Stormstrike's generated weapon-hit children to their attacking hands", () => {
|
||||
const stormstrike = abilityAtLevel(byId("wow335-shaman-stormstrike"), 80);
|
||||
const chain = generatedData.classes.shaman.find((candidate) => candidate.name === "Stormstrike");
|
||||
expect(stormstrike.dbcSpellId).toBe(17364);
|
||||
expect(chain).toBeDefined();
|
||||
|
||||
const expectedHits = [32175, 32176].map((spellId) => {
|
||||
const weaponEffect = chain!.ranks.find((rank) => rank.spellId === spellId)!
|
||||
.effects.find((effect) => effect.effectType === 58)!;
|
||||
expect(weaponEffect.coefficient).toBe(1);
|
||||
return {
|
||||
kind: "damage" as const,
|
||||
coefficient: weaponEffect.coefficient,
|
||||
school: "physical" as const,
|
||||
weaponHand: spellId === 32175 ? "main-hand" as const : "off-hand" as const,
|
||||
};
|
||||
});
|
||||
|
||||
expect(effectsOfKind(stormstrike, "damage")).toEqual(expectedHits);
|
||||
expect(stormstrike.effects).not.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "trigger-spell", spellId: 32175 }),
|
||||
expect.objectContaining({ kind: "trigger-spell", spellId: 32176 }),
|
||||
]));
|
||||
expect(WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID[32175]).toBeUndefined();
|
||||
expect(WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID[32176]).toBeUndefined();
|
||||
expect(WOW335_ABILITIES_BY_CLASS.shaman.some((ability) => [32175, 32176].includes(ability.dbcSpellId ?? 0)))
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
it("keeps Bloodthirst's player cast, next-hit aura, and triggered heal on their source rows", () => {
|
||||
const bloodthirst = abilityAtLevel(byId("wow335-warrior-bloodthirst"), 80);
|
||||
expect(bloodthirst).toMatchObject({
|
||||
|
||||
@@ -753,6 +753,32 @@ 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 stormstrikeWeaponHitForTrigger(
|
||||
spellId: number,
|
||||
): Extract<AbilityEffect, { kind: "damage" }> | null {
|
||||
const weaponHand = spellId === 32175
|
||||
? "main-hand"
|
||||
: spellId === 32176
|
||||
? "off-hand"
|
||||
: undefined;
|
||||
if (!weaponHand) return null;
|
||||
|
||||
// Stormstrike's wrapper invokes these two DBC child spells. Their local
|
||||
// generated SpellEffect rows contain the weapon-damage coefficient.
|
||||
const childRank = data.classes.shaman
|
||||
.find((chain) => chain.name === "Stormstrike")
|
||||
?.ranks.find((candidate) => candidate.spellId === spellId);
|
||||
const weaponEffect = childRank?.effects.find((effect) => WEAPON_DAMAGE_EFFECTS.has(effect.effectType));
|
||||
if (!weaponEffect) return null;
|
||||
|
||||
const coefficient = weaponEffect.coefficient > 0
|
||||
? weaponEffect.coefficient
|
||||
: weaponEffect.effectType === 31
|
||||
? Math.max(0.1, Math.abs(weaponEffect.basePoints) / 100)
|
||||
: 1;
|
||||
return { kind: "damage", coefficient, school: "physical", weaponHand };
|
||||
}
|
||||
|
||||
function utilityAction(effectType: number): Extract<AbilityEffect, { kind: "utility" }>["action"] {
|
||||
if (effectType === 24) return "create-item";
|
||||
if (effectType === 33) return "open-lock";
|
||||
@@ -1877,6 +1903,13 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|
||||
continue;
|
||||
}
|
||||
if (TRIGGER_SPELL_EFFECTS.has(effect.effectType) && effect.triggerSpellId > 0) {
|
||||
if (classId === "shaman" && abilityName === "Stormstrike" && rank.spellId === 17364) {
|
||||
const weaponHit = stormstrikeWeaponHitForTrigger(effect.triggerSpellId);
|
||||
if (weaponHit) {
|
||||
effects.push(weaponHit);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
effects.push({
|
||||
kind: "trigger-spell",
|
||||
spellId: effect.triggerSpellId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { abilityTalentNodeId, type AbilityDefinition } from "./abilityCatalog";
|
||||
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
|
||||
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||
import { useCombatStore, type TimedCombatEffect } from "./combatStore";
|
||||
@@ -102,4 +103,17 @@ describe("WoW Druid active talent runtime", () => {
|
||||
expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, 400);
|
||||
expect(useCombatStore.getState().effects.map((effect) => effect.id)).toEqual([longerRegrowth.id]);
|
||||
});
|
||||
|
||||
it("preserves the selected HoT when Glyph of Swiftmend is active", () => {
|
||||
const swiftmend = prepareSwiftmend();
|
||||
const sourceHot = hot("Rejuvenation", PLAYER_AGGRO_ID, 100, 5, 4_000, "swiftmend-glyph-hot");
|
||||
useCombatStore.setState({ effects: [sourceHot] });
|
||||
expect(useCombatStore.getState().setGlyphActive(WOW335_GLYPH_OF_SWIFTMEND_ID, true)).toBe(true);
|
||||
|
||||
const event = castSwiftmend(swiftmend);
|
||||
|
||||
expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, 400);
|
||||
expect(useCombatStore.getState().glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
|
||||
expect(useCombatStore.getState().effects).toEqual([sourceHot]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Stable character-build ID for the WotLK 3.3.5a Glyph of Swiftmend. */
|
||||
export const WOW335_GLYPH_OF_SWIFTMEND_ID = "wow335:54824";
|
||||
@@ -124,6 +124,85 @@ describe("WoW Shaman Maelstrom Weapon combat runtime", () => {
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the off-hand speed for Shaman off-hand auto-attack Maelstrom rolls", () => {
|
||||
setMaelstromRank(3);
|
||||
useCombatStore.setState((state) => ({
|
||||
inventory: state.inventory.map((item) => item.instanceId === OFF_HAND_WEAPON_ID
|
||||
? { ...item, stats: { ...item.stats, meleeWeapon: { ...item.stats.meleeWeapon!, speedMs: 2_000 } } }
|
||||
: item),
|
||||
gearStats: { ...state.gearStats, hitRating: 10_000, expertiseRating: 10_000 },
|
||||
autoAttackTargetId: TARGET_ID,
|
||||
nextAutoAttackAt: 1_000,
|
||||
nextOffhandAutoAttackAt: 1_000,
|
||||
}));
|
||||
const mainHandRoll = (now: number) => stableCombatUnit(
|
||||
`${PLAYER_AURA_ENTITY_ID}:auto:${TARGET_ID}:${now}:wow335-shaman-maelstrom-weapon`,
|
||||
);
|
||||
const offHandRoll = (now: number) => stableCombatUnit(
|
||||
`${PLAYER_AURA_ENTITY_ID}:offhand-auto:${TARGET_ID}:${now}:wow335-shaman-maelstrom-weapon`,
|
||||
);
|
||||
const now = Array.from({ length: 50_000 }, (_, index) => 1_000 + index)
|
||||
.find((candidate) => mainHandRoll(candidate) >= 0.2
|
||||
&& offHandRoll(candidate) >= 0.2 && offHandRoll(candidate) < 0.4)!;
|
||||
expect(now).toBeDefined();
|
||||
|
||||
useCombatStore.getState().tick(0.1, now);
|
||||
|
||||
const offhandEvents = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage" && event.abilityId === "player-auto-attack-offhand"
|
||||
));
|
||||
expect(offhandEvents, JSON.stringify(offhandEvents)).toHaveLength(1);
|
||||
expect(mainHandRoll(now)).toBeGreaterThanOrEqual(0.2);
|
||||
expect(offHandRoll(now)).toBeLessThan(0.4);
|
||||
expect(maelstromAura(), JSON.stringify({
|
||||
now,
|
||||
roll: offHandRoll(now),
|
||||
rank: useCombatStore.getState().talentRanks["talent-2057"],
|
||||
weapon: useCombatStore.getState().inventory.find((item) => item.instanceId === OFF_HAND_WEAPON_ID)?.stats.meleeWeapon,
|
||||
offhandEvents,
|
||||
})).toMatchObject({ stacks: 1 });
|
||||
});
|
||||
|
||||
it("resolves Stormstrike's hands independently and uses each weapon speed for Maelstrom", () => {
|
||||
setMaelstromRank(3);
|
||||
useCombatStore.setState((state) => ({
|
||||
talentRanks: { ...state.talentRanks, "talent-901": 1 },
|
||||
inventory: state.inventory.map((item) => item.instanceId === OFF_HAND_WEAPON_ID
|
||||
? { ...item, stats: { ...item.stats, meleeWeapon: { ...item.stats.meleeWeapon!, speedMs: 2_000 } } }
|
||||
: item),
|
||||
gearStats: { ...state.gearStats, hitRating: 10_000, expertiseRating: 10_000 },
|
||||
}));
|
||||
const stormstrike = shamanAbility("Stormstrike");
|
||||
const mainHandRoll = (now: number) => stableCombatUnit(
|
||||
`${PLAYER_AURA_ENTITY_ID}:${stormstrike.id}:${TARGET_ID}:${now}:direct:main-hand:wow335-shaman-maelstrom-weapon`,
|
||||
);
|
||||
const offHandRoll = (now: number) => stableCombatUnit(
|
||||
`${PLAYER_AURA_ENTITY_ID}:${stormstrike.id}:${TARGET_ID}:${now}:direct:off-hand:wow335-shaman-maelstrom-weapon`,
|
||||
);
|
||||
const now = Array.from({ length: 50_000 }, (_, index) => 1_000 + index)
|
||||
.find((candidate) => mainHandRoll(candidate) >= 0.2
|
||||
&& offHandRoll(candidate) >= 0.2 && offHandRoll(candidate) < 0.4)!;
|
||||
expect(now).toBeDefined();
|
||||
|
||||
cast(stormstrike.id, now);
|
||||
|
||||
const hitEvents = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage" && event.abilityId === stormstrike.id && event.effectiveAmount > 0
|
||||
));
|
||||
expect(hitEvents, JSON.stringify(useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage" && event.abilityId === stormstrike.id
|
||||
)))).toHaveLength(2);
|
||||
expect(mainHandRoll(now)).toBeGreaterThanOrEqual(0.2);
|
||||
expect(offHandRoll(now)).toBeLessThan(0.4);
|
||||
expect(maelstromAura(), JSON.stringify({
|
||||
now,
|
||||
roll: offHandRoll(now),
|
||||
rank: useCombatStore.getState().talentRanks["talent-2057"],
|
||||
weapon: useCombatStore.getState().inventory.find((item) => item.instanceId === OFF_HAND_WEAPON_ID)?.stats.meleeWeapon,
|
||||
hitEvents,
|
||||
})).toMatchObject({ stacks: 1 });
|
||||
});
|
||||
|
||||
it("adds one stack per proc, refreshes the 30-second duration, and caps at five", () => {
|
||||
setMaelstromRank(3);
|
||||
const lavaLash = shamanAbility("Lava Lash");
|
||||
|
||||
@@ -1405,6 +1405,12 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.talents-panel__points strong { color: #f0d477; font: 700 27px Georgia, serif; }
|
||||
.talents-panel__points span { max-width: 55px; color: var(--muted); font-size: 8px; line-height: 1.1; text-transform: uppercase; }
|
||||
.talents-panel__intro { margin: 9px 0; color: var(--muted); font-size: 10px; }
|
||||
.talents-panel__glyphs { margin: 0 0 8px; padding: 7px 9px; border: 1px solid rgba(191,211,158,.15); border-radius: 6px; background: rgba(0,0,0,.22); }
|
||||
.talents-panel__glyph-option { display: flex; align-items: flex-start; gap: 8px; cursor: pointer; }
|
||||
.talents-panel__glyph-option input { margin: 2px 0 0; accent-color: #a9d47f; }
|
||||
.talents-panel__glyph-option span { display: grid; gap: 2px; }
|
||||
.talents-panel__glyph-option strong { color: var(--ink); font-size: 10px; }
|
||||
.talents-panel__glyph-option small { color: var(--muted); font-size: 8px; line-height: 1.35; }
|
||||
.talents-panel__tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin-bottom: 8px; }
|
||||
.talent-tree-tab { --talent-color: #76965e; display: grid; min-width: 0; min-height: 36px; grid-template-columns: 29px minmax(0, 1fr); align-items: center; gap: 7px; padding: 4px 7px; border: 1px solid rgba(193,211,160,.12); border-radius: 7px; background: rgba(255,255,255,.025); color: var(--ink); cursor: pointer; text-align: left; }
|
||||
.talent-tree-tab.is-active { border-color: color-mix(in srgb, var(--talent-color) 55%, #d7dcbd 18%); background: color-mix(in srgb, var(--talent-color) 22%, rgba(7,17,12,.9)); }
|
||||
|
||||
@@ -21,6 +21,8 @@ export default function TalentGameplayPanel({ readOnly = false, open, onClose }:
|
||||
const classId = useCombatStore((state) => state.classId);
|
||||
const level = useCombatStore((state) => state.level);
|
||||
const talentRanks = useCombatStore((state) => state.talentRanks);
|
||||
const glyphIds = useCombatStore((state) => state.glyphIds);
|
||||
const setGlyphActive = useCombatStore((state) => state.setGlyphActive);
|
||||
const classDefinition = classById(classId);
|
||||
const catalog = loadedTalentUiCatalog(classId) ?? use(loadTalentUiCatalog(classId));
|
||||
const talentTrees = useMemo(() => catalog.trees.map((tree) => ({
|
||||
@@ -41,6 +43,8 @@ export default function TalentGameplayPanel({ readOnly = false, open, onClose }:
|
||||
return <TalentsPanel
|
||||
trees={talentTrees}
|
||||
ranks={talentRanks}
|
||||
glyphIds={glyphIds}
|
||||
onGlyphActiveChange={setGlyphActive}
|
||||
classId={classId}
|
||||
playerLevel={level}
|
||||
availablePoints={runtimeUnspentTalentPoints(level, talentRanks, classId)}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "../game/wow335GlyphCatalog";
|
||||
import { TalentsPanel } from "./TalentsPanel";
|
||||
|
||||
function render(classId: "druid" | "mage", glyphIds: readonly string[] = []) {
|
||||
return renderToStaticMarkup(createElement(TalentsPanel, {
|
||||
open: true,
|
||||
trees: [{
|
||||
id: `${classId}-tree`,
|
||||
name: "Restoration",
|
||||
description: "Heal allies.",
|
||||
points: 0,
|
||||
color: "#83aa65",
|
||||
sigil: "R",
|
||||
backgroundUrl: "/talents.png",
|
||||
nodes: [],
|
||||
}],
|
||||
ranks: {},
|
||||
glyphIds,
|
||||
classId,
|
||||
playerLevel: 80,
|
||||
availablePoints: 0,
|
||||
onInvest: () => undefined,
|
||||
onReset: () => undefined,
|
||||
onGlyphActiveChange: () => undefined,
|
||||
onClose: () => undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("Glyph of Swiftmend build control", () => {
|
||||
it("shows the glyph unchecked by default for Druids", () => {
|
||||
const markup = render("druid");
|
||||
expect(markup).toContain("Glyph of Swiftmend");
|
||||
expect(markup).not.toContain('type="checkbox" checked=""');
|
||||
});
|
||||
|
||||
it("reflects a persisted active glyph and hides the option for other classes", () => {
|
||||
expect(render("druid", [WOW335_GLYPH_OF_SWIFTMEND_ID])).toContain('type="checkbox" checked=""');
|
||||
expect(render("mage", [WOW335_GLYPH_OF_SWIFTMEND_ID])).not.toContain("Glyph of Swiftmend");
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
TalentAllocationFailure,
|
||||
TalentRanks,
|
||||
} from "../game/talentCatalog";
|
||||
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "../game/wow335GlyphCatalog";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useMenuController, type MenuAction, type MenuDirection } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
@@ -29,6 +30,8 @@ export interface TalentsPanelProps {
|
||||
readOnly?: boolean;
|
||||
trees: readonly TalentTreeView[];
|
||||
ranks: TalentRanks;
|
||||
glyphIds?: readonly string[];
|
||||
onGlyphActiveChange?: (glyphId: string, active: boolean) => void;
|
||||
classId: ClassId;
|
||||
playerLevel: number;
|
||||
availablePoints: number;
|
||||
@@ -102,6 +105,8 @@ function failureText(
|
||||
function TalentsDialog({
|
||||
trees,
|
||||
ranks,
|
||||
glyphIds = [],
|
||||
onGlyphActiveChange,
|
||||
classId,
|
||||
playerLevel,
|
||||
availablePoints,
|
||||
@@ -251,6 +256,26 @@ function TalentsDialog({
|
||||
: "Your first talent point arrives at level 10. Every five points spent in a tree unlocks its next row."}
|
||||
</p>
|
||||
|
||||
{classId === "druid" && !isCoa && (
|
||||
<section className="talents-panel__glyphs" aria-label="Glyphs">
|
||||
<label className="talents-panel__glyph-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={glyphIds.includes(WOW335_GLYPH_OF_SWIFTMEND_ID)}
|
||||
disabled={readOnly || !onGlyphActiveChange}
|
||||
onChange={(event) => onGlyphActiveChange?.(
|
||||
WOW335_GLYPH_OF_SWIFTMEND_ID,
|
||||
event.currentTarget.checked,
|
||||
)}
|
||||
/>
|
||||
<span>
|
||||
<strong>Glyph of Swiftmend</strong>
|
||||
<small>Swiftmend no longer consumes your Rejuvenation or Regrowth effect.</small>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<nav className="talents-panel__tabs" aria-label="Talent trees">
|
||||
{trees.map((tree) => (
|
||||
<ControllerButton
|
||||
|
||||
Reference in new issue
Block a user