ModernUO/Projects/Server.Tests/Tests/Network/Packets/Outgoing/ContainerPacketTests.cs
Kamron Batman 89a86d81b4
feat: scope container child removal, skip-serialize and linearly delete container children (#2676)
## Summary

Removing an item from a container now reaches only the clients that were sent the item. This PR also adds container-scoped skip serialization, makes container deletion linear, and closes a lift-rejection resync gap. It is the groundwork for the NPC vendor buyback fix (#2677), and it is safe to merge by itself.

### Container child removal reaches only the clients that were sent the item

Adds and updates for an item inside a private container have always gone only to the root mobile, the secure-trade partner, and the container's `Openers` (`ProcessDelta`, the same as RunUO). Removes, though, were broadcast to every client in range. That too is inherited from RunUO; #326 only converted the packets. Two costs:
- **Bandwidth.** One packet per nearby client for every reagent, bandage or arrow stack that runs out, and every container-to-container move.
- **Disclosure.** It sent the serial and timing of items moved in someone else's pack to every nearby client.

Now:
- **`Item.SendRemovePacket`:** a private container child's remove goes only to the root mobile, both secure-trade parties (found by walking up to the trade container, as `ProcessDelta` does), and the container's `Openers`. Each recipient is deduplicated and must be on the same map and in range.
- **`Container.IsChildPublic(Item child)`** (default `IsPublicContainer`) marks children that were sent to every client in range. Their removal still broadcasts. Overrides can only widen: a public container's children always broadcast.
- **`Corpse`** marks its worn gear public on human corpses. `SendInfoTo` shows that gear to everyone who sees the corpse, so bystanders still see looted gear disappear. Loot stays private, and corpses are not made public.
- **`IsPublicContainer` is unchanged** and remains the escape hatch.
- **Unchanged:** items equipped on mobiles, children of non-container items (spellbooks, bulletin boards), and ground items.
- **`Item.Map` no longer sends removes for contained items.** A contained item only changes map along with its parent or root, and that ancestor's own remove already clears the subtree for everyone who knew it. The owner keeps their nested bags across a facet change, as before, and bystanders no longer get a remove per nested item.

**For forks:** a custom container that sends its contents to clients other than the root, trade parties and openers must override `IsChildPublic` or `IsPublicContainer`. Otherwise those clients keep the item until they reopen the container, it leaves their range, or they log out.

- **`Item.MoveToWorld` no longer sends a second remove for an item that had a parent.** It used to detach through the parent's `RemoveItem` (correctly scoped), then broadcast another remove from the old location. Every lift goes through `Internalize()`, so every lift from a pack or container broadcast the item's serial. Ground items are unchanged.
- **Property-only updates** (`ItemDelta.Properties` without `Update`) of a private child now send the object property list revision only to the same recipients, not every client in range. Gump tooltips that reference such an item without the client holding it no longer get the revision nudge; their cached tooltip refreshes when re-requested.

### Lift rejection resyncs only items the requester was sent

A rejected lift re-sent the item's full data to the requester without checking that their client had ever been sent it. `Item.IsSentTo(Mobile)` now applies the same rule as the adds, including `CanSee`:
- **Private container children:** the root mobile, the trade parties, or the openers.
- **Everything else:** visible and in update range.

`Mobile.Lift` still always sends the lift-reject packet, but resyncs the item only when `IsSentTo` holds.

### Container content packets stay within the protocol limit

`SendContainerContent` stack-allocated `items.Count × 20` bytes, which overflowed the stack for a container with hundreds of thousands of items. The `0x3C` length and count fields are 16-bit anyway. It now caps entries so the packet fits (3448 entries, or 3276 with grid lines), and rents a pooled buffer above a small size. Output is byte-identical for normal containers.

### Container-scoped skip serialization

**`Container.SkipsChildSerialization`** (default `false`). When a container returns true:
- its direct children are left out of world saves (the base `Item.SkipSerialization` checks the parent);
- it writes no child list, so a skipped child's serial can never resolve to an unrelated item at load;
- an item that loads naming such a container as its parent is deleted by the existing missing-parent path.

**Known limitation:** `SkipSerialization` is read while the save files are written, after the world unfreezes. An item that enters an opted-in container in that window can be left out of that save. The next save is correct. Moving the decision into the freeze is a follow-up.

### Linear container deletion

`Item.Delete` removes children back to front, but `RemoveItem` searched from the front with `List.Remove`, so deleting a container of *n* items was O(n²). `RemoveItem` now checks the last entry first.
- 100,000 children took 8.0s before and 162ms after.
- Other removals cost the same as before.

**Save format unchanged for every existing type** (the schema generator produces no diff).

## Test plan

- [x] `ContainerChildRemovalTests`:
  - a private child's removal isn't broadcast;
  - openers, the root owner's client and a trade party (who never opened the pouch) still receive it;
  - public containers and per-child `IsChildPublic` overrides still broadcast;
  - an override can't narrow a public container;
  - no duplicate packets when root and opener overlap;
  - deleting the owner sends no remove per child;
  - a facet change sends no remove for nested children to the owner or bystanders;
  - lifting from a backpack or moving a contained item to the ground sends no remove to bystanders;
  - equipped items and ground items keep their broadcast.
- [x] `PrivateChildPropertiesTests`: a property-only update of a private child reaches the owner and openers, not bystanders; public-container children and ground items still broadcast.
- [x] `CorpseChildPublicTests`: human corpse worn gear is public and loot isn't, bones and non-human bodies aren't; end to end, worn gear leaving the corpse reaches a bystander and deleted loot doesn't.
- [x] `LiftRejectResyncTests`:
  - a bystander and an out-of-range requester get no resync;
  - the owner, an opener and a trade party do;
  - an invisible item isn't resynced to a non-staff owner, but is to staff;
  - equipment on a hidden mobile and an invisible ground item aren't resynced.
- [x] `ContainerPacketTests`: large containers are capped at the protocol limit (with and without grid lines) and the pooled-buffer path is byte-identical.
- [x] `ContainerChildSkipTests`, `ContainerBulkRemovalTests`.
- [x] Server.Tests (944) and UOContent.Tests (1203) green on current `main`; schema generator clean.
- [x] Manual in-game pass with three clients:
  - consumption;
  - snooping;
  - co-opened containers;
  - bank;
  - trade with and without opening the pouch;
  - facet change with nested bags;
  - pack animals;
  - player vendors;
  - human corpse looting by drag and by script;
  - every lift-rejection reason.

  Verified with a ClassicUO build instrumented to count:
  - removes for unknown serials;
  - adds dropped for an unknown parent;
  - items learned inside containers the client never opened;
  - lift resyncs of items the client never had.
2026-10-09 22:21:50 -07:00

331 lines
9.9 KiB
C#

using System.Buffers.Binary;
using Server.Items;
using Server.Network;
using Xunit;
namespace Server.Tests.Network;
[Collection("Sequential Server Tests")]
public class ContainerPacketTests
{
[Fact]
public void TestContainerDisplay()
{
var serial = (Serial)0x1024;
ushort gumpId = 100;
var expected = new ContainerDisplay(serial, gumpId).Compile();
using var ns = PacketTestUtilities.CreateTestNetState();
ns.SendDisplayContainer(serial, gumpId);
var result = ns.SendBuffer.GetReadSpan();
AssertThat.Equal(result, expected);
}
[Fact]
public void TestContainerDisplayHS()
{
var serial = (Serial)0x1024;
ushort gumpId = 100;
var expected = new ContainerDisplayHS(serial, gumpId).Compile();
using var ns = PacketTestUtilities.CreateTestNetState();
ns.ProtocolChanges = ns.ProtocolChanges | ProtocolChanges.ContainerGridLines | ProtocolChanges.HighSeas;
ns.SendDisplayContainer(serial, gumpId);
var result = ns.SendBuffer.GetReadSpan();
AssertThat.Equal(result, expected);
}
[Fact]
public void TestDisplaySpellbook()
{
var serial = (Serial)0x1024;
var expected = new DisplaySpellbook(serial).Compile();
using var ns = PacketTestUtilities.CreateTestNetState();
ns.SendDisplaySpellbook(serial);
var result = ns.SendBuffer.GetReadSpan();
AssertThat.Equal(result, expected);
}
[Fact]
public void TestDisplaySpellbookHS()
{
var serial = (Serial)0x1024;
var expected = new DisplaySpellbookHS(serial).Compile();
using var ns = PacketTestUtilities.CreateTestNetState();
ns.ProtocolChanges = ns.ProtocolChanges | ProtocolChanges.ContainerGridLines | ProtocolChanges.HighSeas;
ns.SendDisplaySpellbook(serial);
var result = ns.SendBuffer.GetReadSpan();
AssertThat.Equal(result, expected);
}
[Fact]
public void TestNewSpellbookContent()
{
var serial = (Serial)0x1024;
ushort graphic = 100;
ushort offset = 10;
ulong content = 0x123456789ABCDEF0;
var opl = ObjectPropertyList.Enabled;
var expected = new NewSpellbookContent(serial, graphic, offset, content).Compile();
using var ns = PacketTestUtilities.CreateTestNetState();
ns.ProtocolChanges = ns.ProtocolChanges | ProtocolChanges.ContainerGridLines | ProtocolChanges.NewSpellbook;
ObjectPropertyList.Enabled = true;
ns.SendSpellbookContent(serial, graphic, offset, content);
ObjectPropertyList.Enabled = opl;
var result = ns.SendBuffer.GetReadSpan();
AssertThat.Equal(result, expected);
}
[Fact]
public void TestSpellbookContent()
{
var serial = (Serial)0x1024;
ushort offset = 10;
ushort graphic = 100;
ulong content = 0x123456789ABCDEF0;
var expected = new SpellbookContent(serial, offset, content).Compile();
using var ns = PacketTestUtilities.CreateTestNetState();
ns.SendSpellbookContent(serial, graphic, offset, content);
var result = ns.SendBuffer.GetReadSpan();
AssertThat.Equal(result, expected);
}
[Fact]
public void TestSpellbookContent6017()
{
var serial = (Serial)0x1024;
ushort offset = 10;
ushort graphic = 100;
ulong content = 0x123456789ABCDEF0;
var expected = new SpellbookContent6017(serial, offset, content).Compile();
using var ns = PacketTestUtilities.CreateTestNetState();
ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines;
ns.SendSpellbookContent(serial, graphic, offset, content);
var result = ns.SendBuffer.GetReadSpan();
AssertThat.Equal(result, expected);
}
[Fact]
public void TestContainerContentUpdate()
{
var serial = (Serial)0x1024;
var item = new Item(serial);
var expected = new ContainerContentUpdate(item).Compile();
using var ns = PacketTestUtilities.CreateTestNetState();
ns.SendContainerContentUpdate(item);
var result = ns.SendBuffer.GetReadSpan();
AssertThat.Equal(result, expected);
}
[Fact]
public void TestContainerContentUpdate6017()
{
var serial = (Serial)0x1024;
var item = new Item(serial);
var expected = new ContainerContentUpdate6017(item).Compile();
using var ns = PacketTestUtilities.CreateTestNetState();
ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines;
ns.SendContainerContentUpdate(item);
var result = ns.SendBuffer.GetReadSpan();
AssertThat.Equal(result, expected);
}
[Fact]
public void TestContainerContent()
{
var cont = new Container(World.NewItem);
cont.AddItem(new Item(World.NewItem));
cont.Map = Map.Felucca;
var m = new Mobile((Serial)0x1);
m.DefaultMobileInit();
m.AccessLevel = AccessLevel.Administrator;
m.Map = Map.Felucca;
var expected = new ContainerContent(m, cont).Compile();
using var ns = PacketTestUtilities.CreateTestNetState();
ns.SendContainerContent(m, cont);
var result = ns.SendBuffer.GetReadSpan();
AssertThat.Equal(result, expected);
}
[Fact]
public void TestContainerContent6017()
{
var cont = new Container(World.NewItem);
cont.AddItem(new Item(World.NewItem));
cont.Map = Map.Felucca;
var m = new Mobile((Serial)0x1);
m.DefaultMobileInit();
m.AccessLevel = AccessLevel.Administrator;
m.Map = Map.Felucca;
var expected = new ContainerContent6017(m, cont).Compile();
using var ns = PacketTestUtilities.CreateTestNetState();
ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines;
ns.SendContainerContent(m, cont);
var result = ns.SendBuffer.GetReadSpan();
AssertThat.Equal(result, expected);
}
[Fact]
public void TestContainerContent_CapsAtProtocolLimit()
{
var cont = new Container(World.NewItem);
// Past the 3448-entry cap (19-byte entries) so truncation engages.
const int itemCount = 5000;
for (var i = 0; i < itemCount; i++)
{
cont.AddItem(new Item(World.NewItem));
}
cont.Map = Map.Felucca;
var m = new Mobile((Serial)0x1);
m.DefaultMobileInit();
m.AccessLevel = AccessLevel.Administrator;
m.Map = Map.Felucca;
try
{
using var ns = PacketTestUtilities.CreateTestNetState();
// A ~64KB packet exceeds the 4KB pre-auth send buffer; an account lets Send() promote it
// on demand instead of exhausting the buffer and disconnecting.
ns.Account = new MockAccount();
var ex = Record.Exception(() => ns.SendContainerContent(m, cont));
Assert.Null(ex);
var span = ns.SendBuffer.GetReadSpan();
Assert.Equal((byte)0x3C, span[0]);
var length = BinaryPrimitives.ReadUInt16BigEndian(span[1..3]);
var count = BinaryPrimitives.ReadUInt16BigEndian(span[3..5]);
const int entrySize = 19; // no grid lines negotiated on this NetState
var expectedMaxEntries = (ushort.MaxValue - 5) / entrySize;
Assert.Equal(65517, length);
Assert.Equal(length, span.Length);
Assert.Equal(expectedMaxEntries, count);
}
finally
{
cont.Delete();
m.Delete();
}
}
[Fact]
public void TestContainerContent_CapsAtProtocolLimit_WithGridLines()
{
var cont = new Container(World.NewItem);
// Past the 3276-entry cap (20-byte entries with grid lines) so truncation engages.
const int itemCount = 5000;
for (var i = 0; i < itemCount; i++)
{
cont.AddItem(new Item(World.NewItem));
}
cont.Map = Map.Felucca;
var m = new Mobile((Serial)0x1);
m.DefaultMobileInit();
m.AccessLevel = AccessLevel.Administrator;
m.Map = Map.Felucca;
try
{
using var ns = PacketTestUtilities.CreateTestNetState();
ns.ProtocolChanges |= ProtocolChanges.ContainerGridLines;
ns.Account = new MockAccount();
var ex = Record.Exception(() => ns.SendContainerContent(m, cont));
Assert.Null(ex);
var span = ns.SendBuffer.GetReadSpan();
Assert.Equal((byte)0x3C, span[0]);
var length = BinaryPrimitives.ReadUInt16BigEndian(span[1..3]);
var count = BinaryPrimitives.ReadUInt16BigEndian(span[3..5]);
Assert.Equal(65525, length);
Assert.Equal(length, span.Length);
Assert.Equal(3276, count);
}
finally
{
cont.Delete();
m.Delete();
}
}
[Fact]
public void TestContainerContent_RentedBuffer_MatchesReferencePacket()
{
var cont = new Container(World.NewItem);
// Below the entry cap but past the 1024-byte stackalloc threshold, so SendContainerContent
// rents its buffer instead of using the stack.
const int itemCount = 100;
for (var i = 0; i < itemCount; i++)
{
cont.AddItem(new Item(World.NewItem));
}
cont.Map = Map.Felucca;
var m = new Mobile((Serial)0x1);
m.DefaultMobileInit();
m.AccessLevel = AccessLevel.Administrator;
m.Map = Map.Felucca;
try
{
var expected = new ContainerContent(m, cont).Compile();
using var ns = PacketTestUtilities.CreateTestNetState();
ns.SendContainerContent(m, cont);
var result = ns.SendBuffer.GetReadSpan();
AssertThat.Equal(result, expected);
}
finally
{
cont.Delete();
m.Delete();
}
}
}