feat(ui): share indicator detail panels

Port the authored Link Status, Vitae, and Mini Game detail roots and register every indicator page with retail's one-active gmPanelUI owner. Helpful/Harmful and the new pages now replace Inventory, Character, or Magic at one canonical window position while preserving the DAT restore-previous flag.

Correct the retail ping wire to its payload-free request/response, publish measured RTT, and port Vitae recovery XP from the live modifier and player properties. Keep transport packet-loss averaging and mini-game gameplay explicitly tracked under AP-110.

Release build and all 5,814 tests pass with five intentional skips. Connected visual gate pending.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 10:27:41 +02:00
parent 52c529be86
commit a96767ba6d
28 changed files with 6539 additions and 32 deletions

View file

@ -143,6 +143,24 @@ public static class FixtureLoader
public static ElementInfo LoadIndicatorsInfos()
=> LoadInfos("indicators_21000071.json");
public static ImportedLayout LoadLinkStatus()
=> LayoutImporter.Build(LoadLinkStatusInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadLinkStatusInfos()
=> LoadInfos("link_status_2100001D.json");
public static ImportedLayout LoadVitae()
=> LayoutImporter.Build(LoadVitaeInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadVitaeInfos()
=> LoadInfos("vitae_21000020.json");
public static ImportedLayout LoadMiniGame()
=> LayoutImporter.Build(LoadMiniGameInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadMiniGameInfos()
=> LoadInfos("mini_game_2100001E.json");
// ── Shared loader ────────────────────────────────────────────────────────
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)

View file

@ -67,6 +67,8 @@ public sealed class IndicatorBarControllerTests
50u, 3u, 60f, 1u, StatModValue: 0.99f, Bucket: 4u));
Assert.True(vitae.Enabled);
Assert.Equal(UiButtonStateMachine.Normal, vitae.ActiveRetailStateId);
vitae.OnEvent(new UiEvent(0, vitae, UiEventType.Click));
Assert.Equal(RetailPanelCatalog.Vitae, h.ToggledPanels[^1]);
h.Spellbook.OnEnchantmentRemoved(3u, 50u);
Assert.False(vitae.Enabled);
@ -164,6 +166,8 @@ public sealed class IndicatorBarControllerTests
Assert.False(miniGame.Enabled);
controller.SetMiniGameActive(true);
Assert.True(miniGame.Enabled);
miniGame.OnEvent(new UiEvent(0, miniGame, UiEventType.Click));
Assert.Equal(RetailPanelCatalog.MiniGame, h.ToggledPanels[^1]);
controller.SetMiniGameActive(false);
Assert.False(miniGame.Enabled);
@ -171,6 +175,18 @@ public sealed class IndicatorBarControllerTests
Assert.Equal(1, h.EndSessionRequests);
}
[Fact]
public void LinkButton_DispatchesSharedLinkStatusPanel()
{
var h = CreateHarness();
using IndicatorBarController controller = h.Controller;
UiButton link = h.Button(IndicatorBarController.LinkButtonId);
link.OnEvent(new UiEvent(0, link, UiEventType.Click));
Assert.Equal([RetailPanelCatalog.LinkStatus], h.ToggledPanels);
}
private static Harness CreateHarness(int strength = 10)
{
var table = SpellTable.LoadFromReader(new StringReader(

View file

@ -0,0 +1,166 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.UI.Layout;
public sealed class IndicatorDetailPanelControllerTests
{
private const uint Player = 0x50000001u;
[Fact]
public void AuthoredFixtures_AreRetailMainPanelPages()
{
AssertPage(
FixtureLoader.LoadLinkStatusInfos(),
LinkStatusUiController.RootId,
0x1000001Du,
LinkStatusUiController.MainTextId,
LinkStatusUiController.CloseId);
AssertPage(
FixtureLoader.LoadVitaeInfos(),
VitaeUiController.RootId,
0x10000020u,
VitaeUiController.MainTextId,
VitaeUiController.CloseId);
AssertPage(
FixtureLoader.LoadMiniGameInfos(),
MiniGameUiController.RootId,
0x1000001Eu,
0x1000016Eu,
MiniGameUiController.CloseId);
Assert.Equal(8u, RetailPanelCatalog.LinkStatus);
Assert.Equal(9u, RetailPanelCatalog.MiniGame);
Assert.Equal(15u, RetailPanelCatalog.Vitae);
}
[Fact]
public void LinkStatus_RequestsPingOnShow_RefreshesText_AndRepeatsAfter120Seconds()
{
ImportedLayout layout = FixtureLoader.LoadLinkStatus();
double now = 0d;
int pings = 0;
int closes = 0;
LinkStatusSnapshot snapshot = new(
Connected: true,
SecondsSinceLastPacket: 0.25d,
PacketLossPercentage: 1.25d,
RoundTripSeconds: 0.123d);
using LinkStatusUiController controller = LinkStatusUiController.Bind(
layout,
() => snapshot,
() => now,
() => pings++,
LinkStatusStrings.English,
() => closes++)!;
controller.OnShown();
Assert.Equal(1, pings);
string text = VisibleText(layout, LinkStatusUiController.MainTextId);
Assert.Contains("Link Indicator", text, StringComparison.Ordinal);
Assert.Contains("1.25", text, StringComparison.Ordinal);
Assert.Contains("123", text, StringComparison.Ordinal);
controller.Tick();
now = 119d;
controller.Tick();
Assert.Equal(1, pings);
now = 124d;
controller.Tick();
Assert.Equal(2, pings);
UiButton close = Assert.IsType<UiButton>(layout.FindElement(LinkStatusUiController.CloseId));
close.OnEvent(new UiEvent(0, close, UiEventType.Click));
Assert.Equal(1, closes);
controller.OnHidden();
now = 300d;
controller.Tick();
Assert.Equal(2, pings);
}
[Fact]
public void Vitae_UsesDeathLevelPoolAndRetailThresholdFormula()
{
ImportedLayout layout = FixtureLoader.LoadVitae();
var spellbook = new Spellbook();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = Player });
objects.UpdateIntProperty(Player, VitaeUiController.DeathLevelProperty, 10);
objects.UpdateIntProperty(Player, VitaeUiController.VitaeCpPoolProperty, 100);
using VitaeUiController controller = VitaeUiController.Bind(
layout,
spellbook,
objects,
() => Player,
VitaeStrings.English)!;
Assert.Contains(
"full strength",
VisibleText(layout, VitaeUiController.MainTextId),
StringComparison.Ordinal);
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 666u,
LayerId: 1u,
Duration: -1f,
CasterGuid: Player,
StatModValue: 0.9f,
Bucket: 4u));
string text = VisibleText(layout, VitaeUiController.MainTextId);
Assert.Contains("lost 10 %", text, StringComparison.Ordinal);
Assert.Contains("earn 379 more experience", text, StringComparison.Ordinal);
Assert.Equal(479, VitaeUiController.VitaeCpPoolThreshold(0.9f, 10));
}
[Fact]
public void MiniGameFixture_BindsItsAuthoredCloseButton()
{
ImportedLayout layout = FixtureLoader.LoadMiniGame();
int closes = 0;
using MiniGameUiController controller = MiniGameUiController.Bind(
layout, () => closes++)!;
UiButton close = Assert.IsType<UiButton>(layout.FindElement(MiniGameUiController.CloseId));
close.OnEvent(new UiEvent(0, close, UiEventType.Click));
Assert.Equal(1, closes);
}
private static void AssertPage(
ElementInfo info,
uint rootId,
uint type,
uint contentId,
uint closeId)
{
Assert.Equal(rootId, info.Id);
Assert.Equal(type, info.Type);
Assert.Equal((300f, 362f), (info.Width, info.Height));
Assert.NotNull(Find(info, contentId));
Assert.NotNull(Find(info, closeId));
Assert.True(info.TryGetEffectiveBool(
RetailPanelUiController.RestorePreviousPropertyId,
out bool restorePrevious));
Assert.True(restorePrevious);
}
private static string VisibleText(ImportedLayout layout, uint id)
{
UiText text = Assert.IsType<UiText>(layout.FindElement(id));
return string.Join(' ', text.LinesProvider().Select(line => line.Text));
}
private static ElementInfo? Find(ElementInfo root, uint id)
{
if (root.Id == id) return root;
foreach (ElementInfo child in root.Children)
if (Find(child, id) is { } found) return found;
return null;
}
}

