// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.GameLogic.Offline; using System.Threading; /// /// Server-side intelligence that drives an after the real /// client disconnects. Mirrors the C++ CMuHelper::Work() loop including: /// /// Basic / conditional / combo skill attack selection /// Buff application (up to 3 configured buff skills) /// Heal / drain-life based on HP % /// Return-to-origin regrouping /// Item pickup (Zen, Jewels, Excellent, Ancient, and named extra items) /// Skill and movement animations broadcast to nearby observers /// Pet control /// /// public sealed class OfflinePlayerMuHelper : AsyncDisposable { private readonly OfflinePlayer _player; private readonly CombatHandler _combatHandler; private readonly BuffHandler _buffHandler; private readonly ItemPickupHandler _itemPickupHandler; private readonly MovementHandler _movementHandler; private readonly RepairHandler _repairHandler; private readonly ZenConsumptionHandler _zenHandler; private readonly HealingHandler _healingHandler; private readonly PetHandler _petHandler; private readonly CancellationTokenSource _cts = new(); private readonly EventHandler _deathHandler; private readonly PeriodicTimer _timer = new(TimeSpan.FromMilliseconds(500)); private Task? _loopTask; private bool _isDead; /// /// Initializes a new instance of the class. /// /// The offline player. public OfflinePlayerMuHelper(OfflinePlayer player) { this._player = player; var originalPosition = player.Position; var config = player.MuHelperSettings; this._buffHandler = new BuffHandler(player, config); this._healingHandler = new HealingHandler(player, config); this._itemPickupHandler = new ItemPickupHandler(player, config); this._movementHandler = new MovementHandler(player, config, originalPosition); this._combatHandler = new CombatHandler(player, config, this._movementHandler, originalPosition); this._repairHandler = new RepairHandler(player, config); this._zenHandler = new ZenConsumptionHandler(player); this._petHandler = new PetHandler(player, config); if (config is null) { this._player.Logger.LogDebug("Offline player started for {CharacterName} without a valid MU Helper configuration.", this._player.Name); } else { this._player.Logger.LogDebug("Offline player configuration for {CharacterName}: MuHelperSettings={Settings}.", this._player.Name, config); } this._deathHandler = (_, e) => this.OnPlayerDied(e); this._player.Died += this._deathHandler; } /// Starts the AI loop and a separate pet AI. public void Start() { _ = this._petHandler.InitializeAsync(); this._loopTask = this.RunLoopAsync(this._cts.Token); } /// protected override async ValueTask DisposeAsyncCore() { this._timer.Dispose(); await this._cts.CancelAsync().ConfigureAwait(false); if (this._loopTask is { } loopTask) { try { await loopTask.ConfigureAwait(false); } catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) { // Expected during shutdown. } catch (Exception ex) { this._player.Logger.LogError(ex, "Error in offline player helper loop task for {AccountLoginName}.", this._player.AccountLoginName); } } await this._petHandler.StopAsync().ConfigureAwait(false); await base.DisposeAsyncCore().ConfigureAwait(false); } /// protected override void Dispose(bool disposing) { if (disposing) { this._player.Died -= this._deathHandler; this._cts.Dispose(); } base.Dispose(disposing); } private void OnPlayerDied(DeathInformation e) { this._player.Logger.LogDebug("Offline player '{Name}' died. Killer: {KillerName}.", this._player.Name, e.KillerName); this._isDead = true; } private async Task RunLoopAsync(CancellationToken cancellationToken) { while (await this._timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) { cancellationToken.ThrowIfCancellationRequested(); await this.SafeTickAsync(cancellationToken).ConfigureAwait(false); } } private async Task SafeTickAsync(CancellationToken cancellationToken) { try { await this.TickAsync(cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { // expected during shutdown } catch (Exception ex) { this._player.Logger.LogError(ex, "Error in offline player helper tick for {AccountLoginName}.", this._player.AccountLoginName); } } private async ValueTask TickAsync(CancellationToken cancellationToken) { if (await this.HandleDeathAsync().ConfigureAwait(false)) { return; } if (cancellationToken.IsCancellationRequested) { return; } if (this._player.PlayerState.CurrentState != PlayerState.EnteredWorld) { return; } if (!await this._zenHandler.DeductZenAsync().ConfigureAwait(false)) { if (this._player.Account?.LoginName is { } loginName) { await this._player.GameContext.OfflinePlayerManager.StopAsync(loginName).ConfigureAwait(false); } return; } await this._repairHandler.PerformRepairsAsync().ConfigureAwait(false); await this._petHandler.CheckPetDurabilityAsync().ConfigureAwait(false); if (this.IsOnSkillCooldown()) { return; } // CMuHelper::Work() order: Buff → RecoverHealth → ObtainItem → Regroup → Attack. if (!await this._buffHandler.PerformBuffsAsync().ConfigureAwait(false)) { return; } await this._healingHandler.PerformHealthRecoveryAsync().ConfigureAwait(false); await this._combatHandler.PerformDrainLifeRecoveryAsync().ConfigureAwait(false); await this._itemPickupHandler.PickupItemsAsync().ConfigureAwait(false); if (this._player.IsWalking) { return; } if (!await this._movementHandler.RegroupAsync().ConfigureAwait(false)) { return; } await this._combatHandler.PerformAttackAsync().ConfigureAwait(false); } private async ValueTask HandleDeathAsync() { if (this._isDead) { if (!this._player.IsAlive) { // Player hasn't respawned yet, skip the tick and check again on the next interval. return true; } if (this._player.Account?.LoginName is { } loginName) { this._player.Logger.LogInformation("Offline player died and successfully respawned. Stopping session for {0}.", loginName); await this._player.GameContext.OfflinePlayerManager.StopAsync(loginName).ConfigureAwait(false); } return true; } return false; } private bool IsOnSkillCooldown() { if (this._combatHandler.SkillCooldownTicks > 0) { this._combatHandler.DecrementCooldown(); return true; } return false; } }