186 lines
6.1 KiB
TypeScript
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);
|
|
}
|