Release Healer Man 0.1.19
This commit is contained in:
1 parent
eaddad4134
commit
d9bebb4e81
13 files changed
+343
-37
No files matched your search
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.18",
|
||||
"versionCode": 1018
|
||||
"versionName": "0.1.19",
|
||||
"versionCode": 1019
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
+244
-19
@@ -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<string, string>([
|
||||
["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<AbilityEffect, { kind: "apply-aura" }>[],
|
||||
): 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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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<string, {
|
||||
sourceId: string;
|
||||
ability: AbilityDefinition;
|
||||
effects: readonly Extract<AbilityEffect, { kind: "apply-aura" }>[];
|
||||
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<AbilityEffect, { kind: "apply-aura" }> => (
|
||||
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<Record<string, CombatActorState>> = {};
|
||||
set((state) => {
|
||||
@@ -4760,12 +4921,14 @@ export const useCombatStore = create<CombatState>((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<CombatState>((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<CombatState>((set, get) => ({
|
||||
critical: false,
|
||||
}),
|
||||
});
|
||||
if (companionActor) mirrorCompanionActor(companionActor);
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -7028,6 +7233,8 @@ export const useCombatStore = create<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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<CombatState>((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."),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ClassId, number>;
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user