baseline: OpenMU upstream b5a0961 (fresh source)

This commit is contained in:
Acentech Dev
2026-07-14 19:00:35 +03:00
parent 450402e47d
commit 36fc125d5c
11968 changed files with 748705 additions and 0 deletions

View File

@@ -0,0 +1,155 @@
// <copyright file="IMuHelperSettings.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MuHelper;
/// <summary>
/// The Mu Helper player settings.
/// </summary>
public interface IMuHelperSettings
{
/// <summary>Gets the always-active basic attack skill ID (0 = no skill, use normal attack).</summary>
int BasicSkillId { get; }
/// <summary>Gets the first conditional skill ID.</summary>
int ActivationSkill1Id { get; }
/// <summary>Gets the second conditional skill ID.</summary>
int ActivationSkill2Id { get; }
/// <summary>Gets the timer interval (seconds) for ActivationSkill1 when Skill1Delay is set.</summary>
int DelayMinSkill1 { get; }
/// <summary>Gets the timer interval (seconds) for ActivationSkill2 when Skill2Delay is set.</summary>
int DelayMinSkill2 { get; }
/// <summary>Gets a value indicating whether to use timer for the skill 1.</summary>
bool Skill1UseTimer { get; }
/// <summary>Gets a value indicating whether to use condition for the skill 1.</summary>
bool Skill1UseCondition { get; }
/// <summary>Gets a value indicating whether to use the precondition for Skill1 (false = nearby, true = attacking).</summary>
bool Skill1ConditionAttacking { get; }
/// <summary>Gets the mob count threshold for Skill1 condition: 0=2+, 1=3+, 2=4+, 3=5+.</summary>
int Skill1SubCondition { get; }
/// <summary>Gets a value indicating whether to use timer for the skill 2.</summary>
bool Skill2UseTimer { get; }
/// <summary>Gets a value indicating whether to use condition for the skill 2.</summary>
bool Skill2UseCondition { get; }
/// <summary>Gets a value indicating whether to use the precondition for Skill2 (false = nearby, true = attacking).</summary>
bool Skill2ConditionAttacking { get; }
/// <summary>Gets the mob count threshold for Skill2 condition.</summary>
int Skill2SubCondition { get; }
/// <summary>Gets a value indicating whether to use combo mode.</summary>
bool UseCombo { get; }
/// <summary>Gets the hunting range nibble (0-15); multiply to get tile distance.</summary>
int HuntingRange { get; }
/// <summary>Gets the max seconds away from original position before regrouping.</summary>
int MaxSecondsAway { get; }
/// <summary>Gets a value indicating whether to counter-attack enemies that attack from long range.</summary>
bool LongRangeCounterAttack { get; }
/// <summary>Gets a value indicating whether to return to original spawn position when away too long.</summary>
bool ReturnToOriginalPosition { get; }
/// <summary>Gets the Buff 0 skill id.</summary>
int BuffSkill0Id { get; }
/// <summary>Gets the Buff 1 skill id.</summary>
int BuffSkill1Id { get; }
/// <summary>Gets the Buff 2 skill id.</summary>
int BuffSkill2Id { get; }
/// <summary>Gets a value indicating whether apply buffs based on duration (i.e. when the buff expires).</summary>
bool BuffOnDuration { get; }
/// <summary>Gets a value indicating whether apply buff duration logic to party members too.</summary>
bool BuffDurationForParty { get; }
/// <summary>Gets the buff cast interval in seconds (0 = disabled).</summary>
int BuffCastIntervalSeconds { get; }
/// <summary>Gets a value indicating whether to use auto-heal.</summary>
bool AutoHeal { get; }
/// <summary>Gets the self-heal threshold (% HP, e.g. 30 means heal when below 30%).</summary>
int HealThresholdPercent { get; }
/// <summary>Gets a value indicating whether to use drain life.</summary>
bool UseDrainLife { get; }
/// <summary>Gets a value indicating whether to use healing potion.</summary>
bool UseHealPotion { get; }
/// <summary>Gets the potion use threshold (% HP).</summary>
int PotionThresholdPercent { get; }
/// <summary>Gets a value indicating whether to support party.</summary>
bool SupportParty { get; }
/// <summary>Gets a value indicating whether to auto heal party.</summary>
bool AutoHealParty { get; }
/// <summary>Gets the party member HP threshold (%) below which healing is applied.</summary>
int HealPartyThresholdPercent { get; }
/// <summary>Gets a value indicating whether to use dark raven.</summary>
bool UseDarkRaven { get; }
/// <summary>Gets the dark raven mode 0 = cease, 1 = auto-attack, 2 = attack with owner.</summary>
int DarkRavenMode { get; }
/// <summary>Gets the obtain range.</summary>
int ObtainRange { get; }
/// <summary>Gets a value indicating whether pickup all items.</summary>
bool PickAllItems { get; }
/// <summary>Gets a value indicating whether pickup selected items.</summary>
bool PickSelectItems { get; }
/// <summary>Gets a value indicating whether pickup jewels.</summary>
bool PickJewel { get; }
/// <summary>Gets a value indicating whether pickup zen.</summary>
bool PickZen { get; }
/// <summary>Gets a value indicating whether pickup ancient items.</summary>
bool PickAncient { get; }
/// <summary>Gets a value indicating whether pickup excellent items.</summary>
bool PickExcellent { get; }
/// <summary>Gets a value indicating whether pickup extra items.</summary>
bool PickExtraItems { get; }
/// <summary>Gets the extra item names. Up to 12 item name substrings; pick any dropped item whose name contains one of these.</summary>
IReadOnlyList<string> ExtraItemNames { get; }
/// <summary>Gets a value indicating whether to repair items.</summary>
bool RepairItem { get; }
/// <summary>Gets a value indicating whether to automatically defend against players attacking the character.</summary>
bool UseSelfDefense { get; }
/// <summary>Gets a value indicating whether to automatically accept requests from friends.</summary>
bool AutoAcceptFriend { get; }
/// <summary>Gets a value indicating whether to automatically accept requests from guild.</summary>
bool AutoAcceptGuild { get; }
/// <summary>Gets a value indicating whether to use basic attack as fallback when the configured skill cannot be used.</summary>
bool FallbackBasicAttack { get; }
}

View File

@@ -0,0 +1,180 @@
// <copyright file="MuHelper.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MuHelper;
using System.Diagnostics;
using System.Threading;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
using MUnique.OpenMU.GameLogic.Views.MuHelper;
/// <summary>
/// Implements the logic of the 'MU Helper'.
/// </summary>
public class MuHelper : AsyncDisposable
{
/// <summary>
/// The <see cref="IElement"/> which is added to the players <see cref="IAttributeSystem"/>
/// when the MU Helper is active.
/// </summary>
private static readonly IElement ActiveElement = new ConstantElement(1);
/// <summary>
/// Associated player.
/// </summary>
private readonly Player _player;
/// <summary>
/// The current configuration.
/// </summary>
private readonly MuHelperConfiguration _configuration;
private CancellationTokenSource? _stopCts;
private Task? _runTask;
private DateTime _startTimestamp;
/// <summary>
/// Initializes a new instance of the <see cref="MuHelper"/> class.
/// </summary>
/// <param name="player">current player.</param>
public MuHelper(Player player)
{
this._player = player;
this._configuration = this._player.GameContext.FeaturePlugIns.GetPlugIn<MuHelperFeaturePlugIn>()?.Configuration ?? new MuHelperConfiguration();
}
/// <summary>
/// Start Mu Helper.
/// </summary>
public async ValueTask<bool> TryStartAsync()
{
if (this._runTask is not null)
{
await this._player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.MuHelperAlreadyRunning)).ConfigureAwait(false);
return false;
}
if (this._player.Level < this._configuration.MinLevel)
{
await this._player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.MuHelperMinimumLevel), this._configuration.MinLevel).ConfigureAwait(false);
return false;
}
if (this._player.Level > this._configuration.MaxLevel)
{
await this._player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.MuHelperMaximumLevel), this._configuration.MaxLevel).ConfigureAwait(false);
return false;
}
this._startTimestamp = DateTime.UtcNow;
var requiredMoney = this.CalculateRequiredMoney();
if (!this._player.TryRemoveMoney(requiredMoney))
{
await this._player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.MuHelperRequiresMoney), requiredMoney).ConfigureAwait(false);
return false;
}
await this._player.InvokeViewPlugInAsync<IMuHelperStatusUpdatePlugIn>(p => p.StartAsync()).ConfigureAwait(false);
await this._player.InvokeViewPlugInAsync<IMuHelperStatusUpdatePlugIn>(p => p.ConsumeMoneyAsync((uint)requiredMoney)).ConfigureAwait(false);
this._player.Attributes?.AddElement(ActiveElement, Stats.IsMuHelperActive);
this._stopCts?.Dispose();
this._stopCts = new CancellationTokenSource();
var cts = this._stopCts.Token;
this._runTask = this.RunLoopAsync(cts);
return true;
}
/// <summary>
/// Stops the MU Helper.
/// </summary>
public async ValueTask StopAsync()
{
if (this._runTask is not { } runTask
|| this._stopCts is not { } stopCts)
{
return;
}
try
{
this._player.Attributes?.RemoveElement(ActiveElement, Stats.IsMuHelperActive);
await stopCts.CancelAsync().ConfigureAwait(false);
// Skip awaiting the loop task if we are currently executing inside it
// (i.e. CollectAsync triggered StopAsync), to avoid a self-deadlock.
if (runTask.Id != Task.CurrentId)
{
await runTask.ConfigureAwait(false);
}
this._runTask = null;
stopCts.Dispose();
this._stopCts = null;
await this._player.InvokeViewPlugInAsync<IMuHelperStatusUpdatePlugIn>(p => p.StopAsync()).ConfigureAwait(false);
}
catch (Exception ex)
{
this._player.Logger.LogWarning(ex, "Exception during stopping the mu helper for {CharacterName}: {Error}", this._player.Name, ex.Message);
}
}
/// <inheritdoc />
protected override async ValueTask DisposeAsyncCore()
{
await this.StopAsync().ConfigureAwait(false);
}
private int CalculateRequiredMoney() =>
MuHelperZenCostCalculator.Calculate(this._player, this._configuration, this._startTimestamp);
private async Task RunLoopAsync(CancellationToken cancellationToken)
{
try
{
if (this._configuration.PayInterval <= TimeSpan.Zero)
{
this._player.Logger.LogDebug("MU Helper PayInterval is {PayInterval}. Stopping for {CharacterName}.", this._configuration.PayInterval, this._player.Name);
return;
}
using var timer = new PeriodicTimer(this._configuration.PayInterval);
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false);
await this.CollectAsync().ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// expected when StopAsync cancels the token.
}
catch (Exception ex)
{
Debug.Fail(ex.Message, ex.StackTrace);
}
}
/// <summary>
/// Performs the money collection.
/// </summary>
private async ValueTask CollectAsync()
{
var amount = this.CalculateRequiredMoney();
if (amount > 0 && this._player.TryRemoveMoney(amount))
{
await this._player.InvokeViewPlugInAsync<IMuHelperStatusUpdatePlugIn>(p => p.ConsumeMoneyAsync((uint)amount)).ConfigureAwait(false);
}
else
{
await this.StopAsync().ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,47 @@
// <copyright file="MuHelperConfiguration.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MuHelper;
/// <summary>
/// Configuration for the <see cref="MuHelper"/>.
/// </summary>
public class MuHelperConfiguration
{
/// <summary>
/// Gets or sets the cost of the helper per stage. This value is applied per
/// <see cref="PayInterval"/>, and multiplied with the total character level.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.MuHelperConfiguration_CostPerStage_Name), Description = nameof(PlugInResources.MuHelperConfiguration_CostPerStage_Description))]
public IList<int> CostPerStage { get; set; } = new List<int>
{
20, 50, 80, 100, 120,
};
/// <summary>
/// Gets or sets the pay interval.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.MuHelperConfiguration_PayInterval_Name))]
public TimeSpan PayInterval { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets the stage interval.
/// After each interval, the stage gets increased to the next level with
/// usually increasing costs.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.MuHelperConfiguration_StageInterval_Name), Description = nameof(PlugInResources.MuHelperConfiguration_StageInterval_Description))]
public TimeSpan StageInterval { get; set; } = TimeSpan.FromMinutes(200);
/// <summary>
/// Gets or sets the minimum character level.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.MuHelperConfiguration_MinLevel_Name))]
public int MinLevel { get; set; } = 1;
/// <summary>
/// Gets or sets the maximum character level.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.MuHelperConfiguration_MaxLevel_Name))]
public int MaxLevel { get; set; } = 400;
}

View File

@@ -0,0 +1,23 @@
// <copyright file="MuHelperFeaturePlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MuHelper;
using System.Runtime.InteropServices;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Feature plugin which provides the configuration for the reset feature.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.MuHelperFeaturePlugIn_Name), Description = nameof(PlugInResources.MuHelperFeaturePlugIn_Description), ResourceType = typeof(PlugInResources))]
[Guid("E90A72C3-0459-4323-B6D3-171F88D35542")]
public class MuHelperFeaturePlugIn : IFeaturePlugIn, ISupportCustomConfiguration<MuHelperConfiguration>, ISupportDefaultCustomConfiguration
{
/// <inheritdoc/>
public MuHelperConfiguration? Configuration { get; set; }
/// <inheritdoc />
public object CreateDefaultConfig() => new MuHelperConfiguration();
}

