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

@ -25,4 +25,20 @@ public class RuntimeOptionsRetailUiTests
Assert.False(opts.RetailUi);
Assert.Null(opts.AcDir);
}
[Fact]
public void Parse_ReadsUiProbeOptions()
{
var env = new Dictionary<string, string?>
{
["ACDREAM_UI_PROBE_DUMP"] = "1",
["ACDREAM_UI_PROBE_SCRIPT"] = @"C:\tmp\ui-probe.txt",
};
var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k));
Assert.True(opts.UiProbeDump);
Assert.Equal(@"C:\tmp\ui-probe.txt", opts.UiProbeScript);
Assert.True(opts.UiProbeEnabled);
}
}

View file

@ -38,6 +38,9 @@ public sealed class RuntimeOptionsTests
Assert.True(opts.RetailCloseDegrades);
Assert.False(opts.DumpSceneryZ);
Assert.Null(opts.LegacyStreamRadius);
Assert.False(opts.UiProbeDump);
Assert.Null(opts.UiProbeScript);
Assert.False(opts.UiProbeEnabled);
Assert.False(opts.HasLiveCredentials);
}

View file

@ -0,0 +1,155 @@
using AcDream.App.UI;
using AcDream.Core.Items;
namespace AcDream.App.Tests.UI;
public sealed class CursorFeedbackControllerTests
{
private const uint Player = 0x50000001u;
private const uint Pack = 0x50000010u;
private const uint Source = 0x50000020u;
private const uint Target = 0x50000021u;
private const uint HealthKitUseability = 0x000A0008u;
[Fact]
public void DragState_winsOverResizeAndUsesAcceptRejectCursors()
{
var c = new CursorFeedbackController();
var accept = c.Resolve(new CursorFeedbackSnapshot(
DragPayload: new object(),
DragAccept: UiItemSlot.DragAcceptState.Accept,
HoverResizeEdges: ResizeEdges.Right));
var reject = c.Resolve(new CursorFeedbackSnapshot(
DragPayload: new object(),
DragAccept: UiItemSlot.DragAcceptState.Reject,
HoverResizeEdges: ResizeEdges.Right));
Assert.Equal(CursorFeedbackKind.DragAccept, accept.Kind);
Assert.Equal(CursorFeedbackKind.DragReject, reject.Kind);
}
[Theory]
[InlineData(ResizeEdges.Right, CursorFeedbackKind.ResizeHorizontal)]
[InlineData(ResizeEdges.Bottom, CursorFeedbackKind.ResizeVertical)]
[InlineData(ResizeEdges.Right | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNwse)]
[InlineData(ResizeEdges.Left | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNesw)]
public void ResizeEdges_chooseExpectedCursor(ResizeEdges edges, CursorFeedbackKind expected)
{
var c = new CursorFeedbackController();
var feedback = c.Resolve(new CursorFeedbackSnapshot(HoverResizeEdges: edges));
Assert.Equal(expected, feedback.Kind);
}
[Fact]
public void MoveAndTextHover_useDedicatedCursorsWhenNoHigherPriorityState()
{
var c = new CursorFeedbackController();
Assert.Equal(CursorFeedbackKind.WindowMove,
c.Resolve(new CursorFeedbackSnapshot(HoverWindowMove: true)).Kind);
Assert.Equal(CursorFeedbackKind.Text,
c.Resolve(new CursorFeedbackSnapshot(HoverTextEdit: true)).Kind);
}
[Fact]
public void TargetMode_reportsValidInvalidAndPendingTargets()
{
var objects = SeedTargetObjects();
var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
nowMs: () => 1_000);
var c = new CursorFeedbackController(interaction);
Assert.True(interaction.ActivateItem(Source));
Assert.Equal(CursorFeedbackKind.TargetValid,
c.Resolve(new CursorFeedbackSnapshot(HoverUi: true, HoverTargetGuid: Player)).Kind);
Assert.Equal(CursorFeedbackKind.TargetInvalid,
c.Resolve(new CursorFeedbackSnapshot(HoverUi: true, HoverTargetGuid: 0x5000BADu)).Kind);
Assert.Equal(CursorFeedbackKind.TargetInvalid,
c.Resolve(new CursorFeedbackSnapshot(HoverUi: true)).Kind);
Assert.Equal(CursorFeedbackKind.TargetPending,
c.Resolve(new CursorFeedbackSnapshot()).Kind);
}
[Fact]
public void UpdateFromRoot_usesUseTargetGuidProviderBeforeSlotItem()
{
var objects = SeedTargetObjects();
var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
nowMs: () => 1_000);
var root = new UiRoot { Width = 800, Height = 600 };
var slot = new UiItemSlot
{
Left = 10,
Top = 10,
Width = 32,
Height = 32,
UseTargetGuidProvider = () => Player,
};
var acceptCursor = new UiCursorMedia(0x06009999u, 11, 12);
slot.SetStateCursors(new Dictionary<string, UiCursorMedia>
{
["Drag_rollover_accept"] = acceptCursor,
});
slot.SetItem(Target, iconTexture: 1u);
root.AddChild(slot);
root.OnMouseMove(20, 20);
var c = new CursorFeedbackController(interaction);
Assert.True(interaction.ActivateItem(Source));
var feedback = c.Update(root);
Assert.Equal(CursorFeedbackKind.TargetValid, feedback.Kind);
Assert.Equal(acceptCursor, feedback.Cursor);
}
private static ClientObjectTable SeedTargetObjects()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Name = "Player",
Type = ItemType.Creature,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = Pack,
Name = "Backpack",
Type = ItemType.Container,
ItemsCapacity = 24,
});
objects.MoveItem(Pack, Player, 0);
objects.AddOrUpdate(new ClientObject
{
ObjectId = Source,
Name = "Health Kit",
Type = ItemType.Misc,
Useability = HealthKitUseability,
});
objects.MoveItem(Source, Pack, 0);
objects.AddOrUpdate(new ClientObject
{
ObjectId = Target,
Name = "Target",
Type = ItemType.Misc,
});
objects.MoveItem(Target, Pack, 1);
return objects;
}
}

View file

@ -0,0 +1,303 @@
using AcDream.App.UI;
using AcDream.Core.Items;
namespace AcDream.App.Tests.UI;
public sealed class ItemInteractionControllerTests
{
private const uint Player = 0x50000001u;
private const uint Pack = 0x50000010u;
private const uint HealthKitUseability = 0x000A0008u;
private sealed class Harness
{
public readonly ClientObjectTable Objects = new();
public readonly List<uint> Uses = new();
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
public readonly List<(uint Item, uint Mask)> Wields = new();
public readonly List<uint> Drops = new();
public readonly List<string> Toasts = new();
public long Now = 1_000;
public Harness()
{
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Name = "Player",
Type = ItemType.Creature,
});
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Pack,
Name = "Backpack",
Type = ItemType.Container,
ItemsCapacity = 24,
});
Objects.MoveItem(Pack, Player, 0);
Controller = new ItemInteractionController(
Objects,
playerGuid: () => Player,
sendUse: Uses.Add,
sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)),
sendWield: (item, mask) => Wields.Add((item, mask)),
sendDrop: Drops.Add,
nowMs: () => Now,
toast: Toasts.Add);
}
public ItemInteractionController Controller { get; }
public ClientObject AddContained(uint id, Action<ClientObject>? configure = null)
{
var item = new ClientObject
{
ObjectId = id,
Name = $"Item {id:X}",
Type = ItemType.Misc,
IconId = 0x06001234u,
};
configure?.Invoke(item);
Objects.AddOrUpdate(item);
Objects.MoveItem(id, Pack, Objects.GetContents(Pack).Count);
return item;
}
}
[Fact]
public void TargetedItem_entersTargetModeAndMarksPendingSource()
{
var h = new Harness();
h.AddContained(0x50000A01u, item => item.Useability = HealthKitUseability);
Assert.True(h.Controller.ActivateItem(0x50000A01u));
Assert.True(h.Controller.IsTargetModeActive);
Assert.True(h.Controller.IsPendingSource(0x50000A01u));
Assert.Empty(h.UseWithTarget);
}
[Fact]
public void SelfTarget_sendsUseWithTargetAndClearsTargetMode()
{
var h = new Harness();
h.AddContained(0x50000A01u, item => item.Useability = HealthKitUseability);
h.Controller.ActivateItem(0x50000A01u);
Assert.True(h.Controller.AcquireSelfTarget());
Assert.Equal(new[] { (0x50000A01u, Player) }, h.UseWithTarget);
Assert.False(h.Controller.IsTargetModeActive);
}
[Fact]
public void ActivateTargetItemDuringTargetMode_usesClickedItemAsTarget()
{
var h = new Harness();
h.AddContained(0x50000A01u, item => item.Useability = 0x00080008u);
h.AddContained(0x50000A02u);
h.Controller.ActivateItem(0x50000A01u);
Assert.True(h.Controller.ActivateItem(0x50000A02u));
Assert.Equal(new[] { (0x50000A01u, 0x50000A02u) }, h.UseWithTarget);
Assert.False(h.Controller.IsTargetModeActive);
}
[Fact]
public void DirectUseItem_sendsUse()
{
var h = new Harness();
h.AddContained(0x50000A03u, item => item.Useability = ItemUseability.Contained);
Assert.True(h.Controller.ActivateItem(0x50000A03u));
Assert.Equal(new[] { 0x50000A03u }, h.Uses);
}
[Fact]
public void ContainerItem_sendsUseToOpen()
{
var h = new Harness();
h.AddContained(0x50000A04u, item =>
{
item.Type = ItemType.Container;
item.ItemsCapacity = 12;
});
Assert.True(h.Controller.ActivateItem(0x50000A04u));
Assert.Equal(new[] { 0x50000A04u }, h.Uses);
}
[Fact]
public void EquippableItemWithFreeSlot_sendsGetAndWieldAndMovesOptimistically()
{
var h = new Harness();
h.AddContained(0x50000A05u, item =>
{
item.Type = ItemType.Clothing;
item.ValidLocations = EquipMask.HeadWear;
});
Assert.True(h.Controller.ActivateItem(0x50000A05u));
Assert.Equal(new[] { (0x50000A05u, (uint)EquipMask.HeadWear) }, h.Wields);
var equipped = h.Objects.Get(0x50000A05u)!;
Assert.Equal(Player, equipped.ContainerId);
Assert.Equal(EquipMask.HeadWear, equipped.CurrentlyEquippedLocation);
}
[Fact]
public void EquippableMultiSlotItemWithFreeSlots_sendsFullCoverageMaskAndMovesOptimistically()
{
var h = new Harness();
const EquipMask coatMask =
EquipMask.ChestWear
| EquipMask.UpperArmWear
| EquipMask.LowerArmWear;
h.AddContained(0x50000A15u, item =>
{
item.Type = ItemType.Clothing;
item.ValidLocations = coatMask;
});
Assert.True(h.Controller.ActivateItem(0x50000A15u));
Assert.Equal(new[] { (0x50000A15u, (uint)coatMask) }, h.Wields);
var equipped = h.Objects.Get(0x50000A15u)!;
Assert.Equal(Player, equipped.ContainerId);
Assert.Equal(coatMask, equipped.CurrentlyEquippedLocation);
}
[Fact]
public void AutoWearItemWithOverlappingSlotButDifferentPriority_sendsFullMask()
{
var h = new Harness();
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x50000AF1u,
Type = ItemType.Clothing,
CurrentlyEquippedLocation = EquipMask.UpperArmWear,
Priority = 0x00000001u,
});
h.Objects.MoveItem(0x50000AF1u, Player, -1, EquipMask.UpperArmWear);
const EquipMask coatMask =
EquipMask.ChestWear
| EquipMask.UpperArmWear
| EquipMask.LowerArmWear;
h.AddContained(0x50000A16u, item =>
{
item.Type = ItemType.Clothing;
item.ValidLocations = coatMask;
item.Priority = 0x00000002u;
});
Assert.True(h.Controller.ActivateItem(0x50000A16u));
Assert.Equal(new[] { (0x50000A16u, (uint)coatMask) }, h.Wields);
Assert.Equal(coatMask, h.Objects.Get(0x50000A16u)!.CurrentlyEquippedLocation);
}
[Fact]
public void AutoWearItemWithOverlappingSlotAndPriority_sendsNothing()
{
var h = new Harness();
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x50000AF2u,
Type = ItemType.Clothing,
CurrentlyEquippedLocation = EquipMask.UpperArmWear,
Priority = 0x00000004u,
});
h.Objects.MoveItem(0x50000AF2u, Player, -1, EquipMask.UpperArmWear);
const EquipMask coatMask =
EquipMask.ChestWear
| EquipMask.UpperArmWear
| EquipMask.LowerArmWear;
h.AddContained(0x50000A17u, item =>
{
item.Type = ItemType.Clothing;
item.ValidLocations = coatMask;
item.Priority = 0x00000004u;
});
Assert.False(h.Controller.ActivateItem(0x50000A17u));
Assert.Empty(h.Wields);
Assert.Equal(Pack, h.Objects.Get(0x50000A17u)!.ContainerId);
}
[Fact]
public void EquippableItemWithNoFreeSlot_sendsNothing()
{
var h = new Harness();
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x50000AF0u,
Type = ItemType.Armor,
CurrentlyEquippedLocation = EquipMask.Shield,
});
h.Objects.MoveItem(0x50000AF0u, Player, -1, EquipMask.Shield);
h.AddContained(0x50000A06u, item =>
{
item.Type = ItemType.Armor;
item.ValidLocations = EquipMask.Shield;
});
Assert.False(h.Controller.ActivateItem(0x50000A06u));
Assert.Empty(h.Wields);
Assert.Equal(Pack, h.Objects.Get(0x50000A06u)!.ContainerId);
}
[Fact]
public void InventoryDragOutsideUi_sendsDropAndMovesToWorldOptimistically()
{
var h = new Harness();
h.AddContained(0x50000A07u);
var payload = new ItemDragPayload(
0x50000A07u,
ItemDragSource.Inventory,
SourceSlot: 0,
SourceCell: new UiItemSlot());
Assert.True(h.Controller.DropToWorld(payload));
Assert.Equal(new[] { 0x50000A07u }, h.Drops);
Assert.Equal(0u, h.Objects.Get(0x50000A07u)!.ContainerId);
}
[Fact]
public void ToolbarShortcutDragOutsideUi_doesNotDropRealItem()
{
var h = new Harness();
h.AddContained(0x50000A08u);
var payload = new ItemDragPayload(
0x50000A08u,
ItemDragSource.ShortcutBar,
SourceSlot: 0,
SourceCell: new UiItemSlot());
Assert.False(h.Controller.DropToWorld(payload));
Assert.Empty(h.Drops);
Assert.Equal(Pack, h.Objects.Get(0x50000A08u)!.ContainerId);
}
[Fact]
public void ActivateItem_appliesRetailUseThrottle()
{
var h = new Harness();
h.AddContained(0x50000A09u, item => item.Useability = ItemUseability.Contained);
h.Controller.ActivateItem(0x50000A09u);
h.Now += 199;
h.Controller.ActivateItem(0x50000A09u);
h.Now += 1;
h.Controller.ActivateItem(0x50000A09u);
Assert.Equal(new[] { 0x50000A09u, 0x50000A09u }, h.Uses);
}
}

