Release Healer Man 0.1.17

This commit is contained in:
phenom committed 2026-09-05 21:27:30 -04:00
1 parent b57d2b2839
commit fc8a7a722a
13 files changed
+235 -41

No files matched your search

+7 -1
View File
@@ -55,9 +55,15 @@ export function createArenaPractice(options: PracticeOptions) {
onSnapshot: (snapshot) => options.onSnapshot({ ...snapshot, controlledActorId: "practice-player" }),
onResult: (result) => options.onResult({ ...result, ratingUpdates: [] }),
});
simulation.start();
options.onSnapshot({ ...simulation.snapshot(), controlledActorId: "practice-player" });
let started = false;
return {
start() {
if (started) return;
started = true;
simulation.resetPreparationClock();
simulation.start();
},
intent(intent: ArenaCombatIntent) {
return simulation.receiveIntent("practice-player", intent);
},
+3
View File
@@ -41,6 +41,7 @@ interface ArenaStoreState {
deleteLoadout: (session: PlayerSession, characterId: string, slot: number) => Promise<boolean>;
queueMatch: (session: PlayerSession | null, character: CharacterProfile) => Promise<boolean>;
ready: (session: PlayerSession) => Promise<void>;
startPractice: () => void;
leave: (session: PlayerSession | null) => Promise<void>;
intent: (intent: Omit<ArenaCombatIntent, "sequence">) => void;
resetResult: () => void;
@@ -221,6 +222,8 @@ export const useArenaStore = create<ArenaStoreState>((set, get) => ({
catch (error) { set({ error: messageFor(error) }); }
},
startPractice: () => get().practice?.start(),
leave: async (session) => {
get().practice?.stop();
get().socket?.close();
+27
View File
@@ -13,6 +13,7 @@ import {
MINIMUM_CORPSE_VISIBLE_MS,
MOB_WOUND_ANIMATION_COOLDOWN_MS,
PLAYER_AURA_ENTITY_ID,
resolvedAbilityCosts,
selectedMob,
useCombatStore,
} from "./combatStore";
@@ -97,6 +98,32 @@ describe("combat store", () => {
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
});
it("uses the displayed primary mana and resolved percentage cost for affordability", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
const initial = useCombatStore.getState();
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) });
registerTarget("mana-target", [10, 0, 0]);
useCombatStore.setState({
resource: cost.amount,
resourcePools: { ...initial.resourcePools, mana: 0 },
});
expect(useCombatStore.getState().castAbility(frostbolt!.id, [0, 0, 0], 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().cancelCast();
useCombatStore.setState({
resource: cost.amount - 1,
resourcePools: { ...initial.resourcePools, mana: initial.maxResource },
});
expect(useCombatStore.getState().castAbility(frostbolt!.id, [0, 0, 0], 3_000)).toMatchObject({
ok: false,
reason: "not-enough-resource",
});
});
it("emits exactly one cast and release event and cancels presentation with the cast", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
registerTarget("presentation-target", [10, 0, 0]);
+15 -7
View File
@@ -1504,16 +1504,19 @@ function initialResourcePools(
return { pools, maximums };
}
function abilityCosts(
export function resolvedAbilityCosts(
ability: AbilityDefinition,
modifiers: readonly TalentModifier[],
state: Pick<CombatState, "maxHealth" | "maxResourcePools"> & Partial<Pick<CombatState, "auras">>,
state: Pick<CombatState, "classId" | "maxHealth" | "maxResource" | "maxResourcePools"> & Partial<Pick<CombatState, "auras">>,
): readonly { resource: ResourceType; amount: number }[] {
const aggregated = new Map<ResourceType, number>();
const activeResourceType = resourceProfileForClass(state.classId).type;
for (const cost of ability.costs?.length ? ability.costs : [ability.cost]) {
const maximum = cost.resource === "health"
? state.maxHealth
: state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource);
: cost.resource === activeResourceType
? state.maxResource
: state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource);
const baseAmount = cost.percentage ? maximum * cost.amount / 100 : cost.amount;
const rawAmount = Math.max(0, Math.round(applyTalentModifierValue(
baseAmount,
@@ -3866,7 +3869,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: ability?.target;
const modifiers = state.talentModifiers;
const resourceCost = ability ? talentAdjustedResourceCost(ability, modifiers) : 0;
const costs = ability ? abilityCosts(ability, modifiers, state) : [];
const costs = ability ? resolvedAbilityCosts(ability, modifiers, state) : [];
const costsToSpend = completingCast?.mode === "channel" ? [] : costs;
const targeting = ability ? targetingRulesForAbility(ability) : null;
const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical";
@@ -3895,7 +3898,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
else if (ability.id === "rom-mage-primary-56" && !hasRuneWakerState("rom-static-field-charge")) failure = "condition-not-met";
else if (costs.some((cost) => cost.resource === "health"
? state.health <= cost.amount
: (state.resourcePools[cost.resource] ?? (cost.resource === resourceProfileForClass(state.classId).type ? state.resource : 0)) < cost.amount)) failure = "not-enough-resource";
: (cost.resource === resourceProfileForClass(state.classId).type
? state.resource
: state.resourcePools[cost.resource] ?? 0) < cost.amount)) failure = "not-enough-resource";
else if (runeCost && !spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)) failure = "not-enough-runes";
const playerOrigin = currentOrigin(state, origin);
@@ -4060,7 +4065,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
state.classId,
);
const cooldownMs = talentAdjustedCooldown(ability, modifiers);
const spentPools = { ...state.resourcePools };
const activeResourceType = resourceProfileForClass(state.classId).type;
const spentPools: Partial<Record<ResourceType, number>> = {
...state.resourcePools,
[activeResourceType]: state.resource,
};
const lastResourceSpentAt = { ...state.lastResourceSpentAt };
let healthAfterCosts = state.health;
for (const cost of costsToSpend) {
@@ -4073,7 +4082,6 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const spentRunes = runeCost && completingCast?.mode !== "channel"
? spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)
: state.deathKnightRunes;
const activeResourceType = resourceProfileForClass(state.classId).type;
const activeResourceAfterCosts = spentPools[activeResourceType] ?? state.resource - resourceCost;
if (!completingCast && castTimeMs > 0) {
castSequence += 1;