Files
healer-man/src/game/enemyAttacks.ts
T
2026-09-09 11:21:33 -04:00

322 lines
11 KiB
TypeScript

import {
normalizeRunewakerDamageFormula,
type RunewakerDamageFormulaSource,
} from "./runewakerCombatFormula";
export type EnemyAttackAnimation = "attack" | "cast";
export type EnemyAttackDelivery = "melee" | "projectile" | "area";
export type EnemyAttackTarget =
| "primary"
| "random-party"
| "nearby-party"
| "self"
| "lowest-health-friendly";
export type EnemyDamageSchool =
| "physical"
| "arcane"
| "fire"
| "frost"
| "nature"
| "shadow"
| "holy";
export type EnemyControlMechanic = "confuse" | "fear" | "sleep" | "root" | "stun" | "silence";
/**
* A persistent enemy aura that procs a short-lived, charged critical-strike
* modifier from successful main-hand auto attacks.
*/
export interface EnemyMeleeAutoCriticalProc {
readonly chance: number;
readonly triggeredAura: string;
readonly durationMs: number;
readonly criticalChancePercent: number;
readonly charges: number;
}
export type EnemyTelegraphShape = "circle" | "cone" | "line";
export type EnemyTelegraphAnchor = "source" | "target";
/** World-space warning geometry shared by simulation and Three presentation. */
export interface EnemyAttackTelegraph {
readonly shape: EnemyTelegraphShape;
readonly anchor: EnemyTelegraphAnchor;
/** Minimum warning time before the mechanic resolves. */
readonly warningMs: number;
readonly radius?: number;
readonly length?: number;
readonly width?: number;
readonly angleDegrees?: number;
}
export type EnemyAbilityEffect =
| { readonly kind: "damage"; readonly multiplier: number }
| { readonly kind: "heal"; readonly multiplier: number }
| {
readonly kind: "aura";
readonly aura: string;
readonly durationMs: number;
readonly magnitude?: number;
readonly meleeAutoCriticalProc?: EnemyMeleeAutoCriticalProc;
}
| {
readonly kind: "target-aura";
readonly aura: string;
readonly durationMs: number;
}
| {
readonly kind: "control";
readonly mechanic: EnemyControlMechanic;
readonly durationMs: number;
/** Optional damage resolved against the same targets as the control. */
readonly damageMultiplier?: number;
}
| { readonly kind: "transform"; readonly form: string }
| {
readonly kind: "summon";
readonly creatureEntry: number;
readonly count: number;
/** Optional stable namespace for content-authored summoned entities. */
readonly entityId?: string;
/** Optional owner group used by encounter counters and reset cleanup. */
readonly spawnGroupId?: string;
}
| { readonly kind: "call-for-help"; readonly radius: number }
| { readonly kind: "encounter-event"; readonly eventId: string };
export type EnemyAbilityCondition =
| { readonly kind: "in-combat" }
| {
readonly kind: "self-health-percent";
readonly minimum: number;
readonly maximum: number;
}
| {
readonly kind: "friendly-missing-health";
readonly amount: number;
readonly range: number;
}
| {
readonly kind: "encounter-state";
readonly encounterId: string;
readonly states: readonly string[];
};
export interface EnemyAbilitySource {
readonly entry: number;
readonly sourceType: number;
readonly rowId: number;
readonly eventType: number;
readonly actionType: number;
readonly targetType: number;
readonly comment: string;
}
export interface EnemyAttackDefinition {
readonly id: string;
readonly name: string;
readonly spellId?: number;
readonly delivery: EnemyAttackDelivery;
readonly target: EnemyAttackTarget;
readonly school: EnemyDamageSchool;
readonly animation: EnemyAttackAnimation;
/** Zero keeps the legacy instant-execution behavior. */
readonly castTimeMs?: number;
/** Casts are interruptible unless explicitly disabled. */
readonly interruptible?: boolean;
readonly range: number;
readonly cooldownMs: number;
readonly initialCooldownMs?: readonly [minimum: number, maximum: number];
readonly repeatCooldownMs?: readonly [minimum: number, maximum: number];
readonly maximumCasts?: number;
readonly damageMultiplier: number;
/** Source-shaped RuneWaker formula evidence for imported damage mechanics. */
readonly runewakerFormula?: RunewakerDamageFormulaSource;
readonly radius?: number;
readonly telegraph?: EnemyAttackTelegraph;
readonly effect?: EnemyAbilityEffect;
readonly condition?: EnemyAbilityCondition;
readonly source?: EnemyAbilitySource;
}
export interface EnemyCombatDefinition {
readonly serverEntry?: number;
readonly level?: number;
readonly healthMultiplier?: number;
readonly damageMultiplier?: number;
readonly moveSpeed?: number;
readonly aggroRange?: number;
readonly leashRange?: number;
/** Stable special-attack ids available for future encounter loadout randomization. */
readonly mechanicIds?: readonly string[];
readonly attacks: readonly EnemyAttackDefinition[];
}
export const DEFAULT_ENEMY_ATTACK: EnemyAttackDefinition = Object.freeze({
id: "basic-attack",
name: "Basic Attack",
delivery: "melee",
target: "primary",
school: "physical",
animation: "attack",
range: 4.5,
cooldownMs: 1_900,
damageMultiplier: 1,
});
function finiteAtLeast(value: number | undefined, fallback: number, minimum: number): number {
return Number.isFinite(value) ? Math.max(minimum, value!) : fallback;
}
function normalizeCooldownRange(
value: readonly [number, number] | undefined,
fallback: number,
minimum: number,
): readonly [number, number] {
const lower = finiteAtLeast(value?.[0], fallback, minimum);
const upper = finiteAtLeast(value?.[1], lower, lower);
return Object.freeze([Math.round(lower), Math.round(upper)]);
}
function harmfulEnemyAttack(value: EnemyAttackDefinition): boolean {
return !value.effect
|| value.effect.kind === "damage"
|| value.effect.kind === "control"
|| value.effect.kind === "target-aura";
}
/**
* Legacy/imported encounters receive safe defaults while authored mechanics can
* override every dimension in the modular mechanic catalog.
*/
export function inferEnemyAttackTelegraph(
value: EnemyAttackDefinition,
): EnemyAttackTelegraph | undefined {
if (value.id === "basic-attack" || !harmfulEnemyAttack(value)) return undefined;
const radius = value.radius ?? Math.max(3, value.range * 0.35);
if (/\b(?:breath|cleave|frontal|sweep|cone)\b/i.test(value.name)) {
return {
shape: "cone",
anchor: "source",
warningMs: 1_650,
length: Math.max(5.5, value.range),
angleDegrees: /breath/i.test(value.name) ? 70 : 95,
};
}
if (/\b(?:beam|charge|line|lance)\b/i.test(value.name)) {
return {
shape: "line",
anchor: "source",
warningMs: 1_650,
length: Math.max(7, value.range),
width: Math.max(2, radius * 0.45),
};
}
if (value.delivery === "area" || value.radius !== undefined) {
const targetAnchored = /\b(?:blizzard|rain|barrage|volley|eruption|ground|meteor)\b/i
.test(value.name) && value.target !== "self";
return {
shape: "circle",
anchor: targetAnchored ? "target" : "source",
warningMs: 1_650,
radius,
};
}
return undefined;
}
function normalizeTelegraph(value: EnemyAttackTelegraph): EnemyAttackTelegraph {
const warningMs = Math.round(finiteAtLeast(value.warningMs, 1_650, 250));
if (value.shape === "circle") {
return Object.freeze({
shape: value.shape,
anchor: value.anchor,
warningMs,
radius: finiteAtLeast(value.radius, 4, 0.5),
});
}
if (value.shape === "cone") {
return Object.freeze({
shape: value.shape,
anchor: "source",
warningMs,
length: finiteAtLeast(value.length, 8, 1),
angleDegrees: Math.min(180, finiteAtLeast(value.angleDegrees, 90, 5)),
});
}
return Object.freeze({
shape: value.shape,
anchor: "source",
warningMs,
length: finiteAtLeast(value.length, 8, 1),
width: finiteAtLeast(value.width, 3, 0.5),
});
}
export function normalizeEnemyAttack(
value: EnemyAttackDefinition,
fallback: EnemyAttackDefinition = DEFAULT_ENEMY_ATTACK,
): EnemyAttackDefinition {
const cooldownMs = Math.round(finiteAtLeast(value.cooldownMs, fallback.cooldownMs, 1));
const telegraph = value.telegraph ?? inferEnemyAttackTelegraph(value);
const inferredCastTimeMs = value.castTimeMs === undefined && value.animation === "cast"
? (fallback.castTimeMs ?? 1_500)
: telegraph?.warningMs ?? fallback.castTimeMs ?? 0;
const castTimeMs = Math.max(
Math.round(finiteAtLeast(value.castTimeMs, inferredCastTimeMs, 0)),
telegraph?.warningMs ?? 0,
);
const runewakerFormula = normalizeRunewakerDamageFormula(value.runewakerFormula);
return Object.freeze({
id: value.id.trim() || fallback.id,
name: value.name.trim() || fallback.name,
...(Number.isInteger(value.spellId) && value.spellId! > 0
? { spellId: value.spellId }
: {}),
delivery: value.delivery,
target: value.target,
school: value.school,
animation: value.animation,
castTimeMs,
interruptible: value.interruptible ?? fallback.interruptible ?? true,
range: finiteAtLeast(value.range, fallback.range, 0),
cooldownMs,
...(value.initialCooldownMs
? { initialCooldownMs: normalizeCooldownRange(value.initialCooldownMs, cooldownMs, 0) }
: {}),
...(value.repeatCooldownMs
? { repeatCooldownMs: normalizeCooldownRange(value.repeatCooldownMs, cooldownMs, 1) }
: {}),
...(Number.isInteger(value.maximumCasts) && value.maximumCasts! > 0
? { maximumCasts: value.maximumCasts }
: {}),
damageMultiplier: finiteAtLeast(value.damageMultiplier, fallback.damageMultiplier, 0),
...(runewakerFormula ? { runewakerFormula } : {}),
...(value.radius !== undefined
? { radius: finiteAtLeast(value.radius, 0, 0) }
: {}),
...(telegraph ? { telegraph: normalizeTelegraph(telegraph) } : {}),
...(value.effect ? { effect: value.effect } : {}),
...(value.condition ? { condition: value.condition } : {}),
...(value.source ? { source: value.source } : {}),
});
}
export function normalizeEnemyAttacks(
attacks: readonly EnemyAttackDefinition[] | undefined,
fallback: EnemyAttackDefinition = DEFAULT_ENEMY_ATTACK,
): readonly EnemyAttackDefinition[] {
const source = attacks?.length ? attacks : [fallback];
const normalized = source.map((attack) => normalizeEnemyAttack(attack, fallback));
const seen = new Set<string>();
return Object.freeze(normalized.filter((attack) => {
if (seen.has(attack.id)) return false;
seen.add(attack.id);
return true;
}));
}
export function maximumEnemyAttackRange(attacks: readonly EnemyAttackDefinition[]): number {
return attacks.reduce((maximum, attack) => Math.max(maximum, attack.range), 0);
}