Files
healer-man/src/game/enemyAttacks.ts
T
2026-08-20 14:05:50 -04:00

186 lines
6.1 KiB
TypeScript

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 = "fear" | "sleep" | "root" | "stun" | "silence";
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 kind: "control";
readonly mechanic: EnemyControlMechanic;
readonly durationMs: number;
}
| { readonly kind: "transform"; readonly form: string }
| { readonly kind: "summon"; readonly creatureEntry: number; readonly count: number }
| { 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;
readonly radius?: number;
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;
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)]);
}
export function normalizeEnemyAttack(
value: EnemyAttackDefinition,
fallback: EnemyAttackDefinition = DEFAULT_ENEMY_ATTACK,
): EnemyAttackDefinition {
const cooldownMs = Math.round(finiteAtLeast(value.cooldownMs, fallback.cooldownMs, 1));
const inferredCastTimeMs = value.castTimeMs === undefined && value.animation === "cast"
? (fallback.castTimeMs ?? 1_500)
: (fallback.castTimeMs ?? 0);
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: Math.round(finiteAtLeast(value.castTimeMs, inferredCastTimeMs, 0)),
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),
...(value.radius !== undefined
? { radius: finiteAtLeast(value.radius, 0, 0) }
: {}),
...(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);
}