import type { MobStatusKind } from "./abilityCatalog"; export type MobAiPhase = "idle" | "chasing" | "attacking" | "returning" | "dead"; export type MobAiPosition = readonly [x: number, y: number, z: number]; export type MutableMobAiPosition = [x: number, y: number, z: number]; /** Structural subset of combatStore's MobStatus, kept free of store dependencies. */ export interface MobAiStatus { readonly kind: MobStatusKind; readonly endsAt: number; readonly magnitude?: number; } /** * Low-frequency combat tuning. Positions stay in the runtime registry; this * profile contains only serializable rules consumed by the store and renderer. */ export interface MobCombatProfile { readonly chaseSpeed: number; readonly returnSpeed: number; readonly attackRange: number; readonly attackHeightTolerance: number; readonly leashRange: number; readonly returnTolerance: number; readonly swingIntervalMs: number; readonly initialSwingDelayMs: number; readonly attackDamage: number; } export interface MobCombatProfileOptions { readonly boss?: boolean; readonly level?: number; readonly overrides?: Partial; } export const DEFAULT_MOB_COMBAT_PROFILE: MobCombatProfile = Object.freeze({ chaseSpeed: 3.25, returnSpeed: 4.25, attackRange: 4.5, attackHeightTolerance: 4, leashRange: 35, returnTolerance: 0.3, swingIntervalMs: 1_600, initialSwingDelayMs: 1_600, attackDamage: 6, }); export const DEFAULT_BOSS_COMBAT_PROFILE: MobCombatProfile = Object.freeze({ chaseSpeed: 2.9, returnSpeed: 3.8, attackRange: 5.5, attackHeightTolerance: 8, leashRange: 45, returnTolerance: 0.45, swingIntervalMs: 1_600, initialSwingDelayMs: 1_600, attackDamage: 14, }); const EMPTY_STATUSES: readonly MobAiStatus[] = Object.freeze([]); const POSITION_EPSILON = 1e-6; function finiteAtLeast(value: number | undefined, fallback: number, minimum: number): number { return Number.isFinite(value) ? Math.max(minimum, value!) : fallback; } function finitePosition(position: MobAiPosition): boolean { return Number.isFinite(position[0]) && Number.isFinite(position[1]) && Number.isFinite(position[2]); } function finiteDistance(value: number): number { return Number.isFinite(value) && value >= 0 ? value : Number.POSITIVE_INFINITY; } /** Creates level-scaled defaults and clamps malformed per-creature overrides. */ export function createMobCombatProfile(options: MobCombatProfileOptions = {}): MobCombatProfile { const boss = options.boss ?? false; const level = Number.isFinite(options.level) ? Math.max(1, Math.trunc(options.level!)) : 1; const base = boss ? DEFAULT_BOSS_COMBAT_PROFILE : DEFAULT_MOB_COMBAT_PROFILE; const overrides = options.overrides ?? {}; const scaledDamage = (boss ? 12 : 4) + level * 2; return { chaseSpeed: finiteAtLeast(overrides.chaseSpeed, base.chaseSpeed, 0), returnSpeed: finiteAtLeast(overrides.returnSpeed, base.returnSpeed, 0), attackRange: finiteAtLeast(overrides.attackRange, base.attackRange, 0), attackHeightTolerance: finiteAtLeast( overrides.attackHeightTolerance, base.attackHeightTolerance, 0, ), leashRange: finiteAtLeast(overrides.leashRange, base.leashRange, 0), returnTolerance: finiteAtLeast(overrides.returnTolerance, base.returnTolerance, 0), swingIntervalMs: finiteAtLeast(overrides.swingIntervalMs, base.swingIntervalMs, 1), initialSwingDelayMs: finiteAtLeast( overrides.initialSwingDelayMs, base.initialSwingDelayMs, 0, ), attackDamage: finiteAtLeast(overrides.attackDamage, scaledDamage, 0), }; } /** Returns Infinity for malformed positions so invalid data cannot authorize a hit. */ export function planarDistanceSquared(from: MobAiPosition, to: MobAiPosition): number { if (!finitePosition(from) || !finitePosition(to)) return Number.POSITIVE_INFINITY; const dx = to[0] - from[0]; const dz = to[2] - from[2]; return dx * dx + dz * dz; } export function distanceSquared(from: MobAiPosition, to: MobAiPosition): number { if (!finitePosition(from) || !finitePosition(to)) return Number.POSITIVE_INFINITY; const dx = to[0] - from[0]; const dy = to[1] - from[1]; const dz = to[2] - from[2]; return dx * dx + dy * dy + dz * dz; } export function isMobInAttackRange( mobPosition: MobAiPosition, targetPosition: MobAiPosition, profile: MobCombatProfile, ): boolean { const range = finiteAtLeast(profile.attackRange, 0, 0); const heightTolerance = finiteAtLeast(profile.attackHeightTolerance, 0, 0); return planarDistanceSquared(mobPosition, targetPosition) <= range * range && Math.abs(targetPosition[1] - mobPosition[1]) <= heightTolerance; } export function isMobOutsideLeash( mobPosition: MobAiPosition, targetPosition: MobAiPosition, homePosition: MobAiPosition, profile: MobCombatProfile, ): boolean { const leash = finiteAtLeast(profile.leashRange, 0, 0); const leashSquared = leash * leash; return planarDistanceSquared(homePosition, mobPosition) > leashSquared || planarDistanceSquared(homePosition, targetPosition) > leashSquared; } export interface MobAiTransitionInput { readonly phase: MobAiPhase; readonly dead: boolean; readonly engaged: boolean; readonly playerAlive: boolean; readonly position: MobAiPosition; readonly homePosition: MobAiPosition; readonly targetPosition: MobAiPosition | null; readonly profile: MobCombatProfile; readonly lineOfSight?: boolean; } export interface MobAiTransitionResult { readonly phase: MobAiPhase; /** Store should discard threat/forced-target state when true. */ readonly clearThreat: boolean; /** Returning actor reached home and may restore health/statuses. */ readonly restoreAtHome: boolean; /** Any pending attack wind-up should be discarded when true. */ readonly cancelSwing: boolean; } /** * Pure orthogonal-state transition. Crowd control is evaluated separately so * stun/root/fear do not explode the phase count or discard engagement state. */ export function resolveMobAiTransition(input: MobAiTransitionInput): MobAiTransitionResult { let phase: MobAiPhase; let restoreAtHome = false; if (input.dead) { phase = "dead"; } else if (input.phase === "dead") { // A dead -> alive transition is an encounter reset, never an implicit re-pull. phase = "idle"; } else if (input.phase === "returning") { const homeDistance = Math.sqrt(distanceSquared(input.position, input.homePosition)); if (homeDistance <= input.profile.returnTolerance) { phase = "idle"; restoreAtHome = true; } else { phase = "returning"; } } else if (!input.engaged) { phase = "idle"; } else if ( !input.playerAlive || !input.targetPosition || isMobOutsideLeash(input.position, input.targetPosition, input.homePosition, input.profile) ) { phase = "returning"; } else if ( (input.lineOfSight ?? true) && isMobInAttackRange(input.position, input.targetPosition, input.profile) ) { phase = "attacking"; } else { phase = "chasing"; } return { phase, clearThreat: phase === "idle" || phase === "returning" || phase === "dead", restoreAtHome, cancelSwing: phase !== "attacking", }; } export function hasActiveMobStatus( statuses: readonly MobAiStatus[], kind: MobStatusKind, now: number, ): boolean { const safeNow = Number.isFinite(now) ? now : 0; return statuses.some((status) => status.kind === kind && Number.isFinite(status.endsAt) && status.endsAt > safeNow); } function strongestActiveMagnitude( statuses: readonly MobAiStatus[], kind: MobStatusKind, now: number, defaultMagnitude: number, ): number { const safeNow = Number.isFinite(now) ? now : 0; let strongest = 0; for (const status of statuses) { if (status.kind !== kind || !Number.isFinite(status.endsAt) || status.endsAt <= safeNow) continue; const magnitude = Number.isFinite(status.magnitude) ? status.magnitude! : defaultMagnitude; strongest = Math.max(strongest, magnitude); } return strongest; } const MOVEMENT_LOCKING_STATUSES: readonly MobStatusKind[] = [ "stun", "root", "fear", "sleep", "knockdown", "freeze", "charm", "confuse", ]; const ATTACK_LOCKING_STATUSES: readonly MobStatusKind[] = [ "stun", "fear", "sleep", "blind", "pacify", "knockdown", "freeze", "charm", "confuse", ]; /** Immobilizing control suppresses directed movement; the strongest slow wins. */ export function mobMovementSpeedMultiplier( statuses: readonly MobAiStatus[], now: number, ): number { if (MOVEMENT_LOCKING_STATUSES.some((kind) => hasActiveMobStatus(statuses, kind, now))) return 0; const slow = Math.min(0.95, Math.max(0, strongestActiveMagnitude(statuses, "slow", now, 0.5))); return 1 - slow; } /** A 20% attack-speed slow yields a 1 / 0.8 = 1.25 interval multiplier. */ export function mobSwingIntervalMultiplier( statuses: readonly MobAiStatus[], now: number, ): number { const reduction = Math.min( 0.9, Math.max(0, strongestActiveMagnitude(statuses, "attack-speed", now, 0.2)), ); return 1 / (1 - reduction); } export function canMobMove( phase: MobAiPhase, dead: boolean, statuses: readonly MobAiStatus[] = EMPTY_STATUSES, now = 0, ): boolean { return !dead && (phase === "chasing" || phase === "returning") && mobMovementSpeedMultiplier(statuses, now) > 0; } export function mobMovementSpeed( profile: MobCombatProfile, phase: MobAiPhase, statuses: readonly MobAiStatus[] = EMPTY_STATUSES, now = 0, ): number { if (!canMobMove(phase, false, statuses, now)) return 0; const base = phase === "returning" ? profile.returnSpeed : profile.chaseSpeed; return finiteAtLeast(base, 0, 0) * mobMovementSpeedMultiplier(statuses, now); } export interface MobSwingEligibilityInput { readonly phase: MobAiPhase; readonly dead: boolean; readonly engaged: boolean; readonly playerAlive: boolean; readonly targetAvailable: boolean; /** Planar distance; callers can use sqrt(planarDistanceSquared(...)). */ readonly distanceToTarget: number; readonly verticalDistance?: number; readonly lineOfSight?: boolean; readonly now: number; readonly nextSwingAt: number; readonly profile: MobCombatProfile; readonly statuses?: readonly MobAiStatus[]; } export function canMobSwing(input: MobSwingEligibilityInput): boolean { const statuses = input.statuses ?? EMPTY_STATUSES; const distance = finiteDistance(input.distanceToTarget); const verticalDistance = finiteDistance(Math.abs(input.verticalDistance ?? 0)); return input.phase === "attacking" && !input.dead && input.engaged && input.playerAlive && input.targetAvailable && (input.lineOfSight ?? true) && Number.isFinite(input.now) && Number.isFinite(input.nextSwingAt) && input.now >= input.nextSwingAt && distance <= input.profile.attackRange && verticalDistance <= input.profile.attackHeightTolerance && !ATTACK_LOCKING_STATUSES.some((kind) => hasActiveMobStatus(statuses, kind, input.now)); } /** First hit is deliberately telegraphed instead of landing on the aggro frame. */ export function firstMobSwingAt(now: number, profile: MobCombatProfile): number { const safeNow = Number.isFinite(now) ? now : 0; return safeNow + finiteAtLeast(profile.initialSwingDelayMs, 0, 0); } /** Schedules from `now` so a throttled tab cannot unleash catch-up attack bursts. */ export function nextMobSwingAt( now: number, profile: MobCombatProfile, statuses: readonly MobAiStatus[] = EMPTY_STATUSES, ): number { const safeNow = Number.isFinite(now) ? now : 0; const interval = finiteAtLeast(profile.swingIntervalMs, 1, 1) * mobSwingIntervalMultiplier(statuses, safeNow); return safeNow + Math.max(1, Math.round(interval)); } /** Allocation-free, no-overshoot 3D steering for the R3F motion adapter. */ export function moveMobToward( current: MobAiPosition, target: MobAiPosition, maximumDistance: number, out: MutableMobAiPosition, ): boolean { if (!finitePosition(current) || !finitePosition(target)) { out[0] = Number.isFinite(current[0]) ? current[0] : 0; out[1] = Number.isFinite(current[1]) ? current[1] : 0; out[2] = Number.isFinite(current[2]) ? current[2] : 0; return false; } const dx = target[0] - current[0]; const dy = target[1] - current[1]; const dz = target[2] - current[2]; const distance = Math.hypot(dx, dy, dz); if (distance <= POSITION_EPSILON) { out[0] = target[0]; out[1] = target[1]; out[2] = target[2]; return true; } const step = Number.isFinite(maximumDistance) ? Math.max(0, maximumDistance) : 0; if (step >= distance) { out[0] = target[0]; out[1] = target[1]; out[2] = target[2]; return true; } const alpha = step / distance; out[0] = current[0] + dx * alpha; out[1] = current[1] + dy * alpha; out[2] = current[2] + dz * alpha; return false; }