View File

@@ -0,0 +1,21 @@
// <copyright file="MuHelperStatus.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MuHelper;
/// <summary>
/// MuBot Status Map.
/// </summary>
public enum MuHelperStatus : byte
{
/// <summary>
/// Enabled.
/// </summary>
Enabled = 0,
/// <summary>
/// Disabled.
/// </summary>
Disabled = 1,
}

View File

@@ -0,0 +1,38 @@
// <copyright file="MuHelperZenCostCalculator.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MuHelper;
using MUnique.OpenMU.GameLogic.Attributes;
/// <summary>
/// Calculates the Zen cost for the MU Helper and offline player based on the
/// player's total level and the elapsed stage derived from the server configuration.
/// </summary>
public static class MuHelperZenCostCalculator
{
/// <summary>
/// Calculates the Zen amount to charge for the current pay interval.
/// </summary>
/// <param name="player">The player being charged.</param>
/// <param name="configuration">The MU helper server configuration.</param>
/// <param name="startTimestamp">The timestamp when the session started, used to determine the current cost stage.</param>
/// <returns>The Zen amount to deduct; 0 if the configuration has no cost entries.</returns>
public static int Calculate(Player player, MuHelperConfiguration configuration, DateTime startTimestamp)
{
if (configuration.CostPerStage.Count == 0 || configuration.StageInterval <= TimeSpan.Zero)
{
return 0;
}
var elapsed = DateTime.UtcNow - startTimestamp;
var currentStage = (int)(elapsed / configuration.StageInterval);
currentStage = Math.Clamp(currentStage, 0, configuration.CostPerStage.Count - 1);
var costMultiplier = configuration.CostPerStage[currentStage];
var totalLevel = player.Level + (int)(player.Attributes?[Stats.MasterLevel] ?? 0);
return costMultiplier * totalLevel;
}
}

