acdream/src/AcDream.App/UI/JournalPersistence.cs
Erik c5cc8ae5fc 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>
2026-08-21 15:42:05 +02:00

121 lines
4 KiB
C#

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;
}
}