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. /// /// 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. /// public sealed record Bindings( IRuntimeContractView Contracts, Func Catalog, Func Now, Func TemplateResolver, Action? 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; /// /// 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 /// set. /// private static readonly Vector4 SelectedNameColor = Vector4.One; private readonly List _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(); } /// 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(); _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(); } /// /// Abandons the selected contract — game action 0x0316. /// /// /// Nothing is removed locally. The server replies with a 0x0315 /// carrying DeleteContract and the tracker drops the row then, so a /// refusal leaves the quest visibly intact rather than vanishing it /// optimistically and having it reappear. /// public void AbandonSelected() { if (_selectedContractId == 0u) return; _bindings.Abandon?.Invoke(_selectedContractId); } /// Points the detail pane at one contract. public void Select(uint contractId) { _selectedContractId = contractId; ApplySelectionHighlight(); RefreshDetail(); } /// /// 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. /// 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); } /// /// 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)]; } }