//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
namespace MUnique.OpenMU.GameLogic;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Globalization;
using System.Threading;
using MUnique.OpenMU.GameLogic.MiniGames;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.PlugIns;
using Nito.AsyncEx;
using org.mariuszgromada.math.mxparser;
///
/// The game context which holds all data of the game together.
///
public class GameContext : AsyncDisposable, IGameContext
{
private const string DefaultExperienceFormula = "if(level == 0, 0, if(level < 256, 10 * (level + 8) * (level - 1) * (level - 1), (10 * (level + 8) * (level - 1) * (level - 1)) + (1000 * (level - 247) * (level - 256) * (level - 256))))";
private const string DefaultMasterExperienceFormula = "(505 * level * level * level) + (35278500 * level) + (228045 * level * level)";
private static readonly Meter Meter = new(MeterName);
private static readonly Counter PlayerCounter = Meter.CreateCounter("PlayerCount");
private static readonly Counter MapCounter = Meter.CreateCounter("MapCount");
private static readonly Counter MiniGameCounter = Meter.CreateCounter("MiniGameCount");
private static readonly IObjectPool PathFinderPoolInstance = new LimitedObjectPool(new PathFinderPoolingPolicy());
private readonly Dictionary _mapList = new();
private readonly Dictionary _miniGames = new();
private readonly Timer _recoverTimer;
private readonly IMapInitializer _mapInitializer;
private readonly AsyncLock _mapInitializerLock = new();
private readonly Timer _tasksTimer;
private readonly AsyncReaderWriterLock _playerListLock = new();
///
/// Keeps the list of all players.
///
private readonly List _playerList = new();
private readonly IDisposable _configChangeHandlerRegistration;
///
/// Initializes a new instance of the class.
///
/// The configuration.
/// The persistence context provider.
/// The map initializer.
/// The logger factory.
/// The plug in manager.
/// The drop generator.
/// The cange mediator.
public GameContext(GameConfiguration configuration, IPersistenceContextProvider persistenceContextProvider, IMapInitializer mapInitializer, ILoggerFactory loggerFactory, PlugInManager plugInManager, IDropGenerator dropGenerator, IConfigurationChangeMediator changeMediator)
{
try
{
this.Configuration = configuration;
this.PersistenceContextProvider = persistenceContextProvider;
this.PlugInManager = plugInManager;
this._mapInitializer = mapInitializer;
this.LoggerFactory = loggerFactory;
this.DropGenerator = dropGenerator;
this.ConfigurationChangeMediator = changeMediator;
this.ItemPowerUpFactory = new ItemPowerUpFactory(loggerFactory.CreateLogger());
this.PartyManager = new PartyManager(configuration.MaximumPartySize, loggerFactory.CreateLogger());
this._recoverTimer = new Timer(this.RecoverTimerElapsed, null, this.Configuration.RecoveryInterval, this.Configuration.RecoveryInterval);
this._tasksTimer = new Timer(this.ExecutePeriodicTasks, null, 1000, 1000);
this.FeaturePlugIns = new FeaturePlugInContainer(this.PlugInManager);
this._configChangeHandlerRegistration = this.ConfigurationChangeMediator.RegisterObject(this.Configuration, this, this.OnGameConfigurationChangeAsync);
this.DuelRoomManager = new DuelRoomManager(this.Configuration.DuelConfiguration!);
this.ExperienceTable = CreateExpTable(this.Configuration.ExperienceFormula ?? DefaultExperienceFormula, this.Configuration.MaximumLevel);
this.MasterExperienceTable = CreateExpTable(this.Configuration.MasterExperienceFormula ?? DefaultMasterExperienceFormula, this.Configuration.MaximumMasterLevel);
}
catch (Exception ex)
{
loggerFactory.CreateLogger().LogError(ex, "Unexpected error in constructor of GameContext.");
throw;
}
}
///
/// Occurs when a game map got created.
///
public event EventHandler? GameMapCreated;
///
/// Occurs when a game map got removed.
///
///
/// Currently, maps are never removed.
/// It may make sense to remove unused maps after a certain period.
///
public event EventHandler? GameMapRemoved;
///
public virtual float ExperienceRate => this.Configuration.ExperienceRate;
///
public virtual float MasterExperienceRate => this.Configuration.MasterExperienceRate;
///
public virtual bool PvpEnabled { get; }
///
public GameConfiguration Configuration { get; }
///
public long[] ExperienceTable { get; private set; }
///
public long[] MasterExperienceTable { get; private set; }
///
public IConfigurationChangeMediator ConfigurationChangeMediator { get; }
///
public PlugInManager PlugInManager { get; }
///
public IDropGenerator DropGenerator { get; }
///
public FeaturePlugInContainer FeaturePlugIns { get; }
///
public Offline.OfflinePlayerManager OfflinePlayerManager { get; } = new();
///
public IItemPowerUpFactory ItemPowerUpFactory { get; }
///
public IPersistenceContextProvider PersistenceContextProvider { get; }
///
/// Gets the players by character name dictionary.
///
public ConcurrentDictionary PlayersByCharacterName { get; } = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
///
public DuelRoomManager DuelRoomManager { get; set; }
///
public ConcurrentDictionary<(Player Attacker, Player Defender), DateTime> SelfDefenseState { get; } = new();
///
public IPartyManager PartyManager { get; }
///
public ILoggerFactory LoggerFactory { get; }
///
/// Gets the path finder pool.
///
public IObjectPool PathFinderPool => PathFinderPoolInstance;
///
public int PlayerCount => this._playerList.Count;
///
/// Gets the name of the meter of this class.
///
internal static string MeterName => typeof(GameContext).FullName ?? nameof(GameContext);
///
/// Gets the initialized maps which are hosted on this context.
///
public async ValueTask> GetMapsAsync()
{
using var l = await this._mapInitializerLock.LockAsync();
return this._mapList.Values.Concat(this._miniGames.Values.Select(g => g.Map)).ToList();
}
///
public async ValueTask GetMapAsync(ushort mapId, bool createIfNotExists = true)
{
if (this._mapList.TryGetValue(mapId, out var map))
{
return map;
}
if (!createIfNotExists)
{
return null;
}
GameMap? createdMap;
using (await this._mapInitializerLock.LockAsync())
{
if (this._mapList.TryGetValue(mapId, out map))
{
return map;
}
createdMap = this._mapInitializer.CreateGameMap(mapId);
if (createdMap is null)
{
return null;
}
this._mapList.Add(mapId, createdMap);
createdMap.ObjectAdded += async args =>
{
if (this.PlugInManager.GetPlugInPoint() is { } plugInPoint)
{
await plugInPoint.ObjectAddedToMapAsync(args.Map, args.Object).ConfigureAwait(false);
}
};
createdMap.ObjectRemoved += async args =>
{
if (this.PlugInManager.GetPlugInPoint() is { } plugInPoint)
{
await plugInPoint.ObjectRemovedFromMapAsync(args.Map, args.Object).ConfigureAwait(false);
}
};
}
// ReSharper disable once InconsistentlySynchronizedField it's desired behavior to initialize the map outside the lock to keep locked timespan short.
await this._mapInitializer.InitializeStateAsync(createdMap).ConfigureAwait(false);
this.GameMapCreated?.Invoke(this, createdMap);
MapCounter.Add(1);
return createdMap;
}
///
/// Gets the mini game map which is meant to be hosted by the game.
///
/// The mini game definition.
/// The requesting player.
/// The hosted mini game instance.
public async ValueTask GetMiniGameAsync(MiniGameDefinition miniGameDefinition, Player requester)
{
var miniGameKey = MiniGameMapKey.Create(miniGameDefinition, requester);
if (this._miniGames.TryGetValue(miniGameKey, out var miniGameContext) && miniGameContext is { IsDisposed: false, IsDisposing: false })
{
return miniGameContext;
}
using (await this._mapInitializerLock.LockAsync().ConfigureAwait(false))
{
if (this._miniGames.TryGetValue(miniGameKey, out miniGameContext))
{
if (miniGameContext.IsDisposed)
{
this._miniGames.Remove(miniGameKey);
}
else
{
return miniGameContext;
}
}
switch (miniGameDefinition.Type)
{
case MiniGameType.ChaosCastle:
miniGameContext = new ChaosCastleContext(miniGameKey, miniGameDefinition, this, this._mapInitializer);
break;
case MiniGameType.DevilSquare:
miniGameContext = new DevilSquareContext(miniGameKey, miniGameDefinition, this, this._mapInitializer);
break;
case MiniGameType.BloodCastle:
miniGameContext = new BloodCastleContext(miniGameKey, miniGameDefinition, this, this._mapInitializer);
break;
default:
miniGameContext = new MiniGameContext(miniGameKey, miniGameDefinition, this, this._mapInitializer);
break;
}
this._miniGames.Add(miniGameKey, miniGameContext);
}
var createdMap = miniGameContext.Map;
// ReSharper disable once InconsistentlySynchronizedField it's desired behavior to initialize the map outside the lock to keep locked timespan short.
await this._mapInitializer.InitializeStateAsync(createdMap).ConfigureAwait(false);
this.GameMapCreated?.Invoke(this, createdMap);
MiniGameCounter.Add(1);
return miniGameContext;
}
///
public async ValueTask RemoveMiniGameAsync(MiniGameContext miniGameContext)
{
using var l = await this._mapInitializerLock.LockAsync().ConfigureAwait(false);
MiniGameCounter.Add(-1);
miniGameContext.Dispose();
this._miniGames.Remove(miniGameContext.Key);
this.GameMapRemoved?.Invoke(this, miniGameContext.Map);
}
///
/// Adds the player to the game.
///
/// The player.
public virtual async ValueTask AddPlayerAsync(Player player)
{
player.PlayerLeftWorld += this.PlayerLeftWorldAsync;
player.PlayerEnteredWorld += this.PlayerEnteredWorldAsync;
player.PlayerDisconnected += this.RemovePlayerAsync;
using (await this._playerListLock.WriterLockAsync())
{
this._playerList.Add(player);
}
PlayerCounter.Add(1);
}
///
public async ValueTask> GetPlayersAsync()
{
using var l = await this._playerListLock.ReaderLockAsync();
if (this._playerList.Count == 0)
{
return [];
}
return this._playerList.ToList();
}
///
/// Removes the player from the game.
///
/// The player.
public virtual async ValueTask RemovePlayerAsync(Player player)
{
bool removed;
using (await this._playerListLock.WriterLockAsync())
{
removed = this._playerList.Remove(player);
}
if (!removed)
{
return;
}
PlayerCounter.Add(-1);
if (player.SelectedCharacter != null)
{
this.PlayersByCharacterName.TryRemove(player.SelectedCharacter.Name, out _);
}
player.CurrentMap?.RemoveAsync(player);
player.PlayerDisconnected -= this.RemovePlayerAsync;
player.PlayerEnteredWorld -= this.PlayerEnteredWorldAsync;
player.PlayerLeftWorld -= this.PlayerLeftWorldAsync;
}
///
/// Gets the player by the character name.
///
/// The character name.
/// The player by character name.
public Player? GetPlayerByCharacterName(string name)
{
this.PlayersByCharacterName.TryGetValue(name, out var player);
return player;
}
///
public async ValueTask ForEachPlayerAsync(Func action)
{
if (this._playerList.Count == 0)
{
return;
}
var playerList = await this.GetPlayersAsync().ConfigureAwait(false);
await playerList.Select(action).WhenAll().ConfigureAwait(false);
}
///
/// Executes the specified action for each player, grouped by their culture.
///
/// The state factory which creates a state for each culture group.
/// The action to execute for each player and culture state.
/// The type of the culture state.
public async ValueTask ForEachPlayerGroupedByCultureAsync(Func stateFactory, Func action)
{
if (this._playerList.Count == 0)
{
return;
}
var playerList = await this.GetPlayersAsync().ConfigureAwait(false);
await playerList
.GroupBy(p => p.Culture)
.SelectMany(g =>
{
var state = stateFactory(g.Key);
return g.Select(player => action(player, state));
})
.WhenAll().ConfigureAwait(false);
}
///
public async ValueTask ShowGlobalLocalizedMessageAsync(MessageType messageType, string messageKey, params object?[] formatArguments)
{
await this.ForEachPlayerGroupedByCultureAsync(
cultureInfo =>
{
if (formatArguments.Length > 0)
{
return string.Format(PlayerMessage.ResourceManager.GetString(messageKey, cultureInfo) ?? string.Empty, formatArguments);
}
return PlayerMessage.ResourceManager.GetString(messageKey, cultureInfo) ?? string.Empty;
},
(player, message) => player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, messageType)).AsTask())
.ConfigureAwait(false);
}
///
public async ValueTask SendGlobalMessageAsync(string message, MessageType messageType)
{
await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, messageType)).AsTask()).ConfigureAwait(false);
}
///
public async ValueTask SendGlobalChatMessageAsync(string sender, string message, ChatMessageType messageType)
{
await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.ChatMessageAsync(message, sender, messageType)).AsTask()).ConfigureAwait(false);
}
///
public async ValueTask SendGlobalNotificationAsync(string message)
{
var sendingMessage = message.TrimStart('!');
await this.SendGlobalMessageAsync(sendingMessage, MessageType.GoldenCenter).ConfigureAwait(false);
}
///
protected override async ValueTask DisposeAsyncCore()
{
this._configChangeHandlerRegistration.Dispose();
await this._recoverTimer.DisposeAsync().ConfigureAwait(false);
await this._tasksTimer.DisposeAsync().ConfigureAwait(false);
await base.DisposeAsyncCore().ConfigureAwait(false);
}
private static long[] CreateExpTable(string experienceFormula, short maximumLevel)
{
var argument = new Argument("level");
var expression = new Expression(experienceFormula);
expression.addArguments(argument);
return Enumerable.Range(0, maximumLevel + 2)
.Select(level =>
{
argument.setArgumentValue(level);
return (long)expression.calculate();
})
.ToArray();
}
#pragma warning disable CS1998
private async ValueTask OnGameConfigurationChangeAsync(Action unregisterAction, GameConfiguration gameConfiguration, GameContext context)
#pragma warning restore CS1998
{
this._recoverTimer.Change(gameConfiguration.RecoveryInterval, gameConfiguration.RecoveryInterval);
this.ExperienceTable = CreateExpTable(gameConfiguration.ExperienceFormula ?? DefaultExperienceFormula, gameConfiguration.MaximumLevel);
this.MasterExperienceTable = CreateExpTable(gameConfiguration.MasterExperienceFormula ?? DefaultMasterExperienceFormula, gameConfiguration.MaximumMasterLevel);
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")]
private async void ExecutePeriodicTasks(object? state)
{
try
{
if (this.PlugInManager.GetPlugInPoint() is { } plugInPoint)
{
await plugInPoint.ExecuteTaskAsync(this).ConfigureAwait(false);
}
}
catch (Exception ex)
{
Debug.Fail(ex.Message, ex.StackTrace);
}
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")]
private async void RecoverTimerElapsed(object? state)
{
try
{
await this.ForEachPlayerAsync(player =>
{
if (player.SelectedCharacter != null && !player.PlayerState.CurrentState.IsDisconnectedOrFinished())
{
return player.RegenerateAsync();
}
return Task.CompletedTask;
}).ConfigureAwait(false);
}
catch
{
// This should never happen as we already handle Exceptions in player.RegenerateAsync.
// However, if the player disconnects in the meantime, it could happen :-).
}
}
private ValueTask PlayerEnteredWorldAsync(Player player)
{
this.PlayersByCharacterName.TryAdd(player.SelectedCharacter!.Name, player);
return ValueTask.CompletedTask;
}
private ValueTask PlayerLeftWorldAsync(Player player)
{
this.PlayersByCharacterName.TryRemove(player.SelectedCharacter!.Name, out _);
return ValueTask.CompletedTask;
}
}