refactor(opl): Consolidate AOS attribute OPL emission into per-family GetProperties (#2501)

## Summary

Consolidates the duplicated inline AOS attribute → `ObjectPropertyList` emission that each item base copy-pastes into per-family `GetProperties(IPropertyList)` methods, mirroring the existing `AosSkillBonuses.GetProperties` precedent.

### Per-family `GetProperties(IPropertyList)`
- **`AosAttributes`** — the 24 common attributes in canonical cliloc-ascending order, with optional `damageBonus` / `hitChanceBonus` / `luckBonus` params so item-computed bonuses (e.g. `GetDamageBonus()`) stay out of the family type.
- **`AosWeaponAttributes`** — `UseBestSkill`, the `Hit*` block (1060416–1060430), `MageWeapon` (`30 - prop`), `SelfRepair`.
- **`AosArmorAttributes`** — `MageArmor`, `SelfRepair` (the always-direct members; `LowerStatReq`/`DurabilityBonus` stay inline since they're item-computed in armor but container-direct in clothing).

### Rewired all 6 `AosAttributes`-emitting item bases
`BaseJewel`, `BaseArmor`, `BaseClothing`, `BaseWeapon`, `BaseTalisman`, `Spellbook` now call the family methods instead of inlining the chain. Net: large dedup in `BaseWeapon`/`BaseArmor`/`BaseClothing`/`BaseTalisman`/`Spellbook`.

## Behavior change: tooltip line **order** (set preserved)

This is **not** a pure no-op refactor, and that's unavoidable. Today the families are emitted **interleaved in cliloc order**, and the relative order differs per item class — e.g. `BonusDex` (1060409) is emitted early in `BaseArmor` but **after** the `Hit*` block in `BaseWeapon`. No single emission order reproduces every class byte-for-byte, so consolidating into contiguous per-family blocks necessarily **de-interleaves**: lines regroup **specific → general** (family-specific, then common `AosAttributes`).

- The **set** of emitted `(cliloc, argument)` lines per item is preserved **exactly** — nothing dropped, added, or value-changed.
- Only the **order** of lines within a tooltip changes for `BaseArmor` / `BaseWeapon` / `BaseClothing`. `BaseJewel` / `BaseTalisman` / `Spellbook` were already canonical, so those are byte-identical.

## Tests
- **Golden set-invariance tests** per item base (`BaseArmor/Clothing/Jewel/Weapon/Talisman/Spellbook PropertiesTests`) — each was written to pass against current `main` **before** the rewire (locking the emitted-line set), then confirmed still passing after, proving no line is lost/added/changed.
- Family-level unit tests for each `GetProperties` (canonical order, computed-bonus folding, the `AosArmorAttributes` exclusions).
- `dotnet build` clean; full `UOContent.Tests` green. (Pre-existing `AccountPacket`/`GumpPacket`/`MobilePacket`/`ClientEnumerator` golden-test failures reproduce on unmodified `main` and are unrelated to this change.)
This commit is contained in:
Kamron Batman 2026-07-02 19:40:54 -07:00 committed by GitHub
parent 0502f98050
commit f7c44f7c10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 779 additions and 842 deletions

View file

@ -0,0 +1,71 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using Server;
using Server.Items;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class AosArmorAttributesPropertiesTests
{
private static Dictionary<int, string> Decode(ObjectPropertyList opl)
{
opl.Terminate();
var buffer = opl.Buffer;
var map = new Dictionary<int, string>();
var pos = 15;
while (true)
{
var cliloc = BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(pos));
pos += 4;
if (cliloc == 0)
{
break;
}
var byteLen = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(pos));
pos += 2;
map[cliloc] = Encoding.Unicode.GetString(buffer, pos, byteLen);
pos += byteLen;
}
return map;
}
[Fact]
public void EmitsMageArmorAndSelfRepair_DoesNotReadLowerStatReqOrDurabilityFromContainer()
{
var attrs = new AosArmorAttributes(null)
{
MageArmor = 1,
SelfRepair = 4,
LowerStatReq = 50, // container value must NOT be auto-emitted (it's passed in by the consumer)
DurabilityBonus = 10 // never emitted by this method
};
var opl = new ObjectPropertyList(null);
attrs.GetProperties(opl); // no lowerStatReq arg
var map = Decode(opl);
Assert.Equal("", map[1060437]); // MageArmor (no-arg)
Assert.Equal("4", map[1060450]); // SelfRepair
Assert.False(map.ContainsKey(1060435)); // LowerStatReq NOT read from container
Assert.False(map.ContainsKey(1060410)); // DurabilityBonus excluded
}
[Fact]
public void EmitsLowerStatReqWhenPassed()
{
var attrs = new AosArmorAttributes(null) { MageArmor = 1, LowerStatReq = 50 };
var opl = new ObjectPropertyList(null);
attrs.GetProperties(opl, lowerStatReq: 77); // computed value passed by the consumer, not the raw 50
var map = Decode(opl);
Assert.Equal("77", map[1060435]); // emitted from the param, not the container's 50
Assert.Equal("", map[1060437]); // MageArmor still emitted
}
}

View file

@ -0,0 +1,75 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using Server;
using Server.Items;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class AosAttributesPropertiesTests
{
private static Dictionary<int, string> Decode(ObjectPropertyList opl)
{
opl.Terminate();
var buffer = opl.Buffer;
var map = new Dictionary<int, string>();
var pos = 15;
while (true)
{
var cliloc = BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(pos));
pos += 4;
if (cliloc == 0)
{
break;
}
var byteLen = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(pos));
pos += 2;
map[cliloc] = Encoding.Unicode.GetString(buffer, pos, byteLen);
pos += byteLen;
}
return map;
}
[Fact]
public void EmitsRawAttributesInCanonicalOrder()
{
var attrs = new AosAttributes(null)
{
DefendChance = 5,
BonusStr = 10,
NightSight = 1,
SpellChanneling = 1,
WeaponSpeed = 7
};
var opl = new ObjectPropertyList(null);
attrs.GetProperties(opl);
var map = Decode(opl);
Assert.Equal("5", map[1060408]); // DefendChance
Assert.Equal("10", map[1060485]); // BonusStr
Assert.Equal("", map[1060441]); // NightSight (no-arg)
Assert.Equal("", map[1060482]); // SpellChanneling (no-arg)
Assert.Equal("7", map[1060486]); // WeaponSpeed
Assert.False(map.ContainsKey(1060401)); // WeaponDamage not set
}
[Fact]
public void AppliesComputedBonuses()
{
var attrs = new AosAttributes(null) { WeaponDamage = 10, AttackChance = 4, Luck = 100 };
var opl = new ObjectPropertyList(null);
attrs.GetProperties(opl, damageBonus: 5, hitChanceBonus: 3, luckBonus: 50);
var map = Decode(opl);
Assert.Equal("15", map[1060401]); // WeaponDamage + damageBonus
Assert.Equal("7", map[1060415]); // AttackChance + hitChanceBonus
Assert.Equal("150", map[1060436]); // Luck + luckBonus
}
}

View file

@ -0,0 +1,74 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using Server;
using Server.Items;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class AosWeaponAttributesPropertiesTests
{
private static Dictionary<int, string> Decode(ObjectPropertyList opl)
{
opl.Terminate();
var buffer = opl.Buffer;
var map = new Dictionary<int, string>();
var pos = 15;
while (true)
{
var cliloc = BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(pos));
pos += 4;
if (cliloc == 0)
{
break;
}
var byteLen = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(pos));
pos += 2;
map[cliloc] = Encoding.Unicode.GetString(buffer, pos, byteLen);
pos += byteLen;
}
return map;
}
[Fact]
public void EmitsHitEffectsUseBestSkillMageWeaponSelfRepair()
{
var attrs = new AosWeaponAttributes(null)
{
UseBestSkill = 1,
HitFireball = 12,
HitLeechHits = 20,
MageWeapon = 25,
SelfRepair = 3
};
var opl = new ObjectPropertyList(null);
attrs.GetProperties(opl);
var map = Decode(opl);
Assert.Equal("", map[1060400]); // UseBestSkill (no-arg)
Assert.Equal("12", map[1060420]); // HitFireball
Assert.Equal("20", map[1060422]); // HitLeechHits
Assert.Equal("5", map[1060438]); // MageWeapon => 30 - 25
Assert.Equal("3", map[1060450]); // SelfRepair
Assert.False(map.ContainsKey(1060435)); // LowerStatReq not emitted without the param
}
[Fact]
public void EmitsLowerStatReqWhenPassed()
{
var attrs = new AosWeaponAttributes(null) { MageWeapon = 25 };
var opl = new ObjectPropertyList(null);
attrs.GetProperties(opl, lowerStatReq: 40); // computed value passed by the weapon
var map = Decode(opl);
Assert.Equal("40", map[1060435]); // lower requirements, in cliloc order before MageWeapon
Assert.Equal("5", map[1060438]); // MageWeapon still emitted
}
}

View file

@ -0,0 +1,40 @@
using Server.Items;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class BaseArmorPropertiesTests
{
[Fact]
public void Armor_AttributeLineSet_Preserved()
{
var armor = new PlateChest();
try
{
armor.Attributes.DefendChance = 5;
armor.Attributes.BonusDex = 8;
armor.Attributes.Luck = 40;
armor.Attributes.SpellChanneling = 1;
armor.Attributes.IncreasedKarmaLoss = 2;
armor.ArmorAttributes.MageArmor = 1;
armor.ArmorAttributes.SelfRepair = 3;
armor.ArmorAttributes.LowerStatReq = 50;
var map = ItemOplTestHelper.DecodeAttributeLines(armor);
Assert.Equal("5", map[1060408]); // DefendChance
Assert.Equal("8", map[1060409]); // BonusDex
Assert.Equal("40", map[1060436]); // Luck (GetLuckBonus()==0 unequipped)
Assert.Equal("", map[1060482]); // SpellChanneling
Assert.Equal("2", map[1075210]); // IncreasedKarmaLoss
Assert.Equal("", map[1060437]); // MageArmor
Assert.Equal("3", map[1060450]); // SelfRepair
Assert.Equal("50", map[1060435]); // LowerStatReq (inline via GetLowerStatReq)
}
finally
{
armor.Delete();
}
}
}

View file

@ -0,0 +1,38 @@
using Server.Items;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class BaseClothingPropertiesTests
{
[Fact]
public void Clothing_AttributeLineSet_Preserved()
{
var shirt = new FancyShirt();
try
{
shirt.Attributes.DefendChance = 5;
shirt.Attributes.Luck = 25;
shirt.Attributes.SpellChanneling = 1;
shirt.ClothingAttributes.MageArmor = 1;
shirt.ClothingAttributes.SelfRepair = 2;
shirt.ClothingAttributes.LowerStatReq = 30;
shirt.ClothingAttributes.DurabilityBonus = 15;
var map = ItemOplTestHelper.DecodeAttributeLines(shirt);
Assert.Equal("5", map[1060408]); // DefendChance
Assert.Equal("25", map[1060436]); // Luck (raw)
Assert.Equal("", map[1060482]); // SpellChanneling
Assert.Equal("", map[1060437]); // MageArmor
Assert.Equal("2", map[1060450]); // SelfRepair
Assert.Equal("30", map[1060435]); // LowerStatReq (direct)
Assert.Equal("15", map[1060410]); // DurabilityBonus (direct)
}
finally
{
shirt.Delete();
}
}
}

View file

@ -0,0 +1,36 @@
using Server.Items;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class BaseJewelPropertiesTests
{
[Fact]
public void Jewel_AttributeLineSet_Preserved()
{
var ring = new GoldRing();
try
{
ring.Attributes.DefendChance = 5;
ring.Attributes.BonusStr = 10;
ring.Attributes.Luck = 100;
ring.Attributes.NightSight = 1;
ring.Attributes.SpellChanneling = 1;
ring.Attributes.IncreasedKarmaLoss = 3;
var map = ItemOplTestHelper.DecodeAttributeLines(ring);
Assert.Equal("5", map[1060408]); // DefendChance
Assert.Equal("10", map[1060485]); // BonusStr
Assert.Equal("100", map[1060436]); // Luck (raw; jewel has no luck bonus)
Assert.Equal("", map[1060441]); // NightSight
Assert.Equal("", map[1060482]); // SpellChanneling
Assert.Equal("3", map[1075210]); // IncreasedKarmaLoss (Core.ML EJ)
}
finally
{
ring.Delete();
}
}
}

View file

