acdream/src/AcDream.App/UI/Layout/JournalContractsPageController.cs
Erik e35d9386e4 fix(journal): the location readout is a FIELD, refresh at the click, and Abandon works
Three fixes from the first connected round.

The location readout is authored EDITABLE (0x16), so it builds as a UiField —
not the UiText its "00.0S, 00.0W" placeholder suggests. The controller resolved
it as text, got null, and threw every write away in silence: Record reached the
model and reached the FILE, and never reached the screen. That is exactly what
was reported, and it is a whole class of bug, so the sweep that found it is now
a test over every element all three controllers bind.

The handlers mutated the model and left redrawing to the next frame's Tick.
Retail's ListenToElementMessage @0x004968D0 ends every one of them in Update()
instead — at the moment of the click. The deferred version happened to work in
the client and made the behaviour untestable and a frame late; the notes-page
tests I had not written until now fail against it.

Abandon is wired. "Retail's abandon path is a contract-registry command we have
not ported" was wrong — it is game action 0x0316 with a single contract id, and
ACE replies with the 0x0315 delete QT3 already handles. Nothing is removed
locally, so a refusal leaves the quest visibly intact rather than vanishing it
optimistically and having it reappear on the next full table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:19:18 +02:00

288 lines
11 KiB
C#

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>
/// <param name="Abandon">
/// Sends the abandon action for one contract. The row disappears only when
/// the SERVER answers with its own delete, so a refused abandon leaves the
/// quest exactly where it was.
/// </param>
public sealed record Bindings(
IRuntimeContractView Contracts,
Func<ContractCatalog> Catalog,
Func<DateTime> Now,
Func<uint, uint, UiElement?> TemplateResolver,
Action<uint>? Abandon = null);
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;
/// <summary>
/// Retail's own selection highlight for a listbox row. UiTemplateListBox
/// has no generic selection mechanism (its class doc), so the page opts in
/// directly — the same precedent
/// <see cref="CharacterCreationSkillsPage"/> set.
/// </summary>
private static readonly Vector4 SelectedNameColor = Vector4.One;
private readonly List<uint> _rowContractIds = [];
private readonly List<(uint ContractId, UiText? Name, Vector4 Unselected)> _rows = [];
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;
if (UiElement.FindDescendant(page, AbandonButtonId) is UiButton abandon)
abandon.OnClick = AbandonSelected;
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();
_rows.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);
var name = UiElement.FindDescendant(row, RowNameId) as UiText;
if (name is not null)
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));
}
// Captured AFTER SetText, which never touches DefaultColor (it is
// read lazily inside the provider closure), so this is the row's
// own authored colour to restore on deselect.
_rows.Add((tracker.ContractId, name, name?.DefaultColor ?? Vector4.One));
uint captured = tracker.ContractId;
if (row is UiDatElement clickable)
{
// A generic Type-3 container is click-THROUGH by default, so
// without this the handler below never fires and the list looks
// dead. UiDatElement carries the opt-in seam for exactly this.
clickable.ClickThrough = false;
clickable.OnClick = () => Select(captured);
}
}
ApplySelectionHighlight();
RefreshDetail();
}
/// <summary>
/// Abandons the selected contract — game action <c>0x0316</c>.
/// </summary>
/// <remarks>
/// Nothing is removed locally. The server replies with a <c>0x0315</c>
/// carrying <c>DeleteContract</c> and the tracker drops the row then, so a
/// refusal leaves the quest visibly intact rather than vanishing it
/// optimistically and having it reappear.
/// </remarks>
public void AbandonSelected()
{
if (_selectedContractId == 0u)
return;
_bindings.Abandon?.Invoke(_selectedContractId);
}
/// <summary>Points the detail pane at one contract.</summary>
public void Select(uint contractId)
{
_selectedContractId = contractId;
ApplySelectionHighlight();
RefreshDetail();
}
/// <summary>
/// Re-painted rather than merely remembered: a rebuild discards the old row
/// objects, so a preserved selection has to be applied to the new ones.
/// </summary>
private void ApplySelectionHighlight()
{
foreach ((uint contractId, UiText? name, Vector4 unselected) in _rows)
{
if (name is not null)
{
name.DefaultColor =
contractId == _selectedContractId ? SelectedNameColor : unselected;
}
}
}
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
? RetailDurationText.Format(
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)];
}
}