View file

@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AcDream.App.Studio;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
public sealed class CharacterLayoutImportProbe
{
private const uint CharacterLayout = 0x2100002Eu;
private static string? DatDir()
{
var d = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
return Directory.Exists(d) ? d : null;
}
[Fact]
public void Selected_attribute_shows_raise_buttons_on_visible_footer_state()
{
var datDir = DatDir();
if (datDir is null) return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
var layout = LayoutImporter.Import(dats, CharacterLayout, _ => (1u, 30, 26), null);
Assert.NotNull(layout);
CharacterStatController.Bind(layout!, SampleData.SampleCharacter, spriteResolve: _ => (1u, 30, 26));
var rows = new List<UiClickablePanel>();
CollectRows(layout!.Root, rows);
Assert.True(rows.Count >= 9, $"expected at least 9 attribute rows, found {rows.Count}");
rows[4].OnClick!();
var buttons = new List<UiButton>();
CollectButtons(layout.Root, buttons);
Assert.Contains(buttons, b =>
b.ElementId == CharacterStatController.RaiseOneId
&& b.ActiveState == "Normal"
&& IsEffectivelyVisible(b));
Assert.Contains(buttons, b =>
b.ElementId == CharacterStatController.RaiseTenId
&& b.ActiveState == "Normal"
&& IsEffectivelyVisible(b));
}
[Fact]
public void Close_button_resolves_and_invokes_controller_close_callback()
{
var datDir = DatDir();
if (datDir is null) return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
var layout = LayoutImporter.Import(dats, CharacterLayout, _ => (1u, 30, 26), null);
Assert.NotNull(layout);
var close = layout!.FindElement(WindowChromeController.CharacterCloseButtonId);
Assert.NotNull(close);
Assert.True(close is UiButton or UiDatElement,
$"character close button resolved to {close?.GetType().Name ?? "null"}");
int closes = 0;
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onClose: () => closes++);
close!.OnEvent(new UiEvent(0u, close, UiEventType.Click));
Assert.Equal(1, closes);
}
private static void CollectRows(UiElement node, List<UiClickablePanel> result)
{
if (node is UiClickablePanel row && row.Height is >= 20f and <= 22f && row.OnClick is not null)
result.Add(row);
foreach (var child in node.Children)
CollectRows(child, result);
}
private static void CollectButtons(UiElement node, List<UiButton> result)
{
if (node is UiButton button
&& (button.ElementId == CharacterStatController.RaiseOneId
|| button.ElementId == CharacterStatController.RaiseTenId))
{
result.Add(button);
}
foreach (var child in node.Children)
CollectButtons(child, result);
}
private static bool IsEffectivelyVisible(UiElement element)
{
for (UiElement? e = element; e is not null; e = e.Parent)
{
if (!e.Visible) return false;
}
return true;
}
}

View 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
}
}

View file

