ModernUO/Projects/UOContent/Engines/Factions/Core/PlayerState.cs
Kamron Batman ca338f023c fix(opl): refuse property list invalidation raised from inside GetProperties
Any property getter reached from GetProperties that calls InvalidateProperties
takes the tooltip build down with it:

  System.ArgumentNullException: Value cannot be null. (Parameter 'array')
    at Server.ObjectPropertyList.AppendStringDirect(String value)
    at Server.Mobiles.PlayerMobile.GetProperties(IPropertyList list)

InvalidateProperties rebuilds in place -- Reset() then GetProperties() again on
the same instance -- and Reset() does two destructive things to a build already
in flight. It returns the pooled interpolation scratch buffer, which the compiler
rents in the interpolated-string handler ctor and returns in the closing Add, so
every hole is evaluated while that buffer is live; the next Append* then spans a
null array. It also rewinds the packet cursor, so properties already written are
overwritten by the nested pass.

There is no correct recovery, and retrying the build would only hide the defect,
so the engine refuses: the nested call logs an error with a stack trace, throws
in DEBUG so it gets found and fixed, and in RELEASE returns without touching the
list -- a possibly stale tooltip, but no crash, no corrupted packet, and nothing
leaked back to the pool. Getters that genuinely must invalidate should defer with
Timer.DelayCall(InvalidateProperties).

The guard flag lives on the ObjectPropertyList rather than on the entity: it is
that list's own lifecycle, it costs nothing (both Item and ObjectPropertyList
absorb it in existing padding, and the list is allocated lazily), and it stays
correct when builds for different entities nest.

Factions PlayerState was the getter that surfaced this, and it is now maintained
rather than lazily computed:

- Rank is a plain field read. The lazy `if (m_InvalidateRank)` recompute is gone
  along with the flag itself; UpdateRank() recomputes at each point an input
  actually changes (the RankIndex setter, the end of the KillPoints setter once
  the swap bookkeeping and ZeroRankOffset have settled, Faction.AddMember after
  the member is inserted, and FactionState after a load once the ordering is
  final). All six readers of Rank were checked; none relied on the old side
  effect.
- Both constructors seed the lowest rank. Nothing recomputes on read any more, so
  Rank must be usable immediately -- including for members that never get a
  RankIndex assigned, which is every member with no kill points.
- Rank always resolves. Ranks are ordered by Required descending ending at 0, so
  a negative percent (RankIndex out of sync with ZeroRankOffset) matched nothing
  and left m_Rank null -- an NRE on Rank.Title. It no longer divides by a zero
  ZeroRankOffset either.
- Fixes a pre-existing staleness bug: the KillPoints setter writes m_RankIndex
  directly in two places, bypassing the property setter, so the cached rank was
  never refreshed when a player crossed zero kill points.

PropertyList also publishes the list into m_PropertyList before building it
rather than assigning through `??=` afterwards, so a nested InvalidateProperties
sees the build in progress instead of recursing into a second throwaway list.

ObjectPropertyList re-rents its scratch buffer instead of spanning a null array,
so a stray Reset() from any other caller degrades rather than aborting
GetProperties.

Documents the rule as audit rule 19 in CLAUDE.md, a new section in
dev-docs/property-lists.md, and the property-lists and code-audit skills.
2026-07-28 21:20:35 -07:00

339 lines
No EOL
8.8 KiB
C#

using System;
using System.Collections.Generic;
using Server.Mobiles;
namespace Server.Factions;
public class PlayerState : IComparable<PlayerState>
{
private Town m_Finance;
private int m_KillPoints;
private MerchantTitle m_MerchantTitle;
private RankDefinition m_Rank;
private int m_RankIndex = -1;
private Town m_Sheriff;
public PlayerState(Mobile mob, Faction faction, List<PlayerState> owner)
{
Mobile = mob;
Faction = faction;
Owner = owner;
// Owner does not contain this state yet, so the count is short by one; the caller ranks it
// after inserting.
SeedLowestRank();
Attach();
Invalidate();
}
public PlayerState(IGenericReader reader, Faction faction, List<PlayerState> owner)
{
Faction = faction;
Owner = owner;
var version = reader.ReadEncodedInt();
switch (version)
{
case 1:
{
IsActive = reader.ReadBool();
LastHonorTime = reader.ReadDateTime();
goto case 0;
}
case 0:
{
Mobile = reader.ReadEntity<Mobile>();
m_KillPoints = reader.ReadEncodedInt();
m_MerchantTitle = (MerchantTitle)reader.ReadEncodedInt();
Leaving = reader.ReadDateTime();
break;
}
}
// Members are still being read; FactionState ranks everyone once the ordering settles.
SeedLowestRank();
Attach();
}
public Mobile Mobile { get; }
public Faction Faction { get; }
public List<PlayerState> Owner { get; }
public MerchantTitle MerchantTitle
{
get => m_MerchantTitle;
set
{
m_MerchantTitle = value;
Invalidate();
}
}
public Town Sheriff
{
get => m_Sheriff;
set
{
m_Sheriff = value;
Invalidate();
}
}
public Town Finance
{
get => m_Finance;
set
{
m_Finance = value;
Invalidate();
}
}
public List<SilverGivenEntry> SilverGiven { get; private set; }
public int KillPoints
{
get => m_KillPoints;
set
{
if (m_KillPoints != value)
{
if (value > m_KillPoints)
{
if (m_KillPoints <= 0)
{
if (value <= 0)
{
m_KillPoints = value;
Invalidate();
return;
}
Owner.Remove(this);
Owner.Insert(Faction.ZeroRankOffset, this);
// Direct, not through RankIndex: ZeroRankOffset is mid-update. The
// UpdateRank() at the end of this setter covers it.
m_RankIndex = Faction.ZeroRankOffset;
Faction.ZeroRankOffset++;
}
while (m_RankIndex - 1 >= 0)
{
var p = Owner[m_RankIndex - 1];
if (value > p.KillPoints)
{
Owner[m_RankIndex] = p;
Owner[m_RankIndex - 1] = this;
RankIndex--;
p.RankIndex++;
}
else
{
break;
}
}
}
else
{
if (value <= 0)
{
if (m_KillPoints <= 0)
{
m_KillPoints = value;
Invalidate();
return;
}
while (m_RankIndex + 1 < Faction.ZeroRankOffset)
{
var p = Owner[m_RankIndex + 1];
Owner[m_RankIndex + 1] = this;
Owner[m_RankIndex] = p;
RankIndex++;
p.RankIndex--;
}
m_RankIndex = -1;
Faction.ZeroRankOffset--;
}
else
{
while (m_RankIndex + 1 < Faction.ZeroRankOffset)
{
var p = Owner[m_RankIndex + 1];
if (value < p.KillPoints)
{
Owner[m_RankIndex + 1] = this;
Owner[m_RankIndex] = p;
RankIndex++;
p.RankIndex--;
}
else
{
break;
}
}
}
}
m_KillPoints = value;
UpdateRank();
Invalidate();
}
}
}
public int RankIndex
{
get => m_RankIndex;
set
{
if (m_RankIndex != value)
{
m_RankIndex = value;
UpdateRank();
Invalidate();
}
}
}
/// <summary>
/// Read from PlayerMobile.GetProperties, so it must stay a plain field read -- recomputing or
/// invalidating here re-enters the property list build. Maintained by <see cref="UpdateRank"/>.
/// </summary>
public RankDefinition Rank => m_Rank;
// Lowest rank (Required 0): correct for an unranked member, and never null, so Rank.Title
// cannot NRE before the first UpdateRank().
private void SeedLowestRank()
{
var ranks = Faction.Definition.Ranks;
if (ranks.Length > 0)
{
m_Rank = ranks[^1];
}
}
/// <summary>
/// Recomputes the cached rank. Call whenever <see cref="RankIndex"/>, the faction's
/// ZeroRankOffset, or the member count changes -- and only once they have settled.
/// </summary>
public void UpdateRank()
{
var ranks = Faction.Definition.Ranks;
if (ranks.Length == 0)
{
return;
}
int percent;
if (Owner.Count == 1)
{
percent = 1000;
}
else if (m_RankIndex == -1 || Faction.ZeroRankOffset <= 0)
{
percent = 0;
}
else
{
percent = (Faction.ZeroRankOffset - m_RankIndex) * 1000 / Faction.ZeroRankOffset;
}
// Ranks run Required-descending ending at 0, so anything >= 0 matches below. A negative
// percent (RankIndex out of sync with ZeroRankOffset) would otherwise leave it null.
m_Rank = ranks[^1];
for (var i = 0; i < ranks.Length; i++)
{
var check = ranks[i];
if (percent >= check.Required)
{
m_Rank = check;
break;
}
}
}
public DateTime LastHonorTime { get; set; }
public DateTime Leaving { get; set; }
public bool IsLeaving => Leaving > DateTime.MinValue;
public bool IsActive { get; set; }
public int CompareTo(PlayerState ps) => (ps?.m_KillPoints ?? 0) - m_KillPoints;
public bool CanGiveSilverTo(Mobile mob)
{
for (var i = 0; i < SilverGiven?.Count; ++i)
{
var sge = SilverGiven[i];
if (sge.IsExpired)
{
SilverGiven.RemoveAt(i--);
}
else if (sge.GivenTo == mob)
{
return false;
}
}
return true;
}
public void OnGivenSilverTo(Mobile mob)
{
SilverGiven ??= [];
SilverGiven.Add(new SilverGivenEntry(mob));
}
public void Invalidate()
{
(Mobile as PlayerMobile)?.InvalidateProperties();
}
public void Attach()
{
if (Mobile is PlayerMobile mobile)
{
mobile.FactionPlayerState = this;
}
}
public void Serialize(IGenericWriter writer)
{
writer.WriteEncodedInt(1); // version
writer.Write(IsActive);
writer.Write(LastHonorTime);
writer.Write(Mobile);
writer.WriteEncodedInt(m_KillPoints);
writer.WriteEncodedInt((int)m_MerchantTitle);
writer.Write(Leaving);
}
public static PlayerState Find(Mobile mob) => mob is PlayerMobile mobile ? mobile.FactionPlayerState : null;
}