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,37 @@
// <copyright file="GatekeeperNpcPlugin.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Resets;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Handles the gatekeeper npc in the Barracks of Balgass.
/// </summary>
[Guid("1B7BCA14-3124-4550-94B4-3FFCEE1FD55A")]
[PlugIn]
[Display(Name = nameof(PlugInResources.GatekeeperNpcPlugin_Name), Description = nameof(PlugInResources.GatekeeperNpcPlugin_Description), ResourceType = typeof(PlugInResources))]
public class GatekeeperNpcPlugin : IPlayerTalkToNpcPlugIn
{
/// <summary>
/// Gets the NPC number of 'Gatekeeper' in Barracks of Balgass.
/// </summary>
public static short GatekeeperNpcNumber => 408;
/// <inheritdoc />
public async ValueTask PlayerTalksToNpcAsync(Player player, NonPlayerCharacter npc, NpcTalkEventArgs eventArgs)
{
if (npc.Definition.Number != GatekeeperNpcNumber)
{
return;
}
// The client opens the dialog itself, so we don't need to do anything here.
eventArgs.HasBeenHandled = true;
eventArgs.LeavesDialogOpen = true;
}
}

View File

@@ -0,0 +1,221 @@
// <copyright file="ResetCharacterAction.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Resets;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlayerActions;
using MUnique.OpenMU.GameLogic.Views.Character;
using MUnique.OpenMU.GameLogic.Views.Login;
using MUnique.OpenMU.GameLogic.Views.NPC;
/// <summary>
/// Action to reset a character.
/// </summary>
public class ResetCharacterAction
{
private readonly Player _player;
private readonly NonPlayerCharacter? _npc;
private readonly LogoutAction _logoutAction = new();
/// <summary>
/// Initializes a new instance of the <see cref="ResetCharacterAction"/> class.
/// </summary>
/// <param name="player">Player to reset.</param>
/// <param name="npc">NPC which the player talks to to initiate the reset action.</param>
public ResetCharacterAction(Player player, NonPlayerCharacter? npc = null)
{
this._player = player;
this._npc = npc;
}
/// <summary>
/// Reset specific character.
/// </summary>
public async ValueTask ResetCharacterAsync()
{
var resetFeature = this._player.GameContext.FeaturePlugIns.GetPlugIn<ResetFeaturePlugIn>();
if (resetFeature is null)
{
await this.ShowMessageAsync(nameof(PlayerMessage.ResetNotEnabled)).ConfigureAwait(false);
return;
}
if (this._player.PlayerState.CurrentState != PlayerState.EnteredWorld && this._npc is null)
{
await this.ShowMessageAsync(nameof(PlayerMessage.CantResetWithOpenedWindows)).ConfigureAwait(false);
return;
}
if (this._player.Attributes is null || this._player.SelectedCharacter is null)
{
await this.ShowMessageAsync(nameof(PlayerMessage.NotEnteredTheGame)).ConfigureAwait(false);
return;
}
var configuration = resetFeature.Configuration;
if (configuration is null)
{
await this.ShowMessageAsync(nameof(PlayerMessage.ResetNotConfigured)).ConfigureAwait(false);
return;
}
var resetProgression = ResetProgressionCalculator.Calculate(this.GetResetCount(), (int)this._player.Attributes[Stats.PointsPerReset], configuration);
if (this._player.Level < configuration.RequiredLevel)
{
await this.ShowMessageAsync(nameof(PlayerMessage.RequiredLevelForReset), configuration.RequiredLevel).ConfigureAwait(false);
return;
}
if (configuration.ResetLimit > 0 && resetProgression.NextResetCount > configuration.ResetLimit)
{
await this.ShowMessageAsync(nameof(PlayerMessage.MaximumResetsReached), configuration.ResetLimit).ConfigureAwait(false);
return;
}
if (!await this.TryConsumeResetCostsAsync(configuration, resetProgression).ConfigureAwait(false))
{
return;
}
this._player.Attributes[Stats.Resets] = resetProgression.NextResetCount;
this._player.Attributes[Stats.Level] = configuration.LevelAfterReset;
this._player.SelectedCharacter.Experience = 0;
this.UpdateStats(configuration, resetProgression);
if (configuration.MoveHome)
{
await this.MoveHomeAsync().ConfigureAwait(false);
}
if (configuration.LogOut)
{
await this._logoutAction.LogoutAsync(this._player, LogoutType.BackToCharacterSelection).ConfigureAwait(false);
}
else
{
await this.UpdateClientStatsAsync(configuration).ConfigureAwait(false);
}
}
private async ValueTask ShowMessageAsync(string messageKey, params object?[] args)
{
var message = this._player.GetLocalizedMessage(messageKey, args);
if (this._npc is null)
{
await this._player.ShowBlueMessageAsync(message).ConfigureAwait(false);
return;
}
await this._player.InvokeViewPlugInAsync<IShowMessageOfObjectPlugIn>(p => p.ShowMessageOfObjectAsync(message, this._npc)).ConfigureAwait(false);
}
private int GetResetCount()
{
return (int)this._player.Attributes![Stats.Resets];
}
private async ValueTask<bool> TryConsumeResetCostsAsync(ResetConfiguration configuration, ResetProgression resetProgression)
{
var requiredItems = await this.GetRequiredItemsToConsumeAsync(configuration, resetProgression.RequiredItemAmount).ConfigureAwait(false);
if (requiredItems is null)
{
return false;
}
if (this._player.Money < resetProgression.RequiredZen)
{
await this.ShowMessageAsync(nameof(PlayerMessage.NotEnoughMoneyForReset), resetProgression.RequiredZen).ConfigureAwait(false);
return false;
}
if (resetProgression.RequiredZen > 0 && !this._player.TryRemoveMoney(resetProgression.RequiredZen))
{
await this.ShowMessageAsync(nameof(PlayerMessage.NotEnoughMoneyForReset), resetProgression.RequiredZen).ConfigureAwait(false);
return false;
}
foreach (var item in requiredItems)
{
await this._player.DestroyInventoryItemAsync(item).ConfigureAwait(false);
}
return true;
}
private async ValueTask<IList<Item>?> GetRequiredItemsToConsumeAsync(ResetConfiguration configuration, int requiredItemAmount)
{
if (requiredItemAmount <= 0 || configuration.RequiredResetItem is null)
{
return [];
}
if (this._player.Inventory is null)
{
return null;
}
var requiredDefinition = configuration.RequiredResetItem;
var requiredItems = this._player.Inventory.Items
.Where(item => item.Definition is { } definition
&& definition.Group == requiredDefinition.Group
&& definition.Number == requiredDefinition.Number)
.Take(requiredItemAmount)
.ToList();
if (requiredItems.Count < requiredItemAmount)
{
await this.ShowMessageAsync(
nameof(PlayerMessage.NotEnoughItemsForReset),
requiredItemAmount,
configuration.RequiredResetItem.Name).ConfigureAwait(false);
return null;
}
return requiredItems;
}
private void UpdateStats(ResetConfiguration configuration, ResetProgression resetProgression)
{
if (configuration.ResetStats)
{
this._player.SelectedCharacter!.CharacterClass!.StatAttributes
.Where(s => s.IncreasableByPlayer)
.ForEach(s => this._player.Attributes![s.Attribute] = s.BaseValue);
}
if (configuration.ReplacePointsPerReset)
{
this._player.SelectedCharacter!.LevelUpPoints = resetProgression.TotalPointsAfterReset;
}
else
{
this._player.SelectedCharacter!.LevelUpPoints += resetProgression.PointsForReset;
}
}
private async ValueTask MoveHomeAsync()
{
var homeMapDef = this._player.SelectedCharacter!.CharacterClass!.HomeMap;
if (homeMapDef is { }
&& await this._player.GameContext.GetMapAsync((ushort)homeMapDef.Number).ConfigureAwait(false) is { SafeZoneSpawnGate: { } spawnGate })
{
this._player.SelectedCharacter.PositionX = (byte)Rand.NextInt(spawnGate.X1, spawnGate.X2);
this._player.SelectedCharacter.PositionY = (byte)Rand.NextInt(spawnGate.Y1, spawnGate.Y2);
this._player.SelectedCharacter.CurrentMap = spawnGate.Map;
this._player.Rotation = spawnGate.Direction;
}
}
private async ValueTask UpdateClientStatsAsync(ResetConfiguration configuration)
{
if (configuration.ResetStats)
{
await this._player.InvokeViewPlugInAsync<IUpdateCharacterBaseStatsPlugIn>(p => p.UpdateCharacterBaseStatsAsync()).ConfigureAwait(false);
}
await this._player.InvokeViewPlugInAsync<IUpdateLevelPlugIn>(p => p.UpdateLevelAsync()).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,37 @@
// <copyright file="ResetCharacterNpcPlugin.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Resets;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Action to reset a character.
/// </summary>
[Guid("08953BE6-DABF-49CC-A500-FDB9DC2C4D80")]
[PlugIn]
[Display(Name = nameof(PlugInResources.ResetCharacterNpcPlugin_Name), Description = nameof(PlugInResources.ResetCharacterNpcPlugin_Description), ResourceType = typeof(PlugInResources))]
public class ResetCharacterNpcPlugin : IPlayerTalkToNpcPlugIn
{
/// <summary>
/// Gets the reset NPC number of 'Leo the Helper'.
/// </summary>
public static short ResetNpcNumber => 371;
/// <inheritdoc />
public async ValueTask PlayerTalksToNpcAsync(Player player, NonPlayerCharacter npc, NpcTalkEventArgs eventArgs)
{
if (npc.Definition.Number != ResetNpcNumber)
{
return;
}
eventArgs.HasBeenHandled = true;
var resetAction = new ResetCharacterAction(player, npc);
await resetAction.ResetCharacterAsync().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,35 @@
// <copyright file="ResetChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Resets;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// A chat command plugin which handles reset command.
/// </summary>
/// <seealso cref="MUnique.OpenMU.GameLogic.PlugIns.ChatCommands.IChatCommandPlugIn" />
[Guid("90B35404-AADE-4F22-B5D2-4CD59B8BB4C8")]
[PlugIn]
[Display(Name = nameof(PlugInResources.ResetChatCommandPlugIn_Name), Description = nameof(PlugInResources.ResetChatCommandPlugIn_Description), ResourceType = typeof(PlugInResources))]
[ChatCommandHelp(Command, "Does a character reset, if available.", null)]
public class ResetChatCommandPlugIn : IChatCommandPlugIn
{
private const string Command = "/reset";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc />
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.Normal;
/// <inheritdoc />
public async ValueTask HandleCommandAsync(Player player, string command)
{
var resetAction = new ResetCharacterAction(player);
await resetAction.ResetCharacterAsync().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,143 @@
// <copyright file="ResetConfiguration.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Resets;
using MUnique.OpenMU.DataModel.Composition;
using MUnique.OpenMU.DataModel.Configuration.Items;
/// <summary>
/// Configuration of the Reset System.
/// </summary>
public class ResetConfiguration
{
/// <summary>
/// Gets or sets the reset limit, which is the maximum amount of possible resets.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.ResetConfiguration_ResetLimit_Name))]
public int? ResetLimit { get; set; }
/// <summary>
/// Gets or sets the required level for a reset.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.ResetConfiguration_RequiredLevel_Name))]
public int RequiredLevel { get; set; } = 400;
/// <summary>
/// Gets or sets the character level after a reset.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.ResetConfiguration_LevelAfterReset_Name))]
public int LevelAfterReset { get; set; } = 10;
/// <summary>
/// Gets or sets the required money for a reset.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.ResetConfiguration_RequiredMoney_Name))]
public int RequiredMoney { get; set; } = 1;
/// <summary>
/// Gets or sets a value indicating whether the required money should
/// be multiplied with the current reset count.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.ResetConfiguration_MultiplyRequiredMoneyByResetCount_Name))]
public bool MultiplyRequiredMoneyByResetCount { get; set; } = true;
/// <summary>
/// Gets or sets the required item for a reset.
/// </summary>
[Display(Name = "Required reset item")]
public ItemDefinition? RequiredResetItem { get; set; }
/// <summary>
/// Gets or sets the item costs per reset range.
/// </summary>
[Display(Name = "Item cost tiers")]
[MemberOfAggregate]
[ScaffoldColumn(true)]
public ICollection<ResetItemCostTier> ItemCostTiers { get; set; } = [];
/// <summary>
/// Gets or sets a value indicating whether a reset sets the stat points back to the initial values.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.ResetConfiguration_ResetStats_Name))]
public bool ResetStats { get; set; } = true;
/// <summary>
/// Gets or sets the legacy amount of points which will be set at the <see cref="Character.LevelUpPoints"/> when doing a reset.
/// Use <see cref="PointsTiers"/> instead.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.ResetConfiguration_PointsPerReset_Name))]
[ScaffoldColumn(false)]
public int PointsPerReset { get; set; } = 1500;
/// <summary>
/// Gets or sets a value indicating whether the legacy <see cref="PointsPerReset"/> should be multiplied with the current reset count.
/// Use <see cref="PointsTiers"/> instead.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.ResetConfiguration_MultiplyPointsByResetCount_Name))]
[ScaffoldColumn(false)]
public bool MultiplyPointsByResetCount { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether a reset will replace (true) or add (false) the <see cref="Character.LevelUpPoints"/>.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.ResetConfiguration_ReplacePointsPerReset_Name))]
public bool ReplacePointsPerReset { get; set; } = true;
/// <summary>
/// Gets or sets the points granted per reset range.
/// </summary>
[Display(Name = "Point tiers")]
[MemberOfAggregate]
[ScaffoldColumn(true)]
public ICollection<ResetPointTier> PointsTiers { get; set; } = [];
/// <summary>
/// Gets or sets a value indicating whether a reset moves the player home.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.ResetConfiguration_MoveHome_Name))]
public bool MoveHome { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether a reset logs the player out back to character selection.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.ResetConfiguration_LogOut_Name))]
public bool LogOut { get; set; } = true;
/// <summary>
/// Tier definition for the points which are granted when resetting.
/// </summary>
public class ResetPointTier
{
/// <summary>
/// Gets or sets the minimum reset count at which this tier applies.
/// </summary>
[Display(Name = "Minimum reset count")]
public int MinimumResetCount { get; set; }
/// <summary>
/// Gets or sets the points granted for this tier.
/// </summary>
[Display(Name = "Points granted")]
public int PointsGranted { get; set; }
}
/// <summary>
/// Tier definition for required reset item amounts.
/// </summary>
public class ResetItemCostTier
{
/// <summary>
/// Gets or sets the minimum reset count at which this tier applies.
/// </summary>
[Display(Name = "Minimum reset count")]
public int MinimumResetCount { get; set; }
/// <summary>
/// Gets or sets the required item amount for this tier.
/// </summary>
[Display(Name = "Required item amount")]
public int RequiredItemAmount { get; set; }
}
}

View File

@@ -0,0 +1,23 @@
// <copyright file="ResetFeaturePlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Resets;
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.ResetFeaturePlugIn_Name), Description = nameof(PlugInResources.ResetFeaturePlugIn_Description), ResourceType = typeof(PlugInResources))]
[Guid("6A9D585D-79D7-4674-B6EA-7E87392FA501")]
public class ResetFeaturePlugIn : IFeaturePlugIn, ISupportCustomConfiguration<ResetConfiguration>, ISupportDefaultCustomConfiguration, IDisabledByDefault
{
/// <inheritdoc/>
public ResetConfiguration? Configuration { get; set; }
/// <inheritdoc />
public object CreateDefaultConfig() => new ResetConfiguration();
}