@ -50,10 +50,114 @@ public class CharacterStatControllerTests
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Aluvian Heritage", heritage.LinesProvider()[0].Text);
Assert.Equal("Female Aluvian Adventurer", heritage.LinesProvider()[0].Text);
Assert.Equal("Non-Player Killer", pk.LinesProvider()[0].Text);
}
[Fact]
public void Bind_WindowChromeButton_InvokesCloseCallback()
{
var close = MakeButton(WindowChromeController.CharacterCloseButtonId);
var layout = Fake((WindowChromeController.CharacterCloseButtonId, close));
int closes = 0;
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onClose: () => closes++);
close.OnEvent(new UiEvent(0u, close, UiEventType.Click));
Assert.Equal(1, closes);
}
[Fact]
public void CharacterIdentityText_StatHeaderLine_ComposesRetailGenderHeritageTitle()
{
var sheet = new CharacterSheet
{
Gender = "Female",
Heritage = "Aluvian",
Title = "the Adventurer",
};
Assert.Equal("Female Aluvian Adventurer", CharacterIdentityText.StatHeaderLine(sheet));
}
[Theory]
[InlineData(1, "Male")]
[InlineData(2, "Female")]
[InlineData(0, null)]
public void CharacterIdentityText_GenderDisplayName_UsesRetailEnum(int value, string? expected)
=> Assert.Equal(expected, CharacterIdentityText.GenderDisplayName(value));
[Theory]
[InlineData(1, "Aluvian")]
[InlineData(2, "Gharu'ndim")]
[InlineData(5, "Umbraen")]
[InlineData(13, "Olthoi")]
[InlineData(99, null)]
public void CharacterIdentityText_HeritageGroupDisplayName_UsesRetailEnum(int value, string? expected)
=> Assert.Equal(expected, CharacterIdentityText.HeritageGroupDisplayName(value));
[Fact]
public void Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated()
{
var root = new UiPanel { Width = 300, Height = 600 };
var attrPage = MakeDatElement(CharacterStatController.AttributesPageId, top: 25, width: 300, height: 575);
var hiddenPage = MakeDatElement(CharacterStatController.SkillsPageId, top: 25, width: 300, height: 575);
var visibleName = new UiText { ElementId = CharacterStatController.NameId };
var hiddenName = new UiText { ElementId = CharacterStatController.NameId };
var visibleHeritage = new UiText { ElementId = CharacterStatController.HeritageId };
var hiddenHeritage = new UiText { ElementId = CharacterStatController.HeritageId };
var visibleLevel = new UiText { ElementId = CharacterStatController.LevelId };
var hiddenLevel = new UiText { ElementId = CharacterStatController.LevelId };
var visibleTotalXp = new UiText { ElementId = CharacterStatController.TotalXpId };
var hiddenTotalXp = new UiText { ElementId = CharacterStatController.TotalXpId };
var visibleTotalXpLabel = new UiText { ElementId = CharacterStatController.TotalXpLabelId };
var hiddenTotalXpLabel = new UiText { ElementId = CharacterStatController.TotalXpLabelId };
var visibleMeter = new UiMeter { ElementId = CharacterStatController.XpMeterId };
var hiddenMeter = new UiMeter { ElementId = CharacterStatController.XpMeterId };
var visibleXpNext = new UiText { ElementId = CharacterStatController.XpNextValueId };
var hiddenXpNext = new UiText { ElementId = CharacterStatController.XpNextValueId };
visibleMeter.AddChild(visibleXpNext);
hiddenMeter.AddChild(hiddenXpNext);
attrPage.AddChild(visibleName);
attrPage.AddChild(visibleHeritage);
attrPage.AddChild(visibleLevel);
attrPage.AddChild(visibleTotalXpLabel);
attrPage.AddChild(visibleTotalXp);
attrPage.AddChild(visibleMeter);
hiddenPage.AddChild(hiddenName);
hiddenPage.AddChild(hiddenHeritage);
hiddenPage.AddChild(hiddenLevel);
hiddenPage.AddChild(hiddenTotalXpLabel);
hiddenPage.AddChild(hiddenTotalXp);
hiddenPage.AddChild(hiddenMeter);
root.AddChild(attrPage);
root.AddChild(hiddenPage);
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
{
[CharacterStatController.NameId] = hiddenName,
[CharacterStatController.HeritageId] = hiddenHeritage,
[CharacterStatController.LevelId] = hiddenLevel,
[CharacterStatController.TotalXpLabelId] = hiddenTotalXpLabel,
[CharacterStatController.TotalXpId] = hiddenTotalXp,
[CharacterStatController.XpMeterId] = hiddenMeter,
[CharacterStatController.XpNextValueId] = hiddenXpNext,
});
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal("Studio Player", visibleName.LinesProvider()[0].Text);
Assert.Equal("Female Aluvian Adventurer", visibleHeritage.LinesProvider()[0].Text);
Assert.Equal("126", visibleLevel.LinesProvider()[0].Text);
Assert.Equal("Total Experience (XP):", visibleTotalXpLabel.LinesProvider()[0].Text);
Assert.Equal((1_250_000_000L).ToString("N0"), visibleTotalXp.LinesProvider()[0].Text);
Assert.Equal((42_000_000L).ToString("N0"), visibleXpNext.LinesProvider()[0].Text);
Assert.Empty(hiddenName.LinesProvider());
Assert.Empty(hiddenXpNext.LinesProvider());
}
// ── XP meter fill ────────────────────────────────────────────────────────
[Fact]
@ -514,6 +618,31 @@ public class CharacterStatControllerTests
Assert.Equal("Normal", btn10.ActiveState);
}
[Fact]
public void RaiseButtons_OnlyOneAffordable_SplitsOneAndTenStates()
{
var btn1 = MakeButton();
var btn10 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.RaiseTenId, btn10),
(CharacterStatController.ListBoxId, list));
var sheet = new CharacterSheet
{
UnassignedXp = 500L,
AttributeRaiseCosts = new long[] { 0L, 0L, 0L, 0L, 110L, 0L, 0L, 0L, 0L },
AttributeRaise10Costs = new long[] { 0L, 0L, 0L, 0L, 1_100L, 0L, 0L, 0L, 0L },
};
CharacterStatController.Bind(layout, () => sheet);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
Assert.Equal("Normal", btn1.ActiveState);
Assert.Equal("Ghosted", btn10.ActiveState);
}
[Fact]
public void RaiseButtons_MaxedRow_ShowsGhostedState()
{
@ -535,6 +664,95 @@ public class CharacterStatControllerTests
Assert.Equal("Ghosted", btn10.ActiveState);
}
[Fact]
public void RaiseButtons_ClickAffordableAttribute_EmitsRaiseRequest()
{
var btn1 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.ListBoxId, list));
var requests = new List<CharacterStatController.RaiseRequest>();
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
btn1.OnClick!();
var request = Assert.Single(requests);
Assert.Equal(CharacterStatController.RaiseTargetKind.Attribute, request.Kind);
Assert.Equal(5u, request.StatId);
Assert.Equal(110L, request.Cost);
Assert.Equal(1, request.Amount);
}
[Fact]
public void RaiseButtons_ClickAffordableAttribute_KeepsNormalStateUntilCostsRefresh()
{
var btn1 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.ListBoxId, list));
var requests = new List<CharacterStatController.RaiseRequest>();
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
Assert.Equal("Normal", btn1.ActiveState);
btn1.OnClick!();
Assert.Single(requests);
Assert.Equal("Normal", btn1.ActiveState);
}
[Fact]
public void RaiseButtons_ClickAffordableVital_EmitsMaxVitalId()
{
var btn1 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.ListBoxId, list));
var requests = new List<CharacterStatController.RaiseRequest>();
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
list.Children.OfType<UiClickablePanel>().ToList()[6].OnClick!();
btn1.OnClick!();
var request = Assert.Single(requests);
Assert.Equal(CharacterStatController.RaiseTargetKind.Vital, request.Kind);
Assert.Equal(1u, request.StatId);
Assert.Equal(90L, request.Cost);
Assert.Equal(1, request.Amount);
}
[Fact]
public void RaiseButtons_ClickUnaffordableTen_DoesNotEmitRequest()
{
var btn10 = MakeButton();
var list = new UiPanel();
var layout = Fake(
(CharacterStatController.RaiseTenId, btn10),
(CharacterStatController.ListBoxId, list));
var requests = new List<CharacterStatController.RaiseRequest>();
var sheet = new CharacterSheet
{
UnassignedXp = 500L,
AttributeRaiseCosts = new long[] { 0L, 0L, 0L, 0L, 110L, 0L, 0L, 0L, 0L },
AttributeRaise10Costs = new long[] { 0L, 0L, 0L, 0L, 1_100L, 0L, 0L, 0L, 0L },
};
CharacterStatController.Bind(layout, () => sheet, onRaiseRequest: requests.Add);
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
btn10.OnClick!();
Assert.Empty(requests);
}
[Fact]
public void RaiseButtons_Deselect_HidesButtons()
{
@ -635,10 +853,7 @@ public class CharacterStatControllerTests
ClickTab(layout, left: 92f);
var headerPanels = list.Children
.Where(c => c is UiPanel and not UiClickablePanel)
.Cast<UiPanel>()
.ToList();
var headerPanels = SkillHeaders(list);
var headers = headerPanels
.Select(c => c.Children.OfType<UiText>().First().LinesProvider()[0].Text)
.ToList();
@ -654,7 +869,7 @@ public class CharacterStatControllerTests
Assert.All(headerPanels, h =>
Assert.Equal(Vector4.One, h.Children.OfType<UiText>().First().LinesProvider()[0].Color));
var rows = list.Children.OfType<UiClickablePanel>().ToList();
var rows = SkillRows(list);
Assert.Equal(12, rows.Count);
Assert.All(rows, row =>
{
@ -692,7 +907,7 @@ public class CharacterStatControllerTests
spriteResolve: id => (id, 16, 16));
ClickTab(layout, left: 92f);
Assert.Equal(12, list.Children.OfType<UiClickablePanel>().Count());
Assert.Equal(12, SkillRows(list).Count);
ClickTab(layout, left: 0f);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
@ -704,8 +919,8 @@ public class CharacterStatControllerTests
public void SkillsTab_MouseClick_RebuildsVisibleListWhenListIdIsDuplicated()
{
var root = new UiPanel { Width = 300, Height = 600 };
var attrPage = new UiPanel { Top = 25, Width = 300, Height = 575 };
var hiddenPage = new UiPanel { Top = 25, Width = 300, Height = 575 };
var attrPage = MakeDatElement(CharacterStatController.AttributesPageId, top: 25, width: 300, height: 575);
var hiddenPage = MakeDatElement(CharacterStatController.SkillsPageId, top: 25, width: 300, height: 575);
var name = new UiText();
var visibleList = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398);
var hiddenDuplicateList = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398);
@ -761,7 +976,7 @@ public class CharacterStatControllerTests
spriteResolve: id => (id, 16, 16));
ClickTab(layout, left: 92f);
var rows = list.Children.OfType<UiClickablePanel>().ToList();
var rows = SkillRows(list);
rows[1].OnClick!();
Assert.Equal("War Magic: 285", title.LinesProvider()[0].Text);
@ -777,6 +992,31 @@ public class CharacterStatControllerTests
Assert.Equal(Vector4.Zero, rows[0].BackgroundColor);
}
[Fact]
public void SkillsTab_ClickRaiseTen_EmitsSkillRaiseRequest()
{
var list = new UiPanel { Width = 300 };
var btn10 = MakeButton();
var layout = Fake(
(CharacterStatController.ListBoxId, list),
(CharacterStatController.RaiseTenId, btn10));
var requests = new List<CharacterStatController.RaiseRequest>();
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16),
onRaiseRequest: requests.Add);
ClickTab(layout, left: 92f);
SkillRows(list)[1].OnClick!();
btn10.OnClick!();
var request = Assert.Single(requests);
Assert.Equal(CharacterStatController.RaiseTargetKind.Skill, request.Kind);
Assert.Equal(34u, request.StatId);
Assert.Equal(111_000_000L, request.Cost);
Assert.Equal(10, request.Amount);
}
[Fact]
public void SkillsTab_SelectHealing_ShowsUntrainedSkillFooter()
{
@ -800,7 +1040,7 @@ public class CharacterStatControllerTests
spriteResolve: id => (id, 16, 16));
ClickTab(layout, left: 92f);
list.Children.OfType<UiClickablePanel>().ToList()[5].OnClick!();
SkillRows(list)[5].OnClick!();
Assert.Equal("Healing", title.LinesProvider()[0].Text);
Assert.Equal("Skill Credits To Raise:", l1Label.LinesProvider()[0].Text);
@ -811,6 +1051,133 @@ public class CharacterStatControllerTests
Assert.Equal("Normal", btn1.ActiveState);
}
[Fact]
public void SkillsTab_ClickUntrainedSkill_EmitsTrainSkillRequest()
{
var list = new UiPanel { Width = 300 };
var btn1 = MakeButton();
var layout = Fake(
(CharacterStatController.ListBoxId, list),
(CharacterStatController.RaiseOneId, btn1));
var requests = new List<CharacterStatController.RaiseRequest>();
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16),
onRaiseRequest: requests.Add);
ClickTab(layout, left: 92f);
SkillRows(list)[5].OnClick!();
btn1.OnClick!();
var request = Assert.Single(requests);
Assert.Equal(CharacterStatController.RaiseTargetKind.TrainSkill, request.Kind);
Assert.Equal(21u, request.StatId);
Assert.Equal(6L, request.Cost);
Assert.Equal(1, request.Amount);
}
[Fact]
public void SkillsTab_ClickTrain_RebuildsSelectedSkillAsTrained()
{
var list = new UiPanel { Width = 300 };
var btn1 = MakeButton();
var btn10 = MakeButton();
CharacterSheet sheet = new()
{
SkillCredits = 10,
UnassignedXp = 1_000,
Skills = new[]
{
new CharacterSkill(100u, "Train Me", 0x06000001u,
CharacterSkillAdvancementClass.Untrained,
BaseLevel: 5,
CurrentLevel: 5,
UsableUntrained: true,
TrainedCost: 4,
SpecializedCost: 0,
RaiseCost: 0,
Raise10Cost: 0),
},
};
var layout = Fake(
(CharacterStatController.ListBoxId, list),
(CharacterStatController.RaiseOneId, btn1),
(CharacterStatController.RaiseTenId, btn10));
var requests = new List<CharacterStatController.RaiseRequest>();
CharacterStatController.Bind(layout, () => sheet,
spriteResolve: id => (id, 16, 16),
onRaiseRequest: request =>
{
requests.Add(request);
sheet = new CharacterSheet
{
SkillCredits = 6,
UnassignedXp = 1_000,
Skills = new[]
{
new CharacterSkill(100u, "Train Me", 0x06000001u,
CharacterSkillAdvancementClass.Trained,
BaseLevel: 5,
CurrentLevel: 5,
UsableUntrained: true,
TrainedCost: 4,
SpecializedCost: 0,
RaiseCost: 10,
Raise10Cost: 100),
},
};
});
ClickTab(layout, left: 92f);
SkillRows(list).Single().OnClick!();
Assert.False(btn10.Visible);
btn1.OnClick!();
var request = Assert.Single(requests);
Assert.Equal(CharacterStatController.RaiseTargetKind.TrainSkill, request.Kind);
Assert.Equal("Train Me", SkillRows(list).Single().Children.OfType<UiText>().ToList()[1].LinesProvider()[0].Text);
Assert.True(btn1.Visible);
Assert.True(btn10.Visible);
Assert.Equal("Normal", btn1.ActiveState);
Assert.Equal("Normal", btn10.ActiveState);
}
[Fact]
public void SkillsTab_BindsCharacterScrollbarToScrollableViewport()
{
var root = new UiPanel { Width = 300, Height = 600 };
var page = new UiPanel { Width = 300, Height = 600 };
var name = new UiText();
var list = MakeDatElement(CharacterStatController.ListBoxId, top: 137, width: 300, height: 80);
var scrollbarShell = MakeDatElement(CharacterStatController.ListScrollbarId, top: 137, width: 16, height: 80);
scrollbarShell.Left = 281;
page.AddChild(name);
page.AddChild(list);
page.AddChild(scrollbarShell);
root.AddChild(page);
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
{
[CharacterStatController.NameId] = name,
[CharacterStatController.ListBoxId] = list,
[CharacterStatController.ListScrollbarId] = scrollbarShell,
});
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
ClickTab(layout, left: 92f);
var bar = page.Children.OfType<UiScrollbar>().Single();
Assert.True(bar.Visible);
Assert.NotNull(bar.Model);
Assert.Contains(list.Children, c => c is UiScrollablePanel);
Assert.False(scrollbarShell.Visible);
}
[Fact]
public void GetRaiseCost_Index4Focus_Returns110()
{
@ -818,6 +1185,13 @@ public class CharacterStatControllerTests
Assert.Equal(110L, CharacterStatController.GetRaiseCost(sheet, 4));
}
[Fact]
public void GetRaiseCost_Amount10Index4Focus_Returns1100()
{
var sheet = SampleData.SampleCharacter();
Assert.Equal(1_100L, CharacterStatController.GetRaiseCost(sheet, 4, amount: 10));
}
[Fact]
public void GetRaiseCost_Index0Strength_Returns0()
{
@ -868,10 +1242,22 @@ public class CharacterStatControllerTests
Assert.Equal(9, costs.Length);
}
[Fact]
public void SampleCharacter_AttributeRaise10Costs_HasNineEntries()
{
var costs = SampleData.SampleCharacter().AttributeRaise10Costs;
Assert.NotNull(costs);
Assert.Equal(9, costs.Length);
}
[Fact]
public void SampleCharacter_AttributeRaiseCosts_FocusAt110()
=> Assert.Equal(110L, SampleData.SampleCharacter().AttributeRaiseCosts[4]);
[Fact]
public void SampleCharacter_AttributeRaise10Costs_FocusAt1100()
=> Assert.Equal(1_100L, SampleData.SampleCharacter().AttributeRaise10Costs[4]);
[Fact]
public void SampleCharacter_AttributeRaiseCosts_StrengthAt0()
=> Assert.Equal(0L, SampleData.SampleCharacter().AttributeRaiseCosts[0]);
@ -1098,12 +1484,32 @@ public class CharacterStatControllerTests
private static string FirstRowName(UiElement list)
{
var row = list.Children.OfType<UiClickablePanel>().FirstOrDefault();
var row = Descendants(list).OfType<UiClickablePanel>().FirstOrDefault();
if (row is null) return "<no row>";
var texts = row.Children.OfType<UiText>().ToList();
return texts.Count > 1 ? texts[1].LinesProvider()[0].Text : "<no text>";
}
private static List<UiClickablePanel> SkillRows(UiElement list)
=> Descendants(list).OfType<UiClickablePanel>().ToList();
private static List<UiPanel> SkillHeaders(UiElement list)
=> Descendants(list)
.Where(c => c is UiPanel and not UiClickablePanel
&& c.Children.OfType<UiText>().Any())
.Cast<UiPanel>()
.ToList();
private static IEnumerable<UiElement> Descendants(UiElement root)
{
foreach (var child in root.Children)
{
yield return child;
foreach (var nested in Descendants(child))
yield return nested;
}
}
private static UiDatElement MakeDatElement(uint id, float top, float width, float height)
{
var info = new ElementInfo
@ -1123,9 +1529,9 @@ public class CharacterStatControllerTests
}
/// <summary>Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).</summary>
private static UiButton MakeButton()
private static UiButton MakeButton(uint id = 0u)
{
var info = new ElementInfo { Id = 0, Type = 1 };
var info = new ElementInfo { Id = id, Type = 1 };
return new UiButton(info, static _ => (0u, 0, 0));
}

View file

@ -136,6 +136,19 @@ public class ChatWindowControllerTests
// ── Test 3: Input is placed as a child of the input bar ─────────────────
[Fact]
public void Bind_Transcript_ForcesScrollableTextMode()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex);
Assert.NotNull(ctrl);
Assert.False(ctrl!.Transcript.Centered);
Assert.False(ctrl.Transcript.RightAligned);
}
[Fact]
public void Bind_Input_IsChildOfInputBar()
{

View file

@ -63,6 +63,18 @@ public class DatWidgetFactoryTests
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors);
}
[Fact]
public void Create_PropagatesStateCursors()
{
var info = new ElementInfo { Type = 3 };
info.StateCursors["Drag_rollover_accept"] = new UiCursorMedia(0x06008888u, 7, 8);
var e = DatWidgetFactory.Create(info, NoTex, null)!;
Assert.Equal(new UiCursorMedia(0x06008888u, 7, 8),
e.CursorForState("Drag_rollover_accept", allowFallback: false));
}
// ── Test 5: ReadOrder propagated to ZOrder ───────────────────────────────
[Fact]

View file

@ -148,6 +148,22 @@ public class ElementReaderTests
Assert.Equal((0x06001001u, 1), merged.StateMedia["HideDetail"]);
}
[Fact]
public void Merge_DerivedStateCursorOverridesBase()
{
var base_ = new ElementInfo();
base_.StateCursors[""] = new UiCursorMedia(0x06003000u, 1, 2);
base_.StateCursors["Drag_rollover_accept"] = new UiCursorMedia(0x06003001u, 3, 4);
var derived = new ElementInfo();
derived.StateCursors[""] = new UiCursorMedia(0x06004000u, 5, 6);
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(new UiCursorMedia(0x06004000u, 5, 6), merged.StateCursors[""]);
Assert.Equal(new UiCursorMedia(0x06003001u, 3, 4), merged.StateCursors["Drag_rollover_accept"]);
}
[Fact]
public void Merge_ChildrenComeFromDerived()
{

View file

@ -22,6 +22,7 @@ public class InventoryControllerTests
private const uint BurdenText = 0x100001D8u;
private const uint BurdenCaption = 0x100001D7u;
private const uint ContentsCaption = 0x100001C5u;
private const uint TitleText = 0x100001D3u;
private const uint ContentsScrollbar = 0x100001C7u;
private static (ImportedLayout layout, UiItemList grid, UiItemList containers,
@ -35,16 +36,17 @@ public class InventoryControllerTests
var burdenText = new TestElement { Width = 36, Height = 15 };
var burdenCap = new TestElement { Width = 36, Height = 15 };
var contentsCap = new TestElement { Width = 192, Height = 15 };
var titleText = new TestElement { Width = 276, Height = 25 };
var scrollbar = new UiScrollbar { Width = 16, Height = 96 };
var root = new TestElement { Width = 300, Height = 362 };
root.AddChild(grid); root.AddChild(containers); root.AddChild(top);
root.AddChild(meter); root.AddChild(burdenText); root.AddChild(burdenCap);
root.AddChild(contentsCap); root.AddChild(scrollbar);
root.AddChild(contentsCap); root.AddChild(titleText); root.AddChild(scrollbar);
var byId = new Dictionary<uint, UiElement>
{
[ContentsGrid] = grid, [ContainerList] = containers, [TopContainer] = top,
[BurdenMeter] = meter, [BurdenText] = burdenText, [BurdenCaption] = burdenCap,
[ContentsCaption] = contentsCap, [ContentsScrollbar] = scrollbar,
[ContentsCaption] = contentsCap, [TitleText] = titleText, [ContentsScrollbar] = scrollbar,
};
return (new ImportedLayout(root, byId), grid, containers, top, meter,
burdenText, burdenCap, contentsCap);
@ -52,13 +54,23 @@ public class InventoryControllerTests
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
List<(uint item, uint container, int placement)>? puts = null)
List<(uint item, uint container, int placement)>? puts = null,
string? ownerName = null,
Action? onClose = null)
=> InventoryController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0u,
strength: () => strength, datFont: null,
ownerName: ownerName is null ? null : () => ownerName,
sendUse: uses is null ? null : g => uses.Add(g),
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)));
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)),
onClose: onClose);
private static UiButton MakeButton(uint id)
{
var info = new ElementInfo { Id = id, Type = 1 };
return new UiButton(info, static _ => (0u, 0, 0)) { Width = 16, Height = 16 };
}
// Seed a side bag (a container) in the player's pack, plus optionally its own contents.
private static void SeedBag(ClientObjectTable t, uint bag, int slot, int itemsCapacity = 24)
@ -100,6 +112,23 @@ public class InventoryControllerTests
Assert.Equal(0u, containers.GetItem(1)!.ItemId); // padded empty frame
}
[Fact]
public void Populate_uses_manifest_container_hint_before_create_object_details()
{
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
objects.ReplaceContents(Player, new[]
{
new ContainerContentEntry(0xC, 1u),
new ContainerContentEntry(0xA, 0u),
});
Bind(layout, objects);
Assert.Equal(0xCu, containers.GetItem(0)!.ItemId);
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
}
[Fact]
public void Equipped_items_are_excluded_from_the_grid_and_selector()
{
@ -125,6 +154,7 @@ public class InventoryControllerTests
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
Bind(layout, new ClientObjectTable());
Assert.Equal(6, grid.Columns);
Assert.Equal(UiItemListFlow.RowMajor, grid.Flow);
Assert.Equal(32f, grid.CellWidth);
Assert.Equal(32f, grid.CellHeight);
}
@ -164,11 +194,27 @@ public class InventoryControllerTests
public void Captions_render_known_strings()
{
var (layout, _, _, _, _, _, burdenCap, contentsCap) = BuildLayout();
Bind(layout, new ClientObjectTable());
var title = layout.FindElement(TitleText)!;
Bind(layout, new ClientObjectTable(), ownerName: "Horan");
Assert.Contains("Inventory of Horan", CaptionText(title));
Assert.Contains("Burden", CaptionText(burdenCap));
Assert.Contains("Contents of Backpack", CaptionText(contentsCap));
}
[Fact]
public void Window_chrome_button_invokes_close_callback()
{
var (layout, _, _, _, _, _, _, _) = BuildLayout();
var close = MakeButton(WindowChromeController.InventoryCloseButtonId);
layout.Root.AddChild(close);
int closes = 0;
Bind(layout, new ClientObjectTable(), onClose: () => closes++);
close.OnEvent(new UiEvent(0u, close, UiEventType.Click));
Assert.Equal(1, closes);
}
[Fact]
public void Burden_reads_wire_EncumbranceVal_over_carried_sum()
{
@ -354,6 +400,42 @@ public class InventoryControllerTests
Assert.Empty(closes);
}
[Fact]
public void TargetMode_suppressesSelectedSquare_onPendingSource()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Type = ItemType.Creature,
ItemsCapacity = 102,
});
SeedContained(objects, 0xA, Player, slot: 0);
objects.Get(0xA)!.Useability = 0x000A0008u;
var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
nowMs: () => 1_000);
InventoryController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0u,
strength: () => 100,
datFont: null,
itemInteraction: interaction);
grid.GetItem(0)!.Clicked!();
Assert.True(grid.GetItem(0)!.Selected);
Assert.True(interaction.ActivateItem(0xAu));
Assert.True(interaction.IsTargetModeActive);
Assert.False(grid.GetItem(0)!.Selected);
}
[Fact]
public void Default_mainPackCell_isOpenContainer()
{

View file

@ -2,6 +2,7 @@ using System;
using System.IO;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
@ -86,4 +87,34 @@ public class InventoryFrameImportProbe
"(else the Alphablend backdrop overpaints/washes out the panel content)");
}
}
[Fact]
public void Close_button_resolves_and_invokes_controller_close_callback()
{
var datDir = DatDir();
if (datDir is null) return; // CI: no live dat - skip
using var dats = new DatCollection(datDir, DatAccessType.Read);
var layout = LayoutImporter.Import(dats, Frame, _ => (0u, 0, 0), null);
Assert.NotNull(layout);
var close = layout!.FindElement(WindowChromeController.InventoryCloseButtonId);
Assert.NotNull(close);
Assert.True(close is UiButton or UiDatElement,
$"inventory close button resolved to {close?.GetType().Name ?? "null"}");
int closes = 0;
InventoryController.Bind(
layout,
new ClientObjectTable(),
playerGuid: static () => 0u,
iconIds: static (_, _, _, _, _) => 0u,
strength: static () => 100,
datFont: null,
onClose: () => closes++);
close!.OnEvent(new UiEvent(0u, close, UiEventType.Click));
Assert.Equal(1, closes);
}
}

