Skip to content

Regens refactoring & add tier 2 chars default movement speeds - #888

Draft
ze-dom wants to merge 8 commits into
MUnique:masterfrom
ze-dom:regens_refactor_default_running_speed_chars
Draft

Regens refactoring & add tier 2 chars default movement speeds#888
ze-dom wants to merge 8 commits into
MUnique:masterfrom
ze-dom:regens_refactor_default_running_speed_chars

Conversation

@ze-dom

@ze-dom ze-dom commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

To-do

  • Comment changes with sources
  • UpdatePlugin

Developments

  • Added resting spots recovery logic
  • Added default running/swimming speed attributes for tier 2 chars (MG, DL, RF)
  • Optimized some attribute relationships to avoid the use of "temporary" stats

Bugfixes

  • Character pose not being updated
  • SdRecoverySpeedInc & IncreaseSdRecoveryRate master skills
  • Reviewed values for health, mana, shield and ability passive regeneration

attributeRelationships.Add(this.CreateAttributeRelationship(Stats.DefenseDecrement, 1, tempInnovDefDec, InputOperator.Add, AggregateType.Multiplicate));
attributeRelationships.Add(this.CreateAttributeRelationship(Stats.DefenseDecrement, -1, Stats.InnovationDefDecrement));

attributeRelationships.Add(this.CreateAttributeRelationship(Stats.HealthRecoveryMultiplier, 0.03f, Stats.IsRecovering));

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.

attributeRelationships.Add(this.CreateAttributeRelationship(Stats.DefenseDecrement, -1, Stats.InnovationDefDecrement));

attributeRelationships.Add(this.CreateAttributeRelationship(Stats.HealthRecoveryMultiplier, 0.03f, Stats.IsRecovering));
attributeRelationships.Add(this.CreateAttributeRelationship(Stats.ManaRecoveryMultiplier, 0.03f, Stats.IsRecovering));

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.

attributeRelationships.Add(this.CreateAttributeRelationship(Stats.DefenseDecrement, 1, tempInnovDefDec, InputOperator.Add, AggregateType.Multiplicate));
attributeRelationships.Add(this.CreateAttributeRelationship(Stats.DefenseDecrement, -1, Stats.InnovationDefDecrement));

attributeRelationships.Add(this.CreateAttributeRelationship(Stats.HealthRecoveryMultiplier, 0.03f, Stats.IsResting));

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.

attributeRelationships.Add(this.CreateAttributeRelationship(Stats.DefenseDecrement, -1, Stats.InnovationDefDecrement));

attributeRelationships.Add(this.CreateAttributeRelationship(Stats.HealthRecoveryMultiplier, 0.03f, Stats.IsResting));
attributeRelationships.Add(this.CreateAttributeRelationship(Stats.ManaRecoveryMultiplier, 0.03f, Stats.IsResting));

@ze-dom ze-dom Aug 20, 2026

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.


attributeRelationships.Add(this.CreateAttributeRelationship(Stats.HealthRecoveryMultiplier, 0.03f, Stats.IsResting));
attributeRelationships.Add(this.CreateAttributeRelationship(Stats.ManaRecoveryMultiplier, 0.03f, Stats.IsResting));
attributeRelationships.Add(this.CreateAttributeRelationship(Stats.AbilityRecoveryAbsolute, 3, Stats.IsInSafezone));

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.

Comment on lines 43 to +55
player.Pose = animation switch
{
0x80 => CharacterPose.Sitting,
0x81 => CharacterPose.Leaning,
0x82 => CharacterPose.Hanging,
_ => default,
};

if (player.Pose > CharacterPose.Standing)
{
player.Attributes?.SetStatAttribute(Stats.IsResting, 1.0f);
}

@ze-dom ze-dom Aug 21, 2026

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.

zTeamS6.3, emu

A great game design detail. Resting spots can be found both within (sitting, leaning, hanging) towns as well as outside (sitting, leaning; in lower level maps - where they may be handy). Think hardcore, first versions, old school MU, where you are scrapping by at starting levels 😅

Comment thread src/GameLogic/GameContext.cs Outdated
this.ItemPowerUpFactory = new ItemPowerUpFactory(loggerFactory.CreateLogger<ItemPowerUpFactory>());
this.PartyManager = new PartyManager(configuration.MaximumPartySize, loggerFactory.CreateLogger<Party>());
this._recoverTimer = new Timer(this.RecoverTimerElapsed, null, this.Configuration.RecoveryInterval, this.Configuration.RecoveryInterval);
this._restingRecoveryInterval = (int)Math.Round(this.Configuration.RecoveryInterval * (1 + (2f / 3))); // Originally, resting recovery interval is 5s

@ze-dom ze-dom Aug 22, 2026

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.

zTeamS6.3, emu
3000 * 5f / 3 = 5000

This way it's always proportional to Configuration.RecoveryInterval.

sven-n commented Aug 22, 2026

Copy link
Copy Markdown
Member

Nice work on the regen rework — the behavior it produces looks right to me. My concern is with where the numbers live: Player.RegenerateAsync now carries a lot of game design that a server owner can't touch without recompiling.

What's hardcoded

  1. A mode flag in a public API. RegenerateAsync(bool isRestingCycle) — the flag then filters Stats.IntervalRegenerationAttributes down to health + mana via reference comparison against static Stats members.
  2. Compensation multipliers as literals. 3f/7f for health, 1 for mana/ability, * 3 for shield. They encode "originally every X seconds" relative to GameConfiguration.RecoveryInterval, which is admin-configurable — set it to 1000 ms and every one of those constants is silently wrong.
  3. The shield ramp table. >= 25 → 3, >= 15 → 2.5, >= 10 → 2, else skip, as a switch inside the generic loop.
  4. The gate conditions. The safezone / ShieldRecoveryEverywhere check and the CurrentHealth && !isRestingCycle check select behavior by comparing against Stats.CurrentShield / Stats.CurrentHealth.
  5. A second Timer in GameContext at RecoveryInterval * (1 + 2/3), not configurable, sharing a callback through a boxed bool in the timer state.
  6. Split ownership of IsResting. AnimationHandlerPlugIn sets it to 1 when Pose > Standing but never back to 0 — a subsequent animation packet resets player.Pose to Standing through the _ => default arm while IsResting stays 1, so only movement clears it. And clearing it inside UpdateIsInSafezoneAfterPlayerMoved gives that plug-in two unrelated jobs, so it can't be deactivated independently.

Also worth deciding explicitly: since the resting cycle keeps mana at multiplier 1, mana regenerates on both cycles (~1.53x while sitting). That may well be intended, but right now it's neither configurable nor obvious from the code.

Suggestion: rate-based regeneration + everything else as attributes

1. Make regeneration rate-based instead of tick-based. Give each Stats.Regeneration its own interval and apply the elapsed fraction:

public class Regeneration
{
    // ...
    public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(3);
    public AttributeDefinition? EnabledAttribute { get; init; }
}

private static Regeneration HealthRegeneration { get; } =
    new(HealthRecoveryMultiplier, MaximumHealth, CurrentHealth, HealthRecoveryAbsolute)
    { Interval = TimeSpan.FromSeconds(7) };
var factor = (float)((now - this._lastRegeneration[r]) / r.Interval);
attributes[r.CurrentAttribute] = Math.Min(
    attributes[r.CurrentAttribute] + (((max * multiplier) + absolute) * factor),
    max);

3f/7f then falls out of 3s tick / 7s interval instead of being written down, and stays correct for any RecoveryInterval. This removes items 2 and 5 — one timer, no bool parameter, RegenerateAsync() keeps its original signature. (If discrete steps feel better for the client, the same timestamp supports "apply only when elapsed >= Interval".)

2. Gate conditions as attributes instead of branches. With the optional EnabledAttribute above, the shield special case becomes configuration:

// ShieldRecoveryActive = Maximum(IsInSafezone, ShieldRecoveryEverywhere)
this.CreateAttributeRelationship(Stats.ShieldRecoveryActive, 1, Stats.IsInSafezone, InputOperator.Maximum, Stats.ShieldRecoveryEverywhere)

and the loop body is just if (r.EnabledAttribute is { } flag && attributes[flag] < 1) continue;.

3. Replace the hard steps with a linear ramp driven by a duration attribute. The attribute system already gives us the cap for free — AttributeDefinition.MaximumValue is enforced in ComposableAttribute.CalculateValue() and in StatAttribute — and CharacterClassInitialization.cs:143-144 already shows the temp attribute + Multiplicate pattern. Your ConstValueAttribute.AggregateType change is what lets the 1 + base term be expressed without a temp stat, so it earns its keep here.

New input attributes, written by code: Stats.RestingDuration (seconds since sitting/leaning/hanging) and Stats.ShieldRecoveryDuration (seconds of uninterrupted recovery). Everything else is data:

// Stats.cs — the cap lives on the definition
public static AttributeDefinition ShieldRecoveryRampFactor { get; } =
    new(new Guid("..."), "Shield recovery ramp factor", "Rises linearly with the uninterrupted shield recovery duration.")
    {
        MaximumValue = 3,
    };
// CharacterClassInitialization — factor = 1 + (duration / 15), capped at 3
baseAttributeValues.Add(this.CreateConstValueAttribute(1, Stats.ShieldRecoveryRampFactor));
attributeRelationships.Add(this.CreateAttributeRelationship(Stats.ShieldRecoveryRampFactor, 1f / 15f, Stats.ShieldRecoveryDuration));
attributeRelationships.Add(this.CreateAttributeRelationship(
    Stats.ShieldRecoveryMultiplier, 1, Stats.ShieldRecoveryRampFactor, InputOperator.Multiply, AggregateType.Multiplicate));

The resting bonus takes the same shape (and there's already precedent one line above it, HealthRecoveryMultiplier += 0.01 * IsInSafezone):

attributeRelationships.Add(this.CreateAttributeRelationship(Stats.RestingRecoveryFactor, 1f / 30f, Stats.RestingDuration));
attributeRelationships.Add(this.CreateAttributeRelationship(
    Stats.HealthRecoveryMultiplier, 1, Stats.RestingRecoveryFactor, InputOperator.Multiply, AggregateType.Multiplicate));

That deletes the switch, the _lastShieldRegenerateStop field, the resting filter and the second timer — and since slope and cap are per-character-class relationships, they're tunable in the admin panel and can differ per class.

Mapping the current numbers: either keep the delay (const base 2, slope 1/15, cap 3, with a configurable Stats.ShieldRecoveryDelay before the duration starts counting), or drop it (slope 3/25, cap 3, counting from 0 — recovery starts immediately but weak, full ramp at 25s). I'd default to the second and keep the delay attribute at hand for servers that want the original hard cut-off.

4. One small plug-in keeps the durations current — no game rules inside it:

[PlugIn]
[Guid("...")]
public class RegenerationDurationPlugIn : IPeriodicTaskPlugIn, IAttackableMovedPlugIn, IAttackableGotHitPlugIn

tick: duration += elapsed (the existing 1s _tasksTimer, no third timer); reset RestingDuration on pose → Standing and on movement; reset ShieldRecoveryDuration on hit, on leaving the safezone without ShieldRecoveryEverywhere, and when the shield is full.

One caveat: I wouldn't make the durations persisted stat attributes the way IsInSafezone is. Those are materialized as EF-tracked entities (Player.AddMissingStatAttributesPersistenceContext.CreateNew<StatAttribute>), so a value changing every second dirties the character on every tick and gets written out by PeriodicSaveProgressPlugIn. Adding a non-persistent SimpleElement to the composable attribute when the player enters the world (the way power-ups attach) avoids that entirely. The remaining churn is cheap: each change raises AttributeValueChangedUpdateStatsBasePlugIn.UpdateStatsAsync, which is a FrozenDictionary miss for an unknown definition, so no packets; dependent composables invalidate lazily and stop changing once the duration saturates.

5. Give IsResting one owner — the Player.Pose setter (or a dedicated IPlayerPoseChangedPlugIn if it should stay overridable):

character.Pose = value;
this.Attributes?.SetStatAttribute(Stats.IsResting, value > CharacterPose.Standing ? 1f : 0f);

which fixes the never-cleared case and lets UpdateIsInSafezoneAfterPlayerMoved go back to doing one thing. With RestingDuration in place, IsResting could even be derived (Minimum(RestingDuration, 1) with MaximumValue = 1), though keeping it explicit as the gate flag is clearer.

Net effect

RegenerateAsync() goes back to one signature and one loop with no Stats.X == comparisons:

foreach (var r in Stats.IntervalRegenerationAttributes)
{
    if (r.EnabledAttribute is { } flag && attributes[flag] < 1) { continue; }
    // current += ((max * multiplier) + absolute) * elapsedFactor(r)
}

Everything the PR currently hardcodes lands in one of two configurable places: the Regeneration definitions (interval, gate attribute) and attribute relationships seeded by Persistence.Initialization (ramp slopes, caps, resting/safezone bonuses — per character class). Defaults can reproduce the current numbers closely enough that this stays a refactor of the same behavior, and it fits the UpdatePlugIn you already have on the to-do list: it would seed the new attributes and relationships for existing databases.

As a bonus, once the durations are real attributes other systems can consume them — an item option that raises the ramp cap, or a skill that scales with resting time — all without touching Player.


Generated by Claude Code

this.ItemPowerUpFactory = new ItemPowerUpFactory(loggerFactory.CreateLogger<ItemPowerUpFactory>());
this.PartyManager = new PartyManager(configuration.MaximumPartySize, loggerFactory.CreateLogger<Party>());
this._recoverTimer = new Timer(this.RecoverTimerElapsed, null, this.Configuration.RecoveryInterval, this.Configuration.RecoveryInterval);
this._restingRecoveryInterval = (int)Math.Round(this.Configuration.RecoveryInterval * 5f / 3); // Originally, resting recovery interval is 5s

@ze-dom ze-dom Aug 22, 2026

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.

zTeamS6.3, emu

3000 * 5f / 3 = 5000
This way it's always proportional to Configuration.RecoveryInterval.

Comment thread src/GameLogic/Player.cs
Comment on lines +1084 to +1098
var secondsSinceLastShieldRegenerate = (int)Math.Round(DateTime.UtcNow.Subtract(this._lastShieldRegenerateStop).TotalSeconds);
switch (secondsSinceLastShieldRegenerate)
{
case >= 25:
multiplier = 3;
break;
case >= 15:
multiplier = 2.5f;
break;
case >= 10:
multiplier = 2;
break;
default:
continue;
}

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.

Comment thread src/GameLogic/Player.cs
}

// Originally, health is recovered every 7s
multiplier = 3f / 7f;

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.

Comment thread src/GameLogic/Player.cs
else
{
// Originally, mana and ability are recovered every 3s (the default recovery interval).
multiplier = 1;

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.

Comment on lines +180 to +181
baseAttributeValues.Add(this.CreateConstValueAttribute(100, Stats.ShieldRecoveryMultiplier));
baseAttributeValues.Add(this.CreateConstValueAttribute(1f / 75000, Stats.ShieldRecoveryMultiplier, AggregateType.Multiplicate)); // 1 / (30 * 100 * 25)

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.

result.BaseAttributeValues.Add(this.CreateConstValueAttribute(48.5f, Stats.MaximumHealth));
result.BaseAttributeValues.Add(this.CreateConstValueAttribute(2, Stats.SkillMultiplier));
result.BaseAttributeValues.Add(this.CreateConstValueAttribute(1.0f / 33f, Stats.AbilityRecoveryMultiplier));
result.BaseAttributeValues.Add(this.CreateConstValueAttribute(0.03f, Stats.AbilityRecoveryMultiplier));

@ze-dom ze-dom Aug 22, 2026

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.

zTeamS6.3, emu

Yes, 1/33 is pretty much the same, but it's more obvious at a glance to have the exact number as the sources imo.
Note that DK is slightly higher at 5% :) (already correct)


definition.PossibleOptions.Add(this.CreateOption(ItemGroups.Helm, Stats.DefenseRatePvp, 10, AggregateType.AddRaw, ItemOptionDefinitionNumbers.GuardianOption1));
definition.PossibleOptions.Add(this.CreateOption(ItemGroups.Helm, Stats.ShieldRecoveryMultiplier, 20, AggregateType.AddRaw, ItemOptionDefinitionNumbers.GuardianOption2)); // 20 absolute, need test
definition.PossibleOptions.Add(this.CreateOption(ItemGroups.Helm, Stats.ShieldRecoveryMultiplier, 20, AggregateType.AddRaw, ItemOptionDefinitionNumbers.GuardianOption2));

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.

this.AddPassiveMasterSkillDefinition(SkillNumber.PoisonResistanceInc, Stats.PoisonResistance, AggregateType.AddRaw, Formula120, Formula120, 2, 1);
this.AddPassiveMasterSkillDefinition(SkillNumber.DurabilityReduction2, Stats.ItemDurationIncrease, AggregateType.Multiplicate, Formula1204, 3, 1, SkillNumber.DurabilityReduction1);
this.AddMasterSkillDefinition(SkillNumber.SdRecoverySpeedInc, SkillNumber.MaximumSDincrease, SkillNumber.Undefined, 1, 3, SkillNumber.Undefined, 20, Formula120);
this.AddPassiveMasterSkillDefinition(SkillNumber.SdRecoverySpeedInc, Stats.ShieldRecoveryMultiplier, AggregateType.Multiplicate, $"1 + {FormulaRecoveryIncrease120}", Formula120, 3, 1, SkillNumber.MaximumSDincrease, SkillNumber.Undefined, 20);

@ze-dom ze-dom Aug 22, 2026

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.

user.cpp: zTeamS6.3, emu
MasterLevelSkillTreeSystem.cpp: zTeamS6.3, emu

private void AddCommonBaseAttributeValues(ICollection<ConstValueAttribute> baseAttributeValues, bool isMaster)
{
baseAttributeValues.Add(this.CreateConstValueAttribute(1.0f / 27.5f, Stats.ManaRecoveryMultiplier));
baseAttributeValues.Add(this.CreateConstValueAttribute(0.037f, Stats.ManaRecoveryMultiplier));

@ze-dom ze-dom Aug 22, 2026

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.

zTeamS6.3, emu

Yes, practically the same, but easier to crosscheck with sources imo

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.

2 participants