Update Healer Man server

This commit is contained in:
phenom committed 2026-09-30 13:25:55 -04:00
1 parent 4fb7a0b9c8
commit 4c768fbec6
24 files changed
+654 -22

No files matched your search

+38
View File
@@ -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({
+13 -2
View File
@@ -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;
});
+28
View File
@@ -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);
+5 -2
View File
@@ -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;
+3 -2
View File
@@ -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) => ({
+2
View File
@@ -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>>;
+2 -1
View File
@@ -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();
}
+1 -1
View File
@@ -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
View File
@@ -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);
+68 -1
View File
@@ -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({
+39 -1
View File
@@ -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(
+25
View File
@@ -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);
+7
View File
@@ -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))]
: [],
}));
}
+29
View File
@@ -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({
+33
View File
@@ -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,
+14
View File
@@ -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]);
});
});
+2
View File
@@ -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");
+6
View File
@@ -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)); }
+4
View File
@@ -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)}
+43
View File
@@ -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");
});
});
+25
View File
@@ -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