@ -0,0 +1,42 @@
using Server.Items;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class BaseTalismanPropertiesTests
{
[Fact]
public void GetProperties_EmitsAosAttributeLines()
{
var item = new RandomTalisman();
try
{
item.Attributes.DefendChance = 10;
item.Attributes.BonusStr = 5;
item.Attributes.Luck = 50;
item.Attributes.NightSight = 1;
item.Attributes.SpellChanneling = 1;
item.Attributes.IncreasedKarmaLoss = 2;
var lines = ItemOplTestHelper.DecodeAttributeLines(item);
Assert.True(lines.ContainsKey(1060408)); // DefendChance
Assert.Equal("10", lines[1060408]);
Assert.True(lines.ContainsKey(1060485)); // BonusStr
Assert.Equal("5", lines[1060485]);
Assert.True(lines.ContainsKey(1060436)); // Luck
Assert.Equal("50", lines[1060436]);
Assert.True(lines.ContainsKey(1060441)); // NightSight
Assert.Equal("", lines[1060441]);
Assert.True(lines.ContainsKey(1060482)); // SpellChanneling
Assert.Equal("", lines[1060482]);
Assert.True(lines.ContainsKey(1075210)); // IncreasedKarmaLoss
Assert.Equal("2", lines[1075210]);
}
finally
{
item.Delete();
}
}
}

View file

@ -0,0 +1,38 @@
using Server.Items;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class BaseWeaponPropertiesTests
{
[Fact]
public void Weapon_AttributeLineSet_Preserved()
{
var weapon = new Longsword();
try
{
weapon.Attributes.DefendChance = 5;
weapon.Attributes.BonusInt = 6;
weapon.Attributes.SpellChanneling = 1;
weapon.WeaponAttributes.UseBestSkill = 1;
weapon.WeaponAttributes.HitFireball = 12;
weapon.WeaponAttributes.MageWeapon = 25;
weapon.WeaponAttributes.SelfRepair = 3;
var map = ItemOplTestHelper.DecodeAttributeLines(weapon);
Assert.Equal("5", map[1060408]); // DefendChance
Assert.Equal("6", map[1060432]); // BonusInt
Assert.Equal("", map[1060482]); // SpellChanneling
Assert.Equal("", map[1060400]); // UseBestSkill
Assert.Equal("12", map[1060420]); // HitFireball
Assert.Equal("5", map[1060438]); // MageWeapon (30 - 25)
Assert.Equal("3", map[1060450]); // SelfRepair
}
finally
{
weapon.Delete();
}
}
}

View file

@ -0,0 +1,44 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Text;
using Server;
namespace UOContent.Tests;
public static class ItemOplTestHelper
{
// Builds the item's OPL and returns attribute/property cliloc lines (>= 1060000),
// ignoring base-item lines (name, weight, etc.) so tests isolate the attribute surface.
public static Dictionary<int, string> DecodeAttributeLines(Item item)
{
var opl = new ObjectPropertyList(item);
item.GetProperties(opl);
opl.Terminate();
var buffer = opl.Buffer;
var map = new Dictionary<int, string>();
var pos = 15;
while (true)
{
var cliloc = BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(pos));
pos += 4;
if (cliloc == 0)
{
break;
}
var byteLen = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(pos));
pos += 2;
var arg = Encoding.Unicode.GetString(buffer, pos, byteLen);
pos += byteLen;
if (cliloc is >= 1060000 and < 1080000)
{
map[cliloc] = arg;
}
}
return map;
}
}

View file

@ -0,0 +1,42 @@
using Server.Items;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class SpellbookPropertiesTests
{
[Fact]
public void GetProperties_EmitsAosAttributeLines()
{
var item = new Spellbook();
try
{
item.Attributes.CastRecovery = 3;
item.Attributes.LowerManaCost = 8;
item.Attributes.Luck = 75;
item.Attributes.NightSight = 1;
item.Attributes.SpellChanneling = 1;
item.Attributes.IncreasedKarmaLoss = 2;
var lines = ItemOplTestHelper.DecodeAttributeLines(item);
Assert.True(lines.ContainsKey(1060412)); // CastRecovery
Assert.Equal("3", lines[1060412]);
Assert.True(lines.ContainsKey(1060433)); // LowerManaCost
Assert.Equal("8", lines[1060433]);
Assert.True(lines.ContainsKey(1060436)); // Luck
Assert.Equal("75", lines[1060436]);
Assert.True(lines.ContainsKey(1060441)); // NightSight
Assert.Equal("", lines[1060441]);
Assert.True(lines.ContainsKey(1060482)); // SpellChanneling
Assert.Equal("", lines[1060482]);
Assert.True(lines.ContainsKey(1075210)); // IncreasedKarmaLoss
Assert.Equal("2", lines[1075210]);
}
finally
{
item.Delete();
}
}
}