Conversation
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.
sven-n
left a comment
There was a problem hiding this comment.
Review
Verified the diff against a clone of master. The core fix is correct.
_lastRegenerate is assigned in the finally of RegenerateAsync (Player.cs:942), so it genuinely holds the previous tick — the old _lastRegenerate.Subtract(DateTime.UtcNow) was negative and -= made the timer grow. Both the sign flip and dropping the > 0 guard are right, and the guard really was the second half of the bug: AttackableNpcBase.OnDeathAsync can push StateRemainingSeconds to ≤ 0 between two ticks, permanently wedging the old code.
Escalation in AfterKilledPlayerAsync checks out too: a hero jumps straight to PlayerKillWarning, the Math.Max prevents a kill from shortening a longer timer, and PK2 kills stack. Three kills → 9 h total, as described. The AttackableNpcBase change is comment-only and accurate.
Three inline comments below (one behavioural, one latent/pre-existing, one description nit), plus one general point:
Existing inflated StateRemainingSeconds values aren't normalized
Nothing clamps the values the old bug inflated. On the servers described in the PR — where "every single one" of the offline/bot players accumulated a permanent outlaw state — those characters now carry countdowns that may represent days of online time per state step. The fix will look like it didn't work on exactly the servers that motivated it.
A clamp on character load (e.g. Math.Min(remaining, stateDuration)) or a one-off migration would avoid that. Happy to treat it as a follow-up if you'd rather keep this PR focused.
Verdict
Sound fix for a real bug. I'd merge it after the first inline comment; the rest are defensible as follow-ups.
Generated by Claude Code
| } | ||
| else | ||
| { | ||
| currentCharacter.State++; |
There was a problem hiding this comment.
Small correction to the PR description, which says HeroState.New "is now excluded explicitly".
New is 0 and Normal is 3, so this else branch would move a New character towards Hero, away from Normal. What actually prevents that is the State < HeroState.Normal && StateRemainingSeconds <= 0 early return at line 1424 — i.e. the same kind of incidental protection the old > 0 guard provided.
No behavioural bug: a freshly created character has no running timer, so it always hits that return. But an explicit State == HeroState.New check (or choosing --/++ from a direct comparison) would make the intent match the description.
Generated by Claude Code
There was a problem hiding this comment.
Made explicit in e6a9dfd, and the PR description is updated.
- 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.
…very 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.
|
Thanks for the thorough review! Pushed two commits: e6a9dfd — review fixes
c6248a6 — countdown independent of the recovery interval
|
Summary
The remaining time of a hero or outlaw state was never counted down. A player killer therefore
stayed an outlaw forever — in practice a permanent 2nd stage outlaw that only
/pkclearcouldremove — no matter how long the player stayed online or how many monsters they hunted. This fixes
the countdown and restores the classic timings around it.
The symptom
becomes a 2nd stage outlaw.
the drop and experience penalties stay, and
Character.StateRemainingSecondsin the databasekeeps growing instead of shrinking.
What did work: escalating into the states, the
IUpdateCharacterHeroStatePlugInnotification tothe observers, and
/pkclear, which is why this stayed unnoticed for so long. Our servers arepopulated with offline/bot players which occasionally kill each other, and every single one of
them accumulated a permanent outlaw state.
Root cause
Two defects in
Player.RegenerateHeroStateAsync()(src/GameLogic/Player.cs), which is calledfrom
RegenerateAsync()on the recovery timer:this._lastRegenerate.Subtract(DateTime.UtcNow). The fieldholds the previous tick, so the result is negative — despite the variable being named
secondsSinceLastRegenerate. Subtracting it madeStateRemainingSecondsgrow by exactly theelapsed time, so the countdown stood still (in net terms) forever.
if (currentCharacter?.StateRemainingSeconds > 0). Killingmonsters reduces the remaining time by the monster's level
(
AttackableNpcBase.OnDeathAsync()), which can push it to zero or below between two ticks.From that moment on, the guard was false and the state change was never reached again.
Together they meant that the state could only ever escalate.
git blamedates both todb15dea508(2021-11-13); nothing else in the code base lowersCharacter.Stateexcept/pkclearand the GM command/pk.The fix
RegenerateHeroStateAsync()is rewritten with early returns:DateTime.UtcNow.Subtract(this._lastRegenerate). It is subtracted inwhole seconds, and the remaining fraction is kept for the next tick. Rounding every tick made the countdown speed depend on
GameConfiguration.RecoveryInterval(at 500 ms it never counted down at all).
whole surplus over into the next step, so a hunting spree can drop more than one step at a time.
HeroState.New(a freshly created character, which has no running timer) is now excludedexplicitly. Previously the
> 0guard did that by accident, and without the guard such acharacter would have been promoted towards
HeroState.Hero.legitimately have: one state duration, and for a 2nd stage outlaw one duration per kill after the
one which reached that stage. Characters of servers which ran with the broken countdown would
otherwise carry a remaining time which grew by all the time they spent online.
HeroState.Normalalso resetsPlayerKillCount, which otherwise kept charging the/pkclearprice for kills the player had already atoned for.The timings follow the classic (pre-Season 9) behavior again, where they were wrong before:
PlayerKillerStateDuration).which can no longer escalate the state (i.e. as a 2nd stage outlaw) stack on top of the
remaining time. Three kills in a row therefore take 3 h + 3 h + 3 h to wear off, and every
further kill adds another three hours.
Math.Maxmakes sure a kill can never shorten an already longer remaining time.HeroStateDuration).Reducing the remaining time by the level of a killed monster is left as it is, and deliberately
stays available on every map and in every outlaw state: that matches the classic rule, where the
penalty was shortened by the monster's level in seconds anywhere, long before later seasons
restricted it to Vulcanus. The only change in
AttackableNpcBaseis a clarifying comment.Since the timer is only advanced while the player is in the game —
_lastRegenerateis reset whenentering the world — offline time still does not count towards wearing the state off.
Testing
dotnet build src/GameLogic/MUnique.OpenMU.GameLogic.csproj -p:ci=truein themcr.microsoft.com/dotnet/sdk:10.0container: 0 errors, and no analyzer warning in either ofthe two touched files.
were reviewed by reading the code only.
related test is
PKClearChatCommandPlugInTest.Possible follow-ups (not in this change)
GameConfiguration) instead ofbeing constants in
Player.WarpActiondoes not check the hero state at all, so an outlaw can still use the warp list. Theclassic rule forbids warping as a 2nd stage outlaw (the original client protocol even has a
MAPMOVE_FAILED_MURDERERresult), and later seasons charge 50x the zen instead.