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)