Release Healer Man 0.1.9

This commit is contained in:
phenom committed 2026-08-30 14:16:56 -04:00
1 parent c245a199b6
commit d060296ade
74 files changed
+5901 -334

No files matched your search

+124 -47
View File
@@ -1,4 +1,5 @@
import { create } from "zustand";
import { combatNow } from "./combatClock";
import { useGameStore } from "./store";
import {
baseClassFor,
@@ -212,6 +213,14 @@ import {
normalizeHudPosition,
type NormalizedHudPosition,
} from "./hudLayout";
import {
DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT,
DEFAULT_PREVIEW_FRAME_RATE_LIMIT,
normalizeGameplayFrameRateLimit,
normalizePreviewFrameRateLimit,
type GameplayFrameRateLimit,
type PreviewFrameRateLimit,
} from "./frameRateSettings";
export type MinimapRotation = "north-up" | "player-up";
@@ -227,6 +236,8 @@ export interface GameplaySettings {
readonly masterVolume: number;
readonly uiScale: number;
readonly minimapRotation: MinimapRotation;
readonly gameplayFrameRate: GameplayFrameRateLimit;
readonly previewFrameRate: PreviewFrameRateLimit;
}
export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
@@ -238,9 +249,11 @@ export const DEFAULT_GAMEPLAY_SETTINGS: GameplaySettings = Object.freeze({
showFloatingCombatText: true,
autoTargetNearest: true,
enableScreenShake: true,
masterVolume: 0.8,
masterVolume: 0,
uiScale: 1,
minimapRotation: "player-up",
gameplayFrameRate: DEFAULT_GAMEPLAY_FRAME_RATE_LIMIT,
previewFrameRate: DEFAULT_PREVIEW_FRAME_RATE_LIMIT,
});
/** Prevents a burst of party hits from restarting the same wound clip every frame. */
@@ -252,6 +265,8 @@ export const MINIMUM_CORPSE_VISIBLE_MS = 1_500;
/** Default proximity pull radii for dungeon enemies, expressed in world units. */
export const DEFAULT_MOB_AGGRO_RANGE = 16;
export const DEFAULT_BOSS_AGGRO_RANGE = 20;
/** Safety ceiling for synchronous proc-to-triggered-spell chains. */
const MAX_TRIGGERED_SPELL_CHAIN_DEPTH = 16;
export interface MobStatus {
readonly kind: MobStatusKind;
@@ -727,6 +742,8 @@ interface MutableCastWork {
gearStats: ItemStats;
raceId: RaceId;
secondaryClassId: ClassId | null;
/** Active synchronous spell/source/target keys used to break proc cycles. */
triggeredSpellStack?: string[];
}
let feedbackSequence = 0;
@@ -1600,9 +1617,34 @@ function normalizedSettings(
minimapRotation: patch.minimapRotation === "north-up" || patch.minimapRotation === "player-up"
? patch.minimapRotation
: current.minimapRotation,
gameplayFrameRate: normalizeGameplayFrameRateLimit(
patch.gameplayFrameRate,
current.gameplayFrameRate,
),
previewFrameRate: normalizePreviewFrameRateLimit(
patch.previewFrameRate,
current.previewFrameRate,
),
};
}
function gameplaySettingsEqual(left: GameplaySettings, right: GameplaySettings): boolean {
return left.showMobHealthBars === right.showMobHealthBars
&& left.showMobAggroRanges === right.showMobAggroRanges
&& left.showTargetFrame === right.showTargetFrame
&& left.showThreatMeter === right.showThreatMeter
&& left.threatMeterPosition.x === right.threatMeterPosition.x
&& left.threatMeterPosition.y === right.threatMeterPosition.y
&& left.showFloatingCombatText === right.showFloatingCombatText
&& left.autoTargetNearest === right.autoTargetNearest
&& left.enableScreenShake === right.enableScreenShake
&& left.masterVolume === right.masterVolume
&& left.uiScale === right.uiScale
&& left.minimapRotation === right.minimapRotation
&& left.gameplayFrameRate === right.gameplayFrameRate
&& left.previewFrameRate === right.previewFrameRate;
}
function defaultMobStats(
playerLevel: number,
boss: boolean,
@@ -1829,6 +1871,7 @@ function auraDefinitionForAbility(
): AuraDefinition {
return {
...definition,
description: definition.description ?? ability.description,
icon: definition.icon ?? ability.icon,
durationMs,
};
@@ -1974,15 +2017,30 @@ function executeTriggeredAuraProcsInWork(
&& (action.id === "wow335-trigger-spell" || action.id === "rom-trigger-skill")
&& typeof action.data?.spellId === "number"
) {
executeTriggeredSpellInWork(
work,
action.data.spellId,
triggered.sourceId,
triggered.event.otherId ?? triggered.targetId,
now,
0,
true,
);
const spellId = action.data.spellId;
const targetId = triggered.event.otherId ?? triggered.targetId;
const triggerKey = `${spellId}:${triggered.sourceId}:${targetId}`;
const triggeredSpellStack = work.triggeredSpellStack ?? [];
if (
triggeredSpellStack.length >= MAX_TRIGGERED_SPELL_CHAIN_DEPTH
|| triggeredSpellStack.includes(triggerKey)
) continue;
triggeredSpellStack.push(triggerKey);
work.triggeredSpellStack = triggeredSpellStack;
try {
executeTriggeredSpellInWork(
work,
spellId,
triggered.sourceId,
targetId,
now,
0,
true,
);
} finally {
triggeredSpellStack.pop();
if (triggeredSpellStack.length === 0) work.triggeredSpellStack = undefined;
}
} else if (
action.kind === "custom"
&& action.id === "classic-status"
@@ -2140,7 +2198,7 @@ function grantMobExperience(
work.raceId,
work.secondaryClassId,
);
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, Date.now());
work.auras = syncPassiveAuras(work.auras, work.abilities, result.level, work.talentRanks, combatNow());
}
return scaledAward;
}
@@ -2696,6 +2754,8 @@ function executeTriggeredSpellInWork(
definition: {
id: `coa-triggered-shield:${spellId}`,
name: ability.name,
description: ability.description,
icon: ability.icon,
disposition: "buff",
durationMs: effect.durationMs,
absorbs: [{ id: `coa-triggered-shield:${spellId}:pool`, amount }],
@@ -3152,7 +3212,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
resourceName: resource.name,
abilities,
deathKnightRunes: classId === "death-knight" ? createDeathKnightRunes() : [],
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, Date.now()),
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, combatNow()),
talentRanks,
talentModifiers,
actionBindings: normalizeActionBindings(profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level)),
@@ -3338,7 +3398,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
};
}),
lootMob: (id, now = Date.now()) => {
lootMob: (id, now = combatNow()) => {
const mob = get().mobs[id];
if (!mob || !mob.dead || !mob.hasLoot || mob.looted || mob.despawned) return false;
const corpseDespawnAt = Math.max(now, (mob.diedAt ?? now) + MINIMUM_CORPSE_VISIBLE_MS);
@@ -3562,12 +3622,18 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const movedWhileCasting = Boolean(cast && !cast.canCastWhileMoving && (
(next[0] - cast.origin[0]) ** 2 + (next[2] - cast.origin[2]) ** 2 > 0.04
));
if (movedWhileCasting) emitPlayerAbilityCancellation(cast!.abilityId, Date.now(), next);
const positionUnchanged = (
(next[0] - state.playerPosition[0]) ** 2
+ (next[1] - state.playerPosition[1]) ** 2
+ (next[2] - state.playerPosition[2]) ** 2
) <= 1e-8;
if (positionUnchanged && !movedWhileCasting) return state;
if (movedWhileCasting) emitPlayerAbilityCancellation(cast!.abilityId, combatNow(), next);
return {
playerPosition: next,
activeCast: movedWhileCasting ? null : cast,
combatEvents: movedWhileCasting ? recordCombatEvent(state.combatEvents, {
occurredAt: Date.now(),
occurredAt: combatNow(),
kind: "interrupt",
sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: PLAYER_AURA_ENTITY_ID,
@@ -3648,7 +3714,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
};
}),
confirmGroundTarget: (position, now = Date.now()) => {
confirmGroundTarget: (position, now = combatNow()) => {
const pending = get().pendingGroundTarget;
if (!pending) return emptyCastResult("invalid-destination");
const destination = position ?? pending.position;
@@ -3691,7 +3757,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}));
},
castAbility: (abilityId, requestOrOrigin, legacyNow = Date.now(), legacyCompletingCast) => {
castAbility: (abilityId, requestOrOrigin, legacyNow = combatNow(), legacyCompletingCast) => {
const request: CastRequest = Array.isArray(requestOrOrigin)
? { origin: requestOrOrigin as CombatPosition, now: legacyNow, completingCast: legacyCompletingCast }
: (requestOrOrigin as CastRequest | undefined) ?? { now: legacyNow, completingCast: legacyCompletingCast };
@@ -4425,6 +4491,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
definition: {
id: `shield:${ability.id}`,
name: ability.name,
description: ability.description,
icon: ability.icon,
disposition: "buff",
durationMs,
@@ -5090,7 +5157,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return get().castAbility(abilityId, origin, now);
},
damageMob: (id, amount, now = Date.now(), source, school = "physical", rollContext) => {
damageMob: (id, amount, now = combatNow(), source, school = "physical", rollContext) => {
const state = get();
const threatSource = source ?? playerThreatSource(state);
const progression = progressionFromState(state);
@@ -5206,7 +5273,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return { mobs };
}),
tauntMob: (id, source, durationMs, now = Date.now()) => {
tauntMob: (id, source, durationMs, now = combatNow()) => {
const target = get().mobs[id];
if (!target || target.dead || durationMs <= 0) return false;
const previous = target.tauntDiminishingReturns;
@@ -5257,7 +5324,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return effectiveDurationMs > 0;
},
interruptMob: (id, sourceAbilityId, lockoutMs, now = Date.now()) => {
interruptMob: (id, sourceAbilityId, lockoutMs, now = combatNow()) => {
let interrupted = false;
set((state) => {
const mob = state.mobs[id];
@@ -5273,7 +5340,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return interrupted;
},
controlMob: (id, sourceAbilityId, status, durationMs, now = Date.now()) => {
controlMob: (id, sourceAbilityId, status, durationMs, now = combatNow()) => {
let controlled = false;
set((state) => {
const mob = state.mobs[id];
@@ -5331,7 +5398,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
},
healActor: (id, amount, request) => {
const now = request.now ?? Date.now();
const now = request.now ?? combatNow();
const state = get();
const sourceAuras = aurasForEntity(state.auras, request.sourceActorId);
const targetAuras = aurasForEntity(state.auras, id);
@@ -5414,7 +5481,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return resolution;
},
reviveActor: (id, percentMaxHealth, sourceActorId, abilityId, now = Date.now()) => {
reviveActor: (id, percentMaxHealth, sourceActorId, abilityId, now = combatNow()) => {
const state = get();
const fraction = clamp(percentMaxHealth, 0.01, 1);
if (id === PLAYER_AURA_ENTITY_ID) {
@@ -5494,7 +5561,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return restored;
},
restoreActorResource: (id, resource, amount, percentage = false, sourceActorId = id, abilityId, now = Date.now()) => {
restoreActorResource: (id, resource, amount, percentage = false, sourceActorId = id, abilityId, now = combatNow()) => {
const state = get();
const actor = state.actors[id];
if (!actor) return 0;
@@ -5533,7 +5600,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return restored;
},
shieldActor: (id, amount, durationMs, sourceActorId, ability, now = Date.now()) => {
shieldActor: (id, amount, durationMs, sourceActorId, ability, now = combatNow()) => {
const state = get();
const absorb = Math.max(0, Math.round(amount));
if (absorb <= 0 || !state.actors[id]) return 0;
@@ -5541,6 +5608,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
definition: {
id: `shield:${sourceActorId}:${ability.id}`,
name: ability.name,
description: ability.description,
icon: ability.icon,
disposition: "buff",
durationMs: Math.max(1, durationMs),
@@ -5570,7 +5638,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return absorb;
},
applyActorAura: (sourceActorId, targetActorId, ability, effect, now = Date.now()) => {
applyActorAura: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
const state = get();
if (!state.actors[sourceActorId] || (!state.actors[targetActorId] && !state.mobs[targetActorId])) return false;
const durationMs = effect.aura.durationMs;
@@ -5600,7 +5668,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return true;
},
applyTimedActorEffect: (sourceActorId, targetActorId, ability, effect, now = Date.now()) => {
applyTimedActorEffect: (sourceActorId, targetActorId, ability, effect, now = combatNow()) => {
const state = get();
if (!state.actors[sourceActorId]) return false;
if (effect.kind === "dot" && (!state.mobs[targetActorId] || state.mobs[targetActorId].dead)) return false;
@@ -5637,7 +5705,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return true;
},
summonActor: (ownerActorId, ability, effect, position, targetId = null, now = Date.now()) => {
summonActor: (ownerActorId, ability, effect, position, targetId = null, now = combatNow()) => {
const state = get();
const owner = state.actors[ownerActorId];
if (!owner?.alive) return null;
@@ -5671,7 +5739,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return id;
},
createActorAreaEffect: (sourceActorId, ability, position, now = Date.now()) => {
createActorAreaEffect: (sourceActorId, ability, position, now = combatNow()) => {
const state = get();
const sourceActor = state.actors[sourceActorId];
if (!sourceActor?.alive) return null;
@@ -5716,7 +5784,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
damagePlayer: (amount, school = "physical", attackerLevel) => {
const state = get();
const now = Date.now();
const now = combatNow();
const mitigated = damageAfterEquipmentMitigation(
talentAdjustedIncomingDamage(amount, state.talentModifiers),
state.gearStats,
@@ -5775,6 +5843,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
definition: {
id: "rom-state-revival-charge",
name: "Revival",
description: "Each stack makes Revival restore 5% of maximum health. Casting Revival consumes every stack.",
disposition: "buff",
durationMs: null,
maxStacks: 10,
@@ -5831,7 +5900,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetId: id,
amount: requested,
school: "physical",
now: Date.now(),
now: combatNow(),
});
applied = Math.min(summon.health, Math.max(0, Math.round(absorbed.remainingDamage)));
const health = summon.health - applied;
@@ -5846,7 +5915,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
summons,
actors,
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: Date.now(),
occurredAt: combatNow(),
kind: "damage",
sourceActorId: "external",
targetActorId: id,
@@ -5878,12 +5947,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
subjectId: PLAYER_AURA_ENTITY_ID,
otherId: PLAYER_AURA_ENTITY_ID,
amount: healed,
}, Date.now(), ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:heal:${state.health}`));
}, combatNow(), ({ aura, proc }) => stableCombatUnit(`${aura.instanceId}:${proc.id}:heal:${state.health}`));
set({
auras: procResult.auras,
health: state.health + healed,
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: Date.now(),
occurredAt: combatNow(),
kind: "healing",
sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: PLAYER_AURA_ENTITY_ID,
@@ -5920,7 +5989,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
set((state) => ({
activeCast: null,
combatEvents: recordCombatEvent(state.combatEvents, {
occurredAt: Date.now(),
occurredAt: combatNow(),
kind: "interrupt",
sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: PLAYER_AURA_ENTITY_ID,
@@ -5936,11 +6005,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
playerAnimationEvent: nextAnimationEvent("ability-cancel", cast.abilityId),
feedback: nextFeedback("error", message, { abilityId: cast.abilityId, targetId: cast.targetId ?? undefined }),
}));
emitPlayerAbilityCancellation(cast.abilityId, Date.now(), get().playerPosition);
emitPlayerAbilityCancellation(cast.abilityId, combatNow(), get().playerPosition);
return true;
},
engageNearbyMobs: (now = Date.now(), playerPosition) => {
engageNearbyMobs: (now = combatNow(), playerPosition) => {
const state = get();
const runtimeState = playerPosition
? { ...state, playerPosition }
@@ -5960,7 +6029,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return engagedGroups;
},
advanceMobCombat: (now = Date.now(), playerPosition) => {
advanceMobCombat: (now = combatNow(), playerPosition) => {
const state = get();
const runtimeState = playerPosition
? { ...state, playerPosition }
@@ -6237,6 +6306,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
definition: {
id: `enemy-control:${id}:${attack.id}:${effect.mechanic}`,
name: `${attack.name}: ${effect.mechanic}`,
description: `${attack.name} inflicts ${effect.mechanic}, preventing normal actions until the effect ends.`,
disposition: "debuff",
durationMs: effect.durationMs,
dispelCategory: "magic",
@@ -6828,7 +6898,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return appliedDamage;
},
tick: (deltaSeconds, now = Date.now()) => {
tick: (deltaSeconds, now = combatNow()) => {
const partyHotTicks: Array<{
memberId: string;
amount: number;
@@ -7687,7 +7757,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
selectedTargetId: null,
selectedFriendlyActorId: null,
effects: [],
auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, Date.now()),
auras: syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow()),
summons: [],
areaEffects: [],
pendingGroundTarget: null,
@@ -7746,7 +7816,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, Date.now()),
auras: syncPassiveAuras(state.auras, state.abilities, state.level, result.ranks, combatNow()),
feedback: nextFeedback("talent", "Conquest node rank allocated."),
});
return true;
@@ -7778,7 +7848,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, Date.now()),
auras: syncPassiveAuras(state.auras, state.abilities, state.level, talentRanks, combatNow()),
feedback: nextFeedback("talent", "Talent points reset."),
};
}),
@@ -7804,8 +7874,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
),
})),
updateSettings: (patch) => set((state) => ({ settings: normalizedSettings(state.settings, patch) })),
resetSettings: () => set({ settings: DEFAULT_GAMEPLAY_SETTINGS }),
updateSettings: (patch) => set((state) => {
const settings = normalizedSettings(state.settings, patch);
return gameplaySettingsEqual(settings, state.settings) ? state : { settings };
}),
resetSettings: () => set((state) => (
gameplaySettingsEqual(state.settings, DEFAULT_GAMEPLAY_SETTINGS)
? state
: { settings: DEFAULT_GAMEPLAY_SETTINGS }
)),
}));
export function selectedMob(state: CombatState): MobCombatState | null {
@@ -7815,14 +7892,14 @@ export function selectedMob(state: CombatState): MobCombatState | null {
export function cooldownRemainingMs(
state: Pick<CombatState, "cooldowns">,
abilityId: string,
now = Date.now(),
now = combatNow(),
): number {
return Math.max(0, (state.cooldowns[abilityId] ?? 0) - now);
}
export function globalCooldownRemainingMs(
state: Pick<CombatState, "globalCooldownEndsAt">,
now = Date.now(),
now = combatNow(),
): number {
return Math.max(0, state.globalCooldownEndsAt - now);
}
@@ -7842,7 +7919,7 @@ useCombatStore.subscribe((state) => {
collectingBossLoot = true;
try {
for (const boss of lootableBosses) {
useCombatStore.getState().lootMob(boss.id, boss.diedAt ?? Date.now());
useCombatStore.getState().lootMob(boss.id, boss.diedAt ?? combatNow());
}
} finally {
collectingBossLoot = false;