View File

@@ -0,0 +1,73 @@
// <copyright file="ResetInfoChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Resets;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// A chat command plugin which shows reset costs and rewards.
/// </summary>
[Guid("79F2C2C2-2E4C-4F4B-8A74-4227D1209D27")]
[PlugIn]
[Display(Name = "Reset Info Command", Description = "Shows required costs and granted points for the next reset.")]
[ChatCommandHelp(Command, "Shows required costs and gained points for the next reset.", null)]
public class ResetInfoChatCommandPlugIn : IChatCommandPlugIn
{
private const string Command = "/resetinfo";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc />
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.Normal;
/// <inheritdoc />
public async ValueTask HandleCommandAsync(Player player, string command)
{
var configuration = player.GameContext.FeaturePlugIns.GetPlugIn<ResetFeaturePlugIn>()?.Configuration;
if (configuration is null)
{
await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.ResetSystemInactive)).ConfigureAwait(false);
return;
}
if (player.Attributes is null || player.SelectedCharacter is null)
{
await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.NotEnteredTheGame)).ConfigureAwait(false);
return;
}
var progression = ResetProgressionCalculator.Calculate(
(int)player.Attributes[Stats.Resets],
(int)player.Attributes[Stats.PointsPerReset],
configuration);
if (progression.RequiredItemAmount > 0 && configuration.RequiredResetItem is { Name: { } itemName })
{
await player.ShowLocalizedBlueMessageAsync(
nameof(PlayerMessage.NextResetInfoCompactWithItem),
progression.NextResetCount,
configuration.RequiredLevel,
progression.RequiredZen,
itemName,
progression.RequiredItemAmount,
progression.PointsForReset,
progression.TotalPointsAfterReset)
.ConfigureAwait(false);
return;
}
await player.ShowLocalizedBlueMessageAsync(
nameof(PlayerMessage.NextResetInfoCompactNoItem),
progression.NextResetCount,
configuration.RequiredLevel,
progression.RequiredZen,
progression.PointsForReset,
progression.TotalPointsAfterReset)
.ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,20 @@
// <copyright file="ResetProgression.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Resets;
/// <summary>
/// A value object that contains costs and rewards for the next reset.
/// </summary>
/// <param name="NextResetCount">The resulting reset count after a successful reset.</param>
/// <param name="RequiredZen">The required zen for the reset.</param>
/// <param name="RequiredItemAmount">The required number of configured reset items.</param>
/// <param name="PointsForReset">The number of points granted for the reset.</param>
/// <param name="TotalPointsAfterReset">The total number of points after the reset when replacement mode is active.</param>
public readonly record struct ResetProgression(
int NextResetCount,
int RequiredZen,
int RequiredItemAmount,
int PointsForReset,
int TotalPointsAfterReset);

View File

@@ -0,0 +1,93 @@
// <copyright file="ResetProgressionCalculator.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Resets;
/// <summary>
/// Calculates costs and rewards for the next character reset.
/// </summary>
public static class ResetProgressionCalculator
{
/// <summary>
/// Calculates the reset progression for the next reset.
/// </summary>
/// <param name="currentResetCount">The current reset count.</param>
/// <param name="pointsPerResetOverride">The player-specific points per reset override (0 means not configured).</param>
/// <param name="configuration">The reset configuration.</param>
/// <returns>The calculated progression.</returns>
public static ResetProgression Calculate(int currentResetCount, int pointsPerResetOverride, ResetConfiguration configuration)
{
var nextResetCount = currentResetCount + 1;
var requiredZen = Math.Max(0, configuration.RequiredMoney);
if (configuration.MultiplyRequiredMoneyByResetCount)
{
requiredZen *= nextResetCount;
}
var pointsForReset = GetPointsForReset(configuration, pointsPerResetOverride, nextResetCount);
var totalPointsAfterReset = GetTotalPointsAfterReset(configuration, pointsPerResetOverride, nextResetCount, pointsForReset);
var requiredItemAmount = GetRequiredItemAmount(configuration, nextResetCount);
return new ResetProgression(nextResetCount, requiredZen, requiredItemAmount, pointsForReset, totalPointsAfterReset);
}
private static int GetPointsForReset(ResetConfiguration configuration, int pointsPerResetOverride, int nextResetCount)
{
if (GetMatchingTier(configuration.PointsTiers, nextResetCount, tier => tier.MinimumResetCount) is { } tier)
{
return Math.Max(0, tier.PointsGranted);
}
var pointsPerReset = pointsPerResetOverride == 0 ? configuration.PointsPerReset : pointsPerResetOverride;
if (configuration.MultiplyPointsByResetCount)
{
pointsPerReset *= nextResetCount;
}
return Math.Max(0, pointsPerReset);
}
private static int GetRequiredItemAmount(ResetConfiguration configuration, int nextResetCount)
{
if (configuration.RequiredResetItem is null)
{
return 0;
}
if (GetMatchingTier(configuration.ItemCostTiers, nextResetCount, tier => tier.MinimumResetCount) is not { } tier)
{
return 0;
}
return Math.Max(0, tier.RequiredItemAmount);
}
private static int GetTotalPointsAfterReset(ResetConfiguration configuration, int pointsPerResetOverride, int nextResetCount, int pointsForReset)
{
if (configuration.PointsTiers.Count == 0)
{
return pointsForReset;
}
long total = 0;
for (var resetCount = 1; resetCount <= nextResetCount; resetCount++)
{
total += GetPointsForReset(configuration, pointsPerResetOverride, resetCount);
if (total >= int.MaxValue)
{
return int.MaxValue;
}
}
return (int)total;
}
private static TTier? GetMatchingTier<TTier>(IEnumerable<TTier> tiers, int resetCount, Func<TTier, int> getMinimumResetCount)
where TTier : class
{
return tiers
.OrderByDescending(getMinimumResetCount)
.FirstOrDefault(tier => getMinimumResetCount(tier) <= resetCount);
}
}

View File

@@ -0,0 +1,178 @@
// <copyright file="ResetStatsAction.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Resets;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlayerActions;
using MUnique.OpenMU.GameLogic.Views.Character;
using MUnique.OpenMU.GameLogic.Views.Login;
/// <summary>
/// Action to reset a character's stats back to base values and refund the invested points.
/// </summary>
public class ResetStatsAction
{
private readonly Player _player;
private readonly LogoutAction _logoutAction = new();
/// <summary>
/// Initializes a new instance of the <see cref="ResetStatsAction"/> class.
/// </summary>
/// <param name="player">Player to reset stats for.</param>
public ResetStatsAction(Player player)
{
this._player = player;
}
/// <summary>
/// Resets the character stats to base values and refunds invested points.
/// </summary>
public async ValueTask ResetStatsAsync()
{
var statResetFeature = this._player.GameContext.FeaturePlugIns.GetPlugIn<StatResetFeaturePlugIn>();
if (statResetFeature is null)
{
await this.ShowMessageAsync(nameof(PlayerMessage.StatResetNotEnabled)).ConfigureAwait(false);
return;
}
if (this._player.PlayerState.CurrentState != PlayerState.EnteredWorld)
{
await this.ShowMessageAsync(nameof(PlayerMessage.CantResetStatsWithOpenedWindows)).ConfigureAwait(false);
return;
}
if (this._player.Attributes is null || this._player.SelectedCharacter is null)
{
await this.ShowMessageAsync(nameof(PlayerMessage.NotEnteredTheGame)).ConfigureAwait(false);
return;
}
var configuration = statResetFeature.Configuration;
if (configuration is null)
{
await this.ShowMessageAsync(nameof(PlayerMessage.StatResetNotConfigured)).ConfigureAwait(false);
return;
}
if (!this._player.IsAtSafezone())
{
await this.ShowMessageAsync(nameof(PlayerMessage.CantResetStatsNotInSafezone)).ConfigureAwait(false);
return;
}
if (this._player.Level < configuration.RequiredLevel)
{
await this.ShowMessageAsync(nameof(PlayerMessage.RequiredLevelForStatReset), configuration.RequiredLevel).ConfigureAwait(false);
return;
}
if (!await this.TryConsumeCostsAsync(configuration).ConfigureAwait(false))
{
return;
}
this.ResetAttributes();
if (configuration.MoveHome)
{
await this.MoveHomeAsync().ConfigureAwait(false);
}
if (configuration.LogOut)
{
await this._logoutAction.LogoutAsync(this._player, LogoutType.BackToCharacterSelection).ConfigureAwait(false);
}
else
{
await this.UpdateClientStatsAsync().ConfigureAwait(false);
}
}
private void ResetAttributes()
{
var selectedCharacter = this._player.SelectedCharacter!;
var investedPoints = 0;
foreach (var statDef in selectedCharacter.CharacterClass!.StatAttributes.Where(s => s.IncreasableByPlayer))
{
if (statDef.Attribute is not { } attribute)
{
continue;
}
var currentValue = (int)this._player.Attributes![attribute];
var baseValue = (int)statDef.BaseValue;
investedPoints += Math.Max(0, currentValue - baseValue);
this._player.Attributes[attribute] = baseValue;
}
selectedCharacter.LevelUpPoints += investedPoints;
}
private async ValueTask<bool> TryConsumeCostsAsync(StatResetConfiguration configuration)
{
if (this._player.Money < configuration.RequiredMoney)
{
await this.ShowMessageAsync(nameof(PlayerMessage.NotEnoughMoneyForStatReset), configuration.RequiredMoney).ConfigureAwait(false);
return false;
}
Item? requiredItem = null;
if (configuration.RequiredResetItem is { } requiredDefinition)
{
if (this._player.Inventory is null)
{
return false;
}
requiredItem = this._player.Inventory.Items
.FirstOrDefault(item => item.Definition is { } definition
&& definition.Group == requiredDefinition.Group
&& definition.Number == requiredDefinition.Number);
if (requiredItem is null)
{
await this.ShowMessageAsync(nameof(PlayerMessage.NotEnoughItemsForStatReset), 1, requiredDefinition.Name).ConfigureAwait(false);
return false;
}
}
if (configuration.RequiredMoney > 0 && !this._player.TryRemoveMoney(configuration.RequiredMoney))
{
await this.ShowMessageAsync(nameof(PlayerMessage.NotEnoughMoneyForStatReset), configuration.RequiredMoney).ConfigureAwait(false);
return false;
}
if (requiredItem is not null)
{
await this._player.DestroyInventoryItemAsync(requiredItem).ConfigureAwait(false);
}
return true;
}
private async ValueTask MoveHomeAsync()
{
var homeMapDef = this._player.SelectedCharacter!.CharacterClass!.HomeMap;
if (homeMapDef is { }
&& await this._player.GameContext.GetMapAsync((ushort)homeMapDef.Number).ConfigureAwait(false) is { SafeZoneSpawnGate: { } spawnGate })
{
await this._player.WarpToAsync(spawnGate).ConfigureAwait(false);
}
}
private async ValueTask UpdateClientStatsAsync()
{
await this._player.InvokeViewPlugInAsync<IUpdateCharacterBaseStatsPlugIn>(p => p.UpdateCharacterBaseStatsAsync()).ConfigureAwait(false);
await this._player.InvokeViewPlugInAsync<IUpdateCharacterStatsPlugIn>(p => p.UpdateCharacterStatsAsync()).ConfigureAwait(false);
}
private async ValueTask ShowMessageAsync(string messageKey, params object?[] args)
{
var message = this._player.GetLocalizedMessage(messageKey, args);
await this._player.ShowBlueMessageAsync(message).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,41 @@
// <copyright file="ResetStatsChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Resets;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// A chat command plugin which handles the stat reset command.
/// </summary>
[Guid("A1B2C3D4-E5F6-7890-ABCD-EF1234567891")]
[PlugIn]
[Display(Name = nameof(PlugInResources.ResetStatsChatCommandPlugIn_Name), Description = nameof(PlugInResources.ResetStatsChatCommandPlugIn_Description), ResourceType = typeof(PlugInResources))]
[ChatCommandHelp(Command, "Resets your character stats to base values and refunds all invested points.", null)]
public class ResetStatsChatCommandPlugIn : IChatCommandPlugIn
{
private const string Command = "/resetstats";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc />
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.Normal;
/// <inheritdoc />
public async ValueTask HandleCommandAsync(Player player, string command)
{
var statResetFeature = player.GameContext.FeaturePlugIns.GetPlugIn<StatResetFeaturePlugIn>();
if (statResetFeature?.Configuration is { } configuration && !configuration.ChatCommandEnabled)
{
await player.ShowLocalizedBlueMessageAsync(PlayerMessage.StatResetChatCommandDisabled).ConfigureAwait(false);
return;
}
var resetAction = new ResetStatsAction(player);
await resetAction.ResetStatsAsync().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,50 @@
// <copyright file="StatResetConfiguration.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Resets;
using MUnique.OpenMU.DataModel.Composition;
using MUnique.OpenMU.DataModel.Configuration.Items;
/// <summary>
/// Configuration of the Stat Reset System.
/// </summary>
public class StatResetConfiguration
{
/// <summary>
/// Gets or sets the required level for a stat reset.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.StatResetConfiguration_RequiredLevel_Name))]
public int RequiredLevel { get; set; }
/// <summary>
/// Gets or sets the required money for a stat reset.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.StatResetConfiguration_RequiredMoney_Name))]
public int RequiredMoney { get; set; } = 1000000;
/// <summary>
/// Gets or sets the required item for a stat reset.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.StatResetConfiguration_RequiredResetItem_Name))]
public ItemDefinition? RequiredResetItem { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the chat command is enabled.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.StatResetConfiguration_ChatCommandEnabled_Name))]
public bool ChatCommandEnabled { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether a stat reset moves the player home (safe zone).
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.StatResetConfiguration_MoveHome_Name))]
public bool MoveHome { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether a stat reset logs the player out back to character selection.
/// </summary>
[Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.StatResetConfiguration_LogOut_Name))]
public bool LogOut { get; set; } = true;
}

View File

@@ -0,0 +1,23 @@
// <copyright file="StatResetFeaturePlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Resets;
using System.Runtime.InteropServices;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Feature plugin which provides the configuration for the stat reset feature.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.StatResetFeaturePlugIn_Name), Description = nameof(PlugInResources.StatResetFeaturePlugIn_Description), ResourceType = typeof(PlugInResources))]
[Guid("0F1E2D3C-4B5A-6978-8C7D-6E5F4A3B2C1D")]
public class StatResetFeaturePlugIn : IFeaturePlugIn, ISupportCustomConfiguration<StatResetConfiguration>, ISupportDefaultCustomConfiguration, IDisabledByDefault
{
/// <inheritdoc/>
public StatResetConfiguration? Configuration { get; set; }
/// <inheritdoc/>
public object CreateDefaultConfig() => new StatResetConfiguration();
}