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:
Erik 2026-08-21 15:12:05 +02:00
parent fac2dc7248
commit ec6eeb120d
12 changed files with 1109 additions and 5 deletions

View 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)];
}
}