//
// 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;
}
}