feat(items): port retail shared cooldown overlays

Preserve public shared-cooldown metadata, resolve the authoritative cooldown enchantment with retail expiry semantics, and project the exact ten DAT-authored radial steps through the shared retained item-slot architecture.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 10:41:16 +02:00
parent 19e8863f4e
commit 6b1ae4fb76
27 changed files with 875 additions and 20 deletions

View file

@ -0,0 +1,62 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.UI.Layout;
public sealed class ItemCooldownUiControllerTests
{
[Fact]
public void Shared_group_updates_existing_and_future_UIItems_from_one_clock()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x5001u,
CooldownId = 42u,
CooldownDuration = 30d,
});
var spellbook = new Spellbook();
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 0x802Au,
LayerId: 1u,
Duration: 30d,
CasterGuid: 0u,
Bucket: Spellbook.CooldownBucket,
StartTime: 100d));
double now = 112.5d;
var root = new UiPanel();
var list = new UiItemList();
list.Cell.SetItem(0x5001u, 99u);
root.AddChild(list);
uint[] sprites = Enumerable.Range(1, 10)
.Select(index => 0x06000000u + (uint)index)
.ToArray();
ItemCooldownUiController controller = ItemCooldownUiController.Bind(
root,
spellbook,
objects,
() => now,
new ItemCooldownAssets(sprites));
Assert.Equal(sprites[5], list.Cell.ActiveCooldownSprite());
var future = new UiItemSlot();
future.SetItem(0x5001u, 100u);
list.AddItem(future);
Assert.Equal(sprites[5], future.ActiveCooldownSprite());
now = 129.999d;
controller.Tick();
Assert.Equal(sprites[0], list.Cell.ActiveCooldownSprite());
now = 130d;
controller.Tick();
Assert.Equal(0u, list.Cell.ActiveCooldownSprite());
Assert.Equal(0, spellbook.ActiveCount);
}
}

View file

@ -92,4 +92,27 @@ public class ItemListCellTemplateTests
// m_elem_Icon ItemSlot_Empty state is the brown/gold 0x06001A97 surface.
Assert.Equal(0x06001A97u, sprite);
}
[Fact]
public void Shared_UIItem_resolves_all_ten_cooldown_overlays()
{
var datDir = DatDir();
if (datDir is null) return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
ItemCooldownAssets? assets = ItemCooldownAssets.TryLoad(dats);
Assert.NotNull(assets);
Assert.Equal(ItemCooldownAssets.OverlayCount, assets.Value.Sprites.Count);
Assert.Equal(
new uint[]
{
0x060067CFu, 0x060067D0u, 0x060067D1u, 0x060067D2u, 0x060067D3u,
0x060067D4u, 0x060067D5u, 0x060067D6u, 0x060067D7u, 0x060067D8u,
},
assets.Value.Sprites);
_out.WriteLine(
string.Join(", ", assets.Value.Sprites.Select(id => $"0x{id:X8}")));
}
}

View file

@ -5,6 +5,24 @@ namespace AcDream.App.Tests.UI;
public class UiItemListTests
{
[Fact]
public void CooldownPresentation_propagates_to_existing_and_future_cells()
{
var list = new UiItemList();
uint[] sprites = Enumerable.Range(1, 10).Select(i => (uint)i).ToArray();
Func<uint, int> provider = _ => 7;
list.CooldownSprites = sprites;
list.CooldownStepProvider = provider;
var future = new UiItemSlot();
list.AddItem(future);
Assert.Same(sprites, list.Cell.CooldownSprites);
Assert.Same(provider, list.Cell.CooldownStepProvider);
Assert.Same(sprites, future.CooldownSprites);
Assert.Same(provider, future.CooldownStepProvider);
}
[Fact]
public void IsLeafWidget() => Assert.True(new UiItemList().ConsumesDatChildren);

View file

@ -194,4 +194,36 @@ public class UiItemSlotTests
[Fact]
public void CapacityFrontSprite_default() => Assert.Equal(0x06004D23u, new UiItemSlot().CapacityFrontSprite);
[Fact]
public void ActiveCooldownSprite_selects_the_exact_one_based_step()
{
uint[] sprites =
[
0x06000001u, 0x06000002u, 0x06000003u, 0x06000004u, 0x06000005u,
0x06000006u, 0x06000007u, 0x06000008u, 0x06000009u, 0x0600000Au,
];
var slot = new UiItemSlot
{
CooldownSprites = sprites,
CooldownStepProvider = id => id == 0x5001u ? 6 : 0,
};
slot.SetItem(0x5001u, 99u);
Assert.Equal(0x06000006u, slot.ActiveCooldownSprite());
}
[Fact]
public void ActiveCooldownSprite_isHidden_for_empty_or_inactive_slot()
{
var slot = new UiItemSlot
{
CooldownSprites = Enumerable.Range(1, 10).Select(i => (uint)i).ToArray(),
CooldownStepProvider = _ => 0,
};
Assert.Equal(0u, slot.ActiveCooldownSprite());
slot.SetItem(0x5001u, 99u);
Assert.Equal(0u, slot.ActiveCooldownSprite());
}
}

View file

@ -625,9 +625,28 @@ public sealed class CreateObjectTests
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(11u, parsed.Value.CooldownId);
Assert.Equal(12.5, parsed.Value.CooldownDuration);
Assert.Equal(0x50000001u, parsed.Value.PetOwnerId);
}
[Fact]
public void WeenieHeader_absentCooldownFields_remainNull()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000026u,
name: "Ordinary Stone",
itemType: (uint)ItemType.Misc,
objectDescriptionFlags: 0x04000000u,
weenieFlags2: 0u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Null(parsed.Value.CooldownId);
Assert.Null(parsed.Value.CooldownDuration);
}
[Fact]
public void WeenieHeader_spellId_isPreservedForCasterEndowment()
{

View file

@ -66,6 +66,8 @@ public sealed class ObjectTableWiringTests
AmmoType = 1,
PluralName = "Iron Swords",
PetOwnerId = 0x50000001u,
CooldownId = 42u,
CooldownDuration = 30d,
};
var d = ObjectTableWiring.ToWeenieData(spawn);
@ -114,6 +116,8 @@ public sealed class ObjectTableWiringTests
Assert.Equal((ushort)1, d.AmmoType);
Assert.Equal("Iron Swords", d.PluralName);
Assert.Equal(0x50000001u, d.PetOwnerId);
Assert.Equal(42u, d.CooldownId);
Assert.Equal(30d, d.CooldownDuration);
}
[Fact]

View file

@ -212,6 +212,22 @@ public sealed class ClientObjectTableTests
Assert.Equal("desc", item.Properties.Strings[100]);
}
[Fact]
public void UpdateProperties_projects_assessed_shared_cooldown_metadata()
{
var repo = new ClientObjectTable();
var item = MakeItem(100);
repo.AddOrUpdate(item);
var patch = new PropertyBundle();
patch.Ints[ClientObjectTable.SharedCooldownPropertyId] = 42;
patch.Floats[ClientObjectTable.CooldownDurationPropertyId] = 30d;
repo.UpdateProperties(100, patch);
Assert.Equal(42u, item.CooldownId);
Assert.Equal(30d, item.CooldownDuration);
}
[Fact]
public void Clear_RemovesAllItems()
{

View file

@ -0,0 +1,22 @@
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
public sealed class ItemCooldownDisplayTests
{
[Theory]
[InlineData(10.0, 10.0, 10)]
[InlineData(10.0, 9.0, 10)]
[InlineData(10.0, 8.999, 9)]
[InlineData(10.0, 5.0, 6)]
[InlineData(10.0, 0.001, 1)]
[InlineData(10.0, 0.0, 0)]
[InlineData(0.0, 5.0, 0)]
public void GetOverlayStep_matches_retail_truncation(
double duration,
double remaining,
int expected)
=> Assert.Equal(
expected,
ItemCooldownDisplay.GetOverlayStep(duration, remaining));
}

View file

@ -160,6 +160,87 @@ public sealed class SpellbookTests
Assert.Equal(0, book.ActiveCount);
}
[Fact]
public void OnCooldown_removes_first_expired_match_then_reveals_next_record()
{
var book = new Spellbook();
var expired = new ActiveEnchantmentRecord(
SpellId: 0x802Au,
LayerId: 1u,
Duration: 5d,
CasterGuid: 0u,
Bucket: Spellbook.CooldownBucket,
StartTime: 0d);
var active = expired with
{
LayerId = 2u,
Duration = 30d,
StartTime = 10d,
};
book.ReplaceManifest(
new Dictionary<uint, float>(),
[expired, active],
Array.Empty<IReadOnlyList<uint>>(),
Array.Empty<(uint Id, uint Amount)>(),
0u);
ActiveEnchantmentRecord? removed = null;
book.EnchantmentRemoved += record => removed = record;
Assert.False(book.OnCooldown(
0x2Au,
currentTime: 20d,
out double expiredRemaining));
Assert.Equal(-15d, expiredRemaining);
Assert.Equal(expired, removed);
Assert.Equal(1, book.ActiveCount);
Assert.True(book.OnCooldown(
0x2Au,
currentTime: 20d,
out double activeRemaining));
Assert.Equal(20d, activeRemaining);
}
[Fact]
public void OnCooldown_ignores_same_spell_outside_cooldown_registry()
{
var book = new Spellbook();
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 0x802Au,
LayerId: 1u,
Duration: 30d,
CasterGuid: 0u,
Bucket: 1u,
StartTime: 10d));
Assert.False(book.OnCooldown(
0x2Au,
currentTime: 20d,
out double remaining));
Assert.Equal(0d, remaining);
Assert.Equal(1, book.ActiveCount);
}
[Fact]
public void OnCooldown_zero_group_is_inactive_and_does_not_remove()
{
var book = new Spellbook();
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 0x8000u,
LayerId: 1u,
Duration: 30d,
CasterGuid: 0u,
Bucket: Spellbook.CooldownBucket,
StartTime: 10d));
Assert.False(book.OnCooldown(
cooldownId: 0u,
currentTime: 20d,
out double remaining));
Assert.Equal(0d, remaining);
Assert.Equal(1, book.ActiveCount);
}
[Fact]
public void OnPurgeAll_RemovesOnlyFiniteMultAndAddEnchantments()
{