208 lines
8.1 KiB
TypeScript
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),
|
|
};
|
|
}
|