feat(quest): QT5/QT6 — the Journal panel, and the button that was already there
The quest log is on screen. Rows come from the live tracker joined to the authored catalog, the Status column runs QT4's port of FillProgressString, and the detail pane shows contact, locations, description and the other timer. Two things measured rather than assumed, each now pinned by an installed-DAT test rather than left to the commit message: The tab pairing is read from the authored 0x2E table, not inferred from x-order — the FA campaign had to correct exactly that mistake, and Contracts turns out to be the authored DEFAULT tab (0x32 = True), so opening on the wrong one would have looked like an empty panel. The open path needed no keybind at all. Toolbar button 0x1000055A authors 0x10000029 = 0x19 and has been sitting in ToolbarController.PanelButtonIds since the toolbar was ported — it just had no panel behind it, so clicking it did nothing. Registering slot 25 finished a wiring that was already three-quarters present. The list rebuild is revision-gated while the repeat countdown is not: nothing on the wire changes as a cooldown runs down, so a rebuild-gated timer would freeze on screen, and a per-frame rebuild would reset the player's scroll under them. Both directions have a test. Deliberately inert: the Abandon button (retail's abandon path is a contract-registry command this campaign did not port — authored and visible, but wiring a no-op handler would look responsive and lie), and the Journal notes and Page List tabs, which are their own feature. Campaign QT slices 5 and 6 of 6 — code-complete, connected gate owed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
fac2dc7248
commit
ec6eeb120d
12 changed files with 1109 additions and 5 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -682,6 +682,18 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
lock (d.DatLock)
|
||||
chargenSkillTable = d.Dats.Get<SkillTable>(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,
|
||||
|
|
|
|||
228
src/AcDream.App/UI/Layout/JournalContractsPageController.cs
Normal file
228
src/AcDream.App/UI/Layout/JournalContractsPageController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The Journal panel's Contracts page — retail <c>gmContractsUI</c>
|
||||
/// (element type <c>0x1000004B</c>, page <c>0x100005D4</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A two-column list (Contract / Status) over a detail pane. Rows come from
|
||||
/// <see cref="IRuntimeContractView"/> — live server state — joined to the
|
||||
/// authored <see cref="ContractCatalog"/> for every word the player reads;
|
||||
/// the wire itself carries only an id, a stage and two timers.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Rebuilds are revision-gated. The tracker changes rarely (accepting or
|
||||
/// advancing a quest) while the panel ticks every frame, so polling
|
||||
/// <see cref="RuntimeContractsSnapshot.Revision"/> is what keeps this from
|
||||
/// rebuilding a template list continuously.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class JournalContractsPageController
|
||||
{
|
||||
/// <summary>The layout the row template lives in — authored property
|
||||
/// <c>0x63</c> on the list's template entry.</summary>
|
||||
public const uint RowTemplateLayoutId = 0x21000069u;
|
||||
|
||||
/// <summary>The row template element — authored <c>0x62</c>, and the id
|
||||
/// retail passes to <c>AddItemFromTemplateListByID @0x00499747</c>.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Live state and services the page reads.</summary>
|
||||
/// <param name="Contracts">The canonical tracker view.</param>
|
||||
/// <param name="Catalog">The authored contract text.</param>
|
||||
/// <param name="Now">
|
||||
/// The clock the repeat countdown is measured against. Injected rather
|
||||
/// than read from <see cref="DateTime.UtcNow"/> so the countdown is
|
||||
/// testable without waiting for it.
|
||||
/// </param>
|
||||
/// <param name="TemplateResolver">Builds one row from the authored template.</param>
|
||||
public sealed record Bindings(
|
||||
IRuntimeContractView Contracts,
|
||||
Func<ContractCatalog> Catalog,
|
||||
Func<DateTime> Now,
|
||||
Func<uint, uint, UiElement?> 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<uint> _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();
|
||||
}
|
||||
|
||||
/// <summary>The contract the detail pane is showing, or 0.</summary>
|
||||
public uint SelectedContractId => _selectedContractId;
|
||||
|
||||
/// <summary>Contract ids in list order, for tests.</summary>
|
||||
public IReadOnlyList<uint> 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<ContractTracker> 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();
|
||||
}
|
||||
|
||||
/// <summary>Points the detail pane at one contract.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coordinates, or retail's literal <c>"Indoors"</c> when the cell has none
|
||||
/// (<c>LandDefs::gid_to_lcoord</c> failing, <c>@0x0049937F</c>).
|
||||
/// </summary>
|
||||
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)];
|
||||
}
|
||||
}
|
||||
120
src/AcDream.App/UI/Layout/JournalPanelController.cs
Normal file
120
src/AcDream.App/UI/Layout/JournalPanelController.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Mounts retail's three-tab <b>Journal</b> panel — LayoutDesc
|
||||
/// <c>0x2100006E</c> slot <c>0x10000559</c>,
|
||||
/// <see cref="AcDream.App.UI.RetailPanelCatalog"/> id <b>25</b>. Campaign QT
|
||||
/// slice QT5, built on the OP3/FA3 tab-host recipe.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The contract tracker is not a panel of its own.</b> `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 <c>0x2E</c>, read from
|
||||
/// the installed dats rather than inferred from x-order — the mistake Campaign
|
||||
/// FA had to correct):
|
||||
/// </para>
|
||||
/// <code>
|
||||
/// button 0x100005D3 ("Contracts") -> page 0x100005D4 DEFAULT (0x32 = True)
|
||||
/// button 0x10000560 ("Journal") -> page 0x10000563
|
||||
/// button 0x10000561 ("Page List") -> page 0x10000564
|
||||
/// </code>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class JournalPanelController : IRetainedPanelController
|
||||
{
|
||||
/// <summary>The floating host LayoutDesc the tab panel is resolved through.</summary>
|
||||
public const uint HostLayoutId = 0x2100006Eu;
|
||||
|
||||
/// <summary>
|
||||
/// The Journal panel's slot within <see cref="HostLayoutId"/>'s shared
|
||||
/// <c>gmPanelUI</c> page stack. Its own authored <c>0x10000029</c> is
|
||||
/// <c>0x19</c> = 25 — the same byte-verified slot-key recipe Options (10),
|
||||
/// the social panel (12) and Map/House (16) already use.
|
||||
/// </summary>
|
||||
public const uint SlotElementId = 0x10000559u;
|
||||
|
||||
/// <summary>The Contracts page — <c>gmContractsUI</c>, element type
|
||||
/// <c>0x1000004B</c>.</summary>
|
||||
public const uint ContractsPageId = 0x100005D4u;
|
||||
|
||||
/// <summary>The panel's own corner button.</summary>
|
||||
private const uint CloseButtonId = 0x10000562u;
|
||||
|
||||
private readonly UiTabPanel _tabPanel;
|
||||
private readonly JournalContractsPageController? _contracts;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Root element of the imported panel — the tab host itself.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Binds an imported <see cref="HostLayoutId"/>/<see cref="SlotElementId"/>
|
||||
/// layout to live behavior — the same "import via the host slot, then
|
||||
/// Build+Bind" shape <see cref="OptionsPanelController.Bind"/> and
|
||||
/// <see cref="SocialPanelController.Bind"/> use.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the authored tab table, which activates the default entry —
|
||||
/// Contracts (<c>0x32 = True</c>).
|
||||
/// </summary>
|
||||
public void ActivateTabs() => _tabPanel.ActivateTabBehavior();
|
||||
|
||||
/// <summary>Switches to the Contracts tab.</summary>
|
||||
public void ShowContracts() => _tabPanel.SwitchTo(ContractsPageId);
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_contracts?.Tick();
|
||||
}
|
||||
|
||||
public void Dispose() => _disposed = true;
|
||||
}
|
||||
|
|
@ -57,6 +57,17 @@ public static class RetailPanelCatalog
|
|||
/// </summary>
|
||||
public const uint MapHouse = 16u;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign QT slice QT5: the three-tab Journal panel (Contracts /
|
||||
/// Journal / Page List) — <c>gmPanelUI</c> slot key byte-verified from the
|
||||
/// live installed DATs (host <c>0x2100006E</c> slot <c>0x10000559</c>'s own
|
||||
/// authored <c>0x10000029 = 0x19</c>). Toolbar button <c>0x1000055A</c>
|
||||
/// authors the same id, so this one is in BOTH <see cref="Mounted"/> and
|
||||
/// <see cref="Toolbar"/> — like <see cref="MapHouse"/>, unlike
|
||||
/// <see cref="SocialPanel"/>.
|
||||
/// </summary>
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -311,6 +311,14 @@ public sealed record MapHouseRuntimeBindings(
|
|||
Func<IReadOnlyList<string>>? HouseLines = null,
|
||||
Action? HouseShown = null);
|
||||
|
||||
/// <summary>
|
||||
/// Campaign QT slice QT5: what the Journal panel's Contracts page reads —
|
||||
/// the canonical tracker view plus the authored contract text.
|
||||
/// </summary>
|
||||
public sealed record QuestRuntimeBindings(
|
||||
AcDream.Runtime.Gameplay.IRuntimeContractView Contracts,
|
||||
Func<AcDream.Core.Quests.ContractCatalog> Catalog);
|
||||
|
||||
public sealed record InventoryRuntimeBindings(
|
||||
ClientObjectTable Objects,
|
||||
Func<uint> 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; }
|
||||
|
||||
/// <summary>Campaign QT slice QT5 — the three-tab Journal panel.</summary>
|
||||
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).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign QT slice QT5: retail's three-tab Journal panel — host
|
||||
/// <c>0x2100006E</c> slot <c>0x10000559</c>,
|
||||
/// <see cref="RetailPanelCatalog.Journal"/> id 25. Same import/Build/Bind
|
||||
/// recipe as <see cref="MountSocialPanel"/>. The Contracts page's list has
|
||||
/// ONE authored row template resolved out of a DIFFERENT layout
|
||||
/// (<c>0x21000069</c>) than the panel itself, which is why it needs the
|
||||
/// caching <see cref="Layout.RowTemplateResolver"/> rather than
|
||||
/// <see cref="UiTemplateListBox.AddItemFromTemplateList"/>'s own path.
|
||||
/// </summary>
|
||||
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).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch C (overnight hover/UI round, 2026-08-17): the two-tab Map/House
|
||||
/// panel — host <c>0x2100006E</c> slot <c>0x1000018C</c>,
|
||||
|
|
|
|||
|
|
@ -44,4 +44,8 @@ public static class WindowNames
|
|||
/// <summary>Batch C (overnight hover/UI round): the two-tab Map/House
|
||||
/// panel (<see cref="RetailPanelCatalog.MapHouse"/>).</summary>
|
||||
public const string MapHouse = "map-house";
|
||||
|
||||
/// <summary>Campaign QT slice QT5: the three-tab Contracts/Journal/Page
|
||||
/// List panel (<see cref="RetailPanelCatalog.Journal"/>).</summary>
|
||||
public const string Journal = "journal";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -37,7 +37,17 @@ public sealed record ContractEntry(
|
|||
string QuestflagFinished,
|
||||
string QuestflagProgress,
|
||||
string QuestflagTimer,
|
||||
string QuestflagRepeatTime)
|
||||
string QuestflagRepeatTime,
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// (<c>LandDefs::gid_to_lcoord</c> failing, <c>@0x0049937F</c>).
|
||||
/// </summary>
|
||||
uint LocationNpcStartCell = 0u,
|
||||
uint LocationNpcEndCell = 0u,
|
||||
/// <summary>Landcell of the quest area — the "Quest Location" row.</summary>
|
||||
uint LocationQuestAreaCell = 0u)
|
||||
{
|
||||
public static readonly ContractEntry Unknown = new(
|
||||
0u, 0u,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign QT slice QT5: the Journal panel's Contracts page.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// A page carrying the same child ids the real one does, so the controller
|
||||
/// resolves exactly what production resolves.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>One row: a name text and a status text, as retail authors.</summary>
|
||||
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<UiText>(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<UiElement> 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<uint, ContractTracker>());
|
||||
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));
|
||||
}
|
||||
}
|
||||
186
tests/AcDream.App.Tests/UI/Layout/JournalPanelSlotProbeTests.cs
Normal file
186
tests/AcDream.App.Tests/UI/Layout/JournalPanelSlotProbeTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign QT slice QT5: pins the authored facts the Journal panel is built
|
||||
/// on, against the live installed DATs rather than a committed fixture.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <c>0x2E</c> table itself.
|
||||
/// </remarks>
|
||||
[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<ElementInfo>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue