diff --git a/docs/plans/2026-08-21-contract-tracker-campaign.md b/docs/plans/2026-08-21-contract-tracker-campaign.md index 66e7623e..16588821 100644 --- a/docs/plans/2026-08-21-contract-tracker-campaign.md +++ b/docs/plans/2026-08-21-contract-tracker-campaign.md @@ -1,6 +1,7 @@ # Campaign QT — the contract tracker (H.3's client half) -**Status:** ACTIVE 2026-08-21. +**Status:** CODE-COMPLETE 2026-08-21. All six slices landed; the connected +user gate is owed. **Why now.** M4's demo scenario is "talk to an NPC, accept a quest, ... complete the quest." Everything in that sentence works today EXCEPT the player's ability @@ -162,10 +163,25 @@ the page is binding rather than new widget work. it is keyboard or menu — to be measured the way FA's F3/F4 was), plus the plugin-visible read surface from `r10-quest-dialogs.md` §11.6. -### Landed so far +### Landed QT1 `ab3934e2` (wire), QT3 `f629ce7f` (state + routing), QT2/QT4 `ef6b7310` -(catalog + progress string). QT5 and QT6 are open. +(catalog + progress string), QT5/QT6 (the panel and its open path). + +**The open path needed no new keybind.** Toolbar button `0x1000055A` authors +`0x10000029 = 0x19` and has been in `ToolbarController.PanelButtonIds` since +the toolbar was ported — it simply had no panel registered behind it, so +clicking it did nothing. Registering slot 25 completed a wiring that was +already three-quarters present. + +### Owed + +- The connected user gate: accept a quest against live ACE, open the Journal + panel, confirm the list, the progress column and a repeat countdown. +- The Abandon button is deliberately unwired — retail's abandon path is a + contract-registry command this campaign did not port. It is authored and + visible; clicking it does nothing. +- The Journal notes page and Page List tabs mount inert, by design. ## Definition of done diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index df53d351..b8d12f28 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -682,6 +682,18 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory lock (d.DatLock) chargenSkillTable = d.Dats.Get(0x0E000004u); var chargenSkillScoreResolver = new ChargenSkillScoreResolver(chargenSkillTable); + // Campaign QT slice QT5: lazily loaded on first open (the panel + // is hidden at mount), then held for the session. + AcDream.Core.Quests.ContractCatalog? contractCatalog = null; + AcDream.Core.Quests.ContractCatalog questCatalog() + { + if (contractCatalog is not null) + return contractCatalog; + lock (d.DatLock) + contractCatalog = AcDream.Content.ContractTableReader.Load(d.Dats); + return contractCatalog; + } + var bindings = new RetailUiRuntimeBindings( Host: host, Assets: assets, @@ -1007,6 +1019,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory CurrentCalendar: d.CurrentCalendar, PlayerCellId: () => d.PlayerController.Controller?.CellId ?? 0u, HouseLines: () => d.Runtime.HouseOwner.Lines), + // Campaign QT slice QT5. The catalog is read from the dats + // ONCE and cached: it is immutable installed content, and the + // panel would otherwise re-read a 322-entry table on every + // refresh under the shared DatLock. + Quests: new QuestRuntimeBindings( + Contracts: d.Runtime.ContractsOwner.View, + Catalog: questCatalog), StackSplitQuantity: d.StackSplitQuantity, Plugins: d.UiRegistry, Persistence: persistence, diff --git a/src/AcDream.App/UI/Layout/JournalContractsPageController.cs b/src/AcDream.App/UI/Layout/JournalContractsPageController.cs new file mode 100644 index 00000000..e148f2e4 --- /dev/null +++ b/src/AcDream.App/UI/Layout/JournalContractsPageController.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Net.Messages; +using AcDream.Core.Quests; +using AcDream.Core.Ui; +using AcDream.Runtime.Gameplay; + +namespace AcDream.App.UI.Layout; + +/// +/// The Journal panel's Contracts page — retail gmContractsUI +/// (element type 0x1000004B, page 0x100005D4). +/// +/// +/// +/// A two-column list (Contract / Status) over a detail pane. Rows come from +/// — live server state — joined to the +/// authored for every word the player reads; +/// the wire itself carries only an id, a stage and two timers. +/// +/// +/// Rebuilds are revision-gated. The tracker changes rarely (accepting or +/// advancing a quest) while the panel ticks every frame, so polling +/// is what keeps this from +/// rebuilding a template list continuously. +/// +/// +public sealed class JournalContractsPageController +{ + /// The layout the row template lives in — authored property + /// 0x63 on the list's template entry. + public const uint RowTemplateLayoutId = 0x21000069u; + + /// The row template element — authored 0x62, and the id + /// retail passes to AddItemFromTemplateListByID @0x00499747. + public const uint RowTemplateElementId = 0x100005D7u; + + private const uint ListId = 0x100005CFu; + private const uint RowNameId = 0x100005D1u; + private const uint RowStatusId = 0x100005D2u; + + private const uint StatusValueId = 0x100005DFu; + private const uint ContactValueId = 0x100005E0u; + private const uint ContactLocationValueId = 0x100005E1u; + private const uint QuestLocationValueId = 0x100005E2u; + private const uint DescriptionId = 0x100005DEu; + private const uint TimedValueId = 0x100005E3u; + private const uint AbandonButtonId = 0x100005DCu; + + /// Live state and services the page reads. + /// The canonical tracker view. + /// The authored contract text. + /// + /// The clock the repeat countdown is measured against. Injected rather + /// than read from so the countdown is + /// testable without waiting for it. + /// + /// Builds one row from the authored template. + public sealed record Bindings( + IRuntimeContractView Contracts, + Func Catalog, + Func Now, + Func TemplateResolver); + + private readonly Bindings _bindings; + private readonly UiTemplateListBox? _list; + private readonly UiText? _statusValue; + private readonly UiText? _contactValue; + private readonly UiText? _contactLocationValue; + private readonly UiText? _questLocationValue; + private readonly UiText? _description; + private readonly UiText? _timedValue; + + private readonly List _rowContractIds = []; + + private long _renderedRevision = -1; + private uint _selectedContractId; + + public JournalContractsPageController(UiElement page, Bindings bindings) + { + ArgumentNullException.ThrowIfNull(page); + _bindings = bindings ?? throw new ArgumentNullException(nameof(bindings)); + + _list = UiElement.FindDescendant(page, ListId) as UiTemplateListBox; + if (_list is not null) + _list.TemplateResolver = bindings.TemplateResolver; + + _statusValue = UiElement.FindDescendant(page, StatusValueId) as UiText; + _contactValue = UiElement.FindDescendant(page, ContactValueId) as UiText; + _contactLocationValue = + UiElement.FindDescendant(page, ContactLocationValueId) as UiText; + _questLocationValue = + UiElement.FindDescendant(page, QuestLocationValueId) as UiText; + _description = UiElement.FindDescendant(page, DescriptionId) as UiText; + _timedValue = UiElement.FindDescendant(page, TimedValueId) as UiText; + + // The Abandon button has no wire message in this campaign's scope — + // retail's own abandon path is a contract-registry command we have not + // ported. Left unwired rather than given a no-op handler that would + // look responsive and do nothing. + _ = AbandonButtonId; + + Refresh(); + } + + /// The contract the detail pane is showing, or 0. + public uint SelectedContractId => _selectedContractId; + + /// Contract ids in list order, for tests. + public IReadOnlyList RowContractIds => _rowContractIds; + + public void Tick() + { + // Cheap every frame; a rebuild only when the tracker actually moved. + if (_bindings.Contracts.Snapshot.Revision != _renderedRevision) + Refresh(); + else + RefreshDetail(); // the repeat countdown ticks without a rebuild + } + + public void Refresh() + { + RuntimeContractsSnapshot snapshot = _bindings.Contracts.Snapshot; + _renderedRevision = snapshot.Revision; + + IReadOnlyList contracts = _bindings.Contracts.GetContracts(); + ContractCatalog catalog = _bindings.Catalog(); + DateTime now = _bindings.Now(); + + // Retail's own default: the server nominates a display contract, and + // otherwise the first row stands. + if (snapshot.DisplayContractId != 0u) + _selectedContractId = snapshot.DisplayContractId; + if (_selectedContractId == 0u && contracts.Count != 0) + _selectedContractId = contracts[0].ContractId; + if (contracts.Count == 0) + _selectedContractId = 0u; + + _rowContractIds.Clear(); + _list?.FlushPreservingScroll(); + + foreach (ContractTracker tracker in contracts) + { + _rowContractIds.Add(tracker.ContractId); + if (_list is null) + continue; + + UiElement? row = _list.AddItemFromTemplateList(0); + if (row is null) + continue; + + ContractEntry entry = catalog.Lookup(tracker.ContractId); + + if (UiElement.FindDescendant(row, RowNameId) is UiText name) + SetText(name, entry.ContractName); + if (UiElement.FindDescendant(row, RowStatusId) is UiText status) + { + SetText(status, ContractProgressText.Build( + (uint)tracker.Stage, tracker.TimeWhenRepeats, + tracker.ReceivedAt, entry, now)); + } + + uint captured = tracker.ContractId; + if (row is UiDatElement clickable) + clickable.OnClick = () => Select(captured); + } + + RefreshDetail(); + } + + /// Points the detail pane at one contract. + public void Select(uint contractId) + { + _selectedContractId = contractId; + RefreshDetail(); + } + + private void RefreshDetail() + { + ContractCatalog catalog = _bindings.Catalog(); + DateTime now = _bindings.Now(); + + if (_selectedContractId == 0u + || !_bindings.Contracts.TryGetContract(_selectedContractId, out ContractTracker tracker)) + { + SetText(_statusValue, string.Empty); + SetText(_contactValue, string.Empty); + SetText(_contactLocationValue, string.Empty); + SetText(_questLocationValue, string.Empty); + SetText(_description, string.Empty); + SetText(_timedValue, string.Empty); + return; + } + + ContractEntry entry = catalog.Lookup(_selectedContractId); + + SetText(_statusValue, ContractProgressText.Build( + (uint)tracker.Stage, tracker.TimeWhenRepeats, tracker.ReceivedAt, entry, now)); + SetText(_contactValue, entry.NameNpcStart); + SetText(_contactLocationValue, LocationText(entry.LocationNpcStartCell)); + SetText(_questLocationValue, LocationText(entry.LocationQuestAreaCell)); + SetText(_description, entry.Description); + + // "Timed:" is the other wire timer — the one FillProgressString never + // reads. It belongs here, not in the Status column. + SetText(_timedValue, tracker.TimeWhenDone > 0d + ? ContractProgressText.DeltaTimeToString( + Math.Max(0d, tracker.TimeWhenDone - (now - tracker.ReceivedAt).TotalSeconds)) + : string.Empty); + } + + /// + /// Coordinates, or retail's literal "Indoors" when the cell has none + /// (LandDefs::gid_to_lcoord failing, @0x0049937F). + /// + private static string LocationText(uint cellId) + { + if (cellId == 0u) return string.Empty; + return RetailPositionFormatter.FormatOutdoorCell(cellId) ?? "Indoors"; + } + + private static void SetText(UiText? text, string value) + { + if (text is null) return; + text.LinesProvider = () => [new UiText.Line(value, text.DefaultColor)]; + } +} diff --git a/src/AcDream.App/UI/Layout/JournalPanelController.cs b/src/AcDream.App/UI/Layout/JournalPanelController.cs new file mode 100644 index 00000000..cf05c8d0 --- /dev/null +++ b/src/AcDream.App/UI/Layout/JournalPanelController.cs @@ -0,0 +1,120 @@ +using System; + +namespace AcDream.App.UI.Layout; + +/// +/// Mounts retail's three-tab Journal panel — LayoutDesc +/// 0x2100006E slot 0x10000559, +/// id 25. Campaign QT +/// slice QT5, built on the OP3/FA3 tab-host recipe. +/// +/// +/// +/// The contract tracker is not a panel of its own. `gmContractsUI` is +/// tab 1 of this panel, which is why the campaign mounts a Journal rather than +/// a Contracts window. The authored tab table (property 0x2E, read from +/// the installed dats rather than inferred from x-order — the mistake Campaign +/// FA had to correct): +/// +/// +/// button 0x100005D3 ("Contracts") -> page 0x100005D4 DEFAULT (0x32 = True) +/// button 0x10000560 ("Journal") -> page 0x10000563 +/// button 0x10000561 ("Page List") -> page 0x10000564 +/// +/// +/// Only the Contracts page is in scope for Campaign QT. The Journal notes page +/// and the Page List are their own feature; mounting the panel with those two +/// tabs inert is the intended state, not a defect. +/// +/// +public sealed class JournalPanelController : IRetainedPanelController +{ + /// The floating host LayoutDesc the tab panel is resolved through. + public const uint HostLayoutId = 0x2100006Eu; + + /// + /// The Journal panel's slot within 's shared + /// gmPanelUI page stack. Its own authored 0x10000029 is + /// 0x19 = 25 — the same byte-verified slot-key recipe Options (10), + /// the social panel (12) and Map/House (16) already use. + /// + public const uint SlotElementId = 0x10000559u; + + /// The Contracts page — gmContractsUI, element type + /// 0x1000004B. + public const uint ContractsPageId = 0x100005D4u; + + /// The panel's own corner button. + private const uint CloseButtonId = 0x10000562u; + + private readonly UiTabPanel _tabPanel; + private readonly JournalContractsPageController? _contracts; + private bool _disposed; + + /// Root element of the imported panel — the tab host itself. + public UiElement Root => _tabPanel; + + public UiTabPanel TabPanel => _tabPanel; + + public JournalContractsPageController? Contracts => _contracts; + + private JournalPanelController( + UiTabPanel tabPanel, + JournalContractsPageController? contracts) + { + _tabPanel = tabPanel; + _contracts = contracts; + } + + public sealed record Callbacks( + Action Toggle, + JournalContractsPageController.Bindings Contracts); + + /// + /// Binds an imported / + /// layout to live behavior — the same "import via the host slot, then + /// Build+Bind" shape and + /// use. + /// + public static JournalPanelController? Bind(ImportedLayout layout, Callbacks callbacks) + { + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(callbacks); + + if (layout.Root is not UiTabPanel tabPanel) + { + Console.WriteLine( + "[D.2b] JournalPanelController.Bind: root did not build as UiTabPanel " + + $"(actual type {layout.Root.GetType().Name}) — journal panel will not open."); + return null; + } + + if (layout.FindElement(CloseButtonId) is UiButton close) + close.OnClick = callbacks.Toggle; + + JournalContractsPageController? contracts = null; + if (layout.FindElement(ContractsPageId) is { } page) + contracts = new JournalContractsPageController(page, callbacks.Contracts); + else + Console.WriteLine("[D.2b] JournalPanelController: contracts page not found."); + + return new JournalPanelController(tabPanel, contracts); + } + + /// + /// Runs the authored tab table, which activates the default entry — + /// Contracts (0x32 = True). + /// + public void ActivateTabs() => _tabPanel.ActivateTabBehavior(); + + /// Switches to the Contracts tab. + public void ShowContracts() => _tabPanel.SwitchTo(ContractsPageId); + + public void Tick() + { + if (_disposed) return; + _contracts?.Tick(); + } + + public void Dispose() => _disposed = true; +} diff --git a/src/AcDream.App/UI/RetailPanelCatalog.cs b/src/AcDream.App/UI/RetailPanelCatalog.cs index 6b9d6e0b..89f50970 100644 --- a/src/AcDream.App/UI/RetailPanelCatalog.cs +++ b/src/AcDream.App/UI/RetailPanelCatalog.cs @@ -57,6 +57,17 @@ public static class RetailPanelCatalog /// public const uint MapHouse = 16u; + /// + /// Campaign QT slice QT5: the three-tab Journal panel (Contracts / + /// Journal / Page List) — gmPanelUI slot key byte-verified from the + /// live installed DATs (host 0x2100006E slot 0x10000559's own + /// authored 0x10000029 = 0x19). Toolbar button 0x1000055A + /// authors the same id, so this one is in BOTH and + /// — like , unlike + /// . + /// + public const uint Journal = 25u; + private static readonly (uint PanelId, string WindowName)[] Mounted = { (CharacterInformation, WindowNames.CharacterInformation), @@ -71,6 +82,7 @@ public static class RetailPanelCatalog (Options, WindowNames.Options), (SocialPanel, WindowNames.SocialPanel), (MapHouse, WindowNames.MapHouse), + (Journal, WindowNames.Journal), }; private static readonly (uint PanelId, string WindowName)[] Toolbar = @@ -80,6 +92,11 @@ public static class RetailPanelCatalog (Magic, WindowNames.Spellbook), (Options, WindowNames.Options), (MapHouse, WindowNames.MapHouse), + // Campaign QT slice QT5: toolbar button 0x1000055A authors + // 0x10000029 = 0x19 and has been in ToolbarController.PanelButtonIds + // all along — it simply had no panel behind it, so clicking it did + // nothing. + (Journal, WindowNames.Journal), }; public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted; diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 864a9e96..3d7b22c8 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -311,6 +311,14 @@ public sealed record MapHouseRuntimeBindings( Func>? HouseLines = null, Action? HouseShown = null); +/// +/// Campaign QT slice QT5: what the Journal panel's Contracts page reads — +/// the canonical tracker view plus the authored contract text. +/// +public sealed record QuestRuntimeBindings( + AcDream.Runtime.Gameplay.IRuntimeContractView Contracts, + Func Catalog); + public sealed record InventoryRuntimeBindings( ClientObjectTable Objects, Func PlayerGuid, @@ -474,6 +482,7 @@ public sealed record RetailUiRuntimeBindings( OptionsRuntimeBindings Options, SocialRuntimeBindings Social, MapHouseRuntimeBindings MapHouse, + QuestRuntimeBindings Quests, StackSplitQuantityState StackSplitQuantity, BufferedUiRegistry? Plugins, RetailUiPersistenceBindings? Persistence, @@ -569,6 +578,7 @@ public sealed class RetailUiRuntime : IDisposable MountTooltipPresenter(); MountSocialPanel(); MountMapHousePanel(); + MountJournalPanel(); MountCharacter(); MountPlugins(); MountInventory(); @@ -692,6 +702,9 @@ public sealed class RetailUiRuntime : IDisposable public VendorUiController? VendorController { get; private set; } public OptionsPanelController? OptionsPanelController { get; private set; } public SocialPanelController? SocialPanelController { get; private set; } + + /// Campaign QT slice QT5 — the three-tab Journal panel. + public Layout.JournalPanelController? JournalPanelController { get; private set; } public MapHousePanelController? MapHousePanelController { get; private set; } internal CharacterManagementUiController? CharacterManagementController => _characterManagementMount?.Controller; @@ -873,6 +886,7 @@ public sealed class RetailUiRuntime : IDisposable SelectedObjectController?.Tick(deltaSeconds); ExternalContainerController?.Tick(); SocialPanelController?.Tick(); + JournalPanelController?.Tick(); MapHousePanelController?.Tick(deltaSeconds); _itemCooldownController?.Tick(); _characterManagementMount?.Tick(); @@ -3445,6 +3459,115 @@ public sealed class RetailUiRuntime : IDisposable Console.WriteLine("[UI] retail social panel from LayoutDesc importer (0x2100006E slot 0x1000018F)."); } + /// + /// Campaign QT slice QT5: retail's three-tab Journal panel — host + /// 0x2100006E slot 0x10000559, + /// id 25. Same import/Build/Bind + /// recipe as . The Contracts page's list has + /// ONE authored row template resolved out of a DIFFERENT layout + /// (0x21000069) than the panel itself, which is why it needs the + /// caching rather than + /// 's own path. + /// + private void MountJournalPanel() + { + ElementInfo? rootInfo; + ImportedLayout? layout; + lock (_bindings.Assets.DatLock) + { + rootInfo = LayoutImporter.ImportInfos( + _bindings.Assets.Dats, + Layout.JournalPanelController.HostLayoutId, + Layout.JournalPanelController.SlotElementId); + var resolver = new DatStringResolver(_bindings.Assets.Dats); + layout = rootInfo is null + ? null + : LayoutImporter.Build( + rootInfo, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont, + resolver.Resolve); + } + if (rootInfo is null || layout is null) + { + Console.WriteLine( + "[UI] journal panel: LayoutDesc 0x2100006E slot 0x10000559 not found."); + return; + } + + var rowTemplates = new Layout.RowTemplateResolver( + (layoutId, elementId) => LayoutImporter.ImportInfos( + _bindings.Assets.Dats, layoutId, elementId), + info => + { + var strings = new DatStringResolver(_bindings.Assets.Dats); + return LayoutImporter.Build( + info, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont, + strings.Resolve).Root; + }); + + var callbacks = new Layout.JournalPanelController.Callbacks( + Toggle: () => ToggleWindow(WindowNames.Journal), + Contracts: new Layout.JournalContractsPageController.Bindings( + Contracts: _bindings.Quests.Contracts, + Catalog: _bindings.Quests.Catalog, + Now: () => DateTime.UtcNow, + TemplateResolver: (templateLayoutId, templateElementId) => + { + lock (_bindings.Assets.DatLock) + return rowTemplates.Resolve(templateLayoutId, templateElementId); + })); + + Layout.JournalPanelController? controller; + lock (_bindings.Assets.DatLock) + controller = Layout.JournalPanelController.Bind(layout, callbacks); + if (controller is null) + { + Console.WriteLine("[UI] journal panel: required root did not build as UiTabPanel."); + return; + } + + controller.ActivateTabs(); + JournalPanelController = controller; + + RetailWindowHandle handle = RetailWindowFrame.Mount( + Host.Root, + controller.Root, + _bindings.Assets.ResolveSprite, + new RetailWindowFrame.Options + { + WindowName = WindowNames.Journal, + Chrome = RetailWindowChrome.NineSlice, + Left = 230f, + Top = 160f, + Visible = false, + ResizeX = false, + ResizeY = true, + ResizableEdges = ResizeEdges.Bottom, + ConstrainDragToParent = true, + ConstrainResizeToParent = true, + ContentAnchors = AnchorEdges.Left | AnchorEdges.Top + | AnchorEdges.Right | AnchorEdges.Bottom, + ContentClickThrough = false, + DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo), + Controller = controller, + }); + _panelUi.RegisterMainPanel( + RetailPanelCatalog.Journal, + WindowNames.Journal, + handle, + rootInfo.TryGetEffectiveBool( + RetailPanelUiController.RestorePreviousPropertyId, + out bool restorePrevious) + && restorePrevious); + Console.WriteLine( + "[UI] retail journal panel from LayoutDesc importer (0x2100006E slot 0x10000559)."); + } + /// /// Batch C (overnight hover/UI round, 2026-08-17): the two-tab Map/House /// panel — host 0x2100006E slot 0x1000018C, diff --git a/src/AcDream.App/UI/WindowNames.cs b/src/AcDream.App/UI/WindowNames.cs index 1d11d7e5..f641c7af 100644 --- a/src/AcDream.App/UI/WindowNames.cs +++ b/src/AcDream.App/UI/WindowNames.cs @@ -44,4 +44,8 @@ public static class WindowNames /// Batch C (overnight hover/UI round): the two-tab Map/House /// panel (). public const string MapHouse = "map-house"; + + /// Campaign QT slice QT5: the three-tab Contracts/Journal/Page + /// List panel (). + public const string Journal = "journal"; } diff --git a/src/AcDream.Content/ContractTableReader.cs b/src/AcDream.Content/ContractTableReader.cs index 9c7f87d7..58c1a4dd 100644 --- a/src/AcDream.Content/ContractTableReader.cs +++ b/src/AcDream.Content/ContractTableReader.cs @@ -61,7 +61,10 @@ public static class ContractTableReader contract.QuestflagFinished ?? string.Empty, contract.QuestflagProgress ?? string.Empty, contract.QuestflagTimer ?? string.Empty, - contract.QuestflagRepeatTime ?? string.Empty); + contract.QuestflagRepeatTime ?? string.Empty, + contract.LocationNPCStart?.CellId ?? 0u, + contract.LocationNPCEnd?.CellId ?? 0u, + contract.LocationQuestArea?.CellId ?? 0u); } return new ContractCatalog(projected.ToFrozenDictionary()); diff --git a/src/AcDream.Core/Quests/ContractEntry.cs b/src/AcDream.Core/Quests/ContractEntry.cs index 272e4057..a2f486e9 100644 --- a/src/AcDream.Core/Quests/ContractEntry.cs +++ b/src/AcDream.Core/Quests/ContractEntry.cs @@ -37,7 +37,17 @@ public sealed record ContractEntry( string QuestflagFinished, string QuestflagProgress, string QuestflagTimer, - string QuestflagRepeatTime) + string QuestflagRepeatTime, + /// + /// Landcell of the NPC who offers the contract. The panel's "Contact + /// Location" row shows this as coordinates, or "Indoors" when the cell has + /// no outdoor coordinates + /// (LandDefs::gid_to_lcoord failing, @0x0049937F). + /// + uint LocationNpcStartCell = 0u, + uint LocationNpcEndCell = 0u, + /// Landcell of the quest area — the "Quest Location" row. + uint LocationQuestAreaCell = 0u) { public static readonly ContractEntry Unknown = new( 0u, 0u, diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs new file mode 100644 index 00000000..49b20d81 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs @@ -0,0 +1,368 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Net.Messages; +using AcDream.Core.Quests; +using AcDream.Runtime.Gameplay; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign QT slice QT5: the Journal panel's Contracts page. +/// +public sealed class JournalContractsPageControllerTests +{ + private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc); + + // The authored ids, from the installed dats. + private const uint ListId = 0x100005CFu; + private const uint RowNameId = 0x100005D1u; + private const uint RowStatusId = 0x100005D2u; + private const uint StatusValueId = 0x100005DFu; + private const uint ContactValueId = 0x100005E0u; + private const uint ContactLocationValueId = 0x100005E1u; + private const uint QuestLocationValueId = 0x100005E2u; + private const uint DescriptionId = 0x100005DEu; + private const uint TimedValueId = 0x100005E3u; + + private static UiText Text(uint id) => new() + { + DatElementId = id, + Width = 200f, + Height = 18f, + }; + + /// + /// A page carrying the same child ids the real one does, so the controller + /// resolves exactly what production resolves. + /// + private static (UiElement Page, UiTemplateListBox List) BuildPage() + { + var listInfo = new ElementInfo + { + Id = ListId, Type = 5, Width = 270, Height = 298, + }; + var list = new UiTemplateListBox( + listInfo, + static _ => (0u, 0, 0), + [new UiTemplateListEntry( + JournalContractsPageController.RowTemplateLayoutId, + JournalContractsPageController.RowTemplateElementId)], + scrollbarElementId: 0u) + { + // LayoutImporter.Build sets this in production; a directly + // constructed widget has it at 0 and FindDescendant never sees it. + DatElementId = ListId, + }; + + var page = new UiPanel { Width = 300f, Height = 500f }; + page.AddChild(list); + foreach (uint id in new[] + { + StatusValueId, ContactValueId, ContactLocationValueId, + QuestLocationValueId, DescriptionId, TimedValueId, + }) + { + page.AddChild(Text(id)); + } + + return (page, list); + } + + /// One row: a name text and a status text, as retail authors. + private static UiElement? RowTemplate(uint layoutId, uint elementId) + { + if (layoutId != JournalContractsPageController.RowTemplateLayoutId + || elementId != JournalContractsPageController.RowTemplateElementId) + { + return null; + } + + var row = new UiPanel { Width = 270f, Height = 16f }; + row.AddChild(Text(RowNameId)); + row.AddChild(Text(RowStatusId)); + return row; + } + + private static ContractCatalog Catalog(params ContractEntry[] entries) + => new(entries.ToDictionary(e => e.ContractId)); + + private static ContractEntry Entry( + uint id, + string name, + string description = "", + string contact = "", + string progressFormat = "", + string repeatFlag = "", + uint contactCell = 0u, + uint questCell = 0u) + => ContractEntry.Unknown with + { + ContractId = id, + ContractName = name, + Description = description, + NameNpcStart = contact, + DescriptionProgress = progressFormat, + QuestflagRepeatTime = repeatFlag, + LocationNpcStartCell = contactCell, + LocationQuestAreaCell = questCell, + }; + + private static JournalContractsPageController Bind( + UiElement page, + RuntimeContractState state, + ContractCatalog catalog, + DateTime? now = null) + => new(page, new JournalContractsPageController.Bindings( + Contracts: state.View, + Catalog: () => catalog, + Now: () => now ?? Now, + TemplateResolver: RowTemplate)); + + private static void Track( + RuntimeContractState state, + uint contractId, + uint stage, + double whenRepeats = 0d, + double whenDone = 0d, + bool setAsDisplay = false) + => state.ApplyUpdate(new ContractTrackerUpdate( + new ContractTracker(1u, contractId, (ContractStage)stage, whenDone, whenRepeats, Now), + Delete: false, + SetAsDisplay: setAsDisplay)); + + private static string TextOf(UiElement page, uint id) + { + var text = UiElement.FindDescendant(page, id) as UiText; + return text?.LinesProvider?.Invoke().FirstOrDefault().Text ?? string.Empty; + } + + [Fact] + public void RowsCarryTheAuthoredNameAndTheRetailProgressText() + { + // The wire sends only an id and a stage; the name comes from the dat + // and the status from FillProgressString. Getting either from the + // wrong source is the failure this pins. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u); + + JournalContractsPageController page1 = Bind( + page, state, Catalog(Entry(0x10u, "Aerlinthe Recall Ring"))); + + Assert.Equal(new[] { 0x10u }, page1.RowContractIds.ToArray()); + UiText name = Assert.IsType(Assert.Single( + UiElement.FindDescendant(page, ListId)!.Children.SelectMany(Flatten), + e => (e as UiText)?.DatElementId == RowNameId)); + Assert.Equal("Aerlinthe Recall Ring", name.LinesProvider!()[0].Text); + } + + private static IEnumerable Flatten(UiElement e) + { + yield return e; + foreach (UiElement child in e.Children) + { + foreach (UiElement descendant in Flatten(child)) + yield return descendant; + } + } + + [Fact] + public void TheDetailPaneShowsTheSelectedContract() + { + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u); + Track(state, 0x20u, stage: 1u); + + JournalContractsPageController controller = Bind(page, state, Catalog( + Entry(0x10u, "First", description: "Kill the thing.", contact: "Bob"), + Entry(0x20u, "Second", description: "Find the other thing.", contact: "Alice"))); + + controller.Select(0x20u); + + Assert.Equal("Find the other thing.", TextOf(page, DescriptionId)); + Assert.Equal("Alice", TextOf(page, ContactValueId)); + Assert.Equal("Available", TextOf(page, StatusValueId)); + } + + [Fact] + public void TheServersDisplayContractIsWhatOpensSelected() + { + // SetAsDisplayContract is the server nominating what to show. Ignoring + // it and always selecting the first row would show the wrong quest + // right after the one the player just accepted. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u); + Track(state, 0x20u, stage: 2u, setAsDisplay: true); + + JournalContractsPageController controller = Bind(page, state, Catalog( + Entry(0x10u, "First"), Entry(0x20u, "Second"))); + + Assert.Equal(0x20u, controller.SelectedContractId); + } + + [Fact] + public void WithNoDisplayContractTheFirstRowStands() + { + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x30u, stage: 2u); + Track(state, 0x10u, stage: 2u); + + JournalContractsPageController controller = Bind(page, state, Catalog( + Entry(0x10u, "First"), Entry(0x30u, "Third"))); + + // GetContracts orders by id, so 0x10 is first. + Assert.Equal(0x10u, controller.SelectedContractId); + } + + [Fact] + public void AnEmptyTrackerClearsTheDetailPaneRatherThanStrandingText() + { + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u); + + JournalContractsPageController controller = Bind( + page, state, Catalog(Entry(0x10u, "First", description: "Kill it."))); + Assert.Equal("Kill it.", TextOf(page, DescriptionId)); + + state.ApplyTable(new Dictionary()); + controller.Tick(); + + Assert.Equal(0u, controller.SelectedContractId); + Assert.Equal(string.Empty, TextOf(page, DescriptionId)); + Assert.Empty(controller.RowContractIds); + } + + [Fact] + public void TheListRebuildsOnlyWhenTheTrackerActuallyMoved() + { + // Tick runs every frame while the tracker changes rarely. A rebuild + // per frame would re-Build a template row list continuously and reset + // the player's scroll under them. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + int builds = 0; + + var controller = new JournalContractsPageController( + page, + new JournalContractsPageController.Bindings( + Contracts: state.View, + Catalog: () => Catalog(Entry(0x10u, "First")), + Now: () => Now, + TemplateResolver: (l, e) => { builds++; return RowTemplate(l, e); })); + + Track(state, 0x10u, stage: 2u); + controller.Tick(); + int afterFirstChange = builds; + + for (int frame = 0; frame < 10; frame++) + controller.Tick(); + + Assert.Equal(afterFirstChange, builds); + } + + [Fact] + public void AContractTheDatHasNeverHeardOfStillGetsARow() + { + // The server can track a contract this dat build does not carry. The + // row must still appear — dropping it would hide a live quest. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0xDEADu, stage: 2u); + + JournalContractsPageController controller = + Bind(page, state, ContractCatalog.Empty); + + Assert.Equal(new[] { 0xDEADu }, controller.RowContractIds.ToArray()); + Assert.Equal("In Progress", TextOf(page, StatusValueId)); + } + + [Fact] + public void ALocationWithNoOutdoorCoordinatesReadsAsIndoors() + { + // Retail's literal string when LandDefs::gid_to_lcoord fails + // (@0x0049937F). An indoor cell must not render as blank or as raw + // numbers. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u); + + Bind(page, state, Catalog(Entry(0x10u, "First", contactCell: 0x01020304u))); + + Assert.Equal("Indoors", TextOf(page, ContactLocationValueId)); + } + + [Fact] + public void AnUnsetLocationIsBlankRatherThanIndoors() + { + // A contract that authors no location at all is different from one + // whose location is inside a dungeon. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u); + + Bind(page, state, Catalog(Entry(0x10u, "First"))); + + Assert.Equal(string.Empty, TextOf(page, QuestLocationValueId)); + } + + [Fact] + public void TheTimedRowUsesTimeWhenDoneNotTimeWhenRepeats() + { + // The two wire timers mean different things and land in different + // places: TimeWhenRepeats drives the Status column, TimeWhenDone this + // row. Swapping them shows a plausible-looking wrong number. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 2u, whenDone: 3661d, whenRepeats: 90d); + + Bind(page, state, Catalog(Entry(0x10u, "First"))); + + Assert.Equal("1h 1m 1s", TextOf(page, TimedValueId)); + } + + [Fact] + public void TheRepeatCountdownTicksWithoutRebuildingTheList() + { + // Nothing on the wire changes while a cooldown runs down, so a + // revision-gated rebuild alone would freeze the timer on screen. + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 3u, whenRepeats: 600d); + + DateTime now = Now; + var controller = new JournalContractsPageController( + page, + new JournalContractsPageController.Bindings( + Contracts: state.View, + Catalog: () => Catalog(Entry(0x10u, "First", repeatFlag: "f")), + Now: () => now, + TemplateResolver: RowTemplate)); + + Assert.Equal("Done (10m 0s to Repeat)", TextOf(page, StatusValueId)); + + now = Now.AddMinutes(5); + controller.Tick(); + + Assert.Equal("Done (5m 0s to Repeat)", TextOf(page, StatusValueId)); + } + + [Fact] + public void AProgressCounterRendersThroughTheAuthoredFormat() + { + (UiElement page, _) = BuildPage(); + using var state = new RuntimeContractState(); + Track(state, 0x10u, stage: 9u); // ProgressCounter + 5 + + Bind(page, state, Catalog( + Entry(0x10u, "First", progressFormat: "%d/20 Tuskers"))); + + Assert.Equal("5/20 Tuskers", TextOf(page, StatusValueId)); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalPanelSlotProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalPanelSlotProbeTests.cs new file mode 100644 index 00000000..582c992d --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/JournalPanelSlotProbeTests.cs @@ -0,0 +1,186 @@ +using System; +using System.IO; +using System.Linq; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign QT slice QT5: pins the authored facts the Journal panel is built +/// on, against the live installed DATs rather than a committed fixture. +/// +/// +/// These are the facts a reader would otherwise have to take on trust from a +/// commit message: the panel's slot key, its three-tab table, and which tab is +/// the authored default. The FA campaign had to CORRECT a tab pairing that had +/// been inferred from x-order, which is why the pairing is asserted here from +/// the authored 0x2E table itself. +/// +[Trait("Lane", "InstalledDat")] +public sealed class JournalPanelSlotProbeTests +{ + private const uint SlotKeyPropertyId = 0x10000029u; + private const uint TabTablePropertyId = 0x2Eu; + private const uint TabButtonPropertyId = 0x30u; + private const uint TabPagePropertyId = 0x31u; + private const uint TabDefaultPropertyId = 0x32u; + + private const uint ToolbarLayoutId = 0x21000016u; + private const uint JournalToolbarButtonId = 0x1000055Au; + + private static ElementInfo Import(uint layoutId, uint rootElementId = 0u) + { + string? datDir = ContentConformanceDatDir(); + if (datDir is null) + { + Assert.Fail( + "Lane=InstalledDat requires an installed retail DAT directory; " + + "see docs/release-gate.md."); + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + using var adapter = new DatCollectionAdapter(dats); + ElementInfo? info = rootElementId == 0u + ? LayoutImporter.ImportInfos(adapter, layoutId) + : LayoutImporter.ImportInfos(adapter, layoutId, rootElementId); + Assert.NotNull(info); + return info!; + } + + private static string? ContentConformanceDatDir() + { + string? fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) + return fromEnv; + + string def = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + return Directory.Exists(def) ? def : null; + } + + private static UiPropertyValue? Property(ElementInfo info, uint propertyId) + { + foreach (UiStateInfo state in info.States.Values) + { + if (state.Properties.Values.TryGetValue(propertyId, out UiPropertyValue? value)) + return value; + } + return null; + } + + [Fact] + public void TheSlotAuthorsTheCatalogPanelId() + { + ElementInfo slot = Import( + JournalPanelController.HostLayoutId, + JournalPanelController.SlotElementId); + + UiPropertyValue? key = Property(slot, SlotKeyPropertyId); + + Assert.NotNull(key); + Assert.Equal(RetailPanelCatalog.Journal, (uint)key!.UnsignedValue); + } + + [Fact] + public void TheAuthoredTabTablePairsContractsWithTheGmContractsUiPage() + { + // Read from the table, never inferred from x-order. + ElementInfo slot = Import( + JournalPanelController.HostLayoutId, + JournalPanelController.SlotElementId); + + UiPropertyValue? tabs = Property(slot, TabTablePropertyId); + Assert.NotNull(tabs); + Assert.Equal(3, tabs!.ArrayValue.Count); + + UiPropertyValue first = tabs.ArrayValue[0]; + Assert.Equal( + 0x100005D3u, + (uint)first.StructValue[TabButtonPropertyId].UnsignedValue); + Assert.Equal( + JournalPanelController.ContractsPageId, + (uint)first.StructValue[TabPagePropertyId].UnsignedValue); + } + + [Fact] + public void ContractsIsTheAuthoredDefaultTab() + { + // Opening on the wrong tab would look like the panel is empty. + ElementInfo slot = Import( + JournalPanelController.HostLayoutId, + JournalPanelController.SlotElementId); + + UiPropertyValue tabs = Property(slot, TabTablePropertyId)!; + + UiPropertyValue defaultTab = Assert.Single( + tabs.ArrayValue, + t => t.StructValue.TryGetValue(TabDefaultPropertyId, out UiPropertyValue? d) + && d.BoolValue); + + Assert.Equal( + JournalPanelController.ContractsPageId, + (uint)defaultTab.StructValue[TabPagePropertyId].UnsignedValue); + } + + [Fact] + public void TheToolbarButtonAuthorsTheSamePanelId() + { + // The button was already in ToolbarController.PanelButtonIds with no + // panel behind it, so clicking it did nothing. This is the fact that + // makes it work rather than a keybind. + ElementInfo toolbar = Import(ToolbarLayoutId); + + ElementInfo? button = Find(toolbar, JournalToolbarButtonId); + Assert.NotNull(button); + + UiPropertyValue? key = Property(button!, SlotKeyPropertyId); + Assert.NotNull(key); + Assert.Equal(RetailPanelCatalog.Journal, (uint)key!.UnsignedValue); + } + + [Fact] + public void TheContractsPageCarriesEveryChildTheControllerResolves() + { + // A renamed or missing child would silently leave a blank row rather + // than fail, because the controller binds defensively. + ElementInfo slot = Import( + JournalPanelController.HostLayoutId, + JournalPanelController.SlotElementId); + + ElementInfo page = Assert.IsType( + Find(slot, JournalPanelController.ContractsPageId)); + + foreach (uint childId in new[] + { + 0x100005CFu, // the list + 0x100005DEu, // description + 0x100005DFu, // status value + 0x100005E0u, // contact value + 0x100005E1u, // contact location + 0x100005E2u, // quest location + 0x100005E3u, // timed value + 0x100005DCu, // Abandon + }) + { + Assert.True( + Find(page, childId) is not null, + $"contracts page is missing authored child 0x{childId:X8}"); + } + } + + private static ElementInfo? Find(ElementInfo root, uint id) + { + if (root.Id == id) return root; + foreach (ElementInfo child in root.Children) + { + ElementInfo? hit = Find(child, id); + if (hit is not null) return hit; + } + return null; + } +} diff --git a/tools/LayoutDump/Program.cs b/tools/LayoutDump/Program.cs index 9ddb6cdf..8b72528c 100644 --- a/tools/LayoutDump/Program.cs +++ b/tools/LayoutDump/Program.cs @@ -333,10 +333,20 @@ void Print(ElementInfo e, int depth) UiPropertyKind.Bool => v.BoolValue.ToString(), UiPropertyKind.Integer => v.IntegerValue.ToString(), UiPropertyKind.Enum => $"0x{v.UnsignedValue:X}", + UiPropertyKind.DataId => $"did:0x{v.UnsignedValue:X8}", // An authored StringInfo is a table id + string id, which says // nothing on its own -- resolve it, because "what does this // label SAY?" is the whole reason to dump properties. UiPropertyKind.StringInfo => DescribeString(v.StringInfoValue), + // A tab table (0x2E) is an array of structs pairing a button + // id with its page id. Printing "Array" hides the one thing it + // is for -- and inferring the pairing from x-order instead is + // exactly the mistake Campaign FA had to correct. + UiPropertyKind.Array => "[" + string.Join( + ", ", v.ArrayValue.Select(Describe)) + "]", + UiPropertyKind.Struct => "{" + string.Join( + ", ", v.StructValue.OrderBy(kv => kv.Key) + .Select(kv => $"0x{kv.Key:X2}={Describe(kv.Value)}")) + "}", _ => v.Kind.ToString(), };