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.
This commit is contained in:
Acentech Dev
2026-07-21 16:57:12 +03:00
parent 538189ea7c
commit 49ffa5bd89
2 changed files with 90 additions and 21 deletions

View File

@@ -260,6 +260,20 @@ public class HeykelSavasiContext : MiniGameContext
/// </summary> /// </summary>
private const int ScoreboardMaxEntries = 8; private const int ScoreboardMaxEntries = 8;
/// <summary>
/// How long the post-battle "event over" ceremony lasts before players are teleported to Lorencia.
/// Matches <c>MiniGameContext.RunGameAsync</c>'s own exit timing for our config (ExitDuration 15s):
/// <c>max(ExitDuration - 30s, 30s) + 30s countdown = 60s</c>. The client shows this as a countdown and
/// moves the scoreboard to the screen centre during it (phase 3).
/// </summary>
private static readonly TimeSpan EndCeremonyDuration = TimeSpan.FromSeconds(60);
/// <summary>
/// Wall-clock instant at which the post-battle ceremony ends and players are teleported out; set in
/// <see cref="GameEndedAsync"/>. Drives the phase-3 countdown in <see cref="ComputeHudPhaseAndRemaining"/>.
/// </summary>
private DateTime? _endCeremonyDeadlineUtc;
/// <summary> /// <summary>
/// Synchronizes the check-then-act sequence of <see cref="TryJoinTeamAsync"/>, so that concurrent /// Synchronizes the check-then-act sequence of <see cref="TryJoinTeamAsync"/>, so that concurrent
/// join attempts cannot both pass the balance check before either of them reserves a slot. /// 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); 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<MUnique.OpenMU.GameLogic.Views.IShowMessagePlugIn>(
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 // 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 // before calling this method), so ComputeHudPhaseAndRemaining reports phase 3 with the ceremony countdown.
// is needed because the periodic loop (HudBroadcastLoopAsync) stops exactly at this same state // The periodic HUD loop (HudBroadcastLoopAsync) stopped at this same transition, so a dedicated ceremony
// transition (it runs off GameEndedToken, which StopAsync cancels right before setting State to Ended). // loop keeps the phase-3 countdown and the final scoreboard flowing until the teleport.
await this.BroadcastHudStateAsync().ConfigureAwait(false); await this.BroadcastHudStateAsync().ConfigureAwait(false);
await this.BroadcastScoreboardAsync().ConfigureAwait(false);
_ = Task.Run(() => this.EndCeremonyBroadcastLoopAsync(this._endCeremonyDeadlineUtc.Value));
await base.GameEndedAsync(finishers).ConfigureAwait(false); await base.GameEndedAsync(finishers).ConfigureAwait(false);
} }
/// <summary>
/// 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 <see cref="HudBroadcastLoopAsync"/>, which has already stopped (its <see cref="MiniGameContext.GameEndedToken"/>
/// was cancelled when the game ended).
/// </summary>
/// <param name="deadlineUtc">The instant the ceremony ends (players get teleported out).</param>
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);
}
}
/// <summary> /// <summary>
/// Gets the players which would receive the winner's Zen reward if the event ended right now, for unit /// Gets the players which would receive the winner's Zen reward if the event ended right now, for unit
/// tests only (a full <see cref="GameEndedAsync"/> run needs a live <see cref="Player"/> with a working /// tests only (a full <see cref="GameEndedAsync"/> run needs a live <see cref="Player"/> with a working
@@ -1262,8 +1319,12 @@ public class HeykelSavasiContext : MiniGameContext
return (2, ClampToUInt16Seconds(this.Definition.GameDuration - (now - this._playingAtUtc.Value))); return (2, ClampToUInt16Seconds(this.Definition.GameDuration - (now - this._playingAtUtc.Value)));
default: default:
// Undefined, Ended, or Disposed: the event is over (or hasn't been set up yet). // Undefined, Ended, or Disposed: the event is over (or hasn't been set up yet). During the
return (3, 0); // 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);
} }
} }

View File

@@ -1161,7 +1161,13 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
{ {
// Older clients use a separate packet for the respawn, while newer don't. // Older clients use a separate packet for the respawn, while newer don't.
// It requires a slightly different logic. // 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 respawnPlugIn.RespawnAsync().ConfigureAwait(false);
await this.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.EnteredWorld).ConfigureAwait(false); await this.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.EnteredWorld).ConfigureAwait(false);
this.IsAlive = true; this.IsAlive = true;
@@ -1890,21 +1896,6 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
/// <param name="killedPlayer">The player killed.</param> /// <param name="killedPlayer">The player killed.</param>
internal async ValueTask AfterKilledPlayerAsync(Player killedPlayer) 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) if (this.DuelRoom?.State == DuelState.DuelStarted)
{ {
return; return;
@@ -2474,6 +2465,23 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
this._respawnAfterDeathCts = new CancellationTokenSource(); this._respawnAfterDeathCts = new CancellationTokenSource();
await this.ForEachWorldObserverAsync<IObjectGotKilledPlugIn>(p => p.ObjectGotKilledAsync(this, killer), true).ConfigureAwait(false); await this.ForEachWorldObserverAsync<IObjectGotKilledPlugIn>(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 if (killer is Player killerAfterKilled
&& !(killerAfterKilled.GuildWarContext?.Score is { } score && score == this.GuildWarContext?.Score) && !(killerAfterKilled.GuildWarContext?.Score is { } score && score == this.GuildWarContext?.Score)
&& this.CurrentMiniGame?.AllowPlayerKilling is not true) && this.CurrentMiniGame?.AllowPlayerKilling is not true)