Skip to content

Respect the spawn area health override in the monster attribute system - #958

Open
nolt wants to merge 1 commit into
MUnique:masterfrom
nolt:fix-bc-gate-health-bar
Open

Respect the spawn area health override in the monster attribute system#958
nolt wants to merge 1 commit into
MUnique:masterfrom
nolt:fix-bc-gate-health-bar

Conversation

@nolt

@nolt nolt commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Summary

An npc whose MonsterSpawnArea defines a MaximumHealthOverride is spawned with that health,
but MonsterAttributeHolder kept resolving Stats.MaximumHealth from the MonsterDefinition.
Everything which reports health as a percentage therefore divided the real health by an
unrelated maximum. This change returns the override from GetValueOfAttribute, so current and
maximum health of such an npc are consistent again.

The symptom

In Blood Castle, the Castle Gate and the Statue of Saint show a health bar which is only a
couple of segments long from the very moment they become attackable, while a regular monster at
full health shows a full bar. Damaging them barely moves the bar until they die.

Regular monsters are unaffected, and the client side math is correct - the client simply renders
the ratio the server sends.

Stock Season 6 data:

Npc definition MaximumHealth real health (spawn override) status byte sent bar (of 20 segments)
131 Castle Gate 5 000 000 150 000 - 650 000 (BC1-BC8) 8 - 33 0 - 2
132-134 Statue of Saint 5 000 000 65 000 - 345 000 (BC1-BC8) 3 - 17 0 - 1

Root cause

  1. AttackableNpcBase.Initialize (src/GameLogic/NPC/AttackableNpcBase.cs:168) takes the
    current health from the spawn area:

    this.Health = this.SpawnArea.MaximumHealthOverride ?? (int)this.Attributes[Stats.MaximumHealth];
  2. MonsterAttributeHolder.GetValueOfAttribute resolves Stats.MaximumHealth from the
    per-definition stat dictionary and knows nothing about the override - only
    Stats.CurrentHealth is mapped to Health.

  3. ShowHitExtendedPlugIn.CalcStatStatus
    (src/GameServer/RemoteView/World/ShowHitExtendedPlugIn.cs:67) sends
    (byte)Math.Round(current / maximum * 250), which for the BC8 gate is
    650 000 / 5 000 000 * 250 = 33.

Summoned monsters are affected the other way around: PlayerSummon.CreateAsync
(src/GameLogic/PlayerSummon.cs:62-63) sets the override to
max + max * SummonedMonsterHealthIncrease, so the ratio becomes greater than 1 and the (byte)
cast wraps around (master level 1 gives 401 -> 145). This is not observable in the original
client, which puts summons in scope as players and gates both health bars on the monster kind,
but the value sent was wrong regardless.

The fix

One lookup in MonsterAttributeHolder.GetValueOfAttribute, placed after the per-instance
_attributes dictionary and before the per-definition _statAttributes:

if (attributeDefinition == Stats.MaximumHealth
    && this._monster.SpawnArea.MaximumHealthOverride is { } maximumHealthOverride)
{
    return maximumHealthOverride;
}

Why here:

  • The shared per-definition cache stays untouched, so ApplyChanges (configuration reload on a
    running server) keeps working and no monster definition gets polluted with instance state.
  • The per-instance composable attributes are still resolved first, so MonsterAttributeScaler
    and CastleSiegeAttackableNpc, which compose elements onto Stats.MaximumHealth, keep
    precedence and their behaviour is unchanged.
  • Initialize becomes self-consistent: whatever it used as the starting health is also what the
    attribute system reports as the maximum.

Alternatives considered and rejected: writing the override into the stat dictionary (would
either mutate the shared cache or force a per-instance copy for every spawned npc), and mapping
it in StatMapping (that dictionary is only consulted after _statAttributes, which already
contains MaximumHealth from the definition).

Testing

  • Built the server image from this branch and ran it against a fresh Season 6 seed
    (PostgreSQL, default configuration, three game servers).
  • Verified in the original client: started Blood Castle with /startbc, entered with an
    Invisibility Cloak +8, and the Castle Gate and Statue of Saint now show a full health bar when
    they become attackable, decreasing proportionally to the damage dealt. Regular monsters on the
    map are unchanged.
  • Not tested: castle siege npcs (no spawn override in the stock data, and their composed
    MaximumHealth element takes precedence by design), and the summon path, which cannot be
    observed in the original client because summons never get a health bar there.
  • No automated test added; there is no existing test fixture for MonsterAttributeHolder.

…te system

An npc whose spawn area defines a MaximumHealthOverride starts with that
health (AttackableNpcBase.Initialize), but MonsterAttributeHolder kept
resolving Stats.MaximumHealth from the monster definition. Every
percentage-based readout therefore divided the real health by an
unrelated maximum.

Most visible in Blood Castle: the castle gate and the statue of saint
have 150k - 650k health while their definitions say 5 million, so the
health status byte of the extended hit packet came out as 8 - 33 of 250
and both showed a nearly empty bar at full health. Summoned monsters are
affected the other way around - their override exceeds the definition
value, so the ratio is greater than 1 and the byte cast wraps around.

Returning the override from GetValueOfAttribute keeps the per-definition
attribute cache untouched, so ApplyChanges stays valid, and leaves the
per-instance composable attributes (MonsterAttributeScaler, castle siege
npcs) in charge, because they are resolved first.
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