Release Healer Man 0.1.7

This commit is contained in:
phenom committed 2026-08-22 15:53:43 -04:00
1 parent b12fb84859
commit eca6b5db9b
99 files changed
+1931 -150

No files matched your search

+141 -41
View File
@@ -1,13 +1,18 @@
import type { ClassId } from "../app/characterCatalog";
import {
COA_RUNTIME_ABILITIES_BY_CLASS as COA_ABILITIES_BY_CLASS,
COA_RUNTIME_CLASS_RESOURCES as COA_CLASS_RESOURCES,
} from "./coaAbilityRuntimeCatalog";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import { ROM_ALL_ABILITIES, ROM_ELITE_ABILITIES, ROM_ORDINARY_ABILITIES_BY_CLASS, type RomSkillGroup } from "./romAbilityCatalog";
BASE_CLASS_IDS,
COA_CLASS_IDS,
ROM_CLASS_IDS,
type BaseClassId,
type ClassId,
type CoaClassId,
type RomClassId,
} from "../app/characterCatalog";
import { loadCoaAbilityCatalogShard } from "./coaAbilityCatalogLoader";
import { registerAbilityAnimationLookup } from "./abilityAnimationLookup";
import type { AuraDefinition, DamageSchool, DispelCategory } from "./combatAuras";
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
export type ResourceType =
| "mana"
| "rage"
@@ -301,7 +306,7 @@ function defineAbility(definition: AbilityDefinitionInput): AbilityDefinition {
};
}
export const CLASS_RESOURCES: Readonly<Record<ClassId, ClassResourceProfile>> = {
const classResources: Partial<Record<ClassId, ClassResourceProfile>> = {
// The prototype has no auto-attack swing timer yet, so a small passive Rage
// gain stands in for white-hit generation and keeps the full kit usable.
warrior: { type: "rage", name: "Rage", color: "#b74439", maximum: 100, initial: 0, regenerationPerSecond: 5 },
@@ -314,7 +319,6 @@ export const CLASS_RESOURCES: Readonly<Record<ClassId, ClassResourceProfile>> =
mage: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
warlock: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
druid: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
...COA_CLASS_RESOURCES,
"rom-warrior": { type: "rage", name: "Rage", color: "#b74439", maximum: 100, initial: 0, regenerationPerSecond: 0 },
"rom-scout": { type: "focus", name: "Focus", color: "#55b995", maximum: 100, initial: 100, regenerationPerSecond: 6 },
"rom-rogue": { type: "energy", name: "Energy", color: "#d8c43f", maximum: 100, initial: 100, regenerationPerSecond: 10 },
@@ -327,6 +331,9 @@ export const CLASS_RESOURCES: Readonly<Record<ClassId, ClassResourceProfile>> =
"rom-champion": { type: "rage", name: "Rage", color: "#bb604c", maximum: 100, initial: 0, regenerationPerSecond: 0 },
};
/** Populated per mode/class by preloadAbilityCatalogsForClasses before gameplay mounts. */
export const CLASS_RESOURCES = classResources as Readonly<Record<ClassId, ClassResourceProfile>>;
const WARRIOR = [
defineAbility({
id: "warrior-heroic-strike", classId: "warrior", dbcSpellId: 78, name: "Heroic Strike",
@@ -789,48 +796,131 @@ function mergeWow335ClassicAbilities(
];
}
export const ABILITIES_BY_CLASS: Readonly<Record<ClassId, readonly AbilityDefinition[]>> = {
warrior: mergeWow335ClassicAbilities(WARRIOR, WOW335_ABILITIES_BY_CLASS.warrior),
paladin: mergeWow335ClassicAbilities(PALADIN, WOW335_ABILITIES_BY_CLASS.paladin),
hunter: mergeWow335ClassicAbilities(HUNTER, WOW335_ABILITIES_BY_CLASS.hunter),
rogue: mergeWow335ClassicAbilities(ROGUE, WOW335_ABILITIES_BY_CLASS.rogue),
priest: mergeWow335ClassicAbilities(PRIEST, WOW335_ABILITIES_BY_CLASS.priest),
"death-knight": mergeWow335ClassicAbilities(DEATH_KNIGHT, WOW335_ABILITIES_BY_CLASS["death-knight"]),
shaman: mergeWow335ClassicAbilities(SHAMAN, WOW335_ABILITIES_BY_CLASS.shaman),
mage: mergeWow335ClassicAbilities(MAGE, WOW335_ABILITIES_BY_CLASS.mage),
warlock: mergeWow335ClassicAbilities(WARLOCK, WOW335_ABILITIES_BY_CLASS.warlock),
druid: mergeWow335ClassicAbilities(DRUID, WOW335_ABILITIES_BY_CLASS.druid),
...COA_ABILITIES_BY_CLASS,
"rom-warrior": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-warrior"],
"rom-scout": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-scout"],
"rom-rogue": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-rogue"],
"rom-mage": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-mage"],
"rom-priest": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-priest"],
"rom-knight": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-knight"],
"rom-warden": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-warden"],
"rom-druid": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-druid"],
"rom-warlock": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-warlock"],
"rom-champion": ROM_ORDINARY_ABILITIES_BY_CLASS["rom-champion"],
const CLASSIC_CORE_ABILITIES: Readonly<Record<BaseClassId, readonly AbilityDefinition[]>> = {
warrior: WARRIOR,
paladin: PALADIN,
hunter: HUNTER,
rogue: ROGUE,
priest: PRIEST,
"death-knight": DEATH_KNIGHT,
shaman: SHAMAN,
mage: MAGE,
warlock: WARLOCK,
druid: DRUID,
};
const abilitiesByClass: Partial<Record<ClassId, readonly AbilityDefinition[]>> = {
...CLASSIC_CORE_ABILITIES,
};
const abilityCatalog: Record<string, AbilityDefinition> = {};
let romEliteAbilities: readonly AbilityDefinition[] = [];
let romTriggeredAbilityResolver: ((spellId: number) => AbilityDefinition | null) | null = null;
const coaTriggeredAbilities = new Map<number, AbilityDefinition>();
let classicLoad: Promise<void> | null = null;
let romLoad: Promise<void> | null = null;
const coaLoads = new Map<CoaClassId, Promise<void>>();
export const ABILITY_CATALOG: Readonly<Record<string, AbilityDefinition>> = Object.freeze(
Object.fromEntries([...Object.values(ABILITIES_BY_CLASS).flat(), ...ROM_ALL_ABILITIES].map((ability) => [ability.id, ability])),
);
/** Live read-only views retained for catalog consumers and validation tests. */
export const ABILITIES_BY_CLASS = abilitiesByClass as Readonly<Record<ClassId, readonly AbilityDefinition[]>>;
export const ABILITY_CATALOG = abilityCatalog as Readonly<Record<string, AbilityDefinition>>;
function registerClassAbilities(classId: ClassId, abilities: readonly AbilityDefinition[]): void {
for (const previous of abilitiesByClass[classId] ?? []) delete abilityCatalog[previous.id];
abilitiesByClass[classId] = abilities;
for (const ability of abilities) abilityCatalog[ability.id] = ability;
}
for (const classId of BASE_CLASS_IDS) {
registerClassAbilities(classId, CLASSIC_CORE_ABILITIES[classId]);
}
async function preloadClassicAbilityCatalog(): Promise<void> {
if (classicLoad) return classicLoad;
classicLoad = import("./wow335AbilityCatalog").then(({ WOW335_ABILITIES_BY_CLASS }) => {
for (const classId of BASE_CLASS_IDS) {
registerClassAbilities(
classId,
mergeWow335ClassicAbilities(CLASSIC_CORE_ABILITIES[classId], WOW335_ABILITIES_BY_CLASS[classId]),
);
}
}).catch((error: unknown) => {
classicLoad = null;
throw error;
});
return classicLoad;
}
async function preloadRomAbilityCatalog(): Promise<void> {
if (romLoad) return romLoad;
romLoad = import("./romAbilityCatalog").then((catalog) => {
for (const classId of ROM_CLASS_IDS) {
registerClassAbilities(classId, catalog.ROM_ORDINARY_ABILITIES_BY_CLASS[classId]);
}
romEliteAbilities = catalog.ROM_ELITE_ABILITIES;
romTriggeredAbilityResolver = catalog.romTriggeredAbilityBySpellId;
for (const ability of catalog.ROM_ALL_ABILITIES) abilityCatalog[ability.id] = ability;
}).catch((error: unknown) => {
romLoad = null;
throw error;
});
return romLoad;
}
async function preloadCoaAbilityCatalog(classId: CoaClassId): Promise<void> {
const cached = coaLoads.get(classId);
if (cached) return cached;
const pending = loadCoaAbilityCatalogShard(classId).then((shard) => {
registerClassAbilities(classId, shard.abilities);
classResources[classId] = shard.resource;
for (const [spellId, ability] of Object.entries(shard.triggeredAbilitiesBySpellId)) {
coaTriggeredAbilities.set(Number(spellId), ability);
}
}).catch((error: unknown) => {
coaLoads.delete(classId);
throw error;
});
coaLoads.set(classId, pending);
return pending;
}
/**
* Loads only the executable catalogs needed by the selected character and its
* party mode. Callers gate gameplay mounting on this promise so combat APIs can
* stay synchronous inside the simulation loop.
*/
export async function preloadAbilityCatalogsForClasses(classIds: readonly ClassId[]): Promise<void> {
const requested = new Set(classIds);
const loads: Promise<void>[] = [];
if (BASE_CLASS_IDS.some((classId) => requested.has(classId))) loads.push(preloadClassicAbilityCatalog());
if (ROM_CLASS_IDS.some((classId) => requested.has(classId))) loads.push(preloadRomAbilityCatalog());
for (const classId of COA_CLASS_IDS) {
if (requested.has(classId)) loads.push(preloadCoaAbilityCatalog(classId));
}
await Promise.all(loads);
}
export function triggeredAbilityBySpellId(
spellId: number,
includeCoa = true,
): AbilityDefinition | null {
return romTriggeredAbilityResolver?.(spellId)
?? (includeCoa ? coaTriggeredAbilities.get(spellId) : undefined)
?? null;
}
export function abilitiesForClass(classId: ClassId): readonly AbilityDefinition[] {
return ABILITIES_BY_CLASS[classId];
return abilitiesByClass[classId] ?? [];
}
export function abilitiesForCharacter(
classId: ClassId,
secondaryClassId: ClassId | null | undefined,
): readonly AbilityDefinition[] {
if (!classId.startsWith("rom-") || !secondaryClassId?.startsWith("rom-")) return ABILITIES_BY_CLASS[classId];
const primary = ABILITIES_BY_CLASS[classId];
const secondaryGeneral = ABILITIES_BY_CLASS[secondaryClassId].filter((ability) => (
if (!classId.startsWith("rom-") || !secondaryClassId?.startsWith("rom-")) return abilitiesForClass(classId);
const primary = abilitiesForClass(classId);
const secondaryGeneral = abilitiesForClass(secondaryClassId).filter((ability) => (
ability.romSkillGroup === "general" || (ability.romSkillGroup === "passive" && ability.id.includes("-general-"))
));
const elites = ROM_ELITE_ABILITIES.filter((ability) => ability.classId === classId && ability.secondaryClassId === secondaryClassId);
const elites = romEliteAbilities.filter((ability) => ability.classId === classId && ability.secondaryClassId === secondaryClassId);
return [...primary, ...secondaryGeneral, ...elites];
}
@@ -885,7 +975,7 @@ export function abilitiesUnlockedBetweenLevels(
previousLevel: number,
currentLevel: number,
): readonly AbilityDefinition[] {
return ABILITIES_BY_CLASS[classId].filter((ability) => (
return abilitiesForClass(classId).filter((ability) => (
ability.unlockLevel > previousLevel && ability.unlockLevel <= currentLevel
));
}
@@ -920,7 +1010,7 @@ export function defaultActionBarForClass(classId: ClassId): readonly string[] {
|| effect.kind === "rune"
|| effect.kind === "trigger-spell"
)) ? 0 : 1;
const ordered = ABILITIES_BY_CLASS[classId]
const ordered = abilitiesForClass(classId)
.map((ability, index) => ({ ability, index }))
.sort((left, right) => (
left.ability.unlockLevel - right.ability.unlockLevel
@@ -962,5 +1052,15 @@ export function defaultActionBarForCharacter(classId: ClassId, secondaryClassId?
}
export function resourceProfileForClass(classId: ClassId): ClassResourceProfile {
return CLASS_RESOURCES[classId];
const profile = classResources[classId];
if (!profile) throw new Error(`Ability catalog for ${classId} was used before it was loaded.`);
return profile;
}
if (import.meta.env.MODE === "test") {
await preloadAbilityCatalogsForClasses([
...BASE_CLASS_IDS,
...COA_CLASS_IDS,
...ROM_CLASS_IDS,
]);
}