diff --git a/src/GameLogic/NPC/AttackableNpcBase.cs b/src/GameLogic/NPC/AttackableNpcBase.cs
index 64c0139c6..960f69362 100644
--- a/src/GameLogic/NPC/AttackableNpcBase.cs
+++ b/src/GameLogic/NPC/AttackableNpcBase.cs
@@ -365,6 +365,8 @@ protected virtual async ValueTask OnDeathAsync(IAttacker attacker)
{
if (selectedCharacter.State > HeroState.Normal)
{
+ // An outlaw can shorten its remaining state time by hunting monsters, on any map:
+ // the level of the killed monster is subtracted in seconds.
selectedCharacter.StateRemainingSeconds -= (int)this.Attributes[Stats.Level];
}
diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs
index b44b8da88..dc5d1570c 100644
--- a/src/GameLogic/Player.cs
+++ b/src/GameLogic/Player.cs
@@ -44,6 +44,18 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
StopByDeath = false,
};
+ ///
+ /// How long an outlaw (player killer) state lasts until it falls back one step. Each player kill
+ /// (re)starts it, and kills which can't escalate the state any further stack on top of it.
+ /// It can be shortened by killing monsters.
+ ///
+ private static readonly TimeSpan PlayerKillerStateDuration = TimeSpan.FromHours(3);
+
+ ///
+ /// The duration until a hero state falls back one step.
+ ///
+ private static readonly TimeSpan HeroStateDuration = TimeSpan.FromHours(1);
+
private readonly PlayerExperience _experience;
///
@@ -75,6 +87,12 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
private DateTime _lastRegenerate = DateTime.UtcNow;
+ ///
+ /// The fraction of a second which elapsed since the last regeneration, but wasn't subtracted from
+ /// yet, because it only counts in whole seconds.
+ ///
+ private double _heroStateSecondsRemainder;
+
private GameMap? _currentMap;
private IDisposable? _accountLoggingScope;
@@ -1255,9 +1273,20 @@ internal async ValueTask AfterKilledPlayerAsync(Player killedPlayer)
{
this._selectedCharacter.State++;
}
+
+ // Stepping up to the next outlaw state restarts the clock for that state. Math.Max, so that
+ // a kill can never shorten an already longer remaining time.
+ this._selectedCharacter.StateRemainingSeconds = Math.Max(
+ this._selectedCharacter.StateRemainingSeconds,
+ (int)PlayerKillerStateDuration.TotalSeconds);
+ }
+ else
+ {
+ // Further kills as a 2nd stage outlaw can't escalate the state anymore, so they stack on
+ // top of the remaining time instead.
+ this._selectedCharacter.StateRemainingSeconds += (int)PlayerKillerStateDuration.TotalSeconds;
}
- this._selectedCharacter.StateRemainingSeconds += (int)TimeSpan.FromHours(1).TotalSeconds;
this._selectedCharacter.PlayerKillCount += 1;
await this.ForEachWorldObserverAsync(o => o.UpdateCharacterHeroStateAsync(this), true).ConfigureAwait(false);
}
@@ -1392,32 +1421,83 @@ private async ValueTask HandleMoveToNextSafezoneAsync()
private async ValueTask RegenerateHeroStateAsync()
{
- var currentCharacter = this._selectedCharacter;
- if (currentCharacter?.StateRemainingSeconds > 0)
+ // A newly created character has no hero state yet, so there is nothing to count down.
+ if (this._selectedCharacter is not { } currentCharacter
+ || currentCharacter.State is HeroState.Normal or HeroState.New)
{
- var secondsSinceLastRegenerate = this._lastRegenerate.Subtract(DateTime.UtcNow).TotalSeconds;
- currentCharacter.StateRemainingSeconds -= (int)Math.Round(secondsSinceLastRegenerate);
- if (currentCharacter.StateRemainingSeconds <= 0)
- {
- // Change the status.
- if (currentCharacter.State > HeroState.Normal)
- {
- currentCharacter.State--;
- }
- else if (currentCharacter.State < HeroState.Normal)
- {
- currentCharacter.State++;
- }
- else
- {
- // State is already Normal, no change needed.
- }
+ this._heroStateSecondsRemainder = 0;
+ return;
+ }
- await this.ForEachWorldObserverAsync(p => p.UpdateCharacterHeroStateAsync(this), true).ConfigureAwait(false);
- currentCharacter.StateRemainingSeconds = currentCharacter.State == HeroState.Normal
- ? 0
- : (int)TimeSpan.FromHours(1).TotalSeconds;
- }
+ // Only whole seconds are subtracted and the fraction is kept for the next tick. Rounding each tick
+ // made the countdown depend on the recovery interval, e.g. at 500 ms it never counted down at all.
+ var elapsedSeconds = DateTime.UtcNow.Subtract(this._lastRegenerate).TotalSeconds + this._heroStateSecondsRemainder;
+ var elapsedWholeSeconds = Math.Floor(elapsedSeconds);
+ this._heroStateSecondsRemainder = elapsedSeconds - elapsedWholeSeconds;
+ currentCharacter.StateRemainingSeconds -= (int)elapsedWholeSeconds;
+ if (currentCharacter.StateRemainingSeconds > 0)
+ {
+ return;
+ }
+
+ // The time is up, so the state falls back one step towards the normal state. Killed monsters may
+ // have pushed the remaining time below zero, so the surplus is carried over to the next step.
+ var surplusSeconds = -currentCharacter.StateRemainingSeconds;
+ if (currentCharacter.State > HeroState.Normal)
+ {
+ currentCharacter.State--;
+ }
+ else
+ {
+ currentCharacter.State++;
+ }
+
+ if (currentCharacter.State == HeroState.Normal)
+ {
+ currentCharacter.StateRemainingSeconds = 0;
+ currentCharacter.PlayerKillCount = 0;
+ }
+ else
+ {
+ var stateDuration = currentCharacter.State > HeroState.Normal ? PlayerKillerStateDuration : HeroStateDuration;
+
+ // May still be below zero, if the surplus exceeds this step as well. Then the next tick steps down again.
+ currentCharacter.StateRemainingSeconds = (int)stateDuration.TotalSeconds - surplusSeconds;
+ }
+
+ await this.ForEachWorldObserverAsync(p => p.UpdateCharacterHeroStateAsync(this), true).ConfigureAwait(false);
+ }
+
+ ///
+ /// Limits the remaining time of the hero state to the longest time which the current state can
+ /// legitimately have. Characters of servers which ran with the formerly broken countdown can have
+ /// a remaining time which grew by all the time they spent online.
+ ///
+ private void LimitHeroStateRemainingTime()
+ {
+ if (this._selectedCharacter is not { } character
+ || character.State is HeroState.Normal or HeroState.New)
+ {
+ return;
+ }
+
+ var maximumSeconds = character.State switch
+ {
+ // Every kill after the one which reached the 2nd stage adds another state duration on top.
+ HeroState.PlayerKiller2ndStage => (int)PlayerKillerStateDuration.TotalSeconds * Math.Max(character.PlayerKillCount - 2, 1),
+ > HeroState.Normal => (int)PlayerKillerStateDuration.TotalSeconds,
+ _ => (int)HeroStateDuration.TotalSeconds,
+ };
+
+ if (character.StateRemainingSeconds > maximumSeconds)
+ {
+ this.Logger.LogInformation(
+ "Limited the remaining hero state time of character {CharacterName} ({HeroState}) from {RemainingSeconds} to {MaximumSeconds} seconds.",
+ character.Name,
+ character.State,
+ character.StateRemainingSeconds,
+ maximumSeconds);
+ character.StateRemainingSeconds = maximumSeconds;
}
}
@@ -1715,7 +1795,9 @@ private async ValueTask OnPlayerEnteredWorldAsync()
}
await this.ClientReadyAfterMapChangeAsync().ConfigureAwait(false);
+ this.LimitHeroStateRemainingTime();
this._lastRegenerate = DateTime.UtcNow;
+ this._heroStateSecondsRemainder = 0;
await this.InvokeViewPlugInAsync(p => p.UpdateRotationAsync()).ConfigureAwait(false);
await this.ResetPetBehaviorAsync().ConfigureAwait(false);