View file

@ -18,6 +18,8 @@ public sealed class RetailLayoutFixtureGenerator
{
(0x21000006u, "chat_21000006.json"),
(0x21000016u, "toolbar_21000016.json"),
(0x2100001Du, "link_status_2100001D.json"),
(0x21000020u, "vitae_21000020.json"),
(0x21000023u, "inventory_21000023.json"),
(0x21000024u, "paperdoll_21000024.json"),
(0x2100002Eu, "character_2100002E.json"),
@ -118,6 +120,17 @@ public sealed class RetailLayoutFixtureGenerator
File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), effectsJson);
}
ElementInfo? miniGame = LayoutImporter.ImportInfos(
dats, MiniGameUiController.LayoutId, MiniGameUiController.RootId);
Assert.NotNull(miniGame);
File.WriteAllText(
Path.Combine(FixtureDirectory(), "mini_game_2100001E.json"),
JsonSerializer.Serialize(miniGame, new JsonSerializerOptions
{
IncludeFields = true,
WriteIndented = true,
}));
foreach ((uint rootId, string fileName) in new[]
{
(0x10000466u,

View file

@ -113,7 +113,7 @@ public sealed class RetailPanelUiControllerTests
}
[Fact]
public void RestorePreviousEffect_DoesNotReplaceMainPanelPlacement()
public void IndicatorDetailPanel_SharesPlacementAndRestoresPreviousPanel()
{
var root = new UiRoot { Width = 1280f, Height = 720f };
RetailWindowHandle inventory = Mount(root, WindowNames.Inventory, 40f, 60f);
@ -122,13 +122,17 @@ public sealed class RetailPanelUiControllerTests
using var controller = Create(root);
controller.RegisterMainPanel(7u, WindowNames.Inventory, inventory);
controller.RegisterMainPanel(11u, WindowNames.Character, character);
controller.Register(4u, WindowNames.PositiveEffects, restorePrevious: true);
controller.RegisterMainPanel(
4u,
WindowNames.PositiveEffects,
effect,
restorePrevious: true);
controller.SetPanelVisibility(7u, visible: true);
inventory.MoveTo(280f, 120f);
controller.SetPanelVisibility(4u, visible: true);
Assert.Equal((900f, 30f), (effect.Left, effect.Top));
Assert.Equal((280f, 120f), (effect.Left, effect.Top));
Assert.False(inventory.IsVisible);
Assert.True(effect.IsVisible);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff