From 49ffa5bd895eff01dee3d8c771200376be917b2b Mon Sep 17 00:00:00 2001 From: Acentech Dev Date: Tue, 21 Jul 2026 16:57:12 +0300 Subject: [PATCH] fix(tvt): count event kills, respawn on the game's own map, end-ceremony countdown - Kills now credited from OnDeathAsync (the AfterKilledPlayerAsync/PK path is skipped when AllowPlayerKilling is true, so event kills never reached the scoreboard). - Respawn after death now stays on the mini-game's OWN map instance (older-client path fetched the shared world map of the same number -> respawned player saw no statues/participants). - On game end: announce the winner + run a 60s ceremony broadcast loop sending phase-3 HUD countdown and the final scoreboard until the Lorencia teleport. --- .../MiniGames/HeykelSavasiContext.cs | 71 +++++++++++++++++-- src/GameLogic/Player.cs | 40 ++++++----- 2 files changed, 90 insertions(+), 21 deletions(-) diff --git a/src/GameLogic/MiniGames/HeykelSavasiContext.cs b/src/GameLogic/MiniGames/HeykelSavasiContext.cs index 620d707..ef3d27f 100644 --- a/src/GameLogic/MiniGames/HeykelSavasiContext.cs +++ b/src/GameLogic/MiniGames/HeykelSavasiContext.cs @@ -260,6 +260,20 @@ public class HeykelSavasiContext : MiniGameContext /// private const int ScoreboardMaxEntries = 8; + /// + /// How long the post-battle "event over" ceremony lasts before players are teleported to Lorencia. + /// Matches MiniGameContext.RunGameAsync's own exit timing for our config (ExitDuration 15s): + /// max(ExitDuration - 30s, 30s) + 30s countdown = 60s. The client shows this as a countdown and + /// moves the scoreboard to the screen centre during it (phase 3). + /// + private static readonly TimeSpan EndCeremonyDuration = TimeSpan.FromSeconds(60); + + /// + /// Wall-clock instant at which the post-battle ceremony ends and players are teleported out; set in + /// . Drives the phase-3 countdown in . + /// + private DateTime? _endCeremonyDeadlineUtc; + /// /// Synchronizes the check-then-act sequence of , so that concurrent /// join attempts cannot both pass the balance check before either of them reserves a slot. @@ -615,15 +629,58 @@ public class HeykelSavasiContext : MiniGameContext await this.RemoveTransformAsync(player).ConfigureAwait(false); } + // Announce the result + start the post-battle countdown to the Lorencia teleport. + this._endCeremonyDeadlineUtc = DateTime.UtcNow.Add(EndCeremonyDuration); + + var resultMessage = winner switch + { + HeykelSavasiTeam.Red => "RED team wins the TvT Event!", + HeykelSavasiTeam.Blue => "BLUE team wins the TvT Event!", + _ => "TvT Event is over - it's a draw!", + }; + foreach (var player in finishers) + { + await player.InvokeViewPlugInAsync( + p => p.ShowMessageAsync(resultMessage, MUnique.OpenMU.Interfaces.MessageType.GoldenCenter)).ConfigureAwait(false); + } + // Final HUD broadcast: by now MiniGameContext.State is already MiniGameState.Ended (StopAsync sets it - // before calling this method), so ComputeHudPhaseAndRemaining naturally reports phase 3/0 seconds. This - // is needed because the periodic loop (HudBroadcastLoopAsync) stops exactly at this same state - // transition (it runs off GameEndedToken, which StopAsync cancels right before setting State to Ended). + // before calling this method), so ComputeHudPhaseAndRemaining reports phase 3 with the ceremony countdown. + // The periodic HUD loop (HudBroadcastLoopAsync) stopped at this same transition, so a dedicated ceremony + // loop keeps the phase-3 countdown and the final scoreboard flowing until the teleport. await this.BroadcastHudStateAsync().ConfigureAwait(false); + await this.BroadcastScoreboardAsync().ConfigureAwait(false); + _ = Task.Run(() => this.EndCeremonyBroadcastLoopAsync(this._endCeremonyDeadlineUtc.Value)); await base.GameEndedAsync(finishers).ConfigureAwait(false); } + /// + /// After the battle ends, keeps broadcasting the phase-3 HUD state (with the countdown to the Lorencia + /// teleport) and the final scoreboard about once per second until the ceremony deadline. Runs independently + /// of , which has already stopped (its + /// was cancelled when the game ended). + /// + /// The instant the ceremony ends (players get teleported out). + private async ValueTask EndCeremonyBroadcastLoopAsync(DateTime deadlineUtc) + { + try + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + while (DateTime.UtcNow < deadlineUtc + && this.State == MiniGameState.Ended + && await timer.WaitForNextTickAsync().ConfigureAwait(false)) + { + await this.BroadcastHudStateAsync().ConfigureAwait(false); + await this.BroadcastScoreboardAsync().ConfigureAwait(false); + } + } + catch (Exception ex) + { + this.Logger.LogError(ex, "{context}: Error in the end-of-event ceremony broadcast loop.", this); + } + } + /// /// Gets the players which would receive the winner's Zen reward if the event ended right now, for unit /// tests only (a full run needs a live with a working @@ -1262,8 +1319,12 @@ public class HeykelSavasiContext : MiniGameContext return (2, ClampToUInt16Seconds(this.Definition.GameDuration - (now - this._playingAtUtc.Value))); default: - // Undefined, Ended, or Disposed: the event is over (or hasn't been set up yet). - return (3, 0); + // Undefined, Ended, or Disposed: the event is over (or hasn't been set up yet). During the + // post-battle ceremony (State == Ended, deadline set) report the seconds left until the + // Lorencia teleport so the client can show a countdown and centre the final scoreboard. + return (3, this._endCeremonyDeadlineUtc is { } deadline + ? ClampToUInt16Seconds(deadline - now) + : (ushort)0); } } diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index 353002d..df72f5c 100644 --- a/src/GameLogic/Player.cs +++ b/src/GameLogic/Player.cs @@ -1161,7 +1161,13 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke { // Older clients use a separate packet for the respawn, while newer don't. // It requires a slightly different logic. - this.CurrentMap = await this.GameContext.GetMapAsync(this.SelectedCharacter!.CurrentMap!.Number.ToUnsigned()).ConfigureAwait(false) ?? throw new InvalidOperationException("Current map not found."); + // ADAMU-CUSTOM: when respawning inside a mini game (e.g. the TvT Event), stay on that game's OWN + // map instance - not the shared world map of the same number - otherwise the respawned player lands + // on an empty copy and can't see the statues or the other participants. Mirrors the newer-client + // path in ClientReadyAfterMapChangeAsync. + this.CurrentMap = this.CurrentMiniGame is { } respawnMiniGame + ? respawnMiniGame.Map + : (await this.GameContext.GetMapAsync(this.SelectedCharacter!.CurrentMap!.Number.ToUnsigned()).ConfigureAwait(false) ?? throw new InvalidOperationException("Current map not found.")); await respawnPlugIn.RespawnAsync().ConfigureAwait(false); await this.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.EnteredWorld).ConfigureAwait(false); this.IsAlive = true; @@ -1890,21 +1896,6 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke /// The player killed. internal async ValueTask AfterKilledPlayerAsync(Player killedPlayer) { - // ADAMU-CUSTOM: TvT Event -> credit the killer with an enemy-team kill for the event scoreboard. - // Done up front, before the PK-state early-returns below, so it counts regardless of hero state. - if (this.CurrentMiniGame is MiniGames.HeykelSavasiContext heykelKillGame - && ReferenceEquals(killedPlayer.CurrentMiniGame, this.CurrentMiniGame)) - { - var killerTeam = heykelKillGame.GetTeam(this); - var victimTeam = heykelKillGame.GetTeam(killedPlayer); - if (killerTeam != victimTeam - && killerTeam != MiniGames.HeykelSavasiTeam.None - && victimTeam != MiniGames.HeykelSavasiTeam.None) - { - heykelKillGame.RecordKill(this); - } - } - if (this.DuelRoom?.State == DuelState.DuelStarted) { return; @@ -2474,6 +2465,23 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke this._respawnAfterDeathCts = new CancellationTokenSource(); await this.ForEachWorldObserverAsync(p => p.ObjectGotKilledAsync(this, killer), true).ConfigureAwait(false); + // ADAMU-CUSTOM: TvT Event -> credit the killer with an enemy-team kill for the scoreboard. Done here + // (not in AfterKilledPlayerAsync) because that PK-penalty path is intentionally skipped for mini-game + // PvP (AllowPlayerKilling == true), which would otherwise mean event kills never count. + if (killer is Player heykelKiller + && heykelKiller.CurrentMiniGame is MiniGames.HeykelSavasiContext heykelKillGame + && ReferenceEquals(this.CurrentMiniGame, heykelKiller.CurrentMiniGame)) + { + var killerTeam = heykelKillGame.GetTeam(heykelKiller); + var victimTeam = heykelKillGame.GetTeam(this); + if (killerTeam != victimTeam + && killerTeam != MiniGames.HeykelSavasiTeam.None + && victimTeam != MiniGames.HeykelSavasiTeam.None) + { + heykelKillGame.RecordKill(heykelKiller); + } + } + if (killer is Player killerAfterKilled && !(killerAfterKilled.GuildWarContext?.Score is { } score && score == this.GuildWarContext?.Score) && this.CurrentMiniGame?.AllowPlayerKilling is not true)