// // 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; // ADAMU-CUSTOM: expose the map initializer so the Castle Siege can spawn guardian statues (Destructibles) at runtime. /// Gets the map initializer (used to spawn NPCs/destructibles at runtime, e.g. Castle Siege statues). public IMapInitializer MapInitializer => this._mapInitializer; /// 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; case MiniGameType.HeykelSavasi: miniGameContext = new HeykelSavasiContext(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; } /// /// Gets the currently open instance, if any. /// /// The open Heykel Savasi context, or if none is currently open. /// /// ADAMU-CUSTOM: used by to look up the event context /// when a player talks to NPC 560. Mutations of (, /// ) are synchronized via , so this /// read takes the same lock and snapshots the values before searching. /// public async ValueTask GetOpenHeykelSavasiAsync() { using var l = await this._mapInitializerLock.LockAsync().ConfigureAwait(false); return this._miniGames.Values.OfType() .FirstOrDefault(g => g.State == MiniGameState.Open); } /// 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; } }