feat(ui): D.2b item interaction + retail cursors + live character sheet
Lands the codex-worktree D.2b stream plus the extraction the 2026-07-02 UI architecture review mandated before commit: - ItemInteractionController: single owner of double-click use/equip/ container-open, targeted-use mode (health kits), drag-out drop; toolbar shortcut drags don't drop the real item. ItemEquipRules for multi-slot (coat) coverage via equip masks. - Cursor phase: CursorFeedbackController (semantic priority chain: drag > resize > window-move > target-mode > text) + RetailCursorCatalog (enums 0x27/0x28/0x29, hotspot 14,14; ClientUISystem::UpdateCursorState 0x00564630) resolved through the portal EnumIDMap chain by RetailCursorResolver; RetailCursorManager applies dat cursor art to the OS cursor. Register row AP-72 covers the OS standard-cursor fallback. - Character window goes live: CharacterSheetProvider owns sheet assembly, XP-curve/raise-cost math and the raise flow — extracted out of GameWindow per Code Structure Rule 1 instead of committing the ~430-line feature body there. Optimistic XP/credit debits go through eventful store APIs (new ClientObjectTable.UpdateInt64Property + LocalPlayerState.DebitIntProperty/DebitInt64Property) instead of raw property-dictionary writes; register row AP-73 covers the still-missing raise ledger (#163). - RetailWindowFrame: the shared nine-slice window mount recipe; the character window uses it, remaining windows migrate via #164. - Status-bar buttons toggle inventory/character windows; retail row-major backpack ordering; WorldSession.SendUseWithTarget + raise/train sends. GameWindow shrinks 14,214 -> 13,877 lines despite the new features; the sheet/raise logic is unit-tested in CharacterSheetProviderTests instead of trapped in the god object. Build green; full suite 3,286 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e3fc7ac5ba
commit
b7dc91a053
74 changed files with 6669 additions and 238 deletions
|
|
@ -152,18 +152,38 @@ public sealed class ClientObjectTableTests
|
|||
Assert.Equal(0u, repo.Get(0x500000ACu)!.Effects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateIntProperty_currentWieldedLocation_updatesTypedEquipLocation()
|
||||
{
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0x500000ADu,
|
||||
CurrentlyEquippedLocation = EquipMask.ChestArmor,
|
||||
});
|
||||
|
||||
Assert.True(repo.UpdateIntProperty(
|
||||
0x500000ADu,
|
||||
ClientObjectTable.CurrentWieldedLocationPropertyId,
|
||||
value: 0));
|
||||
|
||||
Assert.Equal(EquipMask.None, repo.Get(0x500000ADu)!.CurrentlyEquippedLocation);
|
||||
Assert.Equal(0, repo.Get(0x500000ADu)!.Properties.Ints[ClientObjectTable.CurrentWieldedLocationPropertyId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClientObject_NewFields_DefaultAndSettable()
|
||||
{
|
||||
var o = new ClientObject
|
||||
{
|
||||
ObjectId = 1, WielderId = 0x42u, ItemsCapacity = 24, ContainersCapacity = 7,
|
||||
Priority = 8u, Structure = 5, MaxStructure = 10, Workmanship = 7.5f,
|
||||
ContainerTypeHint = 1u, Priority = 8u, Structure = 5, MaxStructure = 10, Workmanship = 7.5f,
|
||||
};
|
||||
o.WeenieClassId = 0xABCDu; // now settable
|
||||
Assert.Equal(0x42u, o.WielderId);
|
||||
Assert.Equal(24, o.ItemsCapacity);
|
||||
Assert.Equal(7, o.ContainersCapacity);
|
||||
Assert.Equal(1u, o.ContainerTypeHint);
|
||||
Assert.Equal(8u, o.Priority);
|
||||
Assert.Equal(5, o.Structure);
|
||||
Assert.Equal(10, o.MaxStructure);
|
||||
|
|
@ -239,6 +259,24 @@ public sealed class ClientObjectTableTests
|
|||
Assert.Equal(0u, table.Get(0x500000B3u)!.Effects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ingest_UseabilityAndTargetType_AssignsTypedFields()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
var data = FullWeenie(0x500000B7u) with
|
||||
{
|
||||
Useability = 0x000A0008u,
|
||||
TargetType = (uint)ItemType.Creature,
|
||||
};
|
||||
|
||||
table.Ingest(data);
|
||||
|
||||
var item = table.Get(0x500000B7u);
|
||||
Assert.NotNull(item);
|
||||
Assert.Equal(0x000A0008u, item!.Useability);
|
||||
Assert.Equal((uint)ItemType.Creature, item.TargetType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordMembership_CreatesEntry_AndSetsEquip()
|
||||
{
|
||||
|
|
@ -271,6 +309,21 @@ public sealed class ClientObjectTableTests
|
|||
Assert.Equal(EquipMask.MeleeWeapon, table.Get(0x500000B6u)!.CurrentlyEquippedLocation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordMembership_inventoryEntryClearsStaleEquipLocation()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0x500000B8u,
|
||||
CurrentlyEquippedLocation = EquipMask.ChestWear | EquipMask.UpperArmWear,
|
||||
});
|
||||
|
||||
table.RecordMembership(0x500000B8u);
|
||||
|
||||
Assert.Equal(EquipMask.None, table.Get(0x500000B8u)!.CurrentlyEquippedLocation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainerIndex_IngestThenContents_OrderedBySlot()
|
||||
{
|
||||
|
|
@ -340,6 +393,40 @@ public sealed class ClientObjectTableTests
|
|||
Assert.Equal(0xC9u, table.Get(0xA02u)!.ContainerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceContents_listedMemberClearsStaleEquipLocation()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0xA10u,
|
||||
ContainerId = 0x50000001u,
|
||||
CurrentlyEquippedLocation = EquipMask.ChestWear | EquipMask.UpperArmWear,
|
||||
});
|
||||
|
||||
table.ReplaceContents(0x50000001u, new uint[] { 0xA10u });
|
||||
|
||||
var item = table.Get(0xA10u)!;
|
||||
Assert.Equal(0x50000001u, item.ContainerId);
|
||||
Assert.Equal(0, item.ContainerSlot);
|
||||
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceContents_detachedMemberClearsStaleEquipLocation()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.AddOrUpdate(new ClientObject { ObjectId = 0xA11u });
|
||||
table.MoveItem(0xA11u, 0x50000001u, newSlot: 0,
|
||||
newEquipLocation: EquipMask.ChestWear | EquipMask.UpperArmWear);
|
||||
|
||||
table.ReplaceContents(0x50000001u, Array.Empty<uint>());
|
||||
|
||||
var item = table.Get(0xA11u)!;
|
||||
Assert.Equal(0u, item.ContainerId);
|
||||
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceContents_ReordersToNewListOrder()
|
||||
{
|
||||
|
|
@ -349,6 +436,54 @@ public sealed class ClientObjectTableTests
|
|||
Assert.Equal(new[] { 0xA02u, 0xA01u }, table.GetContents(0xC9u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceContents_RecordsContainerTypeHintAndOrder()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.ReplaceContents(0xC9u, new[]
|
||||
{
|
||||
new ContainerContentEntry(0xA02u, 1u),
|
||||
new ContainerContentEntry(0xA01u, 0u),
|
||||
});
|
||||
|
||||
Assert.Equal(new[] { 0xA02u, 0xA01u }, table.GetContents(0xC9u));
|
||||
Assert.Equal(1u, table.Get(0xA02u)!.ContainerTypeHint);
|
||||
Assert.Equal(0u, table.Get(0xA01u)!.ContainerTypeHint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceContents_ManifestOrderSurvivesLaterCreateObjects()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
const uint player = 0x50000001u;
|
||||
|
||||
table.ReplaceContents(player, new[]
|
||||
{
|
||||
new ContainerContentEntry(0xA01u, 0u),
|
||||
new ContainerContentEntry(0xA02u, 1u),
|
||||
});
|
||||
table.Ingest(FullWeenie(0xA02u, container: player, type: ItemType.Container));
|
||||
table.Ingest(FullWeenie(0xA01u, container: player));
|
||||
|
||||
Assert.Equal(new[] { 0xA01u, 0xA02u }, table.GetContents(player));
|
||||
Assert.Equal(0, table.Get(0xA01u)!.ContainerSlot);
|
||||
Assert.Equal(1, table.Get(0xA02u)!.ContainerSlot);
|
||||
Assert.Equal(1u, table.Get(0xA02u)!.ContainerTypeHint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reindex_UnknownSlotAppendsAfterKnownSnapshotSlots()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
const uint pack = 0x500000C9u;
|
||||
|
||||
table.ReplaceContents(pack, new uint[] { 0xA01u, 0xA02u });
|
||||
table.Ingest(FullWeenie(0xA03u, container: pack));
|
||||
|
||||
Assert.Equal(new[] { 0xA01u, 0xA02u, 0xA03u }, table.GetContents(pack));
|
||||
Assert.Equal(-1, table.Get(0xA03u)!.ContainerSlot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceContents_ZeroContainer_NoOp()
|
||||
{
|
||||
|
|
@ -516,4 +651,22 @@ public sealed class ClientObjectTableTests
|
|||
Assert.Equal(2, o.ContainerSlot); // and slot
|
||||
Assert.Equal(EquipMask.None, o.CurrentlyEquippedLocation); // pre-wield was un-equipped
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateInt64Property_setsValueAndFiresUpdated()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
table.AddOrUpdate(new ClientObject { ObjectId = 0x960u });
|
||||
int updates = 0;
|
||||
table.ObjectUpdated += _ => updates++;
|
||||
|
||||
Assert.True(table.UpdateInt64Property(0x960u, 2u, 12_345L));
|
||||
|
||||
Assert.Equal(12_345L, table.Get(0x960u)!.Properties.Int64s[2u]);
|
||||
Assert.Equal(1, updates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateInt64Property_unknownObject_false()
|
||||
=> Assert.False(new ClientObjectTable().UpdateInt64Property(0xDEADu, 2u, 1L));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue