Files
healer-man/src/game/mobDamageTypes.ts
T
2026-10-07 13:05:04 -04:00

208 lines
8.1 KiB
TypeScript

import sourceCatalog from "./generated/enemyDamageTypeSources.generated.json";
export type EnemyDamageOrigin = "wow335" | "runewaker";
export type EnemyAttackKind = "melee" | "ranged" | "spell";
export type EnemyDamageSchool =
| "physical"
| "arcane"
| "fire"
| "frost"
| "nature"
| "shadow"
| "holy";
export interface WowEnemyHitSource {
readonly attributes0: number;
readonly attributes3: number;
readonly attributes7: number;
}
export interface WowEnemyDamageTypeSource {
readonly spellId: number;
readonly name: string;
readonly damageClass: number;
readonly schoolMask: number;
/** Raw hit-gate attribute words copied from a matched WoW Spell.dbc row. */
readonly hitSource?: WowEnemyHitSource;
readonly schools: readonly EnemyDamageSchool[];
readonly school?: EnemyDamageSchool;
readonly attackKind?: EnemyAttackKind;
readonly damageEffects: readonly number[];
readonly source: string;
}
export interface RunewakerEnemyDamageTypeComponent {
readonly componentId: number;
/** MagicObject.Attack.CalType; direct packet class, independent of EffectType. */
readonly calculationType: number;
/** Raw MagicObject.EffectType, retained as source metadata. */
readonly effectType: number;
readonly magicType: number;
/** Formula-specific handling; not a packet class and not the MagicCollect hit gate. */
readonly directFormula?: "throw" | "fixed" | "target-hp-percent";
/** Absorption branch selected by the direct formula and raw component flags. */
readonly formulaMitigation?: "weapon-absorb" | "magic-absorb" | "bypassed";
readonly sourceDamageClass?: "physical" | "magical";
readonly attackKind?: EnemyAttackKind;
readonly sourceChannel?: "physical" | "earth" | "water" | "fire" | "wind" | "light" | "darkness";
/** Approximate mapping into HealerMan's seven-school resistance model. */
readonly school?: EnemyDamageSchool;
}
export interface RunewakerEnemyDamageTypeSource {
readonly spellId: number;
readonly name: string;
readonly components: readonly RunewakerEnemyDamageTypeComponent[];
readonly source: string;
}
export type EnemyDamageTypeSource = WowEnemyDamageTypeSource | RunewakerEnemyDamageTypeSource;
export interface ResolveEnemyDamageTypeInput {
readonly origin?: EnemyDamageOrigin;
readonly spellId?: number;
readonly componentId?: number;
/** Raw WoW Spell.dbc DmgClass when present. */
readonly damageClass?: number;
/** Runtime delivery classification, authored or normalized by the importer. */
readonly attackKind?: EnemyAttackKind;
/** Raw WoW Spell.dbc SchoolMask; bit values are preserved. */
readonly schoolMask?: number;
/** Authored runtime school used when source school data is absent or ambiguous. */
readonly school?: EnemyDamageSchool;
}
export interface ResolvedEnemyDamageType {
readonly attackKind?: EnemyAttackKind;
readonly damageClass?: number;
readonly sourceDamageClass?: "none" | "magic" | "melee" | "ranged" | "physical" | "magical";
readonly schoolMask?: number;
/** Copied only from a matched WoW damage row; absent means unknown/not WoW. */
readonly hitSource?: WowEnemyHitSource;
readonly schools: readonly EnemyDamageSchool[];
/** Present only when source evidence identifies exactly one runtime school. */
readonly school?: EnemyDamageSchool;
readonly unknownSchoolMaskBits?: number;
readonly sourceVerified: boolean;
}
type GeneratedCatalog = {
readonly wow335: Readonly<Record<string, WowEnemyDamageTypeSource>>;
readonly runewaker: Readonly<Record<string, RunewakerEnemyDamageTypeSource>>;
};
const catalog = sourceCatalog as GeneratedCatalog;
const WOW_SCHOOL_BITS: readonly (readonly [number, EnemyDamageSchool])[] = [
[1, "physical"],
[2, "holy"],
[4, "fire"],
[8, "nature"],
[16, "frost"],
[32, "shadow"],
[64, "arcane"],
];
const KNOWN_WOW_SCHOOL_BITS = WOW_SCHOOL_BITS.reduce((mask, [bit]) => mask | bit, 0);
export function enemyDamageTypeSourceForSpell(
origin: EnemyDamageOrigin,
spellId: number,
): EnemyDamageTypeSource | undefined {
if (!Number.isSafeInteger(spellId) || spellId <= 0) return undefined;
const key = String(spellId);
return origin === "wow335" ? catalog.wow335[key] : catalog.runewaker[key];
}
export function enemyDamageTypeComponentForSpell(
spellId: number,
componentId: number,
): RunewakerEnemyDamageTypeComponent | undefined {
const source = enemyDamageTypeSourceForSpell("runewaker", spellId);
if (!source || !("components" in source)) return undefined;
return source.components.find((component) => component.componentId === componentId);
}
/**
* Converts WotLK SchoolMask bits without imposing a strongest-school rule.
* Composite masks remain a list; callers should keep the authored single-school
* fallback until the damage resolver supports combined-school mitigation.
*/
export function wowSchoolsFromSchoolMask(mask: number): {
readonly schools: readonly EnemyDamageSchool[];
readonly unknownBits: number;
} {
if (!Number.isSafeInteger(mask) || mask < 0) return { schools: [], unknownBits: 0 };
return {
schools: WOW_SCHOOL_BITS.filter(([bit]) => (mask & bit) !== 0).map(([, school]) => school),
unknownBits: (mask & ~KNOWN_WOW_SCHOOL_BITS) >>> 0,
};
}
function sourceComponentForResolution(
source: RunewakerEnemyDamageTypeSource | undefined,
componentId: number | undefined,
): RunewakerEnemyDamageTypeComponent | undefined {
if (!source) return undefined;
if (componentId !== undefined) {
return source.components.find((component) => component.componentId === componentId);
}
if (source.components.length === 1) return source.components[0];
const first = source.components[0];
if (first && source.components.every((component) => (
component.sourceDamageClass === first.sourceDamageClass
&& component.attackKind === first.attackKind
&& component.school === first.school
&& component.sourceChannel === first.sourceChannel
))) return first;
return undefined;
}
function attackKindForDamageClass(value: number | undefined): EnemyAttackKind | undefined {
if (value === 1) return "spell";
if (value === 2) return "melee";
if (value === 3) return "ranged";
return undefined;
}
export function resolveEnemyDamageType(input: ResolveEnemyDamageTypeInput): ResolvedEnemyDamageType {
const source = input.origin && input.spellId !== undefined
? enemyDamageTypeSourceForSpell(input.origin, input.spellId)
: undefined;
const wowSource = source && "schoolMask" in source ? source : undefined;
const runewakerSource = source && "components" in source ? source : undefined;
const runewakerComponent = sourceComponentForResolution(runewakerSource, input.componentId);
const damageClass = input.damageClass ?? wowSource?.damageClass;
const schoolMask = input.schoolMask ?? wowSource?.schoolMask;
const maskResult = schoolMask === undefined
? { schools: [] as readonly EnemyDamageSchool[], unknownBits: 0 }
: wowSchoolsFromSchoolMask(schoolMask);
const exactWoWSchool = schoolMask !== undefined
&& maskResult.schools.length === 1
&& maskResult.unknownBits === 0
? maskResult.schools[0]
: undefined;
const sourceDamageClass = input.origin === "runewaker"
? runewakerComponent?.sourceDamageClass
: damageClass === 0 ? "none"
: damageClass === 1 ? "magic"
: damageClass === 2 ? "melee"
: damageClass === 3 ? "ranged" : undefined;
const attackKind = input.origin === "runewaker"
? runewakerComponent?.attackKind ?? input.attackKind
: input.attackKind ?? attackKindForDamageClass(damageClass);
const school = exactWoWSchool
?? runewakerComponent?.school
?? input.school;
return {
...(attackKind ? { attackKind } : {}),
...(damageClass !== undefined ? { damageClass } : {}),
...(sourceDamageClass ? { sourceDamageClass } : {}),
...(schoolMask !== undefined ? { schoolMask } : {}),
...(wowSource?.hitSource ? { hitSource: wowSource.hitSource } : {}),
schools: maskResult.schools.length ? maskResult.schools : runewakerComponent?.school ? [runewakerComponent.school] : [],
...(school ? { school } : {}),
...(maskResult.unknownBits ? { unknownSchoolMaskBits: maskResult.unknownBits } : {}),
sourceVerified: Boolean(wowSource || runewakerComponent),
};
}