View file

@ -40,6 +40,22 @@ public class LayoutImporterTests
/// draws nothing until a controller binds its <c>LinesProvider</c>);
/// the Type-3 must also be present.
/// </summary>
[Fact]
public void BuildFromInfos_StampsDatElementId_OnRootAndItemList()
{
const uint RootId = 0x100005F9u;
const uint ListId = 0x100001C6u;
var root = new ElementInfo { Id = RootId, Type = 3, Width = 160, Height = 58 };
var itemList = new ElementInfo { Id = ListId, Type = 0x10000031u, X = 5, Y = 5, Width = 72, Height = 72 };
var tree = LayoutImporter.BuildFromInfos(root, new[] { itemList }, NoTex, null);
Assert.Equal(RootId, tree.Root.DatElementId);
var found = Assert.IsType<UiItemList>(tree.FindElement(ListId));
Assert.Equal(ListId, found.DatElementId);
}
[Fact]
public void BuildFromInfos_Type12Child_IsSkipped_Type3Present()
{

View file

@ -11,6 +11,8 @@ public class PaperdollControllerTests
private const uint Player = 0x50000001u;
private const uint Pack = 0x40000005u;
private const uint HeadSlot = 0x100005ABu; // HeadWear 0x1
private const uint ChestSlot = 0x100001E2u; // ChestWear 0x2
private const uint ChestArmorSlot = 0x100005ACu; // ChestArmor 0x200
private const uint ShieldSlot = 0x100001E1u; // Shield 0x200000
private const uint WeaponSlot = 0x100001DFu; // composite 0x3500000
private const uint FingerLSlot= 0x100001DCu; // FingerWearLeft 0x40000
@ -19,7 +21,7 @@ public class PaperdollControllerTests
private static (ImportedLayout layout, Dictionary<uint, UiItemList> lists) BuildLayout()
{
var ids = new[] { HeadSlot, ShieldSlot, WeaponSlot, FingerLSlot };
var ids = new[] { HeadSlot, ChestSlot, ChestArmorSlot, ShieldSlot, WeaponSlot, FingerLSlot };
var lists = new Dictionary<uint, UiItemList>();
var byId = new Dictionary<uint, UiElement>();
var root = new RootElement { Width = 224, Height = 214 };
@ -112,6 +114,47 @@ public class PaperdollControllerTests
Assert.Equal((uint)EquipMask.FingerWearLeft, wields[0].mask); // ValidLocations & slotMask = left finger only
}
[Fact]
public void HandleDropRelease_onAutoWearSlot_sendsFullValidLocations()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
const EquipMask coatMask =
EquipMask.ChestWear
| EquipMask.UpperArmWear
| EquipMask.LowerArmWear;
SeedPackItem(objects, 0xE02u, coatMask);
var wields = new List<(uint item, uint mask)>();
var ctrl = Bind(layout, objects, wields);
var payload = new ItemDragPayload(0xE02u, ItemDragSource.Inventory, 0, lists[ChestSlot].Cell);
ctrl.HandleDropRelease(lists[ChestSlot], lists[ChestSlot].Cell, payload);
Assert.Equal((uint)coatMask, wields[0].mask);
Assert.Equal(coatMask, objects.Get(0xE02u)!.CurrentlyEquippedLocation);
}
[Fact]
public void HandleDropRelease_onArmorAutoWearSlot_sendsFullValidLocations()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
const EquipMask hauberkMask =
EquipMask.ChestArmor
| EquipMask.AbdomenArmor
| EquipMask.UpperArmArmor
| EquipMask.LowerArmArmor;
SeedPackItem(objects, 0xE03u, hauberkMask);
var wields = new List<(uint item, uint mask)>();
var ctrl = Bind(layout, objects, wields);
var payload = new ItemDragPayload(0xE03u, ItemDragSource.Inventory, 0, lists[ChestArmorSlot].Cell);
ctrl.HandleDropRelease(lists[ChestArmorSlot], lists[ChestArmorSlot].Cell, payload);
Assert.Equal((uint)hauberkMask, wields[0].mask);
Assert.Equal(hauberkMask, objects.Get(0xE03u)!.CurrentlyEquippedLocation);
}
[Fact]
public void Empty_equip_slot_shows_the_configured_frame() // visible frame so all positions are usable (Slice 1; the doll is Slice 2)
{

View file

@ -18,6 +18,8 @@ public class ToolbarControllerTests
// The four mutually-exclusive combat-mode indicator element ids (must match ToolbarController's list).
private static readonly uint[] CombatIds = { 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
private const uint CharacterButtonId = 0x10000199u;
private const uint InventoryButtonId = 0x100001B1u;
private static (ImportedLayout layout, Dictionary<uint, UiItemList> slots,
Dictionary<uint, UiElement> indicators) FakeToolbar()
@ -34,6 +36,8 @@ public class ToolbarControllerTests
var e = new UiPanel { Visible = true };
dict[id] = e; indicators[id] = e; root.AddChild(e);
}
AddButton(CharacterButtonId);
AddButton(InventoryButtonId);
return (new ImportedLayout(root, dict), slots, indicators);
void AddSlot(uint id)
@ -41,6 +45,23 @@ public class ToolbarControllerTests
var list = new UiItemList(_ => (0u, 0, 0)) { Width = 32, Height = 32 };
dict[id] = list; slots[id] = list; root.AddChild(list);
}
void AddButton(uint id)
{
var info = new ElementInfo
{
Id = id,
Type = 1,
Width = 32,
Height = 32,
DefaultStateName = "Normal",
};
info.StateMedia["Normal"] = (0x1u, 1);
info.StateMedia["Highlight"] = (0x2u, 1);
var button = new UiButton(info, _ => (0u, 0, 0)) { Width = 32, Height = 32 };
dict[id] = button;
root.AddChild(button);
}
}
[Fact]
@ -95,6 +116,93 @@ public class ToolbarControllerTests
Assert.Equal(0x5001u, used);
}
[Fact]
public void WindowToggleButtons_clickCallbacks_fire()
{
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
int inventoryClicks = 0;
int characterClicks = 0;
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
ctrl.BindWindowToggles(
toggleInventory: () => inventoryClicks++,
toggleCharacter: () => characterClicks++);
((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
((UiButton)layout.FindElement(CharacterButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
Assert.Equal(1, inventoryClicks);
Assert.Equal(1, characterClicks);
}
[Fact]
public void InventoryButton_whenTargetModeActive_targetsPlayerInsteadOfTogglingInventory()
{
const uint player = 0x50000001u;
const uint pack = 0x50000010u;
const uint kit = 0x50000A01u;
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container, ItemsCapacity = 24 });
repo.MoveItem(pack, player, 0);
repo.AddOrUpdate(new ClientObject { ObjectId = kit, Type = ItemType.Misc, Useability = 0x000A0008u });
repo.MoveItem(kit, pack, 0);
var useWithTarget = new List<(uint Source, uint Target)>();
var interaction = new ItemInteractionController(
repo,
playerGuid: () => player,
sendUse: null,
sendUseWithTarget: (source, target) => useWithTarget.Add((source, target)),
sendWield: null,
sendDrop: null,
nowMs: () => 1_000);
int inventoryClicks = 0;
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u,
useItem: _ => { },
itemInteraction: interaction);
ctrl.BindWindowToggles(
toggleInventory: () => inventoryClicks++,
toggleCharacter: () => { });
interaction.ActivateItem(kit);
((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
Assert.Equal(0, inventoryClicks);
Assert.Equal(new[] { (kit, player) }, useWithTarget);
Assert.False(interaction.IsTargetModeActive);
}
[Fact]
public void WindowButtonState_highlightsWhenOpen()
{
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
var inventoryButton = (UiButton)layout.FindElement(InventoryButtonId)!;
var characterButton = (UiButton)layout.FindElement(CharacterButtonId)!;
ctrl.SetInventoryOpen(true);
ctrl.SetCharacterOpen(true);
Assert.Equal("Highlight", inventoryButton.ActiveState);
Assert.Equal("Highlight", characterButton.ActiveState);
ctrl.SetInventoryOpen(false);
ctrl.SetCharacterOpen(false);
Assert.Equal("Normal", inventoryButton.ActiveState);
Assert.Equal("Normal", characterButton.ActiveState);
}
// ── C1: combat-mode indicator tests ─────────────────────────────────────
/// <summary>

View file

@ -0,0 +1,61 @@
using AcDream.App.Rendering;
using AcDream.App.UI;
using DatReaderWriter;
using DatReaderWriter.Options;
using SysEnv = System.Environment;
namespace AcDream.App.Tests.UI;
public sealed class RetailCursorCatalogTests
{
[Theory]
[InlineData(CursorFeedbackKind.TargetPending, 0x27u, 14, 14)]
[InlineData(CursorFeedbackKind.TargetValid, 0x28u, 14, 14)]
[InlineData(CursorFeedbackKind.TargetInvalid, 0x29u, 14, 14)]
public void TargetUseCursorSpecs_matchClientUISystemUpdateCursorState(
CursorFeedbackKind kind,
uint expectedEnum,
int expectedHotspotX,
int expectedHotspotY)
{
Assert.Equal(6u, RetailCursorCatalog.CursorEnumTable);
Assert.True(RetailCursorCatalog.TryGetGlobalCursor(kind, out var spec));
Assert.Equal(expectedEnum, spec.EnumId);
Assert.Equal(expectedHotspotX, spec.HotspotX);
Assert.Equal(expectedHotspotY, spec.HotspotY);
}
[Fact]
public void TargetUseCursorSpecs_resolveGoldenDatSurfaces()
{
string? datDir = ResolveDatDir();
if (datDir is null)
return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
var resolver = new RetailCursorResolver(dats, new object());
Assert.True(resolver.TryResolve(new RetailCursorSpec(0x27u, 14, 14), out var pending));
Assert.True(resolver.TryResolve(new RetailCursorSpec(0x28u, 14, 14), out var valid));
Assert.True(resolver.TryResolve(new RetailCursorSpec(0x29u, 14, 14), out var invalid));
Assert.Equal(new UiCursorMedia(0x06004D73u, 14, 14), pending);
Assert.Equal(new UiCursorMedia(0x06005E6Bu, 14, 14), valid);
Assert.Equal(new UiCursorMedia(0x06005E6Au, 14, 14), invalid);
}
private static string? ResolveDatDir()
{
string? fromEnv = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
return fromEnv;
string defaultDir = Path.Combine(
SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
return Directory.Exists(defaultDir) ? defaultDir : null;
}
}

View file

@ -0,0 +1,179 @@
using System.Collections.Generic;
using System.IO;
using AcDream.App.UI;
using AcDream.App.UI.Testing;
using AcDream.Core.Items;
namespace AcDream.App.Tests.UI;
public sealed class RetailUiAutomationProbeTests
{
private sealed class SpyHandler : IItemListDragHandler
{
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop;
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift;
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
=> LastLift = (sourceList, sourceCell, payload);
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
=> true;
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
=> LastDrop = (targetList, targetCell, payload);
}
private static (UiRoot root, UiItemList source, UiItemList target, SpyHandler handler, ClientObjectTable objects)
RootWithTwoItemLists()
{
var root = new UiRoot { Width = 240, Height = 120 };
var objects = new ClientObjectTable();
var source = new UiItemList(_ => (1u, 1, 1))
{
DatElementId = 0x10000010u,
Left = 10,
Top = 10,
Width = 32,
Height = 32,
};
source.Cell.SlotIndex = 0;
source.Cell.SourceKind = ItemDragSource.Inventory;
source.Cell.SetItem(0x5001u, 0x99u);
var target = new UiItemList(_ => (1u, 1, 1))
{
DatElementId = 0x10000020u,
Left = 70,
Top = 10,
Width = 32,
Height = 32,
};
target.Cell.SlotIndex = 1;
var handler = new SpyHandler();
target.RegisterDragHandler(handler);
root.AddChild(source);
root.AddChild(target);
return (root, source, target, handler, objects);
}
[Fact]
public void Snapshot_listsDatElementsAndItemSlots()
{
var (root, _, _, _, objects) = RootWithTwoItemLists();
var probe = new RetailUiAutomationProbe(root, objects);
var rows = probe.Snapshot();
Assert.Contains(rows, r => r.DatElementId == 0x10000010u && r.TypeName == nameof(UiItemList));
var itemRow = Assert.Single(rows, r => r.ItemId == 0x5001u);
Assert.Equal(ItemDragSource.Inventory, itemRow.SourceKind);
Assert.Equal(0, itemRow.SlotIndex);
Assert.True(itemRow.Width > 0f);
Assert.True(itemRow.Height > 0f);
}
[Fact]
public void DoubleClickItem_routesThroughUiRootDoubleClick()
{
var (root, source, _, _, objects) = RootWithTwoItemLists();
bool doubleClicked = false;
source.Cell.DoubleClicked = () => doubleClicked = true;
var probe = new RetailUiAutomationProbe(root, objects);
Assert.True(probe.DoubleClickItem(0x5001u, ItemDragSource.Inventory));
Assert.True(doubleClicked);
}
[Fact]
public void DragItemToElement_deliversDropReleaseToTargetCell()
{
var (root, _, target, handler, objects) = RootWithTwoItemLists();
var probe = new RetailUiAutomationProbe(root, objects);
Assert.True(probe.DragItemToElement(0x5001u, 0x10000020u, ItemDragSource.Inventory));
Assert.NotNull(handler.LastDrop);
Assert.Same(target, handler.LastDrop!.Value.list);
Assert.Same(target.Cell, handler.LastDrop.Value.cell);
Assert.Equal(0x5001u, handler.LastDrop.Value.payload.ObjId);
}
[Fact]
public void DragItemOutside_raisesRootOutsideUiEvent()
{
var (root, _, _, _, objects) = RootWithTwoItemLists();
object? payload = null;
(int x, int y) release = default;
root.DragReleasedOutsideUi += (p, x, y) =>
{
payload = p;
release = (x, y);
};
var probe = new RetailUiAutomationProbe(root, objects);
Assert.True(probe.DragItemOutside(0x5001u, 220, 100, ItemDragSource.Inventory));
var itemPayload = Assert.IsType<ItemDragPayload>(payload);
Assert.Equal(0x5001u, itemPayload.ObjId);
Assert.Equal((220, 100), release);
}
[Fact]
public void AssertItem_checksObjectTableState()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x5001u,
ContainerId = 0x7001u,
ContainerSlot = 3,
CurrentlyEquippedLocation = EquipMask.ChestArmor | EquipMask.UpperArmArmor,
});
var probe = new RetailUiAutomationProbe(new UiRoot(), objects);
var ok = probe.AssertItem(
0x5001u,
equippedLocation: EquipMask.ChestArmor | EquipMask.UpperArmArmor,
containerId: 0x7001u,
slot: 3);
var bad = probe.AssertItem(0x5001u, slot: 4);
Assert.True(ok.Success);
Assert.False(bad.Success);
}
[Fact]
public void ScriptRunner_waitItemThenDoubleClick_executesThroughProbe()
{
var (root, source, _, _, objects) = RootWithTwoItemLists();
bool doubleClicked = false;
source.Cell.DoubleClicked = () => doubleClicked = true;
var probe = new RetailUiAutomationProbe(root, objects);
var logs = new List<string>();
string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt");
File.WriteAllLines(path, new[]
{
"wait item 0x5001 inventory 100",
"doubleclick item 0x5001 inventory",
});
try
{
var runner = new RetailUiAutomationScriptRunner(probe, path, dumpOnStart: false, logs.Add);
runner.Tick(0.016);
Assert.True(doubleClicked);
Assert.True(runner.Completed);
Assert.Contains(logs, line => line.Contains("UI probe script complete"));
}
finally
{
File.Delete(path);
}
}
}

View file

@ -0,0 +1,291 @@
using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.UI.Testing;
using AcDream.Core.Items;
namespace AcDream.App.Tests.UI;
public sealed class RetailUiInteractionFlowTests
{
private const uint Player = 0x50000001u;
private const uint Hauberk = 0x50001001u;
private const uint HealthKit = 0x50001002u;
private const uint SlotsButtonId = 0x100005BEu;
private const uint ChestArmorSlotId = 0x100005ACu;
private const uint HealthKitUseability = 0x000A0008u;
private const EquipMask HauberkMask =
EquipMask.ChestArmor
| EquipMask.AbdomenArmor
| EquipMask.UpperArmArmor
| EquipMask.LowerArmArmor;
private sealed class TestElement : UiElement { }
private sealed class Harness
{
public readonly UiRoot Root = new() { Width = 800, Height = 600 };
public readonly ClientObjectTable Objects = new();
public readonly ImportedLayout Layout;
public readonly List<uint> Uses = new();
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
public readonly List<(uint Item, uint Mask)> Wields = new();
public readonly List<uint> Drops = new();
public long Now = 10_000;
public Harness()
{
var layoutRoot = new TestElement { Width = 360, Height = 180 };
var grid = ItemList(
InventoryController.ContentsGridId,
left: 10,
top: 10,
width: 192,
height: 96);
var sideBags = ItemList(
InventoryController.ContainerListId,
left: 210,
top: 10,
width: 36,
height: 96);
var mainPack = ItemList(
InventoryController.TopContainerId,
left: 210,
top: 112,
width: 36,
height: 36);
var chestArmor = ItemList(
ChestArmorSlotId,
left: 280,
top: 10,
width: 32,
height: 32);
var slotsButton = new UiButton(
new ElementInfo { Id = SlotsButtonId, Type = 1 },
static _ => (0u, 0, 0))
{
DatElementId = SlotsButtonId,
Left = 270,
Top = 56,
Width = 56,
Height = 20,
};
var meter = new UiMeter
{
DatElementId = InventoryController.BurdenMeterId,
Left = 248,
Top = 10,
Width = 11,
Height = 58,
};
var titleText = TextHost(InventoryController.TitleTextId, 10, 116, 192, 14);
var burdenText = TextHost(InventoryController.BurdenTextId, 248, 72, 36, 14);
var burdenCaption = TextHost(InventoryController.BurdenCaptionId, 248, 88, 36, 14);
var contentsCaption = TextHost(InventoryController.ContentsCaptionId, 10, 132, 192, 14);
var scrollbar = new UiScrollbar
{
DatElementId = InventoryController.ContentsScrollbarId,
Left = 192,
Top = 10,
Width = 16,
Height = 96,
};
var byId = new Dictionary<uint, UiElement>
{
[InventoryController.ContentsGridId] = grid,
[InventoryController.ContainerListId] = sideBags,
[InventoryController.TopContainerId] = mainPack,
[InventoryController.BurdenMeterId] = meter,
[InventoryController.TitleTextId] = titleText,
[InventoryController.BurdenTextId] = burdenText,
[InventoryController.BurdenCaptionId] = burdenCaption,
[InventoryController.ContentsCaptionId] = contentsCaption,
[InventoryController.ContentsScrollbarId] = scrollbar,
[ChestArmorSlotId] = chestArmor,
[SlotsButtonId] = slotsButton,
};
layoutRoot.AddChild(grid);
layoutRoot.AddChild(sideBags);
layoutRoot.AddChild(mainPack);
layoutRoot.AddChild(chestArmor);
layoutRoot.AddChild(slotsButton);
layoutRoot.AddChild(meter);
layoutRoot.AddChild(titleText);
layoutRoot.AddChild(burdenText);
layoutRoot.AddChild(burdenCaption);
layoutRoot.AddChild(contentsCaption);
layoutRoot.AddChild(scrollbar);
Layout = new ImportedLayout(layoutRoot, byId);
Root.AddChild(layoutRoot);
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Name = "Player",
Type = ItemType.Creature,
ItemsCapacity = 102,
ContainersCapacity = 7,
});
}
public ItemInteractionController BindInventoryInteraction()
{
var interaction = new ItemInteractionController(
Objects,
playerGuid: () => Player,
sendUse: Uses.Add,
sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)),
sendWield: (item, mask) => Wields.Add((item, mask)),
sendDrop: Drops.Add,
nowMs: () => Now);
InventoryController.Bind(
Layout,
Objects,
playerGuid: () => Player,
iconIds: static (_, _, _, _, _) => 0x1234u,
strength: () => 100,
datFont: null,
itemInteraction: interaction);
Root.DragReleasedOutsideUi += (payload, _, _) =>
{
if (payload is ItemDragPayload itemPayload)
interaction.DropToWorld(itemPayload);
};
return interaction;
}
public void BindPaperdoll()
=> PaperdollController.Bind(
Layout,
Objects,
playerGuid: () => Player,
iconIds: static (_, _, _, _, _) => 0x1234u,
sendWield: (item, mask) => Wields.Add((item, mask)),
emptySlotSprite: 0x06004D20u);
public RetailUiAutomationProbe Probe()
=> new(Root, Objects);
public void SeedHauberk(int slot = 0)
{
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Hauberk,
Name = "Hauberk",
Type = ItemType.Armor,
ValidLocations = HauberkMask,
IconId = 0x06001234u,
});
Objects.MoveItem(Hauberk, Player, slot);
}
public void SeedHealthKit(int slot = 0)
{
Objects.AddOrUpdate(new ClientObject
{
ObjectId = HealthKit,
Name = "Health Kit",
Type = ItemType.Misc,
IconId = 0x06001235u,
Useability = HealthKitUseability,
});
Objects.MoveItem(HealthKit, Player, slot);
}
private static UiItemList ItemList(uint id, float left, float top, float width, float height)
=> new(static _ => (1u, 32, 32))
{
DatElementId = id,
Left = left,
Top = top,
Width = width,
Height = height,
};
private static UiElement TextHost(uint id, float left, float top, float width, float height)
=> new TestElement
{
DatElementId = id,
Left = left,
Top = top,
Width = width,
Height = height,
ClickThrough = true,
};
}
[Fact]
public void InventoryDoubleClick_hauberk_routesThroughUiRootAndEquipsFullMask()
{
var h = new Harness();
h.SeedHauberk();
h.BindInventoryInteraction();
var probe = h.Probe();
Assert.True(probe.DoubleClickItem(Hauberk, ItemDragSource.Inventory));
Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields);
var state = probe.AssertItem(
Hauberk,
equippedLocation: HauberkMask,
containerId: Player);
Assert.True(state.Success, state.Message);
}
[Fact]
public void InventoryTargetUse_thenMainPackClick_selfTargetsThroughUiRoot()
{
var h = new Harness();
h.SeedHealthKit();
var interaction = h.BindInventoryInteraction();
var probe = h.Probe();
Assert.True(probe.DoubleClickItem(HealthKit, ItemDragSource.Inventory));
Assert.True(interaction.IsTargetModeActive);
Assert.True(probe.ClickElement(InventoryController.TopContainerId));
Assert.Equal(new[] { (HealthKit, Player) }, h.UseWithTarget);
Assert.False(interaction.IsTargetModeActive);
}
[Fact]
public void InventoryDragOutsideUi_routesThroughRootOutsideHookAndDropsToWorld()
{
var h = new Harness();
h.SeedHauberk();
h.BindInventoryInteraction();
var probe = h.Probe();
Assert.True(probe.DragItemOutside(Hauberk, 700, 500, ItemDragSource.Inventory));
Assert.Equal(new[] { Hauberk }, h.Drops);
var state = probe.AssertItem(Hauberk, containerId: 0u, slot: -1);
Assert.True(state.Success, state.Message);
}
[Fact]
public void InventoryDragToPaperdollChestArmorSlot_usesFullHauberkCoverageMask()
{
var h = new Harness();
h.SeedHauberk();
h.BindInventoryInteraction();
h.BindPaperdoll();
var probe = h.Probe();
Assert.True(probe.ClickElement(SlotsButtonId)); // show armor slots; retail defaults to doll-view.
Assert.True(probe.DragItemToElement(Hauberk, ChestArmorSlotId, ItemDragSource.Inventory));
Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields);
var state = probe.AssertItem(
Hauberk,
equippedLocation: HauberkMask,
containerId: Player);
Assert.True(state.Success, state.Message);
}
}

View file

@ -12,6 +12,14 @@ public class UiItemListGridTests
Assert.Equal((36f, 36f), UiItemList.CellOffset(4, 3, 36, 36)); // col 1, row 1
}
[Fact]
public void CellOffset_ColumnMajor_UsesLogicalRows()
{
Assert.Equal((0f, 0f), UiItemList.CellOffset(0, 3, 7, UiItemListFlow.ColumnMajor, 36, 36));
Assert.Equal((0f, 36f), UiItemList.CellOffset(1, 3, 7, UiItemListFlow.ColumnMajor, 36, 36));
Assert.Equal((36f, 0f), UiItemList.CellOffset(3, 3, 7, UiItemListFlow.ColumnMajor, 36, 36));
}
[Fact]
public void GridMode_PositionsCellsInColumns()
{
@ -26,6 +34,25 @@ public class UiItemListGridTests
Assert.Equal(36f, c4.Height);
}
[Fact]
public void GridMode_ColumnMajor_PositionsCellsDownRowsFirst()
{
var list = new UiItemList
{
Columns = 3,
Flow = UiItemListFlow.ColumnMajor,
CellWidth = 36,
CellHeight = 36,
};
list.Flush();
for (int i = 0; i < 7; i++) list.AddItem(new UiItemSlot());
var c3 = list.GetItem(3)!;
Assert.Equal(36f, c3.Left);
Assert.Equal(0f, c3.Top);
}
[Fact]
public void FillMode_SizesSingleCellToList()
{

View file

@ -39,6 +39,18 @@ public class UiItemSlotTests
Assert.Equal(0u, s.IconTexture);
}
[Fact]
public void DoubleClick_invokesDoubleClicked()
{
var s = new UiItemSlot();
bool fired = false;
s.DoubleClicked = () => fired = true;
s.OnEvent(new UiEvent(0u, s, UiEventType.DoubleClick));
Assert.True(fired);
}
// ── Shortcut number tests ────────────────────────────────────────────────
// Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).

View file

@ -18,11 +18,14 @@ public class UiRootInputTests
{
private readonly bool _handlesClick;
public bool Clicked;
public int Clicks;
public int DoubleClicks;
public ClickRecorder(bool handlesClick) => _handlesClick = handlesClick;
public override bool HandlesClick => _handlesClick;
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click) { Clicked = true; return true; }
if (e.Type == UiEventType.Click) { Clicked = true; Clicks++; return true; }
if (e.Type == UiEventType.DoubleClick) { DoubleClicks++; return true; }
return false;
}
}
@ -45,6 +48,51 @@ public class UiRootInputTests
Assert.True(btn.Clicked);
}
[Fact]
public void DoubleClick_EmitsRealDoubleClickEvent()
{
var root = new UiRoot { Width = 800, Height = 600 };
var btn = new ClickRecorder(handlesClick: true) { Left = 5, Top = 5, Width = 120, Height = 14 };
root.AddChild(btn);
root.Tick(0, nowMs: 1_000);
root.OnMouseDown(UiMouseButton.Left, 20, 10);
root.OnMouseUp(UiMouseButton.Left, 20, 10);
root.Tick(0, nowMs: 1_300);
root.OnMouseDown(UiMouseButton.Left, 20, 10);
root.OnMouseUp(UiMouseButton.Left, 20, 10);
Assert.Equal(2, btn.Clicks);
Assert.Equal(1, btn.DoubleClicks);
}
[Fact]
public void ItemDragReleasedOutsideUi_raisesOutsideReleaseEvent()
{
var root = new UiRoot { Width = 800, Height = 600 };
var cell = new UiItemSlot { Left = 0, Top = 0, Width = 32, Height = 32 };
cell.SetItem(0x50000A01u, 0x99u);
root.AddChild(cell);
object? payload = null;
int releaseX = 0;
int releaseY = 0;
root.DragReleasedOutsideUi += (p, x, y) =>
{
payload = p;
releaseX = x;
releaseY = y;
};
root.OnMouseDown(UiMouseButton.Left, 5, 5);
root.OnMouseMove(20, 20);
root.OnMouseUp(UiMouseButton.Left, 200, 200);
var drag = Assert.IsType<ItemDragPayload>(payload);
Assert.Equal(0x50000A01u, drag.ObjId);
Assert.Equal(200, releaseX);
Assert.Equal(200, releaseY);
}
[Fact]
public void PlainWidget_insideDraggableWindow_doesNotEmitClick()
{
@ -327,14 +375,18 @@ public class UiRootInputTests
root.AddChild(win);
root.RegisterWindow("inventory", win);
Assert.False(root.IsWindowVisible("inventory"));
Assert.True(root.ShowWindow("inventory"));
Assert.True(win.Visible);
Assert.True(root.IsWindowVisible("inventory"));
Assert.True(root.HideWindow("inventory"));
Assert.False(win.Visible);
Assert.False(root.IsWindowVisible("inventory"));
Assert.False(root.ShowWindow("nope"));
Assert.False(root.HideWindow("nope"));
Assert.False(root.ToggleWindow("nope"));
Assert.False(root.IsWindowVisible("nope"));
}
[Fact]

View file

@ -0,0 +1,39 @@
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public sealed class UiScrollablePanelTests
{
[Fact]
public void LayoutScrollableChildren_ClipsRowsOutsideViewport()
{
var panel = new UiScrollablePanel { Width = 100, Height = 40, LineHeight = 20 };
for (int i = 0; i < 4; i++)
panel.AddChild(new UiPanel { Top = i * 20, Width = 100, Height = 20 });
panel.LayoutScrollableChildren();
Assert.True(panel.Children[0].Visible);
Assert.True(panel.Children[1].Visible);
Assert.False(panel.Children[2].Visible);
panel.Scroll.SetScrollY(20);
panel.LayoutScrollableChildren();
Assert.False(panel.Children[0].Visible);
Assert.True(panel.Children[1].Visible);
Assert.True(panel.Children[2].Visible);
}
[Fact]
public void ScrollEvent_MovesByLineHeight()
{
var panel = new UiScrollablePanel { Width = 100, Height = 40, LineHeight = 20 };
for (int i = 0; i < 4; i++)
panel.AddChild(new UiPanel { Top = i * 20, Width = 100, Height = 20 });
panel.LayoutScrollableChildren();
var e = new UiEvent(0u, panel, UiEventType.Scroll, Data0: -1);
Assert.True(panel.OnEvent(in e));
Assert.Equal(20, panel.Scroll.ScrollY);
}
}

View file

@ -37,14 +37,14 @@ public sealed class GameEventWiringTests
return body;
}
private static (GameEventDispatcher, ClientObjectTable, CombatState, Spellbook, ChatLog) MakeAll()
private static (GameEventDispatcher, ClientObjectTable, CombatState, Spellbook, ChatLog) MakeAll(Func<uint>? playerGuid = null)
{
var dispatcher = new GameEventDispatcher();
var items = new ClientObjectTable();
var combat = new CombatState();
var spellbook = new Spellbook();
var chat = new ChatLog();
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat);
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, playerGuid: playerGuid);
return (dispatcher, items, combat, spellbook, chat);
}
@ -123,20 +123,20 @@ public sealed class GameEventWiringTests
[Fact]
public void WireAll_WieldObject_ConfirmsOptimisticWield()
{
var (d, items, _, _, _) = MakeAll();
const uint player = 0x2000u;
var (d, items, _, _, _) = MakeAll(() => player);
items.AddOrUpdate(new ClientObject { ObjectId = 0x1500, WeenieClassId = 1 });
items.MoveItem(0x1500, 0x9999u, newSlot: 0); // start in a pack
items.WieldItemOptimistic(0x1500, player, EquipMask.MeleeWeapon); // optimistic wield → snapshot pending
byte[] payload = new byte[12];
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), player);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload));
d.Dispatch(env!.Value); // server confirms the wield
Assert.False(items.RollbackMove(0x1500)); // pending snapshot was cleared by ConfirmMove
Assert.Equal(player, items.Get(0x1500)!.ContainerId);
}
[Fact]
@ -215,6 +215,59 @@ public sealed class GameEventWiringTests
Assert.Equal(0.5f, local.ManaPercent!.Value, precision: 3);
}
[Fact]
public void WireAll_PlayerDescription_PopulatesLocalPlayerStateSkills()
{
var dispatcher = new GameEventDispatcher();
var items = new ClientObjectTable();
var combat = new CombatState();
var spellbook = new Spellbook();
var chat = new ChatLog();
var local = new LocalPlayerState();
int callbackRun = -1;
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, local,
onSkillsUpdated: (run, _) => callbackRun = run,
resolveSkillFormulaBonus: (skillId, attrs) =>
{
Assert.Equal(24u, skillId);
Assert.Equal(50u, attrs[1u]);
return 80u;
});
var sb = new MemoryStream();
using var w = new BinaryWriter(sb);
w.Write(0u); // propertyFlags
w.Write(0x52u); // weenieType
w.Write(0x03u); // vectorFlags = Attribute | Skill
w.Write(0u); // has_health = false
w.Write(0x01u); // attribute_flags = Strength only
w.Write(0u); // Strength ranks
w.Write(50u); // Strength start
w.Write(0u); // Strength xp
w.Write((ushort)1); // skill count
w.Write((ushort)8); // buckets
w.Write(24u); // Run
w.Write((ushort)12);
w.Write((ushort)1); // const_one
w.Write(2u); // trained
w.Write(3456u); // xp
w.Write(30u); // init
w.Write(0u); // resistance
w.Write(1.5); // last used
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
dispatcher.Dispatch(env!.Value);
var run = local.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(122, callbackRun);
}
[Fact]
public void WireAll_PlayerDescription_FeedsSpellbook()
{
@ -392,6 +445,48 @@ public sealed class GameEventWiringTests
Assert.Equal(2, items.ObjectCount);
Assert.NotNull(items.Get(0x50000A01u));
Assert.NotNull(items.Get(0x50000A02u));
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint);
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
}
[Fact]
public void PlayerDescription_ReplacesPlayerPackContents_InRetailManifestOrder()
{
const uint playerGuid = 0x50000001u;
var dispatcher = new GameEventDispatcher();
var items = new ClientObjectTable();
var combat = new CombatState();
var spellbook = new Spellbook();
var chat = new ChatLog();
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat,
playerGuid: () => playerGuid);
var sb = new MemoryStream();
using var w = new BinaryWriter(sb);
w.Write(0u); // propertyFlags = 0
w.Write(0x52u); // weenieType
w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT
w.Write(1u); // has_health
w.Write(0u); // attribute_flags = 0 (no attrs)
w.Write(0u); // enchantment_mask = 0
w.Write(0u); // option_flags = None (no GAMEPLAY_OPTIONS -> strict inv path)
w.Write(0u); // options1
w.Write(0u); // legacy hotbar list count = 0
w.Write(0u); // spellbook_filters
w.Write(2u);
w.Write(0x50000A02u); w.Write(1u);
w.Write(0x50000A01u); w.Write(0u);
w.Write(0u); // equipped count
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
dispatcher.Dispatch(env!.Value);
Assert.Equal(new[] { 0x50000A02u, 0x50000A01u }, items.GetContents(playerGuid));
Assert.Equal(playerGuid, items.Get(0x50000A02u)!.ContainerId);
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint);
}
[Fact]
@ -441,6 +536,7 @@ public sealed class GameEventWiringTests
Assert.NotNull(items.Get(0x700u));
// (b) WeenieClassId must be 0, NOT the ContainerType discriminator (1) — misuse gone
Assert.Equal(0u, items.Get(0x700u)!.WeenieClassId);
Assert.Equal(1u, items.Get(0x700u)!.ContainerTypeHint);
// (c) equipped guid has its equip slot set
Assert.NotNull(items.Get(0x701u));
Assert.Equal(EquipMask.MeleeWeapon, items.Get(0x701u)!.CurrentlyEquippedLocation);
@ -573,6 +669,9 @@ public sealed class GameEventWiringTests
Assert.Equal(0x500000C9u, items.Get(0x50000A01u)!.ContainerId);
Assert.Equal(0x500000C9u, items.Get(0x50000A02u)!.ContainerId);
Assert.Equal(new[] { 0x50000A01u, 0x50000A02u }, items.GetContents(0x500000C9u));
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint);
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
}
[Fact]
@ -638,13 +737,14 @@ public sealed class GameEventWiringTests
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B02u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x500000C1u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0u); // placement
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u); // containerType
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 1u); // containerType
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjInContainer, payload));
d.Dispatch(env!.Value);
// ConfirmMove cleared the pending snapshot → a later rollback is a no-op (the move stuck).
Assert.False(items.RollbackMove(0x50000B02u));
Assert.Equal(0x500000C1u, items.Get(0x50000B02u)!.ContainerId);
Assert.Equal(1u, items.Get(0x50000B02u)!.ContainerTypeHint);
}
[Fact]
@ -661,4 +761,21 @@ public sealed class GameEventWiringTests
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
}
[Fact]
public void WireAll_InventoryPutObjectIn3D_ConfirmsOptimisticDrop()
{
var (d, items, _, _, _) = MakeAll();
items.RecordMembership(0x50000A03u, containerId: 0x500000C9u);
items.MoveItem(0x50000A03u, 0x500000C9u, 4);
items.MoveItemOptimistic(0x50000A03u, newContainerId: 0u, newSlot: -1);
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A03u);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjectIn3D, payload));
d.Dispatch(env!.Value);
Assert.Equal(0u, items.Get(0x50000A03u)!.ContainerId);
Assert.False(items.RollbackMove(0x50000A03u));
}
}

View file

@ -156,6 +156,22 @@ public sealed class CreateObjectTests
Assert.Equal(2.5f, parsed.Value.UseRadius!.Value, precision: 3);
}
[Fact]
public void TryParse_WeenieFlagsTargetType_ReadsTargetType()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000009u,
name: "Healing Kit",
itemType: (uint)ItemType.Misc,
weenieFlags: 0x00080000u,
targetType: (uint)ItemType.Creature);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal((uint)ItemType.Creature, parsed!.Value.TargetType);
}
// -----------------------------------------------------------------------
// D.5.1 (2026-06-16): IconId was discarded at cs:516 — surface it so the
// action bar / equipment UI can read icon dat ids from spawn messages.
@ -451,6 +467,7 @@ public sealed class CreateObjectTests
uint? value = null,
uint? useability = null,
float? useRadius = null,
uint? targetType = null,
uint iconOverlayId = 0,
uint iconUnderlayId = 0,
// intermediate fields for cursor-arithmetic test
@ -516,7 +533,7 @@ public sealed class CreateObjectTests
BinaryPrimitives.WriteSingleLittleEndian(tmp, useRadius ?? 0f);
bytes.AddRange(tmp.ToArray());
}
if ((weenieFlags & 0x00080000u) != 0) WriteU32(bytes, 0); // TargetType u32
if ((weenieFlags & 0x00080000u) != 0) WriteU32(bytes, targetType ?? 0u); // TargetType u32
if ((weenieFlags & 0x00000080u) != 0) WriteU32(bytes, uiEffects); // UiEffects u32
if ((weenieFlags & 0x00000200u) != 0) bytes.Add(0); // CombatUse sbyte/1 byte
if ((weenieFlags & 0x00000400u) != 0) WriteU16(bytes, structure ?? 0); // Structure u16

View file

@ -1,6 +1,7 @@
using System;
using System.Buffers.Binary;
using System.Text;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using Xunit;
@ -176,6 +177,21 @@ public sealed class GameEventDispatcherTests
Assert.Equal(0.42f, parsed.Value.HealthPercent, 4);
}
[Fact]
public void ParseWieldObject_acceptsAceEightBytePayload()
{
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.ChestArmor);
var parsed = GameEvents.ParseWieldObject(payload);
Assert.NotNull(parsed);
Assert.Equal(0x50000A01u, parsed!.Value.ItemGuid);
Assert.Equal((uint)EquipMask.ChestArmor, parsed.Value.EquipLoc);
Assert.Equal(0u, parsed.Value.WielderGuid);
}
[Fact]
public void ParseWeenieError_RoundTrip()
{

View file

@ -57,6 +57,8 @@ public sealed class ObjectTableWiringTests
Structure = 80,
MaxStructure = 100,
Workmanship = 4.5f,
Useability = 0x000A0008u,
TargetType = (uint)ItemType.Creature,
};
var d = ObjectTableWiring.ToWeenieData(spawn);
@ -96,6 +98,8 @@ public sealed class ObjectTableWiringTests
Assert.Equal(80, d.Structure);
Assert.Equal(100, d.MaxStructure);
Assert.Equal(4.5f, d.Workmanship);
Assert.Equal(0x000A0008u, d.Useability);
Assert.Equal((uint)ItemType.Creature, d.TargetType);
}
// -------------------------------------------------------------------------

View file

@ -28,6 +28,58 @@ public sealed class WorldSessionCombatTests
CharacterActions.CombatMode.Magic), captured);
}
[Fact]
public void SendRaiseAttribute_UsesRetailBuilder()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendRaiseAttribute(5u, 110u);
Assert.NotNull(captured);
Assert.Equal(CharacterActions.BuildRaiseAttribute(1, 5u, 110u), captured);
}
[Fact]
public void SendRaiseVital_UsesRetailBuilder()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendRaiseVital(1u, 90u);
Assert.NotNull(captured);
Assert.Equal(CharacterActions.BuildRaiseVital(1, 1u, 90u), captured);
}
[Fact]
public void SendRaiseSkill_UsesRetailBuilder()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendRaiseSkill(34u, 111_000_000u);
Assert.NotNull(captured);
Assert.Equal(CharacterActions.BuildRaiseSkill(1, 34u, 111_000_000u), captured);
}
[Fact]
public void SendTrainSkill_UsesRetailBuilder()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendTrainSkill(21u, 6u);
Assert.NotNull(captured);
Assert.Equal(CharacterActions.BuildTrainSkill(1, 21u, 6u), captured);
}
[Fact]
public void SendMeleeAttack_UsesRetailMeleeBuilder()
{
@ -88,4 +140,19 @@ public sealed class WorldSessionCombatTests
Assert.NotNull(captured);
Assert.Equal(SocialActions.BuildQueryHealth(1, 0x50000007u), captured);
}
[Fact]
public void SendUseWithTarget_UsesRetailBuilder()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendUseWithTarget(0x50000A01u, 0x50000001u);
Assert.NotNull(captured);
Assert.Equal(
InteractRequests.BuildUseWithTarget(1, 0x50000A01u, 0x50000001u),
captured);
}
}

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
}
}