Some checks failed
.NET Core / build (push) Has been cancelled
MoveHomeAsync wrote PositionX/PositionY/CurrentMap/Rotation on the character record directly, which is a partial copy of Player.PlaceAtGateAsync: it placed the player but skipped removing him from the map and telling the client. Player.Position is backed by those very fields, so the coordinates jumped on the server while the client never got a map change. The client then interpolated a walk to the new spot and the character visibly slid across the map after a reset. WarpToAsync does the same placement plus the map removal and the map change notification, and it handles respawning on the same map. It is the path every other caller uses (duel room, gate NPCs, mini games, castle siege portal). The existing tests all ran with MoveHome = false, which is why this path was never covered. The new test pins the notification: it fails on the old code because MapChangeAsync is never invoked. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
224 lines
8.3 KiB
C#
224 lines
8.3 KiB
C#
// <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 })
|
|
{
|
|
// ADAMU-CUSTOM: warp instead of writing the position fields directly.
|
|
// The direct writes moved the player without removing him from the map or sending a
|
|
// map change, so the client was never told and animated a walk to the new spot: the
|
|
// character visibly slid across the map. WarpToAsync does the same placement plus the
|
|
// map removal and the client notification.
|
|
await this._player.WarpToAsync(spawnGate).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|