// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.GameLogic.MiniGames; using System.Collections.Concurrent; using System.Threading; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.GameLogic.Views.Character; using MUnique.OpenMU.GameLogic.Views.Inventory; using Nito.Disposables.Internals; /// /// The context of a blood castle game. /// /// /// A blood castle event works like that: /// First, a player or his party (and probably another party), maximum 10 players, enter the blood castle. /// The game has several states: /// * After the event starts, first a certain amount of monsters have to be killed, so that a bridge appears on the way to the castle gate. /// * The castle gate has to be destroyed /// * Some stronger monsters wait after the castle gate. A certain amount of "Spirit Sorcerer" have to be killed. /// * The statue appears, which needs to be killed. /// * The statue drops an archangel weapon as quest item. This item has to be brought back to the archangel NPC. /// The game has a time limit of usually 15 or 20 minutes. /// After the time is up, or the quest item has been brought back, the players get some rewards: /// /// Experience: /// 1. For each remaining second, a bonus experience is given as experience. /// 2. The player or party which destroyed the gate, gets extra experience. Dead party members get the half of the exp as bonus. /// 3. For killing the statue, the player/party gets a bonus experience. /// 4. For finishing the quest, the player/party gets a bonus experience. /// 5. To all previous exp rewards, bonuses from seals, maps etc. are applied. /// Money: /// According to the reward table - fixed values per blood castle level, depending if the player was in the winning party, or not. /// The winner/winners party get roughly the double money value. /// Score: /// a) If the event was won by any participant, depending on the individual success state of a player, /// it will get a different score rewarded. A player is categorized into these 5 states: /// - unfinished event /// - died during event /// - winner /// - member of winners party /// - member of winners party, but died during event. /// b) If the event wasn't won by any participant, players are getting a score penalty of 300. /// public sealed class BloodCastleContext : MiniGameContext { private const short CastleGateNumber = 131; private const short StatueOfSaintNumber = 132; /// /// Dialog category for the NPC interactions. /// private const byte DialogCategoryMain = 1; private readonly ConcurrentDictionary _gameStates = new(); private IReadOnlyCollection<(string Name, int Score, int BonusExp, int BonusMoney)>? _highScoreTable; private TimeSpan _remainingTime; private bool _gateDestroyed; private Player? _winner; private Player? _questItemOwner; private Item? _questItem; /// /// Initializes a new instance of the class. /// /// The key of this context. /// The definition of the mini game. /// The game context, to which this game belongs. /// The map initializer, which is used when the event starts. public BloodCastleContext(MiniGameMapKey key, MiniGameDefinition definition, IGameContext gameContext, IMapInitializer mapInitializer) : base(key, definition, gameContext, mapInitializer) { } /// /// Dialog numbers for different interactions with the NPC. /// private enum DialogNumber : byte { EventWinner = 0x17, EventNotRunning = 0x18, EventQuestItemMissing = 0x18, EventFinished = 0x2E, } /// protected override Player? Winner => this._winner; /// protected override TimeSpan RemainingTime => this._remainingTime; /// /// Player interact with Archangel. /// /// The player who talks to Archangel. public async ValueTask TalkToNpcArchangelAsync(Player player) { if (this._winner is not null) { await player.InvokeViewPlugInAsync(p => p.ShowDialogAsync(DialogCategoryMain, (byte)DialogNumber.EventFinished)).ConfigureAwait(false); return; } if (!this.IsEventRunning) { await player.InvokeViewPlugInAsync(p => p.ShowDialogAsync(DialogCategoryMain, (byte)DialogNumber.EventNotRunning)).ConfigureAwait(false); return; } if (!await this.TryRemoveQuestItemFromPlayerAsync(player).ConfigureAwait(false)) { await player.InvokeViewPlugInAsync(p => p.ShowDialogAsync(DialogCategoryMain, (byte)DialogNumber.EventQuestItemMissing)).ConfigureAwait(false); return; } this._winner = player; await player.InvokeViewPlugInAsync(p => p.ShowDialogAsync(DialogCategoryMain, (byte)DialogNumber.EventWinner)).ConfigureAwait(false); this.FinishEvent(); } /// protected override async ValueTask OnObjectRemovedFromMapAsync((GameMap Map, ILocateable Object) args) { if (args.Object is Player player) { if (this.IsEventRunning) { // Drop it, so that the remaining players can pick it up. await this.TryDropQuestItemFromPlayerAsync(player).ConfigureAwait(false); } else { await this.TryRemoveQuestItemFromPlayerAsync(player).ConfigureAwait(false); } await this.UpdateStateAsync(BloodCastleStatus.Ended, player).ConfigureAwait(false); } await base.OnObjectRemovedFromMapAsync(args).ConfigureAwait(false); } /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")] protected override async void OnDestructibleDied(object? sender, DeathInformation e) { try { base.OnDestructibleDied(sender, e); var destructible = sender as Destructible; if (destructible is null) { return; } if (destructible.Definition.Number == StatueOfSaintNumber) { await this.ShowGoldenMessageAsync(nameof(PlayerMessage.BloodCastleCrystalStatusDestroyed), e.KillerName).ConfigureAwait(false); } else if (destructible.Definition.Number == CastleGateNumber) { this._gateDestroyed = true; } else { // we don't have others, so nothing to do } } catch (Exception ex) { this.Logger.LogError(ex, "Unexpected error in OnDestructibleDied."); } } /// protected override async ValueTask OnPlayerPickedUpItemAsync((Player Picker, ILocateable DroppedItem) args) { await base.OnPlayerPickedUpItemAsync(args).ConfigureAwait(false); if (args.DroppedItem is DroppedItem { Item.Definition: { } definition } && definition.IsArchangelQuestItem()) { this._questItemOwner = args.Picker; await this.ForEachPlayerAsync(player => player.ShowLocalizedGoldenMessageAsync( nameof(PlayerMessage.BloodCastleArchangelAquiredMessageFormat), args.Picker.Name, definition.Name.GetTranslation(player.Culture)) .AsTask()) .ConfigureAwait(false); } } /// protected override async ValueTask OnGameStartAsync(ICollection players) { foreach (var player in players) { this._gameStates.TryAdd(player.Name, new PlayerGameState(player)); } _ = Task.Run(async () => await this.ShowRemainingTimeLoopAsync(this.GameEndedToken).ConfigureAwait(false), this.GameEndedToken); await base.OnGameStartAsync(players).ConfigureAwait(false); } /// protected override async ValueTask GameEndedAsync(ICollection finishers) { await this.UpdateStateForAllAsync(BloodCastleStatus.Ended).ConfigureAwait(false); var sortedFinishers = finishers .Select(f => this._gameStates[f.Name]) .WhereNotNull() .OrderByDescending(state => state.Score) .ToList(); var scoreList = new List<(string Name, int Score, int BonusExp, int BonusMoney)>(); int rank = 0; foreach (var state in sortedFinishers) { rank++; state.Rank = rank; var (bonusScore, givenMoney) = await this.GiveRewardsAndGetBonusScoreAsync(state.Player, rank).ConfigureAwait(false); state.AddScore(bonusScore); scoreList.Add(( state.Player.Name, state.Score, this.Definition.Rewards.FirstOrDefault(r => r.RewardType == MiniGameRewardType.Experience && (r.Rank is null || r.Rank == rank))?.RewardAmount ?? 0, givenMoney)); await this.TryRemoveQuestItemFromPlayerAsync(state.Player).ConfigureAwait(false); } this._highScoreTable = scoreList.AsReadOnly(); await this.SaveRankingAsync(sortedFinishers.Select(state => (state.Rank, state.Player.SelectedCharacter!, state.Score))).ConfigureAwait(false); await base.GameEndedAsync(finishers).ConfigureAwait(false); } /// protected override async ValueTask ShowScoreAsync(Player player) { if (this._highScoreTable is { } table) { var isSuccessful = this._winner is not null; var (name, score, bonusMoney, bonusExp) = table.First(t => t.Name == player.Name); await player.InvokeViewPlugInAsync(p => p.ShowScoreTableAsync(isSuccessful, name, score, bonusExp, bonusMoney)).ConfigureAwait(false); } } /// protected override void OnItemDroppedOnMap(DroppedItem item) { base.OnItemDroppedOnMap(item); if (item.Item.Definition.IsArchangelQuestItem()) { this._questItem = item.Item; } } private async ValueTask ShowRemainingTimeLoopAsync(CancellationToken cancellationToken) { try { var timerInterval = TimeSpan.FromSeconds(1); using var timer = new PeriodicTimer(timerInterval); var maximumGameDuration = this.Definition.GameDuration; this._remainingTime = maximumGameDuration; await this.UpdateStateForAllAsync(BloodCastleStatus.Started).ConfigureAwait(false); while (!cancellationToken.IsCancellationRequested && this._remainingTime >= TimeSpan.Zero && await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) { if (this._remainingTime < maximumGameDuration && !this._gateDestroyed) { await this.UpdateStateForAllAsync(BloodCastleStatus.GateNotDestroyed).ConfigureAwait(false); } if (this._remainingTime < maximumGameDuration && this._gateDestroyed) { await this.UpdateStateForAllAsync(BloodCastleStatus.GateDestroyed).ConfigureAwait(false); } this._remainingTime = this._remainingTime.Subtract(timerInterval); } } catch (OperationCanceledException) { // Expected exception when the game ends before running into the timeout. } catch (Exception ex) { this.Logger.LogError(ex, "Unexpected error during update blood castle status: {0}", ex.Message); } } private ValueTask UpdateStateForAllAsync(BloodCastleStatus status) { return this.ForEachPlayerAsync(player => this.UpdateStateAsync(status, player).AsTask()); } private ValueTask UpdateStateAsync(BloodCastleStatus status, Player player) { return player.InvokeViewPlugInAsync( p => p.UpdateStateAsync( status, this._remainingTime, this.NextEvent?.RequiredKills ?? 0, this.NextEvent?.ActualKills ?? 0, this._questItemOwner, this._questItem)); } private async ValueTask TryRemoveQuestItemFromPlayerAsync(Player player) { if (!player.TryGetQuestItem(out var item)) { return false; } await player.DestroyInventoryItemAsync(item).ConfigureAwait(false); this._questItem = null; this._questItemOwner = null; return true; } private async ValueTask TryDropQuestItemFromPlayerAsync(Player player) { if (!player.TryGetQuestItem(out var item)) { return; } var dropped = new DroppedItem(item, player.Position, this.Map, player); await this.Map.AddAsync(dropped).ConfigureAwait(false); await player.Inventory!.RemoveItemAsync(item).ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ItemDropResultAsync(item.ItemSlot, true)).ConfigureAwait(false); this._questItemOwner = null; } private sealed class PlayerGameState { private int _score; public PlayerGameState(Player player) { if (player.SelectedCharacter?.CharacterClass is null) { throw new InvalidOperationException($"The player '{player}' is in the wrong state"); } this.Player = player; } public Player Player { get; } public int Score => this._score; public int Rank { get; set; } public void AddScore(int value) { Interlocked.Add(ref this._score, value); } } }