View File

@@ -0,0 +1,100 @@
// <copyright file="PartyRequestHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MuHelper;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Handles auto-accepting incoming party requests from friends and guild members
/// based on the player's MU Helper settings flags.
/// </summary>
public static class PartyRequestHandler
{
/// <summary>
/// Automatically accepts a party request if the receiver has the relevant flag enabled.
/// </summary>
/// <param name="receiver">The player receiving the party request.</param>
/// <param name="requester">The player who sent the party request.</param>
/// <returns>True if the criteria matched (auto-accept was attempted, regardless of success); false if no criteria matched.</returns>
public static async ValueTask<bool> TryAutoAcceptPartyRequestAsync(Player receiver, Player requester)
{
var settings = receiver.MuHelperSettings;
if (settings is null)
{
return false;
}
if (settings.AutoAcceptGuild && AreGuildMembers(receiver, requester))
{
await AcceptPartyRequestAsync(receiver, requester).ConfigureAwait(false);
return true;
}
if (settings.AutoAcceptFriend && await AreFriendsAsync(receiver, requester).ConfigureAwait(false))
{
await AcceptPartyRequestAsync(receiver, requester).ConfigureAwait(false);
return true;
}
return false;
}
private static bool AreGuildMembers(Player receiver, Player requester)
{
return receiver.GuildStatus?.GuildId != null
&& receiver.GuildStatus.GuildId == requester.GuildStatus?.GuildId;
}
private static async ValueTask<bool> AreFriendsAsync(Player receiver, Player requester)
{
if (receiver.SelectedCharacter is null || requester.SelectedCharacter is null)
{
return false;
}
var friendServer = (receiver.GameContext as IGameServerContext)?.FriendServer;
if (friendServer is null)
{
return false;
}
return await friendServer.IsFriendAsync(receiver.SelectedCharacter.Name, requester.SelectedCharacter.Name).ConfigureAwait(false);
}
private static async ValueTask<bool> AcceptPartyRequestAsync(Player receiver, Player requester)
{
bool success = false;
try
{
if (receiver.Party != null)
{
if (requester.Party == null)
{
// Receiver is the offline party master; add the solo requester to the existing party.
success = await receiver.Party.AddAsync(requester).ConfigureAwait(false);
}
}
else if (requester.Party != null)
{
// Requester already has a party; add the offline receiver to it.
success = await requester.Party.AddAsync(receiver).ConfigureAwait(false);
}
else
{
// Neither side has a party; create a new one.
var party = receiver.GameContext.PartyManager.CreateParty();
success = await party.AddAsync(requester).ConfigureAwait(false)
&& await party.AddAsync(receiver).ConfigureAwait(false);
}
}
finally
{
await receiver.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false);
receiver.LastPartyRequester = null;
}
return success;
}
}