feat(journal): QJ3/QJ4/QJ5 — both remaining tabs are live
The Journal panel now has all three tabs working: contracts from the server, and a per-character notebook with its searchable index. Two ported details that a reimplementation would get wrong in a way nobody notices until they lose work: Every navigation button commits the current page FIRST. Retail's ListenToElementMessage @0x004968D0 calls SaveThisPage on the way out of all five of them, which is why paging away never eats what you just typed. And the file is written when the notes page is HIDDEN, not only at exit — a crash then costs at most the page in front of you. The search is CASE-SENSITIVE across label, title and notes: retail compares with wcsstr and lowercases neither side. Making it insensitive would be friendlier and would be a divergence, so it is ported as-is with a test naming the reason. The double-click window is a full SECOND (m_LastClickTime + 1.0, @0x00493158) rather than the 500 ms the item-interaction path uses, and firing it clears the tracker so a third click does not re-open. Two unlabelled buttons on the notes page turned out to be prev/next: retail switches on (idElement - 0x10000565), which names them without a caption. The running-timer readout is authored at the same x as the three day/hour/minute boxes, so the strip is one or the other — that overlap is the data form of ShowEditableTimer versus ShowRunningTimer, not a layout bug. DeltaTimeToString moved out of the contract code into AcDream.Core.Ui. It is ClientUISystem's, not gmContractsUI's — the journal timer and the contract repeat countdown both call it, and it only lived under Quests because that was its first caller. A bridge class to reach it across features would have been the wrong answer to the same observation. The journal file lives in the client's data directory rather than beside the executable, for the same reason the chat log does. Register QJ-1. Campaign QJ slices 3, 4 and 5 of 5 — code-complete, connected gate owed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
536d17456d
commit
c5cc8ae5fc
15 changed files with 1380 additions and 76 deletions
|
|
@ -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 `<Tell:IIDString:…>` 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` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
121
src/AcDream.App/UI/JournalPersistence.cs
Normal file
121
src/AcDream.App/UI/JournalPersistence.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and writes the per-character journal file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Retail loads on entering the world and saves whenever the notes page is
|
||||
/// hidden (<c>gmJournalUI::OnVisibilityChanged @0x004978F0</c>) rather than
|
||||
/// only at exit, so a crash costs at most the page in front of you. Both
|
||||
/// moments call through here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class JournalPersistence(
|
||||
RuntimeJournalState journal,
|
||||
string directory,
|
||||
Action<string>? 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;
|
||||
|
||||
/// <summary>The file the loaded character's journal lives in, or null.</summary>
|
||||
public string? CurrentPath { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Loads a character's journal, replacing whatever was in memory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the journal if anything has changed since the last write.
|
||||
/// </summary>
|
||||
/// <returns>Whether a file was written.</returns>
|
||||
public bool Save(DateTime now)
|
||||
{
|
||||
if (CurrentPath is null || _characterName is null)
|
||||
return false;
|
||||
if (!_journal.IsDirty)
|
||||
return false;
|
||||
|
||||
IReadOnlyList<JournalPage> 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;
|
||||
}
|
||||
|
||||
/// <summary>Saves and forgets the character — session teardown.</summary>
|
||||
public void Close(DateTime now)
|
||||
{
|
||||
Save(now);
|
||||
_characterName = null;
|
||||
CurrentPath = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
272
src/AcDream.App/UI/Layout/JournalNotesPageController.cs
Normal file
272
src/AcDream.App/UI/Layout/JournalNotesPageController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The Journal panel's notes page — retail <c>gmJournalUI</c> (element type
|
||||
/// <c>0x10000048</c>, page <c>0x10000563</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A per-character notebook: label, title, free notes, a recorded location and
|
||||
/// a countdown timer, paged with First/Previous/Next/Last.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every navigation commits the current page first.</b> Retail's
|
||||
/// <c>ListenToElementMessage @0x004968D0</c> calls <c>SaveThisPage</c> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
/// <param name="Journal">The canonical owner.</param>
|
||||
/// <param name="Commands">Mutations, kept off the read view.</param>
|
||||
/// <param name="PlayerCell">
|
||||
/// The player's current landcell, for the "Record" button. Returns 0 when
|
||||
/// there is no valid cell — indoors, or not in the world.
|
||||
/// </param>
|
||||
/// <param name="Now">The clock the countdown runs against.</param>
|
||||
public sealed record Bindings(
|
||||
IRuntimeJournalView Journal,
|
||||
RuntimeJournalState Commands,
|
||||
Func<uint> PlayerCell,
|
||||
Func<DateTime> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits the edit boxes into the current page — retail's
|
||||
/// <c>SaveThisPage @0x00495360</c>.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>Called when the page is hidden — retail saves here.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The editable-fields / running-readout swap
|
||||
/// (<c>ShowEditableTimer @0x00495770</c> versus <c>ShowRunningTimer</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The journal's own authored placeholder is <c>"00.0S, 00.0W"</c> — with
|
||||
/// a space after the comma, unlike the radar's own combined form.
|
||||
/// </summary>
|
||||
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)];
|
||||
}
|
||||
}
|
||||
252
src/AcDream.App/UI/Layout/JournalPageListController.cs
Normal file
252
src/AcDream.App/UI/Layout/JournalPageListController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The Journal panel's Page List — retail <c>gmPageListUI</c> (element type
|
||||
/// <c>0x10000049</c>, page <c>0x10000564</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 (<c>CheckForDoubleClick @0x00493140</c> →
|
||||
/// <c>gmJournalUI::GotoPage</c>).
|
||||
/// </remarks>
|
||||
public sealed class JournalPageListController
|
||||
{
|
||||
/// <summary>The layout the row template lives in — authored <c>0x63</c>.</summary>
|
||||
public const uint RowTemplateLayoutId = 0x21000067u;
|
||||
|
||||
/// <summary>The row template element — authored <c>0x62</c>.</summary>
|
||||
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;
|
||||
|
||||
/// <param name="Journal">The canonical owner.</param>
|
||||
/// <param name="Commands">Mutations.</param>
|
||||
/// <param name="OpenPage">
|
||||
/// Opens a page on the Journal tab — the panel owns the tab switch, this
|
||||
/// page owns only the choice.
|
||||
/// </param>
|
||||
public sealed record Bindings(
|
||||
IRuntimeJournalView Journal,
|
||||
RuntimeJournalState Commands,
|
||||
Action<int> OpenPage,
|
||||
Func<uint, uint, UiElement?> TemplateResolver,
|
||||
Func<DateTime>? Now = null);
|
||||
|
||||
private readonly Bindings _bindings;
|
||||
private readonly UiTemplateListBox? _list;
|
||||
private readonly UiField? _search;
|
||||
private readonly List<int> _rowPages = [];
|
||||
private readonly List<(int Page, UiText? Title, Vector4 Unselected)> _rows = [];
|
||||
|
||||
/// <summary>
|
||||
/// Retail's double-click window — <c>m_LastClickTime + 1.0</c> in
|
||||
/// <c>CheckForDoubleClick @0x00493158</c>. 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>The 1-based page the list has selected, or 0.</summary>
|
||||
public int SelectedPage => _selectedPage;
|
||||
|
||||
/// <summary>The 1-based page numbers currently listed, in order.</summary>
|
||||
public IReadOnlyList<int> RowPages => _rowPages;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's search predicate — <c>PageContainsString @0x00493B60</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Matches Label, Title OR Notes, and is CASE-SENSITIVE: retail compares
|
||||
/// with <c>wcsstr</c> and never lowercases either side. Making it
|
||||
/// insensitive would be friendlier and would be a divergence.
|
||||
/// </remarks>
|
||||
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<JournalPage> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row click. Port of <c>gmPageListUI::CheckForDoubleClick
|
||||
/// @0x00493140</c>: 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Opens a page on the Journal tab — retail's double-click.</summary>
|
||||
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)];
|
||||
}
|
||||
}
|
||||
|
|
@ -22,9 +22,9 @@ namespace AcDream.App.UI.Layout;
|
|||
/// button 0x10000561 ("Page List") -> page 0x10000564
|
||||
/// </code>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// <see cref="AcDream.Runtime.Gameplay.RuntimeJournalState"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class JournalPanelController : IRetainedPanelController
|
||||
|
|
@ -44,11 +44,20 @@ public sealed class JournalPanelController : IRetainedPanelController
|
|||
/// <c>0x1000004B</c>.</summary>
|
||||
public const uint ContractsPageId = 0x100005D4u;
|
||||
|
||||
/// <summary>The notes page — <c>gmJournalUI</c>, type <c>0x10000048</c>.</summary>
|
||||
public const uint NotesPageId = 0x10000563u;
|
||||
|
||||
/// <summary>The index page — <c>gmPageListUI</c>, type <c>0x10000049</c>.</summary>
|
||||
public const uint PageListPageId = 0x10000564u;
|
||||
|
||||
/// <summary>The panel's own corner button.</summary>
|
||||
private const uint CloseButtonId = 0x10000562u;
|
||||
|
||||
private readonly UiTabPanel _tabPanel;
|
||||
private readonly JournalContractsPageController? _contracts;
|
||||
private readonly JournalNotesPageController? _notes;
|
||||
private JournalPageListController? _pageList;
|
||||
private readonly Action<uint, uint> _onActivePageChanged;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Root element of the imported panel — the tab host itself.</summary>
|
||||
|
|
@ -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,
|
||||
/// <summary>Writes the journal file — retail's save-on-hide.</summary>
|
||||
Action SaveJournal,
|
||||
Func<Action<int>, JournalPageListController.Bindings> PageList);
|
||||
|
||||
/// <summary>
|
||||
/// Binds an imported <see cref="HostLayoutId"/>/<see cref="SlotElementId"/>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -110,11 +177,32 @@ public sealed class JournalPanelController : IRetainedPanelController
|
|||
/// <summary>Switches to the Contracts tab.</summary>
|
||||
public void ShowContracts() => _tabPanel.SwitchTo(ContractsPageId);
|
||||
|
||||
/// <summary>Switches to the notes tab — what opening a page from the index does.</summary>
|
||||
public void ShowNotes() => _tabPanel.SwitchTo(NotesPageId);
|
||||
|
||||
/// <summary>
|
||||
/// Completes construction. The index needs a callback that switches tabs,
|
||||
/// which needs the panel — so it is attached rather than constructed.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -317,7 +317,17 @@ public sealed record MapHouseRuntimeBindings(
|
|||
/// </summary>
|
||||
public sealed record QuestRuntimeBindings(
|
||||
AcDream.Runtime.Gameplay.IRuntimeContractView Contracts,
|
||||
Func<AcDream.Core.Quests.ContractCatalog> Catalog);
|
||||
Func<AcDream.Core.Quests.ContractCatalog> 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<uint> PlayerCell,
|
||||
/// <summary>Where the per-character journal file lives.</summary>
|
||||
string JournalDirectory,
|
||||
/// <summary>How a load or save failure reaches the player.</summary>
|
||||
Action<string> Report);
|
||||
|
||||
public sealed record InventoryRuntimeBindings(
|
||||
ClientObjectTable Objects,
|
||||
|
|
@ -705,6 +715,29 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
|
||||
/// <summary>Campaign QT slice QT5 — the three-tab Journal panel.</summary>
|
||||
public Layout.JournalPanelController? JournalPanelController { get; private set; }
|
||||
|
||||
private JournalPersistence? _journalFile;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private JournalPersistence JournalFile =>
|
||||
_journalFile ??= new JournalPersistence(
|
||||
_bindings.Quests.JournalCommands,
|
||||
_bindings.Quests.JournalDirectory,
|
||||
_bindings.Quests.Report);
|
||||
|
||||
/// <summary>Loads a character's journal — called on entering the world.</summary>
|
||||
public void LoadJournal(string characterName) => JournalFile.Load(characterName);
|
||||
|
||||
/// <summary>
|
||||
/// Writes the journal if anything changed. Retail's own moment is the notes
|
||||
/// page being hidden, not only session exit.
|
||||
/// </summary>
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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;
|
|||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>ClientUISystem::DeltaTimeToString @0x00565E10</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Largest-unit-first, each unit omitted when zero, seconds always shown:
|
||||
/// <c>"2d 3h 4m 5s"</c>, <c>"45s"</c>. A "month" is a flat 30 days.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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 <c>0x00565F0E</c>, <c>mov byte ptr [esp+eax+0x1b], cl</c>
|
||||
/// with <c>cl == 0</c> and <c>eax == strlen</c> writes the terminator over
|
||||
/// <c>buffer[len - 1]</c>. Without it, the caller composes
|
||||
/// <c>"Done (1h 30s to Repeat)"</c> with a double space.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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(' ');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The progress text for one tracked contract.
|
||||
/// </summary>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
74
src/AcDream.Core/Ui/RetailDurationText.cs
Normal file
74
src/AcDream.Core/Ui/RetailDurationText.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace AcDream.Core.Ui;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's client-wide duration wording —
|
||||
/// <c>ClientUISystem::DeltaTimeToString @0x00565E10</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>ClientUISystem::DeltaTimeToString @0x00565E10</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Largest-unit-first, each unit omitted when zero, seconds always shown:
|
||||
/// <c>"2d 3h 4m 5s"</c>, <c>"45s"</c>. A "month" is a flat 30 days.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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 <c>0x00565F0E</c>, <c>mov byte ptr [esp+eax+0x1b], cl</c>
|
||||
/// with <c>cl == 0</c> and <c>eax == strlen</c> writes the terminator over
|
||||
/// <c>buffer[len - 1]</c>. Without it, the caller composes
|
||||
/// <c>"Done (1h 30s to Repeat)"</c> with a double space.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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(' ');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
180
tests/AcDream.App.Tests/UI/JournalPersistenceTests.cs
Normal file
180
tests/AcDream.App.Tests/UI/JournalPersistenceTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign QJ slice QJ5: the per-character journal file.
|
||||
/// </summary>
|
||||
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<string> Reports) New()
|
||||
{
|
||||
var journal = new RuntimeJournalState();
|
||||
var reports = new List<string>();
|
||||
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<string> 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<string> reports) = New();
|
||||
Directory.CreateDirectory(_directory);
|
||||
File.WriteAllText(
|
||||
Path.Combine(_directory, JournalFile.FileNameFor("acdream", "Acdream")),
|
||||
"<TITL> 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>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign QJ slice QJ4: the journal's searchable index
|
||||
/// (<c>gmPageListUI</c>).
|
||||
/// </summary>
|
||||
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<int> Opened) Bind(params JournalPage[] pages)
|
||||
{
|
||||
var state = new RuntimeJournalState();
|
||||
state.Load(pages);
|
||||
(UiElement page, UiField search) = BuildPage();
|
||||
var opened = new List<int>();
|
||||
|
||||
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<UiText>()
|
||||
.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<int>();
|
||||
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<UiElement> Flatten(UiElement e)
|
||||
{
|
||||
yield return e;
|
||||
foreach (UiElement child in e.Children)
|
||||
{
|
||||
foreach (UiElement descendant in Flatten(child))
|
||||
yield return descendant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ──────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue