ModernUO/Projects/UOContent/Engines/Factions/Core/FactionState.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

315 lines
8.1 KiB
C#

using System;
using System.Collections.Generic;
namespace Server.Factions;
public class FactionState
{
private const int BroadcastsPerPeriod = 2;
private static readonly TimeSpan BroadcastPeriod = TimeSpan.FromHours(1.0);
private readonly Faction m_Faction;
private readonly DateTime[] m_LastBroadcasts = new DateTime[BroadcastsPerPeriod];
private Mobile m_Commander;
public FactionState(Faction faction)
{
m_Faction = faction;
Tithe = 50;
Members = [];
Election = new Election(faction);
FactionItems = [];
Traps = [];
}
public FactionState(IGenericReader reader)
{
var version = reader.ReadEncodedInt();
switch (version)
{
case 5:
{
LastAtrophy = reader.ReadDateTime();
goto case 4;
}
case 4:
{
var count = reader.ReadEncodedInt();
for (var i = 0; i < count; ++i)
{
var time = reader.ReadDateTime();
if (i < m_LastBroadcasts.Length)
{
m_LastBroadcasts[i] = time;
}
}
goto case 3;
}
case 3:
case 2:
case 1:
{
Election = new Election(reader);
goto case 0;
}
case 0:
{
m_Faction = Faction.ReadReference(reader);
m_Commander = reader.ReadEntity<Mobile>();
if (version < 5)
{
LastAtrophy = Core.Now;
}
if (version < 4)
{
var time = reader.ReadDateTime();
if (m_LastBroadcasts.Length > 0)
{
m_LastBroadcasts[0] = time;
}
}
Tithe = reader.ReadEncodedInt();
Silver = reader.ReadEncodedInt();
var memberCount = reader.ReadEncodedInt();
Members = [];
for (var i = 0; i < memberCount; ++i)
{
var pl = new PlayerState(reader, m_Faction, Members);
if (pl.Mobile != null)
{
Members.Add(pl);
}
}
m_Faction.State = this;
m_Faction.ZeroRankOffset = Members.Count;
Members.Sort();
for (var i = Members.Count - 1; i >= 0; i--)
{
var player = Members[i];
if (player.KillPoints <= 0)
{
m_Faction.ZeroRankOffset = i;
}
else
{
player.RankIndex = i;
}
}
// The loop above only assigns RankIndex to members with kill points, and nothing
// computes rank on read, so rank everyone now that the ordering has settled.
foreach (var player in Members)
{
player.UpdateRank();
}
FactionItems = [];
if (version >= 2)
{
var factionItemCount = reader.ReadEncodedInt();
for (var i = 0; i < factionItemCount; ++i)
{
var factionItem = new FactionItem(reader, m_Faction);
Timer.StartTimer(factionItem.CheckAttach); // sandbox attachment
}
}
Traps = [];
if (version >= 3)
{
var factionTrapCount = reader.ReadEncodedInt();
for (var i = 0; i < factionTrapCount; ++i)
{
if (reader.ReadEntity<Item>() is BaseFactionTrap trap && !trap.CheckDecay())
{
Traps.Add(trap);
}
}
}
break;
}
}
if (version < 1)
{
Election = new Election(m_Faction);
}
}
public DateTime LastAtrophy { get; set; }
public bool FactionMessageReady
{
get
{
for (var i = 0; i < m_LastBroadcasts.Length; ++i)
{
if (Core.Now >= m_LastBroadcasts[i] + BroadcastPeriod)
{
return true;
}
}
return false;
}
}
public bool IsAtrophyReady => Core.Now >= LastAtrophy + TimeSpan.FromHours(47.0);
public List<FactionItem> FactionItems { get; set; }
public List<BaseFactionTrap> Traps { get; set; }
public Election Election { get; set; }
public Mobile Commander
{
get => m_Commander;
set
{
m_Commander?.InvalidateProperties();
m_Commander = value;
if (m_Commander != null)
{
m_Commander.SendLocalizedMessage(1042227); // You have been elected Commander of your faction
m_Commander.InvalidateProperties();
var pl = PlayerState.Find(m_Commander);
if (pl?.Finance != null)
{
pl.Finance.Finance = null;
}
if (pl?.Sheriff != null)
{
pl.Sheriff.Sheriff = null;
}
}
}
}
public int Tithe { get; set; }
public int Silver { get; set; }
public List<PlayerState> Members { get; set; }
public int CheckAtrophy()
{
if (Core.Now < LastAtrophy + TimeSpan.FromHours(47.0))
{
return 0;
}
var distrib = 0;
LastAtrophy = Core.Now;
var members = new List<PlayerState>(Members);
for (var i = 0; i < members.Count; ++i)
{
var ps = members[i];
if (ps.IsActive)
{
ps.IsActive = false;
continue;
}
if (ps.KillPoints > 0)
{
var atrophy = (ps.KillPoints + 9) / 10;
ps.KillPoints -= atrophy;
distrib += atrophy;
}
}
return distrib;
}
public void RegisterBroadcast()
{
for (var i = 0; i < m_LastBroadcasts.Length; ++i)
{
if (Core.Now >= m_LastBroadcasts[i] + BroadcastPeriod)
{
m_LastBroadcasts[i] = Core.Now;
break;
}
}
}
public void Serialize(IGenericWriter writer)
{
writer.WriteEncodedInt(5); // version
writer.Write(LastAtrophy);
writer.WriteEncodedInt(m_LastBroadcasts.Length);
for (var i = 0; i < m_LastBroadcasts.Length; ++i)
{
writer.Write(m_LastBroadcasts[i]);
}
Election.Serialize(writer);
Faction.WriteReference(writer, m_Faction);
writer.Write(m_Commander);
writer.WriteEncodedInt(Tithe);
writer.WriteEncodedInt(Silver);
writer.WriteEncodedInt(Members.Count);
for (var i = 0; i < Members.Count; ++i)
{
var pl = Members[i];
pl.Serialize(writer);
}
writer.WriteEncodedInt(FactionItems.Count);
for (var i = 0; i < FactionItems.Count; ++i)
{
FactionItems[i].Serialize(writer);
}
writer.WriteEncodedInt(Traps.Count);
for (var i = 0; i < Traps.Count; ++i)
{
writer.Write(Traps[i]);
}
}
}