feat(journal): QJ1/QJ2 — the journal's pages and their file
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 <NEWP>, 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: <PNUM> 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 <noreply@anthropic.com>
This commit is contained in:
parent
45c964dbf0
commit
536d17456d
8 changed files with 1198 additions and 16 deletions
219
src/AcDream.Core/Journal/JournalFile.cs
Normal file
219
src/AcDream.Core/Journal/JournalFile.cs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace AcDream.Core.Journal;
|
||||
|
||||
/// <summary>The outcome of reading a journal file.</summary>
|
||||
/// <param name="Pages">The pages read, oldest first.</param>
|
||||
/// <param name="Error">
|
||||
/// 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.
|
||||
/// </param>
|
||||
public readonly record struct JournalReadResult(
|
||||
IReadOnlyList<JournalPage> Pages,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Retail's journal file — <c>gmJournalUI::LoadPages @0x00496AC0</c> and
|
||||
/// <c>SavePages @0x00497270</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A plain tagged text file, one tag per line, <c><NEWP></c> opening each
|
||||
/// page. Retail writes it with <c>fopen</c> mode <c>w+</c> — the whole file is
|
||||
/// rewritten, never appended.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class JournalFile
|
||||
{
|
||||
private const string NewPage = "<NEWP>";
|
||||
private const string PageNumber = "<PNUM>";
|
||||
private const string Label = "<LABE>";
|
||||
private const string Title = "<TITL>";
|
||||
private const string Notes = "<NOTE>";
|
||||
private const string Days = "<DAYS>";
|
||||
private const string Hours = "<HOUR>";
|
||||
private const string Minutes = "<MINU>";
|
||||
private const string LocationX = "<LOCX>";
|
||||
private const string LocationY = "<LOCY>";
|
||||
private const string RunningTime = "<TIME>";
|
||||
|
||||
/// <summary>Retail's own load failure, byte-decoded from the paired binary.</summary>
|
||||
public const string MalformedFileMessage =
|
||||
"Problem loading journal: Your journal file does not create a new page!";
|
||||
|
||||
/// <summary>
|
||||
/// The per-character file name — retail's <c>"%s%s-%s-%s.txt"</c> with the
|
||||
/// literal prefix <c>"Journal"</c> both call sites pass.
|
||||
/// </summary>
|
||||
public static string FileNameFor(string serverName, string characterName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
|
||||
return $"Journal-{Sanitize(serverName)}-{Sanitize(characterName)}.txt";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips anything a file name cannot carry. Retail had no need for this —
|
||||
/// its server and character names never contained a path separator — but a
|
||||
/// name is outside data here and must not be able to redirect a write.
|
||||
/// </summary>
|
||||
private static string Sanitize(string value)
|
||||
{
|
||||
var text = new StringBuilder(value.Length);
|
||||
foreach (char c in value)
|
||||
{
|
||||
text.Append(Array.IndexOf(Path.GetInvalidFileNameChars(), c) >= 0 ? '_' : c);
|
||||
}
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
public static JournalReadResult Read(string text)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
|
||||
var pages = new List<JournalPage>();
|
||||
JournalPage? current = null;
|
||||
bool sawAnyContent = false;
|
||||
|
||||
foreach (string rawLine in text.Split('\n'))
|
||||
{
|
||||
string line = rawLine.TrimEnd('\r');
|
||||
if (line.Length == 0)
|
||||
continue;
|
||||
|
||||
if (!line.StartsWith('<'))
|
||||
{
|
||||
// Retail's reader is tag-driven; a line with no tag belongs to
|
||||
// nothing and is skipped rather than guessed at.
|
||||
sawAnyContent = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
string tag = line.Length >= 6 ? line[..6] : line;
|
||||
string value = line.Length > 7 ? line[7..] : string.Empty;
|
||||
|
||||
if (tag == NewPage)
|
||||
{
|
||||
if (current is not null)
|
||||
pages.Add(current);
|
||||
current = JournalPage.Empty;
|
||||
sawAnyContent = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current is null)
|
||||
{
|
||||
// Content before the first page marker: the file does not
|
||||
// create a new page.
|
||||
return new JournalReadResult([], MalformedFileMessage);
|
||||
}
|
||||
|
||||
sawAnyContent = true;
|
||||
current = tag switch
|
||||
{
|
||||
Label => current with { Label = value },
|
||||
Title => current with { Title = value },
|
||||
Notes => current with { Notes = value },
|
||||
Days => current with { TimerDays = ParseInt(value) },
|
||||
Hours => current with { TimerHours = ParseInt(value) },
|
||||
Minutes => current with { TimerMinutes = ParseInt(value) },
|
||||
LocationX => current with
|
||||
{
|
||||
LocationX = ParseFloat(value),
|
||||
HasLocation = true,
|
||||
},
|
||||
LocationY => current with
|
||||
{
|
||||
LocationY = ParseFloat(value),
|
||||
HasLocation = true,
|
||||
},
|
||||
RunningTime => current with { RunningTimerSeconds = ParseFloat(value) },
|
||||
// <PNUM> is written but carries no information the reader
|
||||
// needs: page order IS file order. Anything unrecognised is
|
||||
// skipped for the same reason a newer client's tag should not
|
||||
// make an older one refuse the file.
|
||||
_ => current,
|
||||
};
|
||||
}
|
||||
|
||||
if (current is not null)
|
||||
pages.Add(current);
|
||||
|
||||
// An empty file is a character who has never written a page.
|
||||
if (pages.Count == 0 && sawAnyContent)
|
||||
return new JournalReadResult([], MalformedFileMessage);
|
||||
|
||||
for (int i = 0; i < pages.Count; i++)
|
||||
pages[i] = pages[i].Clipped();
|
||||
|
||||
return new JournalReadResult(pages, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the pages in retail's tag order. Every page is written whole,
|
||||
/// including empty fields, because that is what retail's own writer does
|
||||
/// and a reader keyed on tag presence must not have to infer absence.
|
||||
/// </summary>
|
||||
public static string Write(IReadOnlyList<JournalPage> pages)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pages);
|
||||
|
||||
var text = new StringBuilder();
|
||||
for (int i = 0; i < pages.Count; i++)
|
||||
{
|
||||
JournalPage page = pages[i];
|
||||
text.Append(NewPage).Append('\n');
|
||||
Append(text, PageNumber, (i + 1).ToString(CultureInfo.InvariantCulture));
|
||||
Append(text, Label, page.Label);
|
||||
Append(text, Title, page.Title);
|
||||
Append(text, Notes, page.Notes);
|
||||
Append(text, Days, page.TimerDays.ToString(CultureInfo.InvariantCulture));
|
||||
Append(text, Hours, page.TimerHours.ToString(CultureInfo.InvariantCulture));
|
||||
Append(text, Minutes, page.TimerMinutes.ToString(CultureInfo.InvariantCulture));
|
||||
if (page.HasLocation)
|
||||
{
|
||||
Append(text, LocationX, Format(page.LocationX));
|
||||
Append(text, LocationY, Format(page.LocationY));
|
||||
}
|
||||
|
||||
Append(text, RunningTime, Format(page.RunningTimerSeconds));
|
||||
}
|
||||
|
||||
return text.ToString();
|
||||
|
||||
static void Append(StringBuilder text, string tag, string value)
|
||||
{
|
||||
// A newline inside a value would read back as a separate line and
|
||||
// silently truncate the field. Retail's single-line edit boxes
|
||||
// cannot produce one; the multi-line notes box can, so they are
|
||||
// folded to spaces rather than corrupting the file.
|
||||
text.Append(tag).Append(' ')
|
||||
.Append(value.Replace('\n', ' ').Replace('\r', ' '))
|
||||
.Append('\n');
|
||||
}
|
||||
}
|
||||
|
||||
private static string Format(double value) =>
|
||||
value.ToString("0.######", CultureInfo.InvariantCulture);
|
||||
|
||||
private static int ParseInt(string value) =>
|
||||
int.TryParse(value.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int result)
|
||||
? result
|
||||
: 0;
|
||||
|
||||
private static float ParseFloat(string value) =>
|
||||
float.TryParse(value.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out float result)
|
||||
? result
|
||||
: 0f;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue