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>
288 lines
11 KiB
C#
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)];
|
|
}
|
|
}
|