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
192
tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs
Normal file
192
tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Player;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="CharacterSheetProvider"/> — the extracted character-sheet
|
||||
/// assembly + raise flow (formerly private methods inside GameWindow). Covers the
|
||||
/// XP-curve math against a known synthetic ExperienceTable and the single-owner
|
||||
/// spend routing (table debits fire ObjectUpdated; LocalPlayerState debits fire
|
||||
/// CharacterChanged).
|
||||
/// </summary>
|
||||
public sealed class CharacterSheetProviderTests
|
||||
{
|
||||
private const uint PlayerGuid = 0x50000001u;
|
||||
|
||||
/// <summary>Synthetic cumulative-XP curves. Levels band 1→2 spans 100..250.</summary>
|
||||
private static DatReaderWriter.DBObjs.ExperienceTable MakeXpTable() => new()
|
||||
{
|
||||
Levels = new ulong[] { 0, 100, 250, 450 },
|
||||
Attributes = new uint[] { 0, 10, 30, 60, 100 },
|
||||
Vitals = new uint[] { 0, 4, 12, 24 },
|
||||
TrainedSkills = new uint[] { 0, 5, 15, 30 },
|
||||
SpecializedSkills = new uint[] { 0, 8, 24, 48 },
|
||||
};
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public ClientObjectTable Table { get; } = new();
|
||||
public LocalPlayerState Player { get; } = new();
|
||||
public CharacterSheetProvider Provider { get; }
|
||||
public (uint statId, ulong cost)? SentAttribute;
|
||||
public (uint skillId, uint credits)? SentTrain;
|
||||
public bool CanSend = true;
|
||||
|
||||
public Harness()
|
||||
{
|
||||
Provider = new CharacterSheetProvider(
|
||||
Table, Player,
|
||||
playerGuid: () => PlayerGuid,
|
||||
activeToonName: () => "default",
|
||||
fallbackSheet: name => new CharacterSheet { Name = name, Level = -1 },
|
||||
canSendRaise: () => CanSend,
|
||||
sendRaiseAttribute: (statId, cost) => SentAttribute = (statId, cost),
|
||||
sendRaiseVital: (_, _) => { },
|
||||
sendRaiseSkill: (_, _) => { },
|
||||
sendTrainSkill: (skillId, credits) => SentTrain = (skillId, credits))
|
||||
{
|
||||
ExperienceTable = MakeXpTable(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Put the player's ClientObject in the table with live sheet properties.</summary>
|
||||
public ClientObject AddPlayerObject(long unassignedXp = 1000L)
|
||||
{
|
||||
var player = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" };
|
||||
player.Properties.Ints[0x19u] = 1; // level
|
||||
player.Properties.Int64s[1u] = 150L; // total XP — mid 100..250 band
|
||||
player.Properties.Int64s[2u] = unassignedXp;
|
||||
Table.AddOrUpdate(player);
|
||||
return player;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSheet_NoLiveData_UsesFallbackSheet()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
var sheet = h.Provider.BuildSheet();
|
||||
|
||||
Assert.Equal(-1, sheet.Level); // fallback marker
|
||||
Assert.Equal("Player", sheet.Name); // toon key "default" + no object → "Player"
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSheet_LiveData_ComputesLevelBandAndRaiseCosts()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddPlayerObject(unassignedXp: 777L);
|
||||
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u); // Strength
|
||||
|
||||
var sheet = h.Provider.BuildSheet();
|
||||
|
||||
Assert.Equal("Testy", sheet.Name); // live object name beats "Player"
|
||||
Assert.Equal(1, sheet.Level);
|
||||
Assert.Equal(150L, sheet.TotalXp);
|
||||
Assert.Equal(777L, sheet.UnassignedXp);
|
||||
// Level band 100..250, at 150: 100 XP to next, 1/3 through the band.
|
||||
Assert.Equal(100L, sheet.XpToNextLevel);
|
||||
Assert.Equal(1f / 3f, sheet.XpFraction, precision: 4);
|
||||
Assert.Equal(11, sheet.Strength); // ranks + start
|
||||
// Raise x1: Attributes[2] − xpSpent = 30 − 10; x10 clamps at curve end: 100 − 10.
|
||||
Assert.Equal(20L, sheet.AttributeRaiseCosts[0]);
|
||||
Assert.Equal(90L, sheet.AttributeRaise10Costs[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSheet_Skills_MapsAdvancementAndCurveCosts()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddPlayerObject();
|
||||
h.Player.OnSkillUpdate(skillId: 6u, ranks: 1u, status: 2u, xp: 5u,
|
||||
init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // trained
|
||||
h.Player.OnSkillUpdate(skillId: 7u, ranks: 0u, status: 0u, xp: 0u,
|
||||
init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // inactive → excluded
|
||||
|
||||
var sheet = h.Provider.BuildSheet();
|
||||
|
||||
var skill = Assert.Single(sheet.Skills);
|
||||
Assert.Equal(6u, skill.Id);
|
||||
Assert.Equal("Skill 6", skill.Name); // no SkillTable → id fallback name
|
||||
Assert.Equal(CharacterSkillAdvancementClass.Trained, skill.AdvancementClass);
|
||||
// TrainedSkills curve: x1 = 15 − 5; x10 clamps at index 3: 30 − 5.
|
||||
Assert.Equal(10L, skill.RaiseCost);
|
||||
Assert.Equal(25L, skill.Raise10Cost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleRaiseRequest_Attribute_SendsAndDebitsThroughTableEvents()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddPlayerObject(unassignedXp: 1000L);
|
||||
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u);
|
||||
int tableUpdates = 0;
|
||||
h.Table.ObjectUpdated += _ => tableUpdates++;
|
||||
|
||||
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
|
||||
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1));
|
||||
|
||||
Assert.Equal((1u, 20ul), h.SentAttribute);
|
||||
var strength = h.Player.GetAttribute(LocalPlayerState.AttributeKind.Strength);
|
||||
Assert.Equal(2u, strength!.Value.Ranks); // optimistic rank apply
|
||||
Assert.Equal(980L, h.Table.Get(PlayerGuid)!.Properties.GetInt64(2u)); // XP debited
|
||||
Assert.True(tableUpdates >= 1); // via the eventful API
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleRaiseRequest_Blocked_WhenCanSendIsFalse()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddPlayerObject(unassignedXp: 1000L);
|
||||
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u);
|
||||
h.CanSend = false;
|
||||
|
||||
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
|
||||
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1));
|
||||
|
||||
Assert.Null(h.SentAttribute);
|
||||
Assert.Equal(1u, h.Player.GetAttribute(LocalPlayerState.AttributeKind.Strength)!.Value.Ranks);
|
||||
Assert.Equal(1000L, h.Table.Get(PlayerGuid)!.Properties.GetInt64(2u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleRaiseRequest_TrainSkill_DebitsFirstPresentCreditProperty()
|
||||
{
|
||||
var h = new Harness();
|
||||
var player = h.AddPlayerObject();
|
||||
player.Properties.Ints[0xC0u] = 4; // only the second id in the 0x18→0xC0→0xB5 chain
|
||||
h.Player.OnSkillUpdate(skillId: 6u, ranks: 0u, status: 1u, xp: 0u,
|
||||
init: 0u, resistance: 0u, lastUsed: 0, formulaBonus: 0u); // untrained
|
||||
|
||||
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
|
||||
CharacterStatController.RaiseTargetKind.TrainSkill, StatId: 6u, Cost: 4L, Amount: 1));
|
||||
|
||||
Assert.Equal((6u, 4u), h.SentTrain);
|
||||
Assert.Equal(2u, h.Player.GetSkill(6u)!.Value.Status); // promoted to trained
|
||||
Assert.Equal(0, player.Properties.GetInt(0xC0u)); // credits debited
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpendUnassignedXp_FallsBackToLocalPlayer_WhenPlayerObjectAbsent()
|
||||
{
|
||||
var h = new Harness(); // note: nothing added to the table
|
||||
var props = new PropertyBundle();
|
||||
props.Int64s[2u] = 500L;
|
||||
h.Player.OnProperties(props);
|
||||
h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u);
|
||||
int changed = 0;
|
||||
h.Player.CharacterChanged += () => changed++;
|
||||
|
||||
h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest(
|
||||
CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 100L, Amount: 1));
|
||||
|
||||
Assert.Equal(400L, h.Player.Properties.GetInt64(2u)); // debited on the LPS side
|
||||
Assert.True(changed >= 1); // and CharacterChanged fired
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue