Skip to content

Let the outlaw (player killer) state actually wear off again - #956

Open
nolt wants to merge 1 commit into
MUnique:masterfrom
nolt:fix-pk-state-decay
Open

Let the outlaw (player killer) state actually wear off again#956
nolt wants to merge 1 commit into
MUnique:masterfrom
nolt:fix-pk-state-decay

Conversation

@nolt

@nolt nolt commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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 /pkclear could
remove — 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

  1. Kill another player three times (no self-defense, no duel, no rival guild), so the character
    becomes a 2nd stage outlaw.
  2. Stay online for hours, hunt any amount of monsters.
  3. The character never returns to the 1st stage, let alone to normal. Its name stays red/black,
    the drop and experience penalties stay, and Character.StateRemainingSeconds in the database
    keeps growing instead of shrinking.

What did work: escalating into the states, the IUpdateCharacterHeroStatePlugIn notification to
the observers, and /pkclear, which is why this stayed unnoticed for so long. Our servers are
populated 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 called
from RegenerateAsync() on the recovery timer:

  • The elapsed time was calculated as this._lastRegenerate.Subtract(DateTime.UtcNow). The field
    holds the previous tick, so the result is negative — despite the variable being named
    secondsSinceLastRegenerate. Subtracting it made StateRemainingSeconds grow by exactly the
    elapsed time, so the countdown stood still (in net terms) forever.
  • The whole method body was guarded by if (currentCharacter?.StateRemainingSeconds > 0). Killing
    monsters 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 blame dates both to
db15dea508 (2021-11-13); nothing else in the code base lowers Character.State except
/pkclear and the GM command /pk.

The fix

RegenerateHeroStateAsync() is rewritten with early returns:

  • The elapsed time is now DateTime.UtcNow.Subtract(this._lastRegenerate).
  • A remaining time which ran below zero steps the state one step towards normal and carries the
    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 excluded
    explicitly. Previously the > 0 guard did that by accident, and without the guard such a
    character would have been promoted towards HeroState.Hero.
  • Returning to HeroState.Normal also resets PlayerKillCount, which otherwise kept charging the
    /pkclear price for kills the player had already atoned for.

The timings follow the classic (pre-Season 9) behavior again, where they were wrong before:

  • An outlaw state lasts three hours instead of one (PlayerKillerStateDuration).
  • Stepping up to the next outlaw state restarts that clock instead of adding to it; only kills
    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.
  • A Math.Max makes sure a kill can never shorten an already longer remaining time.
  • Hero states keep their one hour (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 AttackableNpcBase is a clarifying comment.

Since the timer is only advanced while the player is in the game — _lastRegenerate is reset when
entering the world — offline time still does not count towards wearing the state off.

Testing

  • dotnet build src/GameLogic/MUnique.OpenMU.GameLogic.csproj -p:ci=true in the
    mcr.microsoft.com/dotnet/sdk:10.0 container: 0 errors, and no analyzer warning in either of
    the two touched files.
  • Not verified at runtime: this has not been run on a game server yet, so the state transitions
    were reviewed by reading the code only.
  • No unit tests added. There is currently no test coverage for the hero state at all; the only
    related test is PKClearChatCommandPlugInTest.

Possible follow-ups (not in this change)

  • The three hours and one hour could be made configurable (e.g. in GameConfiguration) instead of
    being constants in Player.
  • WarpAction does not check the hero state at all, so an outlaw can still use the warp list. The
    classic rule forbids warping as a 2nd stage outlaw (the original client protocol even has a
    MAPMOVE_FAILED_MURDERER result), and later seasons charge 50x the zen instead.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant