diff --git a/package-lock.json b/package-lock.json index 44d109d0..4bbbca55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.18", + "version": "0.1.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.18", + "version": "0.1.19", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index 40fde697..5907dc9b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.18", + "version": "0.1.19", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/release-version.json b/release-version.json index c9c0c1a7..e168c9fd 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.18", - "versionCode": 1018 + "versionName": "0.1.19", + "versionCode": 1019 } diff --git a/src/game/PartyBridge.tsx b/src/game/PartyBridge.tsx index 24b63799..d5613889 100644 --- a/src/game/PartyBridge.tsx +++ b/src/game/PartyBridge.tsx @@ -36,6 +36,8 @@ export function PartyBridge() { character.categoryId ?? "wow", ); usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory); + combat.synchronizeActors(); + combat.applyDefaultPartyBuffs(); resetPartyRuntimeClock(); }, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole]); @@ -44,6 +46,8 @@ export function PartyBridge() { const combat = useCombatStore.getState(); party.syncLevel(level); usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory); + combat.synchronizeActors(); + combat.applyDefaultPartyBuffs(); }, [level]); useEffect(() => { @@ -52,6 +56,8 @@ export function PartyBridge() { const combat = useCombatStore.getState(); usePartyStore.getState().resetCombat(combat.level, combat.health); usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory); + combat.synchronizeActors(); + combat.applyDefaultPartyBuffs(); resetPartyRuntimeClock(); }, [resetRevision]); diff --git a/src/game/combatAuraRuntime.test.ts b/src/game/combatAuraRuntime.test.ts index 45d1bf36..45a9d7fd 100644 --- a/src/game/combatAuraRuntime.test.ts +++ b/src/game/combatAuraRuntime.test.ts @@ -24,6 +24,8 @@ describe("named aura combat runtime", () => { it("applies and expires an imported named player buff", () => { const now = 10_000; + const baseHealth = useCombatStore.getState().health; + const baseMaxHealth = useCombatStore.getState().maxHealth; const result = useCombatStore.getState().castAbility( "wow335-priest-power-word-fortitude", undefined, @@ -46,9 +48,49 @@ describe("named aura combat runtime", () => { ))?.icon, }, }); + expect(useCombatStore.getState().maxHealth).toBeGreaterThan(baseMaxHealth); + expect(useCombatStore.getState().health - baseHealth).toBe( + useCombatStore.getState().maxHealth - baseMaxHealth, + ); useCombatStore.getState().tick(0, now + 1_800_000); expect(useCombatStore.getState().auras.some((aura) => aura.definition.id === fortitude?.definition.id)).toBe(false); + expect(useCombatStore.getState()).toMatchObject({ health: baseHealth, maxHealth: baseMaxHealth }); + }); + + it("grants learned long-term stat buffs to the full party on load", () => { + useCombatStore.getState().initializeCharacter({ classId: "priest", level: 8 }); + usePartyStore.getState().initializeParty("default-party-buffs", 8, useCombatStore.getState().health); + useCombatStore.getState().synchronizeActors(); + const playerMaxHealth = useCombatStore.getState().maxHealth; + const memberMaxHealth = new Map(usePartyStore.getState().members.map((member) => [member.id, member.maxHealth])); + + useCombatStore.getState().applyDefaultPartyBuffs(20_000); + + const state = useCombatStore.getState(); + const targetIds = [PLAYER_AGGRO_ID, ...usePartyStore.getState().members.map((member) => member.id)]; + for (const targetId of targetIds) { + expect(state.auras).toContainEqual(expect.objectContaining({ + targetId, + definition: expect.objectContaining({ exclusiveGroup: "default-party-buff:fortitude" }), + })); + } + expect(state.maxHealth).toBeGreaterThan(playerMaxHealth); + for (const member of usePartyStore.getState().members) { + expect(member.maxHealth).toBeGreaterThan(memberMaxHealth.get(member.id)!); + } + + const buffedPlayerMaxHealth = state.maxHealth; + expect(state.auras.filter((aura) => ( + aura.targetId === PLAYER_AGGRO_ID + && aura.definition.exclusiveGroup === "default-party-buff:fortitude" + ))).toHaveLength(1); + expect(state.castAbility("wow335-priest-power-word-fortitude", undefined, 21_000).ok).toBe(true); + expect(useCombatStore.getState().maxHealth).toBe(buffedPlayerMaxHealth); + expect(useCombatStore.getState().auras.filter((aura) => ( + aura.targetId === PLAYER_AGGRO_ID + && aura.definition.exclusiveGroup === "default-party-buff:fortitude" + ))).toHaveLength(1); }); it("consumes a named absorb before player health", () => { diff --git a/src/game/combatResources.test.ts b/src/game/combatResources.test.ts index 3553e5fa..a550071e 100644 --- a/src/game/combatResources.test.ts +++ b/src/game/combatResources.test.ts @@ -27,6 +27,18 @@ describe("authentic combat resources", () => { expect(outsideRule.current).toBe(62); }); + it("regenerates at least fifteen percent mana per five seconds out of combat", () => { + const mana = normalizeActorResourcePool("mana", 1_000, 3_000, 4, 9_000); + const regenerated = advanceCombatResourcePool(mana, { + inCombat: false, + actorLevel: 8, + now: 10_000, + deltaSeconds: 1, + manaSpiritRegenerationPerSecond: 3, + }); + expect(regenerated.current).toBe(1_090); + }); + it("decays rage and runic power only out of combat", () => { const rage = normalizeActorResourcePool("rage", 20, 100); const runic = normalizeActorResourcePool("runic-power", 20, 100); diff --git a/src/game/combatResources.ts b/src/game/combatResources.ts index b1d39fd3..263aa57b 100644 --- a/src/game/combatResources.ts +++ b/src/game/combatResources.ts @@ -6,6 +6,7 @@ import { } from "./combatActors"; export const MANA_FIVE_SECOND_RULE_MS = 5_000; +export const OUT_OF_COMBAT_MANA_REGEN_PER_FIVE_SECONDS = 0.15; export const DEATH_KNIGHT_RUNE_RECHARGE_MS = 10_000; export interface CombatResourceAdvanceRules { @@ -24,6 +25,9 @@ export function advanceCombatResourcePool( if (pool.id === "mana" && rules.now - pool.lastSpentAt < MANA_FIVE_SECOND_RULE_MS) { rate = Math.max(0, rate - Math.max(0, rules.manaSpiritRegenerationPerSecond ?? 0)); } + if (pool.id === "mana" && !rules.inCombat) { + rate = Math.max(rate, pool.maximum * OUT_OF_COMBAT_MANA_REGEN_PER_FIVE_SECONDS / 5); + } if ((pool.id === "rage" || pool.id === "runic-power") && !rules.inCombat) { rate = pool.id === "rage" ? -3 : -2.5; } diff --git a/src/game/combatStore.test.ts b/src/game/combatStore.test.ts index 0feb5204..b8092a5f 100644 --- a/src/game/combatStore.test.ts +++ b/src/game/combatStore.test.ts @@ -21,7 +21,7 @@ import { TALENT_NODES, talentNodesForTree } from "./talentCatalog"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { abilitiesForClass, defaultActionBarForClass } from "./abilityCatalog"; import { defaultActionBindings } from "./actionBindings"; -import { deriveCharacterStats } from "./wotlkStats"; +import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats"; import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation"; import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds"; @@ -83,7 +83,7 @@ describe("combat store", () => { const first = useCombatStore.getState().castSlot(0, undefined, 1000); expect(first).toMatchObject({ ok: true, abilityId: "mage-frostbolt", damage: 0 }); expect(useCombatStore.getState().mobs.ravager.health).toBe(100); - expect(startingResource).toBe(165); + expect(startingResource).toBe(165 * MANA_POOL_MULTIPLIER); expect(useCombatStore.getState().resource).toBe(startingResource); expect(useCombatStore.getState().activeCast).toMatchObject({ completesAt: 2500, mode: "cast" }); @@ -93,7 +93,9 @@ describe("combat store", () => { expect(useCombatStore.getState().mobs.ravager.health).toBe(100); useCombatStore.getState().tick(0.1, 2500); expect(useCombatStore.getState().mobs.ravager.health).toBeLessThan(100); - expect(useCombatStore.getState().resource).toBe(startingResource - Math.round(startingResource * 0.14)); + expect(useCombatStore.getState().resource).toBe( + startingResource - Math.round(startingResource / MANA_POOL_MULTIPLIER * 0.14), + ); expect(useCombatStore.getState().activeCast).toBeNull(); expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true); }); @@ -104,7 +106,10 @@ describe("combat store", () => { const frostbolt = initial.abilities.find((ability) => ability.id === "mage-frostbolt"); expect(frostbolt).toBeDefined(); const [cost] = resolvedAbilityCosts(frostbolt!, initial.talentModifiers, initial); - expect(cost).toEqual({ resource: "mana", amount: Math.round(initial.maxResource * 0.14) }); + expect(cost).toEqual({ + resource: "mana", + amount: Math.round(initial.maxResource / MANA_POOL_MULTIPLIER * 0.14), + }); registerTarget("mana-target", [10, 0, 0]); useCombatStore.setState({ @@ -208,8 +213,8 @@ describe("combat store", () => { const chronomancerBase = deriveCharacterStats("chronomancer", 1, EMPTY_ITEM_STATS, "human"); useCombatStore.getState().initializeCharacter({ classId: "chronomancer", level: 1 }); expect(useCombatStore.getState()).toMatchObject({ - resource: Math.round(chronomancerBase.mana * 1.12), - maxResource: Math.round(chronomancerBase.mana * 1.12), + resource: Math.round(chronomancerBase.mana / MANA_POOL_MULTIPLIER * 1.12) * MANA_POOL_MULTIPLIER, + maxResource: Math.round(chronomancerBase.mana / MANA_POOL_MULTIPLIER * 1.12) * MANA_POOL_MULTIPLIER, }); useCombatStore.getState().initializeCharacter({ classId: "barbarian", level: 10 }); @@ -1065,7 +1070,9 @@ describe("combat store", () => { expect(useCombatStore.getState().castSlot(0, undefined, 10_000).ok).toBe(true); const boltCompletion = useCombatStore.getState().activeCast!.completesAt; useCombatStore.getState().tick(0.1, boltCompletion); - expect(useCombatStore.getState().resource).toBe(startingMana - Math.round(startingMana * 0.1 * 0.85)); + expect(useCombatStore.getState().resource).toBe( + startingMana - Math.round(startingMana / MANA_POOL_MULTIPLIER * 0.1 * 0.85), + ); }); it("routes a cast-time direct heal to the selected living party member", () => { diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts index 74045f11..76ca01dc 100644 --- a/src/game/combatStore.ts +++ b/src/game/combatStore.ts @@ -155,6 +155,8 @@ import { deriveCharacterStats, equipmentAttackPower, hastedDuration, + healthFromTotalStamina, + MANA_POOL_MULTIPLIER, ratingPercent, type CharacterStats, type WotlkAttackKind, @@ -673,6 +675,8 @@ export interface CombatState { confirmGroundTarget: (position?: CombatPosition, now?: number) => CastResult; cancelGroundTarget: () => void; synchronizeActors: () => void; + /** Grants learned long-term class stat buffs to the full party without setup casts. */ + applyDefaultPartyBuffs: (now?: number) => void; /** Commits companion simulation results to authoritative actors and mirrors party frames. */ commitCompanionActors: (members: readonly import("./partyStore").PartyMember[]) => void; castSlot: (index: number, origin?: CombatPosition, now?: number) => CastResult; @@ -1517,7 +1521,10 @@ export function resolvedAbilityCosts( : cost.resource === activeResourceType ? state.maxResource : state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource); - const baseAmount = cost.percentage ? maximum * cost.amount / 100 : cost.amount; + const percentageMaximum = cost.resource === "mana" && ability.source === "wow335-progression" + ? maximum / MANA_POOL_MULTIPLIER + : maximum; + const baseAmount = cost.percentage ? percentageMaximum * cost.amount / 100 : cost.amount; const rawAmount = Math.max(0, Math.round(applyTalentModifierValue( baseAmount, modifiers, @@ -1889,11 +1896,16 @@ function auraDefinitionForAbility( ability: AbilityDefinition, durationMs = definition.durationMs, ): AuraDefinition { + const defaultPartyBuffFamily = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()); return { ...definition, description: definition.description ?? ability.description, icon: definition.icon ?? ability.icon, durationMs, + ...(defaultPartyBuffFamily ? { + uniqueBySource: false, + exclusiveGroup: `default-party-buff:${defaultPartyBuffFamily}`, + } : {}), }; } @@ -1916,14 +1928,16 @@ function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): Cha "all-resistance", "magical-defense", ); + const stamina = primary(base.stamina, "stamina"); + const staminaHealth = healthFromTotalStamina(stamina) - healthFromTotalStamina(base.stamina); return { ...base, strength: primary(base.strength, "strength"), agility: primary(base.agility, "agility", "dexterity"), - stamina: primary(base.stamina, "stamina"), + stamina, intellect: primary(base.intellect, "intellect"), spirit: primary(base.spirit, "spirit", "wisdom"), - health: stat(base.health, "maximum-health"), + health: stat(base.health + staminaHealth, "maximum-health"), mana: stat(base.mana, "maximum-mana"), attackPower: stat(base.attackPower, "attack-power", "physical-attack"), rangedAttackPower: stat(base.rangedAttackPower, "ranged-attack-power", "physical-attack"), @@ -1951,6 +1965,53 @@ function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): Cha }; } +function auraMaximumHealthBonus( + base: CharacterStats, + auras: readonly ActiveAura[], + targetId: string, +): number { + return Math.round(applyAuraStats(base, aurasForEntity(auras, targetId)).health - base.health); +} + +function auraAdjustedPlayerMaxHealth( + classId: ClassId, + level: number, + modifiers: readonly TalentModifier[], + gearStats: ItemStats, + raceId: RaceId, + secondaryClassId: ClassId | null | undefined, + auras: readonly ActiveAura[], +): number { + const baseStats = playerCharacterStats(classId, level, gearStats, raceId, secondaryClassId ?? null, modifiers); + return Math.max( + 1, + talentAdjustedMaxHealth(classId, level, modifiers, gearStats, raceId, secondaryClassId) + + auraMaximumHealthBonus(baseStats, auras, PLAYER_AURA_ENTITY_ID), + ); +} + +const DEFAULT_PARTY_BUFF_FAMILY = Object.freeze(new Map([ + ["power word: fortitude", "fortitude"], + ["prayer of fortitude", "fortitude"], + ["divine spirit", "spirit"], + ["prayer of spirit", "spirit"], + ["shadow protection", "shadow-protection"], + ["prayer of shadow protection", "shadow-protection"], + ["arcane intellect", "arcane-intellect"], + ["arcane brilliance", "arcane-intellect"], + ["mark of the wild", "mark-of-the-wild"], + ["gift of the wild", "mark-of-the-wild"], +])); + +function defaultPartyBuffPotency( + effects: readonly Extract[], +): number { + return effects.reduce((total, effect) => total + (effect.aura.modifiers ?? []).reduce( + (subtotal, modifier) => subtotal + Math.abs(modifier.value) * (modifier.operation === "percent" ? 1_000 : 1), + 0, + ), 0); +} + function syncPassiveAuras( current: readonly ActiveAura[], abilities: readonly AbilityDefinition[], @@ -2199,13 +2260,15 @@ function grantMobExperience( progression.levelsGained += result.levelsGained; if (result.levelsGained > 0) { work.talentModifiers = characterStatModifiers(classId, result.level, work.talentRanks); - progression.maxHealth = talentAdjustedMaxHealth( + work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow()); + progression.maxHealth = auraAdjustedPlayerMaxHealth( classId, result.level, work.talentModifiers, work.gearStats, work.raceId, work.secondaryClassId, + work.auras, ); progression.health = progression.maxHealth; work.health = progression.health; @@ -2218,7 +2281,6 @@ function grantMobExperience( work.raceId, work.secondaryClassId, ); - work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow()); } return scaledAward; } @@ -2722,12 +2784,14 @@ function executeTriggeredSpellInWork( const recipient = effect.recipient === "caster" ? sourceId : targetId ?? sourceId; - work.auras = [...applyCombatAura(work.auras, { + const previousAuras = work.auras; + work.auras = [...applyCombatAura(previousAuras, { definition: auraDefinitionForAbility(effect.aura, ability), sourceId, targetId: recipient, now, }).auras]; + applyAuraHealthTransitionInWork(work, recipient, previousAuras, work.auras); if (effect.control && work.mobs[recipient]) { work.mobs[recipient] = applyStatus(work.mobs[recipient], { kind: "status", @@ -2966,6 +3030,47 @@ function mirrorCompanionActor(actor: CombatActorState): void { }); } +function applyAuraHealthTransitionInWork( + work: MutableCastWork, + targetId: string, + previousAuras: readonly ActiveAura[], + nextAuras: readonly ActiveAura[], +): void { + if (targetId === PLAYER_AURA_ENTITY_ID) { + const maxHealth = auraAdjustedPlayerMaxHealth( + work.classId, + work.progression.level, + work.talentModifiers, + work.gearStats, + work.raceId, + work.secondaryClassId, + nextAuras, + ); + const delta = maxHealth - work.maxHealth; + if (!delta) return; + work.maxHealth = maxHealth; + work.health = work.health <= 0 ? 0 : clamp(work.health + delta, 1, work.maxHealth); + work.progression.maxHealth = work.maxHealth; + work.progression.health = work.health; + const actor = work.actors[targetId]; + if (actor) work.actors[targetId] = { ...actor, health: work.health, maxHealth: work.maxHealth }; + return; + } + + const member = usePartyStore.getState().members.find((candidate) => candidate.id === targetId); + const actor = ensureCompanionActorInWork(work, targetId); + if (!member || !actor) return; + const base = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId); + const delta = auraMaximumHealthBonus(base, nextAuras, targetId) + - auraMaximumHealthBonus(base, previousAuras, targetId); + if (!delta) return; + const maxHealth = Math.max(1, actor.maxHealth + delta); + const health = actor.health <= 0 ? 0 : clamp(actor.health + delta, 1, maxHealth); + const nextActor = Object.freeze({ ...actor, health, maxHealth }); + work.actors[targetId] = nextActor; + mirrorCompanionActor(nextActor); +} + function healCompanionActorInWork(work: MutableCastWork, id: string, amount: number): number { const actor = ensureCompanionActorInWork(work, id); const requested = Math.max(0, Math.round(Number.isFinite(amount) ? amount : 0)); @@ -3268,13 +3373,14 @@ export const useCombatStore = create((set, get) => ({ const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(state.equipment, state.inventory, context) : activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId); - const maxHealth = talentAdjustedMaxHealth( + const maxHealth = auraAdjustedPlayerMaxHealth( state.classId, state.level, state.talentModifiers, gearStats, state.raceId, state.secondaryClassId, + state.auras, ); const maxResource = talentAdjustedMaxResource( state.classId, @@ -3542,13 +3648,14 @@ export const useCombatStore = create((set, get) => ({ role: state.gearRole, level: state.level, }) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId); - const maxHealth = talentAdjustedMaxHealth( + const maxHealth = auraAdjustedPlayerMaxHealth( state.classId, state.level, state.talentModifiers, gearStats, state.raceId, state.secondaryClassId, + state.auras, ); const maxResource = talentAdjustedMaxResource( state.classId, @@ -3591,13 +3698,14 @@ export const useCombatStore = create((set, get) => ({ role: state.gearRole, level: state.level, }) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId); - const maxHealth = talentAdjustedMaxHealth( + const maxHealth = auraAdjustedPlayerMaxHealth( state.classId, state.level, state.talentModifiers, gearStats, state.raceId, state.secondaryClassId, + state.auras, ); const maxResource = talentAdjustedMaxResource( state.classId, @@ -3814,6 +3922,59 @@ export const useCombatStore = create((set, get) => ({ members: party.members.map((member) => memberFromCompanionActor(member, actors[member.id])), })); }, + applyDefaultPartyBuffs: (now = combatNow()) => { + get().synchronizeActors(); + const state = get(); + const partyMembers = usePartyStore.getState().members; + const sources = [ + { + id: PLAYER_AURA_ENTITY_ID, + level: state.level, + abilities: state.abilities, + }, + ...partyMembers.filter((member) => member.health > 0).map((member) => ({ + id: member.id, + level: member.level, + abilities: abilitiesForClass(member.classId), + })), + ]; + const selected = new Map[]; + potency: number; + }>(); + for (const source of sources) { + for (const catalogAbility of source.abilities) { + const family = DEFAULT_PARTY_BUFF_FAMILY.get(catalogAbility.name.toLowerCase()); + if (!family || catalogAbility.passive || !isAbilityUnlocked(catalogAbility, source.level)) continue; + const ability = abilityAtLevel(catalogAbility, source.level); + const effects = ability.effects.filter((effect): effect is Extract => ( + effect.kind === "apply-aura" && effect.aura.disposition === "buff" + )); + if (!effects.length) continue; + const potency = defaultPartyBuffPotency(effects); + if ((selected.get(family)?.potency ?? Number.NEGATIVE_INFINITY) >= potency) continue; + selected.set(family, { sourceId: source.id, ability, effects, potency }); + } + } + const recipients = [PLAYER_AURA_ENTITY_ID, ...partyMembers.filter((member) => member.health > 0).map((member) => member.id)]; + for (const [family, buff] of selected) { + for (const recipientId of recipients) { + for (const effect of buff.effects) { + get().applyActorAura(buff.sourceId, recipientId, buff.ability, { + ...effect, + aura: { + ...effect.aura, + uniqueBySource: false, + exclusiveGroup: `default-party-buff:${family}`, + }, + }, now); + } + } + } + get().synchronizeActors(); + }, commitCompanionActors: (members) => { let actors: Readonly> = {}; set((state) => { @@ -4760,12 +4921,14 @@ export const useCombatStore = create((set, get) => ({ ? affectedMobIds : [targetId ?? PLAYER_AURA_ENTITY_ID]; for (const recipientId of recipients) { - work.auras = [...applyCombatAura(work.auras, { + const previousAuras = work.auras; + work.auras = [...applyCombatAura(previousAuras, { definition: auraDefinition, sourceId: PLAYER_AURA_ENTITY_ID, targetId: recipientId, now, }).auras]; + applyAuraHealthTransitionInWork(work, recipientId, previousAuras, work.auras); if (auraDefinition.controlImmunities?.length) { if ( recipientId === PLAYER_AURA_ENTITY_ID @@ -5733,8 +5896,49 @@ export const useCombatStore = create((set, get) => ({ targetId: targetActorId, now, }); + let health = state.health; + let maxHealth = state.maxHealth; + const actors = { ...state.actors }; + let companionActor: CombatActorState | null = null; + if (targetActorId === PLAYER_AURA_ENTITY_ID) { + const nextMaxHealth = auraAdjustedPlayerMaxHealth( + state.classId, + state.level, + state.talentModifiers, + state.gearStats, + state.raceId, + state.secondaryClassId, + result.auras, + ); + const delta = nextMaxHealth - maxHealth; + maxHealth = nextMaxHealth; + health = health <= 0 ? 0 : clamp(health + delta, 1, maxHealth); + const actor = actors[targetActorId]; + if (actor) actors[targetActorId] = { ...actor, health, maxHealth }; + } else { + const member = usePartyStore.getState().members.find((candidate) => candidate.id === targetActorId); + const actor = member ? companionActorFromMember( + member, + entityAbsorbAmount(state.auras, targetActorId), + state.actors[targetActorId], + ) : null; + if (member && actor) { + const base = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId); + const delta = auraMaximumHealthBonus(base, result.auras, targetActorId) + - auraMaximumHealthBonus(base, state.auras, targetActorId); + if (delta) { + const actorMaxHealth = Math.max(1, actor.maxHealth + delta); + const actorHealth = actor.health <= 0 ? 0 : clamp(actor.health + delta, 1, actorMaxHealth); + companionActor = Object.freeze({ ...actor, health: actorHealth, maxHealth: actorMaxHealth }); + actors[targetActorId] = companionActor; + } + } + } set({ auras: result.auras, + health, + maxHealth, + actors, combatEvents: recordCombatEvent(state.combatEvents, { occurredAt: now, kind: "aura", @@ -5749,6 +5953,7 @@ export const useCombatStore = create((set, get) => ({ critical: false, }), }); + if (companionActor) mirrorCompanionActor(companionActor); return true; }, @@ -7028,6 +7233,8 @@ export const useCombatStore = create((set, get) => ({ const delta = clamp(Number.isFinite(deltaSeconds) ? deltaSeconds : 0, 0, 1); const resourceProfile = resourceProfileForClass(state.classId); const inCombat = Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead); + const auraExpiration = expireAuras(state.auras, now); + const activeAuras = [...auraExpiration.active]; const baseRegeneration = applyTalentModifierValue( (resourceProfile.type === "rage" || resourceProfile.type === "runic-power") ? 0 @@ -7037,7 +7244,7 @@ export const useCombatStore = create((set, get) => ({ state.talentModifiers, "resource-regeneration-percent", ); - const playerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID); + const playerAuras = aurasForEntity(activeAuras, PLAYER_AURA_ENTITY_ID); const regeneration = resolveAuraResourceValue( resolveAuraResourceValue(baseRegeneration, playerAuras, resourceProfile.type, "regeneration"), playerAuras, @@ -7097,7 +7304,7 @@ export const useCombatStore = create((set, get) => ({ abilities: state.abilities, mobs: { ...state.mobs }, effects: [], - auras: [...expireAuras(state.auras, now).active], + auras: activeAuras, summons: state.summons.filter((summon) => summon.expiresAt > now), areaEffects: state.areaEffects.filter((effect) => effect.expiresAt > now), combatEvents: [...state.combatEvents], @@ -7132,6 +7339,9 @@ export const useCombatStore = create((set, get) => ({ raceId: state.raceId, secondaryClassId: state.secondaryClassId, }; + for (const targetId of new Set(auraExpiration.expired.map((aura) => aura.targetId))) { + applyAuraHealthTransitionInWork(work, targetId, state.auras, activeAuras); + } let autoAttackTargetId = state.autoAttackTargetId; let nextAutoAttackAt = state.nextAutoAttackAt; @@ -7847,9 +8057,20 @@ export const useCombatStore = create((set, get) => ({ activeCast: null, schoolLockedUntil: {}, nextWoundAnimationAt: 0, - }])); + }])); + const auras = syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow()); + const maxHealth = auraAdjustedPlayerMaxHealth( + state.classId, + state.level, + state.talentModifiers, + state.gearStats, + state.raceId, + state.secondaryClassId, + auras, + ); return { - health: state.maxHealth, + health: maxHealth, + maxHealth, controlledUntil: 0, controlMechanic: null, shield: 0, @@ -7876,7 +8097,7 @@ export const useCombatStore = create((set, get) => ({ selectedTargetId: null, selectedFriendlyActorId: null, effects: [], - auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow()), + auras, summons: [], areaEffects: [], pendingGroundTarget: null, @@ -7912,13 +8133,15 @@ export const useCombatStore = create((set, get) => ({ return false; } const talentModifiers = characterStatModifiers(state.classId, state.level, result.ranks); - const maxHealth = talentAdjustedMaxHealth( + const auras = syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, combatNow()); + const maxHealth = auraAdjustedPlayerMaxHealth( state.classId, state.level, talentModifiers, state.gearStats, state.raceId, state.secondaryClassId, + auras, ); const maxResource = talentAdjustedMaxResource( state.classId, @@ -7935,7 +8158,7 @@ export const useCombatStore = create((set, get) => ({ health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth), maxResource, resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource), - auras: syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, combatNow()), + auras, feedback: nextFeedback("talent", "Conquest node rank allocated."), }); return true; @@ -7944,13 +8167,15 @@ export const useCombatStore = create((set, get) => ({ resetTalents: () => set((state) => { const talentRanks = resetTalentRanks(state.talentRanks, state.classId); const talentModifiers = characterStatModifiers(state.classId, state.level, talentRanks); - const maxHealth = talentAdjustedMaxHealth( + const auras = syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, combatNow()); + const maxHealth = auraAdjustedPlayerMaxHealth( state.classId, state.level, talentModifiers, state.gearStats, state.raceId, state.secondaryClassId, + auras, ); const maxResource = talentAdjustedMaxResource( state.classId, @@ -7967,7 +8192,7 @@ export const useCombatStore = create((set, get) => ({ health: clamp(state.maxHealth > 0 ? state.health / state.maxHealth * maxHealth : maxHealth, 0, maxHealth), maxResource, resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource), - auras: syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, combatNow()), + auras, feedback: nextFeedback("talent", "Talent points reset."), }; }), diff --git a/src/game/deathRespawn.test.ts b/src/game/deathRespawn.test.ts index e3539e66..76255b3f 100644 --- a/src/game/deathRespawn.test.ts +++ b/src/game/deathRespawn.test.ts @@ -5,6 +5,7 @@ import { useCombatStore } from "./combatStore"; import { respawnDefeatedPlayer } from "./deathRespawn"; import { useGameStore } from "./store"; import { useWailingEncounterStore } from "./wailingCavernsEncounter"; +import { MANA_POOL_MULTIPLIER } from "./wotlkStats"; describe("death respawn", () => { beforeEach(() => { @@ -24,7 +25,9 @@ describe("death respawn", () => { useCombatStore.getState().selectMob("respawn-pack:a"); useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000); expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true); - expect(useCombatStore.getState().resource).toBe(startingResource - Math.round(startingResource * 0.15)); + expect(useCombatStore.getState().resource).toBe( + startingResource - Math.round(startingResource / MANA_POOL_MULTIPLIER * 0.15), + ); const revisionBefore = useGameStore.getState().resetRevision; useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 }); diff --git a/src/game/romPlayerCatalog.test.ts b/src/game/romPlayerCatalog.test.ts index fdac9772..b157f6f0 100644 --- a/src/game/romPlayerCatalog.test.ts +++ b/src/game/romPlayerCatalog.test.ts @@ -9,7 +9,7 @@ import { import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES } from "./romAbilityCatalog"; import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats"; import { EMPTY_ITEM_STATS } from "./itemStats"; -import { deriveCharacterStats } from "./wotlkStats"; +import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats"; const RACE_MATRIX = { "rom-human": ["rom-warrior", "rom-scout", "rom-rogue", "rom-mage", "rom-priest", "rom-knight"], @@ -118,8 +118,12 @@ describe("generated RuneWaker player catalog", () => { }); it("gives level-one healers native mana pools and imports their real spell costs", () => { - expect(deriveCharacterStats("rom-priest", 1, EMPTY_ITEM_STATS, "rom-human").mana).toBe(103); - expect(deriveCharacterStats("rom-druid", 1, EMPTY_ITEM_STATS, "rom-elf").mana).toBe(86); + expect(deriveCharacterStats("rom-priest", 1, EMPTY_ITEM_STATS, "rom-human").mana).toBe( + 103 * MANA_POOL_MULTIPLIER, + ); + expect(deriveCharacterStats("rom-druid", 1, EMPTY_ITEM_STATS, "rom-elf").mana).toBe( + 86 * MANA_POOL_MULTIPLIER, + ); expect(generated.skills.find((ability) => ability.id === "rom-priest-general-1")?.costs).toEqual([ { resource: "mana", amount: 12, percentage: false }, ]); diff --git a/src/game/wotlkStats.test.ts b/src/game/wotlkStats.test.ts index f4b54f3c..7d9b38ec 100644 --- a/src/game/wotlkStats.test.ts +++ b/src/game/wotlkStats.test.ts @@ -7,6 +7,7 @@ import { deriveCharacterStats, hastedDuration, healthFromTotalStamina, + MANA_POOL_MULTIPLIER, ratingMultiplier, ratingPerPercent, } from "./wotlkStats"; @@ -50,7 +51,7 @@ describe("WoW 3.3.5a character stats", () => { spellPower: 7, mp5: 4, }); - expect(mageGeared.mana - mageBase.mana).toBe(150); + expect(mageGeared.mana - mageBase.mana).toBe(150 * MANA_POOL_MULTIPLIER); expect(mageGeared.spellPower).toBe(7); expect(mageGeared.spellCritPercent).toBeGreaterThan(mageBase.spellCritPercent); expect(mageGeared.manaPer5).toBeGreaterThan(mageBase.manaPer5 + 4); diff --git a/src/game/wotlkStats.ts b/src/game/wotlkStats.ts index 03d69cae..90469d69 100644 --- a/src/game/wotlkStats.ts +++ b/src/game/wotlkStats.ts @@ -105,6 +105,8 @@ interface CombatRatingsData { const BASE_STATS = baseStatsData as BaseStatsData; const COMBAT_RATINGS = combatRatingsData as CombatRatingsData; +/** Healer Man's larger combat pool; imported Wrath costs keep their pre-boost baseline. */ +export const MANA_POOL_MULTIPLIER = 3; const CLASS_DBC = Object.fromEntries( CLASSES.map((entry) => [entry.id, classById(baseClassFor(entry.id)).dbcId]), ) as Record; @@ -279,7 +281,7 @@ export function deriveCharacterStats( modifiers.maxHealth, ); const mana = classUsesMana(classId) - ? percentAdjusted( + ? MANA_POOL_MULTIPLIER * percentAdjusted( resources.mana + (romBase ? intellect + spirit * 5 : manaFromTotalIntellect(intellect)) + gear.mana,