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
77
src/AcDream.Core/Journal/JournalPage.cs
Normal file
77
src/AcDream.Core/Journal/JournalPage.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
namespace AcDream.Core.Journal;
|
||||
|
||||
/// <summary>
|
||||
/// One page of retail's per-character journal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Entirely client-authored: no wire, no server, no dat. The player writes it.
|
||||
/// Retail's own <c>PageInfo</c> (64 bytes, <c>g_JournalPages</c>).
|
||||
/// </remarks>
|
||||
/// <param name="Label">Short name, shown in the Page List. Authored max 16 characters.</param>
|
||||
/// <param name="Title">Page title. Authored max 32.</param>
|
||||
/// <param name="Notes">Free-form body. Authored max 2048.</param>
|
||||
/// <param name="TimerDays">Countdown days.</param>
|
||||
/// <param name="TimerHours">Countdown hours.</param>
|
||||
/// <param name="TimerMinutes">Countdown minutes.</param>
|
||||
/// <param name="LocationX">
|
||||
/// Recorded location, in retail's own north-south / east-west units — what the
|
||||
/// "Record" button stamps and the page shows as "00.0S, 00.0W".
|
||||
/// </param>
|
||||
/// <param name="LocationY">See <paramref name="LocationX"/>.</param>
|
||||
/// <param name="HasLocation">
|
||||
/// Whether a location was ever recorded. Distinct from (0, 0), which is a real
|
||||
/// place.
|
||||
/// </param>
|
||||
/// <param name="RunningTimerSeconds">
|
||||
/// The running countdown's remaining seconds, or 0 when the timer is not
|
||||
/// running. Retail's <c><TIME></c>.
|
||||
/// </param>
|
||||
public sealed record JournalPage(
|
||||
string Label = "",
|
||||
string Title = "",
|
||||
string Notes = "",
|
||||
int TimerDays = 0,
|
||||
int TimerHours = 0,
|
||||
int TimerMinutes = 0,
|
||||
float LocationX = 0f,
|
||||
float LocationY = 0f,
|
||||
bool HasLocation = false,
|
||||
double RunningTimerSeconds = 0d)
|
||||
{
|
||||
/// <summary>Authored <c>0x1E</c> on the label edit box.</summary>
|
||||
public const int MaxLabelLength = 16;
|
||||
|
||||
/// <summary>Authored <c>0x1E</c> on the title edit box.</summary>
|
||||
public const int MaxTitleLength = 32;
|
||||
|
||||
/// <summary>Authored <c>0x1E</c> on the notes edit box.</summary>
|
||||
public const int MaxNotesLength = 2048;
|
||||
|
||||
/// <summary>An untouched page — what "New" produces.</summary>
|
||||
public static readonly JournalPage Empty = new();
|
||||
|
||||
/// <summary>Whether the timer fields describe any duration at all.</summary>
|
||||
public bool HasTimer =>
|
||||
TimerDays != 0 || TimerHours != 0 || TimerMinutes != 0;
|
||||
|
||||
/// <summary>The timer fields as a single duration.</summary>
|
||||
public TimeSpan TimerDuration =>
|
||||
new(TimerDays, TimerHours, TimerMinutes, 0);
|
||||
|
||||
/// <summary>Whether a countdown is currently running on this page.</summary>
|
||||
public bool IsTimerRunning => RunningTimerSeconds > 0d;
|
||||
|
||||
/// <summary>
|
||||
/// The page with every field clipped to its authored maximum. Applied at
|
||||
/// the seams that accept outside text — a load from disk, or a paste.
|
||||
/// </summary>
|
||||
public JournalPage Clipped() => this with
|
||||
{
|
||||
Label = Clip(Label, MaxLabelLength),
|
||||
Title = Clip(Title, MaxTitleLength),
|
||||
Notes = Clip(Notes, MaxNotesLength),
|
||||
};
|
||||
|
||||
private static string Clip(string value, int max) =>
|
||||
value.Length <= max ? value : value[..max];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue