Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/GameLogic/NPC/AttackableNpcBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}

Expand Down
132 changes: 107 additions & 25 deletions src/GameLogic/Player.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
StopByDeath = false,
};

/// <summary>
/// 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.
/// </summary>
private static readonly TimeSpan PlayerKillerStateDuration = TimeSpan.FromHours(3);

/// <summary>
/// The duration until a hero state falls back one step.
/// </summary>
private static readonly TimeSpan HeroStateDuration = TimeSpan.FromHours(1);

private readonly PlayerExperience _experience;

/// <summary>
Expand Down Expand Up @@ -75,6 +87,12 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke

private DateTime _lastRegenerate = DateTime.UtcNow;

/// <summary>
/// The fraction of a second which elapsed since the last regeneration, but wasn't subtracted from
/// <see cref="Character.StateRemainingSeconds"/> yet, because it only counts in whole seconds.
/// </summary>
private double _heroStateSecondsRemainder;

private GameMap? _currentMap;

private IDisposable? _accountLoggingScope;
Expand Down Expand Up @@ -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<IUpdateCharacterHeroStatePlugIn>(o => o.UpdateCharacterHeroStateAsync(this), true).ConfigureAwait(false);
}
Expand Down Expand Up @@ -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<IUpdateCharacterHeroStatePlugIn>(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++;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made explicit in e6a9dfd, and the PR description is updated.

}

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<IUpdateCharacterHeroStatePlugIn>(p => p.UpdateCharacterHeroStateAsync(this), true).ConfigureAwait(false);
}

/// <summary>
/// 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.
/// </summary>
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;
}
}

Expand Down Expand Up @@ -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<IUpdateRotationPlugIn>(p => p.UpdateRotationAsync()).ConfigureAwait(false);
await this.ResetPetBehaviorAsync().ConfigureAwait(false);
Expand Down