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:
Erik 2026-07-03 09:18:43 +02:00
parent e3fc7ac5ba
commit b7dc91a053
74 changed files with 6669 additions and 238 deletions

View file

@ -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));
}

View file

@ -0,0 +1,30 @@
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
public sealed class ItemUseabilityTests
{
[Fact]
public void TargetedUse_hasHighTargetBits()
{
const uint healthKit = 0x000A0008u; // source contained, target self or contained
Assert.True(ItemUseability.IsTargeted(healthKit));
Assert.True(ItemUseability.AllowsSelfTarget(healthKit));
Assert.Equal(ItemUseability.Contained, ItemUseability.SourceFlags(healthKit));
Assert.Equal(ItemUseability.Self | ItemUseability.Contained, ItemUseability.TargetFlags(healthKit));
}
[Fact]
public void DirectContainedUse_isNotTargeted()
{
Assert.False(ItemUseability.IsTargeted(ItemUseability.Contained));
Assert.True(ItemUseability.IsDirectUseable(ItemUseability.Contained));
}
[Fact]
public void UseableNo_isNotDirectUseable()
{
Assert.False(ItemUseability.IsDirectUseable(ItemUseability.No));
}
}

View file

@ -1,3 +1,4 @@
using AcDream.Core.Items;
using AcDream.Core.Player;
namespace AcDream.Core.Tests.Player;
@ -244,4 +245,179 @@ public sealed class LocalPlayerStateTests
// Now MaxApprox = 0 + 200 = 200; percent = 100/200 = 0.5.
Assert.Equal(0.5f, s.StaminaPercent!.Value, precision: 3);
}
[Fact]
public void OnProperties_ClonesBundleAndExposesInt64()
{
var s = new LocalPlayerState();
var props = new PropertyBundle();
props.Ints[0x19u] = 126;
props.Int64s[1u] = 1_234_567_890L;
s.OnProperties(props);
props.Ints[0x19u] = 1;
props.Int64s[1u] = 2L;
Assert.Equal(126, s.Properties.GetInt(0x19u));
Assert.Equal(1_234_567_890L, s.Properties.GetInt64(1u));
}
[Fact]
public void OnSkillUpdate_StoresFormulaAdjustedCurrent()
{
var s = new LocalPlayerState();
int changed = 0;
s.CharacterChanged += () => changed++;
s.OnSkillUpdate(
skillId: 24u,
ranks: 12u,
status: 2u,
xp: 3456u,
init: 30u,
resistance: 0u,
lastUsed: 1.5,
formulaBonus: 80u);
var run = s.GetSkill(24u);
Assert.NotNull(run);
Assert.Equal(122u, run!.Value.CurrentLevel);
Assert.Equal(2u, run.Value.Status);
Assert.Equal(3456u, run.Value.Xp);
Assert.Equal(1, changed);
}
[Fact]
public void ApplySkillTraining_PromotesUntrainedSkillAndFiresCharacterChanged()
{
var s = new LocalPlayerState();
int changed = 0;
s.CharacterChanged += () => changed++;
s.OnSkillUpdate(
skillId: 21u,
ranks: 0u,
status: 1u,
xp: 0u,
init: 5u,
resistance: 0u,
lastUsed: 0,
formulaBonus: 10u);
changed = 0;
Assert.True(s.ApplySkillTraining(21u));
var skill = s.GetSkill(21u);
Assert.NotNull(skill);
Assert.Equal(2u, skill!.Value.Status);
Assert.Equal(1, changed);
}
[Fact]
public void ApplySkillRaise_AddsRanksAndSpentXp()
{
var s = new LocalPlayerState();
s.OnSkillUpdate(
skillId: 24u,
ranks: 12u,
status: 2u,
xp: 3456u,
init: 30u,
resistance: 0u,
lastUsed: 0,
formulaBonus: 80u);
Assert.True(s.ApplySkillRaise(24u, amount: 10u, xpSpent: 500u));
var run = s.GetSkill(24u);
Assert.NotNull(run);
Assert.Equal(22u, run!.Value.Ranks);
Assert.Equal(3956u, run.Value.Xp);
Assert.Equal(132u, run.Value.CurrentLevel);
}
[Fact]
public void ApplyAttributeRaise_AddsRanksAndSpentXp()
{
var s = new LocalPlayerState();
s.OnAttributeUpdate(atType: 5u, ranks: 10u, start: 10u, xp: 100u);
Assert.True(s.ApplyAttributeRaise(atType: 5u, amount: 1u, xpSpent: 25u));
var focus = s.GetAttribute(LocalPlayerState.AttributeKind.Focus);
Assert.NotNull(focus);
Assert.Equal(11u, focus!.Value.Ranks);
Assert.Equal(125u, focus.Value.Xp);
Assert.Equal(21u, focus.Value.Current);
}
[Fact]
public void ApplyVitalRaise_AddsRanksAndSpentXpWithoutChangingCurrent()
{
var s = new LocalPlayerState();
s.OnVitalUpdate(vitalId: 7u, ranks: 5u, start: 10u, xp: 100u, current: 12u);
Assert.True(s.ApplyVitalRaise(vitalId: 1u, amount: 1u, xpSpent: 25u));
var health = s.Get(LocalPlayerState.VitalKind.Health);
Assert.NotNull(health);
Assert.Equal(6u, health!.Value.Ranks);
Assert.Equal(125u, health.Value.Xp);
Assert.Equal(12u, health.Value.Current);
}
[Fact]
public void DebitIntProperty_Present_DebitsClampsAndFiresCharacterChanged()
{
var s = new LocalPlayerState();
var props = new PropertyBundle();
props.Ints[0x18u] = 3;
s.OnProperties(props);
int changed = 0;
s.CharacterChanged += () => changed++;
Assert.True(s.DebitIntProperty(0x18u, 2));
Assert.Equal(1, s.Properties.GetInt(0x18u));
Assert.True(s.DebitIntProperty(0x18u, 5)); // over-debit clamps at 0
Assert.Equal(0, s.Properties.GetInt(0x18u));
Assert.Equal(2, changed);
}
[Fact]
public void DebitIntProperty_Absent_FalseAndNoEvent()
{
var s = new LocalPlayerState();
int changed = 0;
s.CharacterChanged += () => changed++;
Assert.False(s.DebitIntProperty(0x18u, 1));
Assert.Equal(0, changed);
}
[Fact]
public void DebitInt64Property_Present_DebitsAndFiresCharacterChanged()
{
var s = new LocalPlayerState();
var props = new PropertyBundle();
props.Int64s[2u] = 500L;
s.OnProperties(props);
int changed = 0;
s.CharacterChanged += () => changed++;
Assert.True(s.DebitInt64Property(2u, 100L));
Assert.Equal(400L, s.Properties.GetInt64(2u));
Assert.Equal(1, changed);
}
[Fact]
public void DebitInt64Property_ZeroOrAbsent_False()
{
var s = new LocalPlayerState();
Assert.False(s.DebitInt64Property(2u, 100L)); // absent
var props = new PropertyBundle();
props.Int64s[2u] = 0L;
s.OnProperties(props);
Assert.False(s.DebitInt64Property(2u, 100L)); // already 0 — no negative banking
}
}