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>
121 lines
4 KiB
C#
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;
|
|
}
|
|
}
|