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