diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 720737ee..e01f0253 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -496,6 +496,7 @@ equivalence argument (promote to AD/AP) or a fix. | CT-4 | A media `Jump`/`State` step with a probability below 1 FALLS THROUGH rather than branching; retail rolls for it | `src/AcDream.App/UI/Layout/UiMediaSequence.cs` (`Sample`) | The roll's distribution and its re-roll cadence (per visit? per state entry?) are not in the decomp. Falling through is the conservative direction: a sequence that ends early stops animating, where treating it as certain would animate forever and could pin a state that never hands off | A probabilistic sequence plays its deterministic tail instead of its branch. The chat indicator authors p=1 throughout, so it is exact there | `MediaDescJump{Probability}` / `MediaDescState{Probability}` in the LayoutDesc dat | | CT-5 | A bare `@log` filename lands in the client's own log directory (`ApplicationPathSet.LogsDirectory`), not the install directory retail names ("a log file named Aclog.txt in your Asheron's Call directory"). Rooted paths are honoured verbatim, as retail's `fopen` would | `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs` (`_chatLogDirectory`); `src/AcDream.Core/Chat/ChatSessionLog.cs` | acdream's launcher replaces the install directory atomically on update, so a log written there is wiped by the next update or blocks it outright. Retail had no updater with that property. The client's own data directory is the equivalent that survives | A player following retail-era instructions looks for the file next to the executable and does not find it. The `/log` reply names the file, not the directory, so the path is discoverable only from this row and the code | `ClientCommunicationSystem::StartCopyOutputToFile @0x0057C8A0`; help text at `DoSetOutputHelp @0x0057A950` | | CT-6 | The `@log` file records the composed line WITHOUT retail's inline text-tag markup. Retail's `fprintf` runs before glyph parsing, so its logs contain literal `` markers around tagged names | `src/AcDream.App/UI/ChatTranscriptLogWriter.cs` | acdream never puts markup in the line: `ChatVM` carries tags as SPANS beside the text (CT-A2/A3), so there is no markup at that seam to preserve. Reconstructing it purely to write it to a file would be inventing a string the client does not otherwise produce | A log diffed against a retail-era log differs on tagged lines — acdream's are the clean ones. No in-client effect | `ClientSystem::AddTextToScroll` write at `@0x00563E5B`, upstream of `UIElement_Text::InqGlyphs @0x00468EA0` | +| QJ-1 | The per-character journal file lives in the client's own data directory (`{data}/journal/Journal-{server}-{character}.txt`), not beside the executable where retail's sits | `src/AcDream.App/UI/JournalPersistence.cs`; path composed in `InteractionRetainedUiComposition` | Identical reasoning to CT-5: acdream's launcher replaces the install directory atomically on update, so a journal written there is destroyed by the next update. The file NAME follows retail's own `"%s%s-%s-%s.txt"` pattern exactly | A player migrating a retail journal must copy the file rather than find it picked up in place. No in-client effect | `gmJournalUI::LoadPages @0x00496AC0` / `SavePages @0x00497270` | --- diff --git a/docs/plans/2026-08-21-journal-campaign.md b/docs/plans/2026-08-21-journal-campaign.md index 33e8e419..d7326164 100644 --- a/docs/plans/2026-08-21-journal-campaign.md +++ b/docs/plans/2026-08-21-journal-campaign.md @@ -1,6 +1,7 @@ # Campaign QJ — the Journal and Page List tabs -**Status:** ACTIVE 2026-08-21. Completes the panel Campaign QT mounted: QT +**Status:** CODE-COMPLETE 2026-08-21. All five slices landed; the connected +user gate is owed. Completes the panel Campaign QT mounted: QT shipped the Contracts tab and left the other two inert by design. **Scope:** retail's `gmJournalUI` (element type `0x10000048`, page diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index b8d12f28..b65fdd36 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -1025,7 +1025,15 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory // refresh under the shared DatLock. Quests: new QuestRuntimeBindings( Contracts: d.Runtime.ContractsOwner.View, - Catalog: questCatalog), + Catalog: questCatalog, + Journal: d.Runtime.JournalOwner.View, + JournalCommands: d.Runtime.JournalOwner, + PlayerCell: () => d.PlayerController.Controller?.CellId ?? 0u, + JournalDirectory: System.IO.Path.Combine( + AcDream.Platform.ApplicationPathSet.Resolve().DataDirectory, + "journal"), + Report: message => + d.Communication.Chat.OnSystemMessage(message, 0x0Fu)), StackSplitQuantity: d.StackSplitQuantity, Plugins: d.UiRegistry, Persistence: persistence, diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index b3acfe7d..fe3a19c0 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -226,7 +226,15 @@ internal sealed class LiveSessionRuntimeFactory _ui.RetailUi?.RedeclareSocialPanelAfterWorldEntry(); }, SyncToolbar: () => _ui.RetailUi?.SyncToolbarWindowButtons(), - LoadCharacterSettings: _interaction.Settings.LoadCharacterContext, + LoadCharacterSettings: name => + { + _interaction.Settings.LoadCharacterContext(name); + // Campaign QJ slice QJ5: retail loads the journal on + // entering the world, from the same per-character moment + // the settings context uses. RetailUiRuntime owns the file + // because the panel that writes it does. + _ui.RetailUi?.LoadJournal(name); + }, ArmPlayerModeAutoEntry: _interaction.PlayerModeAutoEntry.Arm, // Logout-audio round (2026-08-17): reopen the world-audio // pool the session reset closed (see the reset manifest's diff --git a/src/AcDream.App/UI/JournalPersistence.cs b/src/AcDream.App/UI/JournalPersistence.cs new file mode 100644 index 00000000..d55938c1 --- /dev/null +++ b/src/AcDream.App/UI/JournalPersistence.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AcDream.Core.Journal; +using AcDream.Runtime.Gameplay; + +namespace AcDream.App.UI; + +/// +/// Reads and writes the per-character journal file. +/// +/// +/// +/// Retail loads on entering the world and saves whenever the notes page is +/// hidden (gmJournalUI::OnVisibilityChanged @0x004978F0) rather than +/// only at exit, so a crash costs at most the page in front of you. Both +/// moments call through here. +/// +/// +/// The file lives in the client's own data directory rather than beside the +/// executable, for the same reason the chat log does: the launcher replaces +/// the install atomically on update, and a file written there is wiped by the +/// next update. Register row QJ-1. +/// +/// +public sealed class JournalPersistence( + RuntimeJournalState journal, + string directory, + Action? report = null) +{ + private readonly RuntimeJournalState _journal = + journal ?? throw new ArgumentNullException(nameof(journal)); + + private readonly string _directory = string.IsNullOrWhiteSpace(directory) + ? throw new ArgumentException("A journal directory is required.", nameof(directory)) + : directory; + + private string? _characterName; + + /// The file the loaded character's journal lives in, or null. + public string? CurrentPath { get; private set; } + + /// + /// Loads a character's journal, replacing whatever was in memory. + /// + /// + /// A character with no file is the ordinary first-time case and loads as an + /// empty journal. A file that fails retail's own validity check reports the + /// message and leaves the journal EMPTY rather than partially populated — + /// a half-read notebook silently loses pages, and the next save would then + /// write the loss back over the original. + /// + public void Load(string characterName, string serverName = "acdream") + { + if (string.IsNullOrWhiteSpace(characterName)) + return; + + _characterName = characterName; + CurrentPath = Path.Combine( + _directory, JournalFile.FileNameFor(serverName, characterName)); + + string text; + try + { + text = File.Exists(CurrentPath) ? File.ReadAllText(CurrentPath) : string.Empty; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + report?.Invoke($"Problem loading journal: {e.Message}"); + _journal.Load([]); + return; + } + + JournalReadResult result = JournalFile.Read(text); + if (result.Error is not null) + { + report?.Invoke(result.Error); + _journal.Load([]); + return; + } + + _journal.Load(result.Pages); + } + + /// + /// Writes the journal if anything has changed since the last write. + /// + /// Whether a file was written. + public bool Save(DateTime now) + { + if (CurrentPath is null || _characterName is null) + return false; + if (!_journal.IsDirty) + return false; + + IReadOnlyList pages = _journal.CaptureForSave(now); + try + { + Directory.CreateDirectory(_directory); + File.WriteAllText(CurrentPath, JournalFile.Write(pages)); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + // Reported, not thrown: losing a note is bad, taking the client + // down while the player is writing one is worse. + report?.Invoke($"Problem saving journal: {e.Message}"); + return false; + } + + _journal.MarkSaved(); + return true; + } + + /// Saves and forgets the character — session teardown. + public void Close(DateTime now) + { + Save(now); + _characterName = null; + CurrentPath = null; + } +} diff --git a/src/AcDream.App/UI/Layout/JournalContractsPageController.cs b/src/AcDream.App/UI/Layout/JournalContractsPageController.cs index 1791ec74..8f2b0c62 100644 --- a/src/AcDream.App/UI/Layout/JournalContractsPageController.cs +++ b/src/AcDream.App/UI/Layout/JournalContractsPageController.cs @@ -245,7 +245,7 @@ public sealed class JournalContractsPageController // "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( + ? RetailDurationText.Format( Math.Max(0d, tracker.TimeWhenDone - (now - tracker.ReceivedAt).TotalSeconds)) : string.Empty); } diff --git a/src/AcDream.App/UI/Layout/JournalNotesPageController.cs b/src/AcDream.App/UI/Layout/JournalNotesPageController.cs new file mode 100644 index 00000000..b7d2bb81 --- /dev/null +++ b/src/AcDream.App/UI/Layout/JournalNotesPageController.cs @@ -0,0 +1,272 @@ +using System; +using System.Globalization; +using AcDream.Core.Journal; +using AcDream.Core.Ui; +using AcDream.Runtime.Gameplay; + +namespace AcDream.App.UI.Layout; + +/// +/// The Journal panel's notes page — retail gmJournalUI (element type +/// 0x10000048, page 0x10000563). +/// +/// +/// +/// A per-character notebook: label, title, free notes, a recorded location and +/// a countdown timer, paged with First/Previous/Next/Last. +/// +/// +/// Every navigation commits the current page first. Retail's +/// ListenToElementMessage @0x004968D0 calls SaveThisPage on the +/// way out of every one of the five navigation buttons, which is why paging +/// away never loses what you just typed. Skipping that is the obvious +/// simplification and it silently eats edits. +/// +/// +public sealed class JournalNotesPageController +{ + // Retail switches on (idElement - 0x10000565), so these two carry no + // caption of their own: they are the prev/next arrows. + private const uint PreviousButtonId = 0x10000565u; + private const uint NextButtonId = 0x10000566u; + private const uint NewButtonId = 0x10000567u; + + private const uint LabelFieldId = 0x10000569u; + private const uint TitleFieldId = 0x1000056Bu; + private const uint NotesFieldId = 0x1000056Du; + + private const uint FirstButtonId = 0x1000056Fu; + private const uint PageNumberId = 0x10000570u; + private const uint LastButtonId = 0x10000571u; + + private const uint LocationTextId = 0x10000573u; + private const uint RecordButtonId = 0x10000574u; + + private const uint TimerDaysFieldId = 0x10000576u; + private const uint TimerHoursFieldId = 0x10000578u; + private const uint TimerMinutesFieldId = 0x1000057Au; + private const uint RunningTimerTextId = 0x1000057Cu; + private const uint StartButtonId = 0x1000057Du; + + /// The canonical owner. + /// Mutations, kept off the read view. + /// + /// The player's current landcell, for the "Record" button. Returns 0 when + /// there is no valid cell — indoors, or not in the world. + /// + /// The clock the countdown runs against. + public sealed record Bindings( + IRuntimeJournalView Journal, + RuntimeJournalState Commands, + Func PlayerCell, + Func Now); + + private readonly Bindings _bindings; + private readonly UiField? _label; + private readonly UiField? _title; + private readonly UiField? _notes; + private readonly UiField? _timerDays; + private readonly UiField? _timerHours; + private readonly UiField? _timerMinutes; + private readonly UiText? _pageNumber; + private readonly UiText? _location; + private readonly UiText? _runningTimer; + private readonly UiButton? _start; + + private long _renderedRevision = -1; + + public JournalNotesPageController(UiElement page, Bindings bindings) + { + ArgumentNullException.ThrowIfNull(page); + _bindings = bindings ?? throw new ArgumentNullException(nameof(bindings)); + + _label = UiElement.FindDescendant(page, LabelFieldId) as UiField; + _title = UiElement.FindDescendant(page, TitleFieldId) as UiField; + _notes = UiElement.FindDescendant(page, NotesFieldId) as UiField; + _timerDays = UiElement.FindDescendant(page, TimerDaysFieldId) as UiField; + _timerHours = UiElement.FindDescendant(page, TimerHoursFieldId) as UiField; + _timerMinutes = UiElement.FindDescendant(page, TimerMinutesFieldId) as UiField; + _pageNumber = UiElement.FindDescendant(page, PageNumberId) as UiText; + _location = UiElement.FindDescendant(page, LocationTextId) as UiText; + _runningTimer = UiElement.FindDescendant(page, RunningTimerTextId) as UiText; + _start = UiElement.FindDescendant(page, StartButtonId) as UiButton; + + // The three timer boxes take digits only. Their authored 0x1E is 2, so + // the width is already handled; this stops a letter reaching an int + // parse that would silently read as zero. + foreach (UiField? field in new[] { _timerDays, _timerHours, _timerMinutes }) + { + if (field is not null) + field.CharacterFilter = static c => char.IsAsciiDigit(c); + } + + // Committing on focus loss is what makes clicking straight from a text + // box to another tab keep the edit. + foreach (UiField? field in new[] { _label, _title, _notes }) + { + if (field is not null) + field.OnFocusLost = _ => CommitText(); + } + + Bind(page, NewButtonId, () => { CommitText(); _bindings.Commands.NewPage(); }); + Bind(page, FirstButtonId, () => Navigate(1)); + Bind(page, LastButtonId, () => Navigate(_bindings.Journal.Snapshot.PageCount)); + Bind(page, PreviousButtonId, + () => Navigate(_bindings.Journal.Snapshot.CurrentPage - 1)); + Bind(page, NextButtonId, + () => Navigate(_bindings.Journal.Snapshot.CurrentPage + 1)); + Bind(page, RecordButtonId, RecordLocation); + Bind(page, StartButtonId, ToggleTimer); + + Refresh(); + } + + /// + /// Commits the edit boxes into the current page — retail's + /// SaveThisPage @0x00495360. + /// + public void CommitText() + { + if (_bindings.Journal.Snapshot.CurrentPage == 0) + return; + + _bindings.Commands.UpdateCurrent( + _label?.Text ?? string.Empty, + _title?.Text ?? string.Empty, + _notes?.Text ?? string.Empty); + + _bindings.Commands.SetTimer( + ParseField(_timerDays), + ParseField(_timerHours), + ParseField(_timerMinutes)); + } + + public void Tick() + { + if (_bindings.Journal.Snapshot.Revision != _renderedRevision) + Refresh(); + else + RefreshTimer(); // the countdown ticks without a page rebuild + } + + /// Called when the page is hidden — retail saves here. + public void OnHidden() => CommitText(); + + public void Refresh() + { + RuntimeJournalSnapshot snapshot = _bindings.Journal.Snapshot; + _renderedRevision = snapshot.Revision; + + JournalPage page = _bindings.Journal.Current; + + _label?.SetText(page.Label); + _title?.SetText(page.Title); + _notes?.SetText(page.Notes); + _timerDays?.SetText(Field(page.TimerDays)); + _timerHours?.SetText(Field(page.TimerHours)); + _timerMinutes?.SetText(Field(page.TimerMinutes)); + + // Retail's own "~ N ~". An empty journal shows no number rather than + // "~ 0 ~", which would name a page that does not exist. + SetText(_pageNumber, snapshot.CurrentPage == 0 + ? string.Empty + : $"~ {snapshot.CurrentPage.ToString(CultureInfo.InvariantCulture)} ~"); + + SetText(_location, page.HasLocation + ? FormatLocation(page.LocationX, page.LocationY) + : string.Empty); + + RefreshTimer(); + } + + /// + /// The editable-fields / running-readout swap + /// (ShowEditableTimer @0x00495770 versus ShowRunningTimer). + /// + /// + /// The readout is authored at the SAME x as the three number boxes, so + /// showing both at once overlaps them illegibly — the strip is one or the + /// other. + /// + private void RefreshTimer() + { + double remaining = _bindings.Journal.RemainingTimerSeconds(_bindings.Now()); + bool running = remaining > 0d; + + if (_timerDays is not null) _timerDays.Visible = !running; + if (_timerHours is not null) _timerHours.Visible = !running; + if (_timerMinutes is not null) _timerMinutes.Visible = !running; + if (_runningTimer is not null) _runningTimer.Visible = running; + + SetText(_runningTimer, running + ? RetailDurationText.Format(remaining) + : string.Empty); + + if (_start is not null) + _start.Label = running ? "Stop" : "Start"; + } + + private void Navigate(int pageNumber) + { + CommitText(); + _bindings.Commands.GotoPage(pageNumber); + } + + private void RecordLocation() + { + uint cell = _bindings.PlayerCell(); + if (cell == 0u) + return; + + if (!AcDream.Core.Ui.RadarCoordinates.TryFromCell(cell, out var coordinates)) + return; // indoors: retail's own gid_to_lcoord failure + + _bindings.Commands.RecordLocation((float)coordinates.X, (float)coordinates.Y); + } + + private void ToggleTimer() + { + if (_bindings.Journal.RemainingTimerSeconds(_bindings.Now()) > 0d) + { + _bindings.Commands.ResetTimer(); + return; + } + + CommitText(); // the fields the countdown reads + _bindings.Commands.StartTimer(_bindings.Now()); + } + + /// + /// The journal's own authored placeholder is "00.0S, 00.0W" — with + /// a space after the comma, unlike the radar's own combined form. + /// + private static string FormatLocation(float x, float y) + { + var coordinates = new AcDream.Core.Ui.RadarCoordinates(x, y); + return $"{coordinates.YText}, {coordinates.XText}"; + } + + private void Bind(UiElement page, uint elementId, Action action) + { + if (UiElement.FindDescendant(page, elementId) is UiButton button) + button.OnClick = action; + } + + private static int ParseField(UiField? field) => + int.TryParse( + field?.Text, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int value) && value >= 0 + ? value + : 0; + + private static string Field(int value) => + value == 0 ? string.Empty : value.ToString(CultureInfo.InvariantCulture); + + private static void SetText(UiText? text, string value) + { + if (text is null) return; + text.LinesProvider = () => [new UiText.Line(value, text.DefaultColor)]; + } +} diff --git a/src/AcDream.App/UI/Layout/JournalPageListController.cs b/src/AcDream.App/UI/Layout/JournalPageListController.cs new file mode 100644 index 00000000..9749bd3f --- /dev/null +++ b/src/AcDream.App/UI/Layout/JournalPageListController.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Numerics; +using AcDream.Core.Journal; +using AcDream.Core.Ui; +using AcDream.Runtime.Gameplay; + +namespace AcDream.App.UI.Layout; + +/// +/// The Journal panel's Page List — retail gmPageListUI (element type +/// 0x10000049, page 0x10000564). +/// +/// +/// A searchable index over the journal's pages: number, title, timer and +/// label, with Delete and a search box. Double-clicking a row opens that page +/// on the Journal tab (CheckForDoubleClick @0x00493140 → +/// gmJournalUI::GotoPage). +/// +public sealed class JournalPageListController +{ + /// The layout the row template lives in — authored 0x63. + public const uint RowTemplateLayoutId = 0x21000067u; + + /// The row template element — authored 0x62. + public const uint RowTemplateElementId = 0x10000589u; + + private const uint ListId = 0x10000583u; + private const uint DeleteButtonId = 0x10000585u; + private const uint SearchFieldId = 0x10000587u; + private const uint ResetButtonId = 0x10000588u; + + private const uint RowNumberId = 0x1000058Au; + private const uint RowTitleId = 0x1000058Bu; + private const uint RowTimerId = 0x1000058Cu; + private const uint RowLabelId = 0x1000058Du; + + private static readonly Vector4 SelectedNameColor = Vector4.One; + + /// The canonical owner. + /// Mutations. + /// + /// Opens a page on the Journal tab — the panel owns the tab switch, this + /// page owns only the choice. + /// + public sealed record Bindings( + IRuntimeJournalView Journal, + RuntimeJournalState Commands, + Action OpenPage, + Func TemplateResolver, + Func? Now = null); + + private readonly Bindings _bindings; + private readonly UiTemplateListBox? _list; + private readonly UiField? _search; + private readonly List _rowPages = []; + private readonly List<(int Page, UiText? Title, Vector4 Unselected)> _rows = []; + + /// + /// Retail's double-click window — m_LastClickTime + 1.0 in + /// CheckForDoubleClick @0x00493158. A full SECOND, not the 500 ms + /// the item-interaction path uses; the two are separate mechanisms and + /// borrowing the wrong constant makes the list feel unresponsive. + /// + private static readonly TimeSpan DoubleClickWindow = TimeSpan.FromSeconds(1d); + + private long _renderedRevision = -1; + private string _renderedSearch = string.Empty; + private int _selectedPage; + private int _lastClickPage; + private DateTime _lastClickAt; + + public JournalPageListController(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; + + _search = UiElement.FindDescendant(page, SearchFieldId) as UiField; + + if (UiElement.FindDescendant(page, DeleteButtonId) is UiButton delete) + delete.OnClick = DeleteSelected; + if (UiElement.FindDescendant(page, ResetButtonId) is UiButton reset) + { + reset.OnClick = () => + { + _search?.SetText(string.Empty); + Refresh(); + }; + } + + Refresh(); + } + + /// The 1-based page the list has selected, or 0. + public int SelectedPage => _selectedPage; + + /// The 1-based page numbers currently listed, in order. + public IReadOnlyList RowPages => _rowPages; + + /// + /// Retail's search predicate — PageContainsString @0x00493B60. + /// + /// + /// Matches Label, Title OR Notes, and is CASE-SENSITIVE: retail compares + /// with wcsstr and never lowercases either side. Making it + /// insensitive would be friendlier and would be a divergence. + /// + public static bool PageContainsString(JournalPage page, string search) + { + ArgumentNullException.ThrowIfNull(page); + if (string.IsNullOrEmpty(search)) + return true; + + return page.Label.Contains(search, StringComparison.Ordinal) + || page.Title.Contains(search, StringComparison.Ordinal) + || page.Notes.Contains(search, StringComparison.Ordinal); + } + + public void Tick() + { + string search = _search?.Text ?? string.Empty; + if (_bindings.Journal.Snapshot.Revision != _renderedRevision + || !string.Equals(search, _renderedSearch, StringComparison.Ordinal)) + { + Refresh(); + } + } + + public void Refresh() + { + RuntimeJournalSnapshot snapshot = _bindings.Journal.Snapshot; + _renderedRevision = snapshot.Revision; + _renderedSearch = _search?.Text ?? string.Empty; + + IReadOnlyList pages = _bindings.Journal.Pages; + + _rowPages.Clear(); + _rows.Clear(); + _list?.FlushPreservingScroll(); + + for (int i = 0; i < pages.Count; i++) + { + JournalPage page = pages[i]; + if (!PageContainsString(page, _renderedSearch)) + continue; + + int pageNumber = i + 1; + _rowPages.Add(pageNumber); + if (_list is null) + continue; + + UiElement? row = _list.AddItemFromTemplateList(0); + if (row is null) + continue; + + SetText(UiElement.FindDescendant(row, RowNumberId) as UiText, + pageNumber.ToString(CultureInfo.InvariantCulture)); + var title = UiElement.FindDescendant(row, RowTitleId) as UiText; + SetText(title, page.Title); + SetText(UiElement.FindDescendant(row, RowTimerId) as UiText, + page.IsTimerRunning + ? RetailDurationText.Format(page.RunningTimerSeconds) + : page.HasTimer + ? RetailDurationText.Format(page.TimerDuration.TotalSeconds) + : string.Empty); + SetText(UiElement.FindDescendant(row, RowLabelId) as UiText, page.Label); + + _rows.Add((pageNumber, title, title?.DefaultColor ?? Vector4.One)); + + int captured = pageNumber; + if (row is UiDatElement clickable) + { + // A Type-3 row is click-through by default; without this the + // list cannot be selected at all. + clickable.ClickThrough = false; + clickable.OnClick = () => Click(captured); + } + } + + // A filtered-out selection is dropped: Delete must never act on a row + // the player cannot see. + if (_selectedPage != 0 && !_rowPages.Contains(_selectedPage)) + _selectedPage = 0; + + ApplySelectionHighlight(); + } + + /// + /// One row click. Port of gmPageListUI::CheckForDoubleClick + /// @0x00493140: a second click on the SAME row within the window opens + /// it, and firing resets the tracker so a third click does not re-trigger. + /// + public void Click(int pageNumber) + { + DateTime now = _bindings.Now?.Invoke() ?? DateTime.UtcNow; + + if (pageNumber == _lastClickPage && now - _lastClickAt <= DoubleClickWindow) + { + _lastClickPage = 0; + _lastClickAt = default; + Open(pageNumber); + return; + } + + _lastClickPage = pageNumber; + _lastClickAt = now; + Select(pageNumber); + } + + public void Select(int pageNumber) + { + _selectedPage = pageNumber; + ApplySelectionHighlight(); + } + + /// Opens a page on the Journal tab — retail's double-click. + public void Open(int pageNumber) + { + Select(pageNumber); + _bindings.OpenPage(pageNumber); + } + + private void DeleteSelected() + { + if (_selectedPage == 0) + return; + + _bindings.Commands.DeletePage(_selectedPage); + _selectedPage = 0; + Refresh(); + } + + private void ApplySelectionHighlight() + { + foreach ((int pageNumber, UiText? title, Vector4 unselected) in _rows) + { + if (title is not null) + title.DefaultColor = pageNumber == _selectedPage ? SelectedNameColor : unselected; + } + } + + private static void SetText(UiText? text, string value) + { + if (text is null) return; + text.LinesProvider = () => [new UiText.Line(value, text.DefaultColor)]; + } +} diff --git a/src/AcDream.App/UI/Layout/JournalPanelController.cs b/src/AcDream.App/UI/Layout/JournalPanelController.cs index cf05c8d0..141a1485 100644 --- a/src/AcDream.App/UI/Layout/JournalPanelController.cs +++ b/src/AcDream.App/UI/Layout/JournalPanelController.cs @@ -22,9 +22,9 @@ namespace AcDream.App.UI.Layout; /// button 0x10000561 ("Page List") -> page 0x10000564 /// /// -/// 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. +/// All three pages are live as of Campaign QJ. Contracts is server state; +/// the other two are one per-character notebook and its index, sharing +/// . /// /// public sealed class JournalPanelController : IRetainedPanelController @@ -44,11 +44,20 @@ public sealed class JournalPanelController : IRetainedPanelController /// 0x1000004B. public const uint ContractsPageId = 0x100005D4u; + /// The notes page — gmJournalUI, type 0x10000048. + public const uint NotesPageId = 0x10000563u; + + /// The index page — gmPageListUI, type 0x10000049. + public const uint PageListPageId = 0x10000564u; + /// The panel's own corner button. private const uint CloseButtonId = 0x10000562u; private readonly UiTabPanel _tabPanel; private readonly JournalContractsPageController? _contracts; + private readonly JournalNotesPageController? _notes; + private JournalPageListController? _pageList; + private readonly Action _onActivePageChanged; private bool _disposed; /// Root element of the imported panel — the tab host itself. @@ -58,17 +67,47 @@ public sealed class JournalPanelController : IRetainedPanelController public JournalContractsPageController? Contracts => _contracts; + public JournalNotesPageController? Notes => _notes; + + public JournalPageListController? PageList => _pageList; + + private readonly Action _saveJournal; + private JournalPanelController( UiTabPanel tabPanel, - JournalContractsPageController? contracts) + JournalContractsPageController? contracts, + JournalNotesPageController? notes, + JournalPageListController? pageList, + Action saveJournal) { _tabPanel = tabPanel; _contracts = contracts; + _notes = notes; + _pageList = pageList; + _saveJournal = saveJournal; + + // Retail saves the journal when the notes page is HIDDEN + // (gmJournalUI::OnVisibilityChanged @0x004978F0), not only at exit, so + // a crash costs at most the page you are looking at. Leaving the tab + // is that moment here. + _onActivePageChanged = (previous, _) => + { + if (previous == NotesPageId) + { + _notes?.OnHidden(); + _saveJournal(); + } + }; + _tabPanel.ActivePageChanged += _onActivePageChanged; } public sealed record Callbacks( Action Toggle, - JournalContractsPageController.Bindings Contracts); + JournalContractsPageController.Bindings Contracts, + JournalNotesPageController.Bindings Notes, + /// Writes the journal file — retail's save-on-hide. + Action SaveJournal, + Func, JournalPageListController.Bindings> PageList); /// /// Binds an imported / @@ -93,12 +132,40 @@ public sealed class JournalPanelController : IRetainedPanelController close.OnClick = callbacks.Toggle; JournalContractsPageController? contracts = null; - if (layout.FindElement(ContractsPageId) is { } page) - contracts = new JournalContractsPageController(page, callbacks.Contracts); + if (layout.FindElement(ContractsPageId) is { } contractsPage) + contracts = new JournalContractsPageController(contractsPage, callbacks.Contracts); else Console.WriteLine("[D.2b] JournalPanelController: contracts page not found."); - return new JournalPanelController(tabPanel, contracts); + JournalNotesPageController? notes = null; + if (layout.FindElement(NotesPageId) is { } notesPage) + notes = new JournalNotesPageController(notesPage, callbacks.Notes); + else + Console.WriteLine("[D.2b] JournalPanelController: notes page not found."); + + JournalPageListController? pageList = null; + var built = new JournalPanelController( + tabPanel, contracts, notes, pageList: null, callbacks.SaveJournal); + if (layout.FindElement(PageListPageId) is { } listPage) + { + // The index opens a page on the NOTES tab, so it needs the panel + // that owns the tab switch — hence the deferred binding. + pageList = new JournalPageListController( + listPage, + callbacks.PageList(pageNumber => + { + notes?.CommitText(); + callbacks.Notes.Commands.GotoPage(pageNumber); + built.ShowNotes(); + })); + } + else + { + Console.WriteLine("[D.2b] JournalPanelController: page list not found."); + } + + built.AttachPageList(pageList); + return built; } /// @@ -110,11 +177,32 @@ public sealed class JournalPanelController : IRetainedPanelController /// Switches to the Contracts tab. public void ShowContracts() => _tabPanel.SwitchTo(ContractsPageId); + /// Switches to the notes tab — what opening a page from the index does. + public void ShowNotes() => _tabPanel.SwitchTo(NotesPageId); + + /// + /// Completes construction. The index needs a callback that switches tabs, + /// which needs the panel — so it is attached rather than constructed. + /// + private void AttachPageList(JournalPageListController? pageList) + => _pageList = pageList; + public void Tick() { if (_disposed) return; _contracts?.Tick(); + _notes?.Tick(); + _pageList?.Tick(); } - public void Dispose() => _disposed = true; + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _tabPanel.ActivePageChanged -= _onActivePageChanged; + + // The panel closing is the other half of retail's save-on-hide. + _notes?.OnHidden(); + _saveJournal(); + } } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 3d7b22c8..454be105 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -317,7 +317,17 @@ public sealed record MapHouseRuntimeBindings( /// public sealed record QuestRuntimeBindings( AcDream.Runtime.Gameplay.IRuntimeContractView Contracts, - Func Catalog); + Func Catalog, + // Campaign QJ (2026-08-21): the notebook the other two tabs share. The + // command owner is passed alongside its own read view because the journal + // is WRITTEN by the panel — unlike contracts, which are server state. + AcDream.Runtime.Gameplay.IRuntimeJournalView Journal, + AcDream.Runtime.Gameplay.RuntimeJournalState JournalCommands, + Func PlayerCell, + /// Where the per-character journal file lives. + string JournalDirectory, + /// How a load or save failure reaches the player. + Action Report); public sealed record InventoryRuntimeBindings( ClientObjectTable Objects, @@ -705,6 +715,29 @@ public sealed class RetailUiRuntime : IDisposable /// Campaign QT slice QT5 — the three-tab Journal panel. public Layout.JournalPanelController? JournalPanelController { get; private set; } + + private JournalPersistence? _journalFile; + + /// + /// Campaign QJ slice QJ5. One owner, here rather than in the session + /// factory, because the panel that writes the journal lives here — two + /// instances would each track their own file path and overwrite each + /// other. + /// + private JournalPersistence JournalFile => + _journalFile ??= new JournalPersistence( + _bindings.Quests.JournalCommands, + _bindings.Quests.JournalDirectory, + _bindings.Quests.Report); + + /// Loads a character's journal — called on entering the world. + public void LoadJournal(string characterName) => JournalFile.Load(characterName); + + /// + /// Writes the journal if anything changed. Retail's own moment is the notes + /// page being hidden, not only session exit. + /// + public void SaveJournal() => JournalFile.Save(DateTime.UtcNow); public MapHousePanelController? MapHousePanelController { get; private set; } internal CharacterManagementUiController? CharacterManagementController => _characterManagementMount?.Controller; @@ -3520,7 +3553,23 @@ public sealed class RetailUiRuntime : IDisposable { lock (_bindings.Assets.DatLock) return rowTemplates.Resolve(templateLayoutId, templateElementId); - })); + }), + Notes: new Layout.JournalNotesPageController.Bindings( + Journal: _bindings.Quests.Journal, + Commands: _bindings.Quests.JournalCommands, + PlayerCell: _bindings.Quests.PlayerCell, + Now: () => DateTime.UtcNow), + SaveJournal: SaveJournal, + PageList: openPage => new Layout.JournalPageListController.Bindings( + Journal: _bindings.Quests.Journal, + Commands: _bindings.Quests.JournalCommands, + OpenPage: openPage, + TemplateResolver: (templateLayoutId, templateElementId) => + { + lock (_bindings.Assets.DatLock) + return rowTemplates.Resolve(templateLayoutId, templateElementId); + }, + Now: () => DateTime.UtcNow)); Layout.JournalPanelController? controller; lock (_bindings.Assets.DatLock) diff --git a/src/AcDream.Core/Quests/ContractProgressText.cs b/src/AcDream.Core/Quests/ContractProgressText.cs index 29cc5b2f..aa605fb2 100644 --- a/src/AcDream.Core/Quests/ContractProgressText.cs +++ b/src/AcDream.Core/Quests/ContractProgressText.cs @@ -1,6 +1,6 @@ using System; using System.Globalization; -using System.Text; +using AcDream.Core.Ui; namespace AcDream.Core.Quests; @@ -10,62 +10,6 @@ namespace AcDream.Core.Quests; /// public static class ContractProgressText { - private const int SecondsPerMonth = 0x278D00; // 2,592,000 — a 30-day month - private const int SecondsPerDay = 0x15180; // 86,400 - private const int SecondsPerHour = 0xE10; // 3,600 - private const int SecondsPerMinute = 0x3C; // 60 - - /// - /// Port of ClientUISystem::DeltaTimeToString @0x00565E10. - /// - /// - /// - /// Largest-unit-first, each unit omitted when zero, seconds always shown: - /// "2d 3h 4m 5s", "45s". A "month" is a flat 30 days. - /// - /// - /// Every part is emitted with a TRAILING space and the final one is then - /// truncated. That truncation is not visible in the decompiler output — - /// the instruction reads as noise — so it was settled by decoding the - /// bytes: at 0x00565F0E, mov byte ptr [esp+eax+0x1b], cl - /// with cl == 0 and eax == strlen writes the terminator over - /// buffer[len - 1]. Without it, the caller composes - /// "Done (1h 30s to Repeat)" with a double space. - /// - /// - public static string DeltaTimeToString(double seconds) - { - // Retail's _ftol2 — truncation toward zero, matching a C cast. - long total = (long)seconds; - if (total < 0) total = 0; - - long months = total / SecondsPerMonth; - long rest = total % SecondsPerMonth; - long days = rest / SecondsPerDay; - rest %= SecondsPerDay; - long hours = rest / SecondsPerHour; - rest %= SecondsPerHour; - long minutes = rest / SecondsPerMinute; - long secs = rest % SecondsPerMinute; - - var text = new StringBuilder(); - if (months != 0) Append(text, months, "mo"); - if (days != 0) Append(text, days, "d"); - if (hours != 0) Append(text, hours, "h"); - if (minutes != 0) Append(text, minutes, "m"); - Append(text, secs, "s"); - - // The trailing space the last part just wrote. - return text.ToString(0, text.Length - 1); - - static void Append(StringBuilder text, long value, string unit) - { - text.Append(value.ToString(CultureInfo.InvariantCulture)); - text.Append(unit); - text.Append(' '); - } - } - /// /// The progress text for one tracked contract. /// @@ -119,7 +63,7 @@ public static class ContractProgressText double remaining = timeWhenRepeats - elapsed; if (remaining <= 0d) return "Available"; - return $"Done ({DeltaTimeToString(remaining)} to Repeat)"; + return $"Done ({RetailDurationText.Format(remaining)} to Repeat)"; } if (stage >= 4u) diff --git a/src/AcDream.Core/Ui/RetailDurationText.cs b/src/AcDream.Core/Ui/RetailDurationText.cs new file mode 100644 index 00000000..91458a7a --- /dev/null +++ b/src/AcDream.Core/Ui/RetailDurationText.cs @@ -0,0 +1,74 @@ +using System; +using System.Globalization; +using System.Text; + +namespace AcDream.Core.Ui; + +/// +/// Retail's client-wide duration wording — +/// ClientUISystem::DeltaTimeToString @0x00565E10. +/// +/// +/// Client-wide rather than per-feature: the contract tracker's repeat countdown +/// and the journal page's timer both call it, so both read identically. It +/// lived in the contract code first only because that was its first caller. +/// +public static class RetailDurationText +{ + private const int SecondsPerMonth = 0x278D00; // 2,592,000 — a 30-day month + private const int SecondsPerDay = 0x15180; // 86,400 + private const int SecondsPerHour = 0xE10; // 3,600 + private const int SecondsPerMinute = 0x3C; // 60 + + /// + /// Port of ClientUISystem::DeltaTimeToString @0x00565E10. + /// + /// + /// + /// Largest-unit-first, each unit omitted when zero, seconds always shown: + /// "2d 3h 4m 5s", "45s". A "month" is a flat 30 days. + /// + /// + /// Every part is emitted with a TRAILING space and the final one is then + /// truncated. That truncation is not visible in the decompiler output — + /// the instruction reads as noise — so it was settled by decoding the + /// bytes: at 0x00565F0E, mov byte ptr [esp+eax+0x1b], cl + /// with cl == 0 and eax == strlen writes the terminator over + /// buffer[len - 1]. Without it, the caller composes + /// "Done (1h 30s to Repeat)" with a double space. + /// + /// + public static string Format(double seconds) + { + // Retail's _ftol2 — truncation toward zero, matching a C cast. + long total = (long)seconds; + if (total < 0) total = 0; + + long months = total / SecondsPerMonth; + long rest = total % SecondsPerMonth; + long days = rest / SecondsPerDay; + rest %= SecondsPerDay; + long hours = rest / SecondsPerHour; + rest %= SecondsPerHour; + long minutes = rest / SecondsPerMinute; + long secs = rest % SecondsPerMinute; + + var text = new StringBuilder(); + if (months != 0) Append(text, months, "mo"); + if (days != 0) Append(text, days, "d"); + if (hours != 0) Append(text, hours, "h"); + if (minutes != 0) Append(text, minutes, "m"); + Append(text, secs, "s"); + + // The trailing space the last part just wrote. + return text.ToString(0, text.Length - 1); + + static void Append(StringBuilder text, long value, string unit) + { + text.Append(value.ToString(CultureInfo.InvariantCulture)); + text.Append(unit); + text.Append(' '); + } + } + +} diff --git a/tests/AcDream.App.Tests/UI/JournalPersistenceTests.cs b/tests/AcDream.App.Tests/UI/JournalPersistenceTests.cs new file mode 100644 index 00000000..f0a93abb --- /dev/null +++ b/tests/AcDream.App.Tests/UI/JournalPersistenceTests.cs @@ -0,0 +1,180 @@ +using System; +using System.IO; +using System.Linq; +using AcDream.App.UI; +using AcDream.Core.Journal; +using AcDream.Runtime.Gameplay; + +namespace AcDream.App.Tests.UI; + +/// +/// Campaign QJ slice QJ5: the per-character journal file. +/// +public sealed class JournalPersistenceTests : IDisposable +{ + private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc); + + private readonly string _directory = + Path.Combine(Path.GetTempPath(), "acdream-journal-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + try { Directory.Delete(_directory, recursive: true); } + catch (IOException) { /* the test already said what it needed to */ } + } + + private (RuntimeJournalState Journal, JournalPersistence File, List Reports) New() + { + var journal = new RuntimeJournalState(); + var reports = new List(); + return (journal, new JournalPersistence(journal, _directory, reports.Add), reports); + } + + [Fact] + public void APageWrittenInOneSessionIsThereInTheNext() + { + // The whole point of the feature. + (RuntimeJournalState first, JournalPersistence firstFile, _) = New(); + firstFile.Load("Acdream"); + first.NewPage(); + first.UpdateCurrent("label", "A title", "Some notes."); + Assert.True(firstFile.Save(Now)); + first.Dispose(); + + (RuntimeJournalState second, JournalPersistence secondFile, _) = New(); + secondFile.Load("Acdream"); + + JournalPage page = Assert.Single(second.View.Pages); + Assert.Equal("A title", page.Title); + Assert.Equal("Some notes.", page.Notes); + second.Dispose(); + } + + [Fact] + public void EachCharacterGetsItsOwnFile() + { + // Sharing one file would show a character another's notes. + (RuntimeJournalState journal, JournalPersistence file, _) = New(); + + file.Load("Acdream"); + journal.NewPage(); + journal.UpdateCurrent("a", "Acdream's page", string.Empty); + file.Save(Now); + + file.Load("Someone Else"); + + Assert.Empty(journal.View.Pages); + journal.Dispose(); + } + + [Fact] + public void ACharacterWithNoFileLoadsAnEmptyJournalWithoutComplaining() + { + // First-time use is the ordinary case, not an error. + (RuntimeJournalState journal, JournalPersistence file, List reports) = New(); + + file.Load("Newcomer"); + + Assert.Empty(journal.View.Pages); + Assert.Empty(reports); + journal.Dispose(); + } + + [Fact] + public void AMalformedFileReportsAndLeavesTheJournalEmpty() + { + // Half-reading a notebook loses pages, and the next save would then + // write that loss back over the original. + (RuntimeJournalState journal, JournalPersistence file, List reports) = New(); + Directory.CreateDirectory(_directory); + File.WriteAllText( + Path.Combine(_directory, JournalFile.FileNameFor("acdream", "Acdream")), + " no page marker first\n"); + + file.Load("Acdream"); + + Assert.Equal(JournalFile.MalformedFileMessage, Assert.Single(reports)); + Assert.Empty(journal.View.Pages); + journal.Dispose(); + } + + [Fact] + public void SavingIsSkippedWhenNothingChanged() + { + // Retail's save fires on every hide; rewriting an unchanged file on + // every tab switch is pure disk churn. + (RuntimeJournalState journal, JournalPersistence file, _) = New(); + file.Load("Acdream"); + journal.NewPage(); + Assert.True(file.Save(Now)); + + Assert.False(file.Save(Now)); + journal.Dispose(); + } + + [Fact] + public void SavingBeforeAnyCharacterIsLoadedIsANoOp() + { + // The panel can be disposed before a character ever entered the world. + (RuntimeJournalState journal, JournalPersistence file, _) = New(); + + Assert.False(file.Save(Now)); + Assert.False(Directory.Exists(_directory)); + journal.Dispose(); + } + + [Fact] + public void ARunningTimerPersistsItsREMAININGTime() + { + // Saving the value it started at would resurrect the full duration on + // every reload. + (RuntimeJournalState journal, JournalPersistence file, _) = New(); + file.Load("Acdream"); + journal.NewPage(); + journal.SetTimer(0, 1, 0); + journal.StartTimer(Now); + + file.Save(Now.AddMinutes(30)); + journal.Dispose(); + + (RuntimeJournalState reloaded, JournalPersistence reloadedFile, _) = New(); + reloadedFile.Load("Acdream"); + + Assert.Equal(1800d, Assert.Single(reloaded.View.Pages).RunningTimerSeconds); + reloaded.Dispose(); + } + + [Fact] + public void CloseSavesAndForgetsTheCharacter() + { + (RuntimeJournalState journal, JournalPersistence file, _) = New(); + file.Load("Acdream"); + journal.NewPage(); + + file.Close(Now); + + Assert.Null(file.CurrentPath); + Assert.False(file.Save(Now)); + journal.Dispose(); + } + + [Fact] + public void AnUnwritableDirectoryReportsRatherThanThrowing() + { + // Losing a note is bad; taking the client down while the player is + // writing one is worse. + var journal = new RuntimeJournalState(); + var reports = new List(); + string blocked = Path.Combine(_directory, "blocked"); + Directory.CreateDirectory(_directory); + File.WriteAllText(blocked, "not a directory"); + + var file = new JournalPersistence(journal, blocked, reports.Add); + file.Load("Acdream"); + journal.NewPage(); + + Assert.False(file.Save(Now)); + Assert.NotEmpty(reports); + journal.Dispose(); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalPageListControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalPageListControllerTests.cs new file mode 100644 index 00000000..152137e8 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/JournalPageListControllerTests.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Journal; +using AcDream.Runtime.Gameplay; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign QJ slice QJ4: the journal's searchable index +/// (gmPageListUI). +/// +public sealed class JournalPageListControllerTests +{ + private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc); + + private const uint ListId = 0x10000583u; + private const uint SearchFieldId = 0x10000587u; + private const uint DeleteButtonId = 0x10000585u; + private const uint RowNumberId = 0x1000058Au; + private const uint RowTitleId = 0x1000058Bu; + + // ── the search predicate, ported from PageContainsString ──────────── + + [Fact] + public void SearchMatchesLabelTitleOrNotes() + { + var page = new JournalPage(Label: "lab", Title: "tit", Notes: "not"); + + Assert.True(JournalPageListController.PageContainsString(page, "lab")); + Assert.True(JournalPageListController.PageContainsString(page, "tit")); + Assert.True(JournalPageListController.PageContainsString(page, "not")); + Assert.False(JournalPageListController.PageContainsString(page, "zzz")); + } + + [Fact] + public void SearchIsCaseSensitiveBecauseRetailUsesWcsstr() + { + // Making it insensitive would be friendlier and would be a divergence. + var page = new JournalPage(Title: "Aerlinthe"); + + Assert.True(JournalPageListController.PageContainsString(page, "Aer")); + Assert.False(JournalPageListController.PageContainsString(page, "aer")); + } + + [Fact] + public void AnEmptySearchMatchesEverything() + { + Assert.True(JournalPageListController.PageContainsString( + new JournalPage(), string.Empty)); + } + + // ── the list ──────────────────────────────────────────────────────── + + private static UiText Text(uint id) => new() + { + DatElementId = id, + Width = 90f, + Height = 20f, + DefaultColor = new System.Numerics.Vector4(0.8f, 0.8f, 0.8f, 1f), + }; + + private static UiElement? RowTemplate(uint layoutId, uint elementId) + { + if (layoutId != JournalPageListController.RowTemplateLayoutId + || elementId != JournalPageListController.RowTemplateElementId) + { + return null; + } + + // A UiDatElement, as the real Type-3 template resolves to. + var row = new UiDatElement( + new ElementInfo { Type = 3, Width = 270, Height = 20 }, + static _ => (0u, 0, 0)); + row.AddChild(Text(RowNumberId)); + row.AddChild(Text(RowTitleId)); + return row; + } + + private static (UiElement Page, UiField Search) BuildPage() + { + var list = new UiTemplateListBox( + new ElementInfo { Id = ListId, Type = 5, Width = 270, Height = 430 }, + static _ => (0u, 0, 0), + [new UiTemplateListEntry( + JournalPageListController.RowTemplateLayoutId, + JournalPageListController.RowTemplateElementId)], + scrollbarElementId: 0u) + { + DatElementId = ListId, + }; + + var search = new UiField { ElementId = SearchFieldId, Width = 118f, Height = 18f }; + search.DatElementId = SearchFieldId; + + var deleteButton = new UiButton( + new ElementInfo { Id = DeleteButtonId, Type = 1, Width = 60, Height = 18 }, + static _ => (0u, 0, 0)) + { + DatElementId = DeleteButtonId, + }; + + var page = new UiPanel { Width = 300f, Height = 500f }; + page.AddChild(list); + page.AddChild(search); + page.AddChild(deleteButton); + return (page, search); + } + + private static (JournalPageListController Controller, RuntimeJournalState State, + UiElement Page, UiField Search, List Opened) Bind(params JournalPage[] pages) + { + var state = new RuntimeJournalState(); + state.Load(pages); + (UiElement page, UiField search) = BuildPage(); + var opened = new List(); + + var controller = new JournalPageListController( + page, + new JournalPageListController.Bindings( + Journal: state.View, + Commands: state, + OpenPage: opened.Add, + TemplateResolver: RowTemplate, + Now: () => Now)); + + return (controller, state, page, search, opened); + } + + [Fact] + public void EveryPageIsListedWithItsNumber() + { + var (controller, state, page, _, _) = Bind( + new JournalPage(Title: "one"), new JournalPage(Title: "two")); + + Assert.Equal(new[] { 1, 2 }, controller.RowPages.ToArray()); + string[] numbers = Flatten(page) + .OfType() + .Where(t => t.DatElementId == RowNumberId) + .Select(t => t.LinesProvider!()[0].Text) + .ToArray(); + Assert.Equal(new[] { "1", "2" }, numbers); + state.Dispose(); + } + + [Fact] + public void SearchingFiltersTheListButKeepsRealPageNumbers() + { + // The row number must name the page in the JOURNAL, not its position + // in the filtered list — otherwise opening row 1 of a filtered list + // opens the wrong page. + var (controller, state, _, search, _) = Bind( + new JournalPage(Title: "alpha"), + new JournalPage(Title: "beta"), + new JournalPage(Title: "gamma")); + + search.SetText("beta"); + controller.Tick(); + + Assert.Equal(new[] { 2 }, controller.RowPages.ToArray()); + state.Dispose(); + } + + [Fact] + public void AFilteredOutSelectionIsDroppedSoDeleteCannotHitAHiddenPage() + { + var (controller, state, _, search, _) = Bind( + new JournalPage(Title: "alpha"), new JournalPage(Title: "beta")); + controller.Select(1); + + search.SetText("beta"); + controller.Tick(); + + Assert.Equal(0, controller.SelectedPage); + state.Dispose(); + } + + [Fact] + public void DeleteWithNothingSelectedDoesNothing() + { + var (_, state, page, _, _) = Bind(new JournalPage(Title: "only")); + + (UiElement.FindDescendant(page, DeleteButtonId) as UiButton)!.OnClick!(); + + Assert.Single(state.View.Pages); + state.Dispose(); + } + + [Fact] + public void DeleteRemovesTheSelectedPage() + { + var (controller, state, page, _, _) = Bind( + new JournalPage(Title: "one"), new JournalPage(Title: "two")); + controller.Select(1); + + (UiElement.FindDescendant(page, DeleteButtonId) as UiButton)!.OnClick!(); + + Assert.Equal("two", Assert.Single(state.View.Pages).Title); + Assert.Equal(0, controller.SelectedPage); + state.Dispose(); + } + + // ── CheckForDoubleClick ───────────────────────────────────────────── + + [Fact] + public void OneClickSelectsAndDoesNotOpen() + { + var (controller, state, _, _, opened) = Bind(new JournalPage(Title: "one")); + + controller.Click(1); + + Assert.Equal(1, controller.SelectedPage); + Assert.Empty(opened); + state.Dispose(); + } + + [Fact] + public void TwoClicksOnTheSameRowOpenIt() + { + var (controller, state, _, _, opened) = Bind(new JournalPage(Title: "one")); + + controller.Click(1); + controller.Click(1); + + Assert.Equal(new[] { 1 }, opened.ToArray()); + state.Dispose(); + } + + [Fact] + public void AThirdClickDoesNotReopenBecauseFiringResetsTheTracker() + { + // Retail clears m_LastClickIndex on a successful double-click + // (@0x0049318A). Without that, every click after the second re-opens. + var (controller, state, _, _, opened) = Bind(new JournalPage(Title: "one")); + + controller.Click(1); + controller.Click(1); + controller.Click(1); + + Assert.Single(opened); + state.Dispose(); + } + + [Fact] + public void ClicksOnDifferentRowsAreNotADoubleClick() + { + var (controller, state, _, _, opened) = Bind( + new JournalPage(Title: "one"), new JournalPage(Title: "two")); + + controller.Click(1); + controller.Click(2); + + Assert.Empty(opened); + Assert.Equal(2, controller.SelectedPage); + state.Dispose(); + } + + [Fact] + public void TheDoubleClickWindowIsAFullSecond() + { + // Retail's is m_LastClickTime + 1.0 (@0x00493158) — NOT the 500 ms the + // item-interaction path uses. Borrowing the wrong constant makes the + // list feel unresponsive. + var state = new RuntimeJournalState(); + state.Load([new JournalPage(Title: "one")]); + (UiElement page, _) = BuildPage(); + var opened = new List(); + DateTime now = Now; + + var controller = new JournalPageListController( + page, + new JournalPageListController.Bindings( + Journal: state.View, + Commands: state, + OpenPage: opened.Add, + TemplateResolver: RowTemplate, + Now: () => now)); + + controller.Click(1); + now = Now.AddMilliseconds(900); + controller.Click(1); + Assert.Single(opened); + + opened.Clear(); + now = Now.AddSeconds(10); + controller.Click(1); + now = now.AddMilliseconds(1100); + controller.Click(1); + Assert.Empty(opened); + + state.Dispose(); + } + + private static IEnumerable Flatten(UiElement e) + { + yield return e; + foreach (UiElement child in e.Children) + { + foreach (UiElement descendant in Flatten(child)) + yield return descendant; + } + } +} diff --git a/tests/AcDream.Core.Tests/Quests/ContractProgressTextTests.cs b/tests/AcDream.Core.Tests/Quests/ContractProgressTextTests.cs index 7de63e38..737399c1 100644 --- a/tests/AcDream.Core.Tests/Quests/ContractProgressTextTests.cs +++ b/tests/AcDream.Core.Tests/Quests/ContractProgressTextTests.cs @@ -1,5 +1,6 @@ using System; using AcDream.Core.Quests; +using AcDream.Core.Ui; namespace AcDream.Core.Tests.Quests; @@ -32,7 +33,7 @@ public sealed class ContractProgressTextTests [InlineData(2592000 + 86400 + 3600 + 61, "1mo 1d 1h 1m 1s")] public void DeltaTimeFormatsLargestUnitFirstAndAlwaysShowsSeconds( double seconds, string expected) - => Assert.Equal(expected, ContractProgressText.DeltaTimeToString(seconds)); + => Assert.Equal(expected, RetailDurationText.Format(seconds)); [Fact] public void DeltaTimeHasNoTrailingSpace() @@ -42,7 +43,7 @@ public sealed class ContractProgressTextTests // gives "Done (30s to Repeat)" with a double space — and the // instruction is invisible in the decompiler output, so this is the // assertion that pins the byte-level reading. - string text = ContractProgressText.DeltaTimeToString(30); + string text = RetailDurationText.Format(30); Assert.Equal("30s", text); Assert.DoesNotContain(" ", ContractProgressText.Build( @@ -52,7 +53,7 @@ public sealed class ContractProgressTextTests [Fact] public void DeltaTimeTruncatesTowardZeroLikeRetailsFtol() { - Assert.Equal("59s", ContractProgressText.DeltaTimeToString(59.99)); + Assert.Equal("59s", RetailDurationText.Format(59.99)); } // ── the stage arms ──────────────────────────────────────────────────