From ef92b1aad0c0d60b88f320a14a9b55eace0d73e7 Mon Sep 17 00:00:00 2001 From: nolt Date: Fri, 11 Sep 2026 14:24:46 +0200 Subject: [PATCH 1/3] fix(gamelogic): let the outlaw state actually wear off again The remaining time of a hero/outlaw state was never counted down, so a player killer stayed a 2nd stage outlaw forever, until /pkclear was used. Two defects worked together in RegenerateHeroStateAsync: - The elapsed time was calculated as _lastRegenerate.Subtract(DateTime.UtcNow), which is negative, because _lastRegenerate is the previous tick. Subtracting it made StateRemainingSeconds grow at exactly the rate time passed. - The method only did anything while StateRemainingSeconds was above zero. Once killed monsters had pushed it to zero or below, the state change was skipped. The countdown now uses the elapsed time with the correct sign, and a remaining time which ran below zero steps the state down and carries the surplus over to the next step, so hunting monsters can drop more than one step at once. Additionally the timings follow the classic behavior again: an outlaw state lasts three hours instead of one, stepping up to the next state restarts that clock, and further kills as a 2nd stage outlaw stack on top of the remaining time. Returning to the normal state also resets the player kill count, which otherwise kept the /pkclear price of kills the player already atoned for. Reducing the remaining time by the level of a killed monster is unchanged and stays available on every map. --- src/GameLogic/NPC/AttackableNpcBase.cs | 2 + src/GameLogic/Player.cs | 88 ++++++++++++++++++-------- 2 files changed, 65 insertions(+), 25 deletions(-) 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..989eeea28 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; /// @@ -1255,9 +1267,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,33 +1415,48 @@ private async ValueTask HandleMoveToNextSafezoneAsync() private async ValueTask RegenerateHeroStateAsync() { - var currentCharacter = this._selectedCharacter; - if (currentCharacter?.StateRemainingSeconds > 0) + if (this._selectedCharacter is not { } currentCharacter + || currentCharacter.State == HeroState.Normal) { - 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. - } + return; + } - await this.ForEachWorldObserverAsync(p => p.UpdateCharacterHeroStateAsync(this), true).ConfigureAwait(false); - currentCharacter.StateRemainingSeconds = currentCharacter.State == HeroState.Normal - ? 0 - : (int)TimeSpan.FromHours(1).TotalSeconds; - } + if (currentCharacter.State < HeroState.Normal && currentCharacter.StateRemainingSeconds <= 0) + { + // A hero state without a running timer, e.g. a newly created character. + return; + } + + currentCharacter.StateRemainingSeconds -= (int)Math.Round(DateTime.UtcNow.Subtract(this._lastRegenerate).TotalSeconds); + 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; + currentCharacter.StateRemainingSeconds = Math.Max((int)stateDuration.TotalSeconds - surplusSeconds, 0); + } + + await this.ForEachWorldObserverAsync(p => p.UpdateCharacterHeroStateAsync(this), true).ConfigureAwait(false); } private async ValueTask HitAsync(HitInfo hitInfo, IAttacker attacker, Skill? skill, bool? isFinalStreakHit = null) From e6a9dfdcdebf7a6b80ea5845f3f8d37b20bd8d99 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 16 Sep 2026 14:29:55 +0200 Subject: [PATCH 2/3] fix(gamelogic): address review of the outlaw state countdown - Carry the whole surplus over when the state falls back a step. The remaining time was clamped at zero, so a surplus larger than one step was lost. A value below zero is handled by the next tick, which steps down again. - Exclude HeroState.New explicitly instead of relying on the check for a hero state without a running timer, which only protected it by accident. - Limit the remaining time when a character enters the world. Servers which ran with the broken countdown have characters whose remaining time grew by all the time they spent online, so the fix would not show there for a very long time. The limit is one state duration, and for a 2nd stage outlaw one duration per kill after the one which reached that stage, so legitimately stacked time is kept. --- src/GameLogic/Player.cs | 47 ++++++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index 989eeea28..aabee2206 100644 --- a/src/GameLogic/Player.cs +++ b/src/GameLogic/Player.cs @@ -1415,18 +1415,13 @@ private async ValueTask HandleMoveToNextSafezoneAsync() private async ValueTask RegenerateHeroStateAsync() { + // A newly created character has no hero state yet, so there is nothing to count down. if (this._selectedCharacter is not { } currentCharacter - || currentCharacter.State == HeroState.Normal) + || currentCharacter.State is HeroState.Normal or HeroState.New) { return; } - if (currentCharacter.State < HeroState.Normal && currentCharacter.StateRemainingSeconds <= 0) - { - // A hero state without a running timer, e.g. a newly created character. - return; - } - currentCharacter.StateRemainingSeconds -= (int)Math.Round(DateTime.UtcNow.Subtract(this._lastRegenerate).TotalSeconds); if (currentCharacter.StateRemainingSeconds > 0) { @@ -1453,12 +1448,47 @@ private async ValueTask RegenerateHeroStateAsync() else { var stateDuration = currentCharacter.State > HeroState.Normal ? PlayerKillerStateDuration : HeroStateDuration; - currentCharacter.StateRemainingSeconds = Math.Max((int)stateDuration.TotalSeconds - surplusSeconds, 0); + + // 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; + } + } + private async ValueTask HitAsync(HitInfo hitInfo, IAttacker attacker, Skill? skill, bool? isFinalStreakHit = null) { this._summon.RegisterHit(attacker); @@ -1753,6 +1783,7 @@ private async ValueTask OnPlayerEnteredWorldAsync() } await this.ClientReadyAfterMapChangeAsync().ConfigureAwait(false); + this.LimitHeroStateRemainingTime(); this._lastRegenerate = DateTime.UtcNow; await this.InvokeViewPlugInAsync(p => p.UpdateRotationAsync()).ConfigureAwait(false); From c6248a6eef43f79dec88c662a2e4b86f13ba2afb Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 16 Sep 2026 14:31:05 +0200 Subject: [PATCH 3/3] fix(gamelogic): count the outlaw state down independently of the recovery interval The remaining time was reduced by the elapsed time of each tick, rounded to whole seconds, and the fraction was dropped. So the countdown speed depended on GameConfiguration.RecoveryInterval: at the default of 3000 ms it is correct, but at 600 ms a state wore off about 1.7 times too fast, and at 500 ms the elapsed time rounded to zero on every tick, so the state never wore off at all. Now only whole seconds are subtracted and the remaining fraction is kept for the next tick. It is kept in memory only and reset when entering the world and while there is no state to count down. --- src/GameLogic/Player.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index aabee2206..dc5d1570c 100644 --- a/src/GameLogic/Player.cs +++ b/src/GameLogic/Player.cs @@ -87,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; @@ -1419,10 +1425,16 @@ private async ValueTask RegenerateHeroStateAsync() if (this._selectedCharacter is not { } currentCharacter || currentCharacter.State is HeroState.Normal or HeroState.New) { + this._heroStateSecondsRemainder = 0; return; } - currentCharacter.StateRemainingSeconds -= (int)Math.Round(DateTime.UtcNow.Subtract(this._lastRegenerate).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; @@ -1785,6 +1797,7 @@ 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);