From 536d17456d7913f1c7b159751e4e39f9ec56884e Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 15:31:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(journal):=20QJ1/QJ2=20=E2=80=94=20the=20jo?= =?UTF-8?q?urnal's=20pages=20and=20their=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Journal tab is not a quest feature: it is a per-character notebook with no wire, no server and no dat content. The player writes it, and it persists to a tagged text file recovered whole from LoadPages/SavePages. Retail refuses a journal file that does not OPEN with , with its own message. That strictness is ported rather than softened — accepting such a file would scatter the first page's text into no page at all. An ABSENT or empty file is the opposite case and must not error: that is simply a character who has never written a page. Three things the format does not say out loud, each with a test: is written but page order IS file order, so a reader that trusted the number would reshuffle a hand-edited file. A recorded location of (0, 0) is a real place, so the location tags are written on a HasLocation flag rather than on the numbers being non-zero. And the notes box is multi-line while the file is line-oriented — an embedded newline would read back as a tagless line and silently truncate the notes, so they are folded to spaces at the write. The countdown belongs to the page it was started on, and what belongs in the file is what is LEFT rather than what it started at — saving the start value would resurrect the full duration on every reload. Deleting the last remaining page empties the journal instead of leaving a blank one behind; inventing a replacement would make the journal impossible to empty. An out-of-range page is refused rather than clamped, because clamping moves the player somewhere they did not ask to go. Campaign QJ slices 1 and 2 of 5. Co-Authored-By: Claude Opus 5 --- src/AcDream.Core/Journal/JournalFile.cs | 219 ++++++++++ src/AcDream.Core/Journal/JournalPage.cs | 77 ++++ src/AcDream.Runtime/GameRuntime.cs | 42 +- .../Gameplay/RuntimeJournalState.cs | 386 ++++++++++++++++++ src/AcDream.Runtime/RuntimeGenerationReset.cs | 32 +- .../Journal/JournalFileTests.cs | 175 ++++++++ .../AcDream.Runtime.Tests/GameRuntimeTests.cs | 1 + .../Gameplay/RuntimeJournalStateTests.cs | 282 +++++++++++++ 8 files changed, 1198 insertions(+), 16 deletions(-) create mode 100644 src/AcDream.Core/Journal/JournalFile.cs create mode 100644 src/AcDream.Core/Journal/JournalPage.cs create mode 100644 src/AcDream.Runtime/Gameplay/RuntimeJournalState.cs create mode 100644 tests/AcDream.Core.Tests/Journal/JournalFileTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/RuntimeJournalStateTests.cs diff --git a/src/AcDream.Core/Journal/JournalFile.cs b/src/AcDream.Core/Journal/JournalFile.cs new file mode 100644 index 00000000..6cd08331 --- /dev/null +++ b/src/AcDream.Core/Journal/JournalFile.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; + +namespace AcDream.Core.Journal; + +/// The outcome of reading a journal file. +/// The pages read, oldest first. +/// +/// Retail's own message when the file is unusable, or null on success. An +/// ABSENT file is success with no pages, not an error — a character who has +/// never written a page has no file. +/// +public readonly record struct JournalReadResult( + IReadOnlyList Pages, + string? Error); + +/// +/// Retail's journal file — gmJournalUI::LoadPages @0x00496AC0 and +/// SavePages @0x00497270. +/// +/// +/// +/// A plain tagged text file, one tag per line, <NEWP> opening each +/// page. Retail writes it with fopen mode w+ — the whole file is +/// rewritten, never appended. +/// +/// +/// A file that does not OPEN with a page marker is refused outright, with its +/// own message. That is retail being strict about a file the player could have +/// hand-edited, and it is ported rather than softened: silently accepting a +/// malformed file would scatter the first page's text into no page at all. +/// +/// +public static class JournalFile +{ + private const string NewPage = ""; + private const string PageNumber = ""; + private const string Label = ""; + private const string Title = ""; + private const string Notes = ""; + private const string Days = ""; + private const string Hours = ""; + private const string Minutes = ""; + private const string LocationX = ""; + private const string LocationY = ""; + private const string RunningTime = "