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;
|
||||
}
|
||||
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];
|
||||
}
|
||||
|
|
@ -54,6 +54,8 @@ public enum GameRuntimeTeardownStage
|
|||
EntityObjectsDisposed = 1 << 13,
|
||||
// Campaign QT (2026-08-21): fourth sibling J-owner, same shape.
|
||||
ContractsDisposed = 1 << 14,
|
||||
// Campaign QJ (2026-08-21): fifth — the per-character journal.
|
||||
JournalDisposed = 1 << 15,
|
||||
Complete =
|
||||
HostLeasesReleased
|
||||
| EventsDetached
|
||||
|
|
@ -68,6 +70,7 @@ public enum GameRuntimeTeardownStage
|
|||
| AllegianceDisposed
|
||||
| TradeDisposed
|
||||
| ContractsDisposed
|
||||
| JournalDisposed
|
||||
| IdentityDisposed
|
||||
| EntityObjectsDisposed,
|
||||
}
|
||||
|
|
@ -113,6 +116,7 @@ internal enum GameRuntimeConstructionPoint
|
|||
AllegianceCreated,
|
||||
TradeCreated,
|
||||
ContractsCreated,
|
||||
JournalCreated,
|
||||
HouseCreated,
|
||||
MovementCreated,
|
||||
ActionsCreated,
|
||||
|
|
@ -133,6 +137,7 @@ internal sealed class GameRuntimeConstructionContext
|
|||
public RuntimeAllegianceState? Allegiance { get; set; }
|
||||
public RuntimeTradeState? Trade { get; set; }
|
||||
public RuntimeContractState? Contracts { get; set; }
|
||||
public RuntimeJournalState? Journal { get; set; }
|
||||
public RuntimeHouseState? House { get; set; }
|
||||
public RuntimeLocalPlayerMovementState? Movement { get; set; }
|
||||
public RuntimeActionState? Actions { get; set; }
|
||||
|
|
@ -149,11 +154,11 @@ public sealed class GameRuntime
|
|||
IRuntimeEventSource,
|
||||
IDisposable
|
||||
{
|
||||
// Campaign QT (2026-08-21): 15 with the contract owner. This bound and
|
||||
// Campaign QJ (2026-08-21): 16 with the journal owner. This bound and
|
||||
// GameRuntimeTeardownStage.Complete have to move together — the drain
|
||||
// loop stops here, so leaving it behind would silently never dispose
|
||||
// the last owner while the ledger kept demanding its flag.
|
||||
private const int TeardownStageCount = 15;
|
||||
private const int TeardownStageCount = 16;
|
||||
|
||||
private readonly object _lifetimeGate = new();
|
||||
private readonly Dictionary<long, string> _hostLeases = [];
|
||||
|
|
@ -304,6 +309,15 @@ public sealed class GameRuntime
|
|||
context,
|
||||
faultInjection);
|
||||
|
||||
// Campaign QJ (2026-08-21): the per-character journal. Client-
|
||||
// authored and file-backed — no wire reaches it.
|
||||
context.Journal = new RuntimeJournalState();
|
||||
construction.Own(context.Journal);
|
||||
Fault(
|
||||
GameRuntimeConstructionPoint.JournalCreated,
|
||||
context,
|
||||
faultInjection);
|
||||
|
||||
// House tab (Batch C, Map/House toolbar panel, 2026-08-17):
|
||||
// deliberately minimal owner (ISSUES #413's own sizing note) —
|
||||
// no live-object side effects, nothing to dispose, so no
|
||||
|
|
@ -372,7 +386,8 @@ public sealed class GameRuntime
|
|||
context.Allegiance,
|
||||
context.Trade,
|
||||
context.House,
|
||||
context.Contracts);
|
||||
context.Contracts,
|
||||
context.Journal);
|
||||
|
||||
context.Movement.AttachPhysicsPublication(
|
||||
new RuntimeLocalPlayerPhysicsPublicationState(
|
||||
|
|
@ -429,6 +444,7 @@ public sealed class GameRuntime
|
|||
AllegianceOwner = context.Allegiance;
|
||||
TradeOwner = context.Trade;
|
||||
ContractsOwner = context.Contracts;
|
||||
JournalOwner = context.Journal;
|
||||
HouseOwner = context.House;
|
||||
MovementOwner = context.Movement;
|
||||
ActionOwner = context.Actions;
|
||||
|
|
@ -539,6 +555,7 @@ public sealed class GameRuntime
|
|||
/// <summary>Secure trade (2026-08-14): third sibling J-owner.</summary>
|
||||
public RuntimeTradeState TradeOwner { get; }
|
||||
public RuntimeContractState ContractsOwner { get; }
|
||||
public RuntimeJournalState JournalOwner { get; }
|
||||
|
||||
/// <summary>Batch C (2026-08-17): House tab minimal owner — see
|
||||
/// <see cref="RuntimeHouseState"/>'s own class doc for the sizing
|
||||
|
|
@ -596,6 +613,7 @@ public sealed class GameRuntime
|
|||
|
||||
public IRuntimeTradeView Trade => TradeOwner.View;
|
||||
public IRuntimeContractView Contracts => ContractsOwner.View;
|
||||
public IRuntimeJournalView Journal => JournalOwner.View;
|
||||
public IRuntimeActionView Actions => ActionOwner.View;
|
||||
public IRuntimeMovementView Movement => MovementOwner.View;
|
||||
public IRuntimeWorldEnvironmentView Environment => EnvironmentOwner;
|
||||
|
|
@ -825,27 +843,35 @@ public sealed class GameRuntime
|
|||
& ~GameRuntimeTeardownStage.AllegianceDisposed
|
||||
& ~GameRuntimeTeardownStage.TradeDisposed
|
||||
& ~GameRuntimeTeardownStage.ContractsDisposed
|
||||
& ~GameRuntimeTeardownStage.JournalDisposed
|
||||
& ~GameRuntimeTeardownStage.IdentityDisposed
|
||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
10 => GameRuntimeTeardownStage.Complete
|
||||
& ~GameRuntimeTeardownStage.AllegianceDisposed
|
||||
& ~GameRuntimeTeardownStage.TradeDisposed
|
||||
& ~GameRuntimeTeardownStage.ContractsDisposed
|
||||
& ~GameRuntimeTeardownStage.JournalDisposed
|
||||
& ~GameRuntimeTeardownStage.IdentityDisposed
|
||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
11 => GameRuntimeTeardownStage.Complete
|
||||
& ~GameRuntimeTeardownStage.TradeDisposed
|
||||
& ~GameRuntimeTeardownStage.ContractsDisposed
|
||||
& ~GameRuntimeTeardownStage.JournalDisposed
|
||||
& ~GameRuntimeTeardownStage.IdentityDisposed
|
||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
12 => GameRuntimeTeardownStage.Complete
|
||||
& ~GameRuntimeTeardownStage.ContractsDisposed
|
||||
& ~GameRuntimeTeardownStage.JournalDisposed
|
||||
& ~GameRuntimeTeardownStage.IdentityDisposed
|
||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
13 => GameRuntimeTeardownStage.Complete
|
||||
& ~GameRuntimeTeardownStage.JournalDisposed
|
||||
& ~GameRuntimeTeardownStage.IdentityDisposed
|
||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
14 => GameRuntimeTeardownStage.Complete
|
||||
& ~GameRuntimeTeardownStage.IdentityDisposed
|
||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
15 => GameRuntimeTeardownStage.Complete
|
||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
_ => GameRuntimeTeardownStage.Complete,
|
||||
};
|
||||
|
|
@ -903,9 +929,12 @@ public sealed class GameRuntime
|
|||
ContractsOwner.Dispose();
|
||||
return ContractsOwner.CaptureOwnership().IsConverged;
|
||||
case 13:
|
||||
JournalOwner.Dispose();
|
||||
return JournalOwner.CaptureOwnership().IsConverged;
|
||||
case 14:
|
||||
PlayerIdentity.Dispose();
|
||||
return PlayerIdentity.CaptureOwnership().IsConverged;
|
||||
case 14:
|
||||
case 15:
|
||||
EntityObjects.Dispose();
|
||||
return EntityObjects.CaptureOwnership().IsConverged
|
||||
&& EntityObjects.Physics.CaptureOwnership().IsConverged;
|
||||
|
|
@ -930,8 +959,9 @@ public sealed class GameRuntime
|
|||
10 => AllegianceOwner.CaptureOwnership().IsConverged,
|
||||
11 => TradeOwner.CaptureOwnership().IsConverged,
|
||||
12 => ContractsOwner.CaptureOwnership().IsConverged,
|
||||
13 => PlayerIdentity.CaptureOwnership().IsConverged,
|
||||
14 => EntityObjects.CaptureOwnership().IsConverged
|
||||
13 => JournalOwner.CaptureOwnership().IsConverged,
|
||||
14 => PlayerIdentity.CaptureOwnership().IsConverged,
|
||||
15 => EntityObjects.CaptureOwnership().IsConverged
|
||||
&& EntityObjects.Physics.CaptureOwnership().IsConverged,
|
||||
_ => true,
|
||||
};
|
||||
|
|
|
|||
386
src/AcDream.Runtime/Gameplay/RuntimeJournalState.cs
Normal file
386
src/AcDream.Runtime/Gameplay/RuntimeJournalState.cs
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.Core.Journal;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
public readonly record struct RuntimeJournalOwnershipSnapshot(
|
||||
bool IsDisposed,
|
||||
int PageCount)
|
||||
{
|
||||
public bool IsConverged => IsDisposed && PageCount == 0;
|
||||
}
|
||||
|
||||
/// <summary>Whole-journal state at one revision.</summary>
|
||||
/// <param name="CurrentPage">1-based, or 0 when the journal is empty.</param>
|
||||
public readonly record struct RuntimeJournalSnapshot(
|
||||
long Revision,
|
||||
int PageCount,
|
||||
int CurrentPage,
|
||||
bool IsDirty);
|
||||
|
||||
/// <summary>Borrowed read surface over <see cref="RuntimeJournalState"/>.</summary>
|
||||
public interface IRuntimeJournalView
|
||||
{
|
||||
RuntimeJournalSnapshot Snapshot { get; }
|
||||
|
||||
/// <summary>Every page, in file order.</summary>
|
||||
IReadOnlyList<JournalPage> Pages { get; }
|
||||
|
||||
/// <summary>The page <see cref="RuntimeJournalSnapshot.CurrentPage"/> names,
|
||||
/// or <see cref="JournalPage.Empty"/> when the journal is empty.</summary>
|
||||
JournalPage Current { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Remaining seconds on the current page's countdown at
|
||||
/// <paramref name="now"/>, or 0 when nothing is running.
|
||||
/// </summary>
|
||||
double RemainingTimerSeconds(DateTime now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical owner for retail's per-character journal — Campaign QJ slice QJ2.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Purely client-authored: no wire touches this. The player writes the pages,
|
||||
/// and they persist to a per-character file
|
||||
/// (<see cref="JournalFile"/>). Retail loads on entering the world and saves
|
||||
/// whenever the journal page is hidden
|
||||
/// (<c>gmJournalUI::OnVisibilityChanged @0x004978F0</c>) rather than only at
|
||||
/// exit, so a crash costs at most the page you are looking at.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Pages are 1-BASED throughout, matching retail's <c>m_CurrentPage</c> and the
|
||||
/// "~ 1 ~" the panel shows. 0 means "no pages".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RuntimeJournalState : IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly List<JournalPage> _pages = [];
|
||||
private int _currentPage;
|
||||
private long _revision;
|
||||
private bool _dirty;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>When the running countdown was started, and what it had left then.</summary>
|
||||
private DateTime? _timerStartedAt;
|
||||
private double _timerSecondsAtStart;
|
||||
|
||||
public RuntimeJournalState() => View = new JournalView(this);
|
||||
|
||||
public IRuntimeJournalView View { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether anything has changed since the last <see cref="MarkSaved"/> —
|
||||
/// what the host checks before writing the file.
|
||||
/// </summary>
|
||||
public bool IsDirty
|
||||
{
|
||||
get { lock (_gate) return _dirty; }
|
||||
}
|
||||
|
||||
/// <summary>Replaces the whole journal, as a file load does.</summary>
|
||||
public void Load(IReadOnlyList<JournalPage> pages)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pages);
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
_pages.Clear();
|
||||
_pages.AddRange(pages);
|
||||
_currentPage = _pages.Count == 0 ? 0 : 1;
|
||||
_timerStartedAt = null;
|
||||
_timerSecondsAtStart = 0d;
|
||||
|
||||
// A load is not an edit: the file already says this.
|
||||
_dirty = false;
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A snapshot for writing to disk, with the live countdown folded in.</summary>
|
||||
public IReadOnlyList<JournalPage> CaptureForSave(DateTime now)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var saved = new JournalPage[_pages.Count];
|
||||
for (int i = 0; i < _pages.Count; i++)
|
||||
{
|
||||
saved[i] = i + 1 == _currentPage
|
||||
? _pages[i] with { RunningTimerSeconds = RemainingLocked(now) }
|
||||
: _pages[i];
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkSaved()
|
||||
{
|
||||
lock (_gate) _dirty = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's "New" — appends a blank page and moves to it
|
||||
/// (<c>gmJournalUI::NewPage @0x00496880</c>).
|
||||
/// </summary>
|
||||
public void NewPage()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_pages.Add(JournalPage.Empty);
|
||||
_currentPage = _pages.Count;
|
||||
ClearTimerLocked();
|
||||
_dirty = true;
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes one 1-based page (<c>DeletePage @0x004965D0</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deleting the last remaining page leaves an EMPTY journal rather than a
|
||||
/// blank page — retail's own <c>ClearCurrentPage</c> handles the "one page
|
||||
/// left" case separately, and inventing a replacement here would make the
|
||||
/// journal impossible to empty.
|
||||
/// </remarks>
|
||||
public bool DeletePage(int pageNumber)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed || pageNumber < 1 || pageNumber > _pages.Count)
|
||||
return false;
|
||||
|
||||
_pages.RemoveAt(pageNumber - 1);
|
||||
if (_currentPage > _pages.Count)
|
||||
_currentPage = _pages.Count;
|
||||
if (_pages.Count == 0)
|
||||
_currentPage = 0;
|
||||
|
||||
ClearTimerLocked();
|
||||
_dirty = true;
|
||||
Bump();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Moves to a 1-based page (<c>GotoPage @0x00496430</c>).</summary>
|
||||
public bool GotoPage(int pageNumber)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed || pageNumber < 1 || pageNumber > _pages.Count)
|
||||
return false;
|
||||
if (_currentPage == pageNumber)
|
||||
return true;
|
||||
|
||||
_currentPage = pageNumber;
|
||||
// The countdown belongs to the page it was started on.
|
||||
ClearTimerLocked();
|
||||
Bump();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits edited text into the current page
|
||||
/// (<c>SaveThisPage @0x00495360</c>).
|
||||
/// </summary>
|
||||
public void UpdateCurrent(string label, string title, string notes)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed || _currentPage == 0) return;
|
||||
|
||||
JournalPage updated = (_pages[_currentPage - 1] with
|
||||
{
|
||||
Label = label ?? string.Empty,
|
||||
Title = title ?? string.Empty,
|
||||
Notes = notes ?? string.Empty,
|
||||
}).Clipped();
|
||||
|
||||
if (updated == _pages[_currentPage - 1])
|
||||
return;
|
||||
|
||||
_pages[_currentPage - 1] = updated;
|
||||
_dirty = true;
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The "Record" button (<c>UpdateLocation @0x004958F0</c>).</summary>
|
||||
public void RecordLocation(float x, float y)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed || _currentPage == 0) return;
|
||||
_pages[_currentPage - 1] = _pages[_currentPage - 1] with
|
||||
{
|
||||
LocationX = x,
|
||||
LocationY = y,
|
||||
HasLocation = true,
|
||||
};
|
||||
_dirty = true;
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetTimer(int days, int hours, int minutes)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed || _currentPage == 0) return;
|
||||
_pages[_currentPage - 1] = _pages[_currentPage - 1] with
|
||||
{
|
||||
TimerDays = Math.Max(0, days),
|
||||
TimerHours = Math.Max(0, hours),
|
||||
TimerMinutes = Math.Max(0, minutes),
|
||||
};
|
||||
_dirty = true;
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Starts the current page's countdown from its authored fields.</summary>
|
||||
public bool StartTimer(DateTime now)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed || _currentPage == 0) return false;
|
||||
|
||||
JournalPage page = _pages[_currentPage - 1];
|
||||
double seconds = page.TimerDuration.TotalSeconds;
|
||||
if (seconds <= 0d)
|
||||
return false;
|
||||
|
||||
_timerStartedAt = now;
|
||||
_timerSecondsAtStart = seconds;
|
||||
_pages[_currentPage - 1] = page with { RunningTimerSeconds = seconds };
|
||||
_dirty = true;
|
||||
Bump();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stops the countdown (<c>ResetTimer @0x00495150</c>).</summary>
|
||||
public void ResetTimer()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed) return;
|
||||
bool wasRunning = _timerStartedAt is not null;
|
||||
ClearTimerLocked();
|
||||
if (_currentPage != 0)
|
||||
{
|
||||
_pages[_currentPage - 1] =
|
||||
_pages[_currentPage - 1] with { RunningTimerSeconds = 0d };
|
||||
}
|
||||
|
||||
if (wasRunning) _dirty = true;
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the current page is the last one
|
||||
/// (<c>IsLastPage @0x00494ED0</c>) — the "Last" button's own gate.
|
||||
/// </summary>
|
||||
public bool IsLastPage
|
||||
{
|
||||
get { lock (_gate) return _currentPage != 0 && _currentPage == _pages.Count; }
|
||||
}
|
||||
|
||||
public RuntimeJournalOwnershipSnapshot CaptureOwnership()
|
||||
{
|
||||
lock (_gate) return new RuntimeJournalOwnershipSnapshot(_disposed, _pages.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleared at generation reset: the journal is PER-CHARACTER, so carrying
|
||||
/// it across a reconnect would show one character another's notes. The
|
||||
/// host saves before the reset and loads after it.
|
||||
/// </summary>
|
||||
public void ResetSession()
|
||||
{
|
||||
lock (_gate) ClearLocked();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed) return;
|
||||
ClearLocked();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearLocked()
|
||||
{
|
||||
bool changed = _pages.Count != 0 || _currentPage != 0;
|
||||
_pages.Clear();
|
||||
_currentPage = 0;
|
||||
_dirty = false;
|
||||
ClearTimerLocked();
|
||||
if (changed) Bump();
|
||||
}
|
||||
|
||||
private void ClearTimerLocked()
|
||||
{
|
||||
_timerStartedAt = null;
|
||||
_timerSecondsAtStart = 0d;
|
||||
}
|
||||
|
||||
private double RemainingLocked(DateTime now)
|
||||
{
|
||||
if (_timerStartedAt is not { } started)
|
||||
return _currentPage == 0 ? 0d : _pages[_currentPage - 1].RunningTimerSeconds;
|
||||
|
||||
double remaining = _timerSecondsAtStart - (now - started).TotalSeconds;
|
||||
return remaining > 0d ? remaining : 0d;
|
||||
}
|
||||
|
||||
private void Bump() => _revision++;
|
||||
|
||||
private sealed class JournalView(RuntimeJournalState owner) : IRuntimeJournalView
|
||||
{
|
||||
public RuntimeJournalSnapshot Snapshot
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (owner._gate)
|
||||
return new RuntimeJournalSnapshot(
|
||||
owner._revision,
|
||||
owner._pages.Count,
|
||||
owner._currentPage,
|
||||
owner._dirty);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<JournalPage> Pages
|
||||
{
|
||||
get { lock (owner._gate) return owner._pages.ToArray(); }
|
||||
}
|
||||
|
||||
public JournalPage Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (owner._gate)
|
||||
return owner._currentPage == 0
|
||||
? JournalPage.Empty
|
||||
: owner._pages[owner._currentPage - 1];
|
||||
}
|
||||
}
|
||||
|
||||
public double RemainingTimerSeconds(DateTime now)
|
||||
{
|
||||
lock (owner._gate) return owner.RemainingLocked(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -78,15 +78,21 @@ public enum RuntimeGenerationResetStage
|
|||
/// previous character's quests.
|
||||
/// </summary>
|
||||
Contracts = 16,
|
||||
BeginEntityRetirement = 17,
|
||||
RetireEntities = 18,
|
||||
DrainHostProjection = 19,
|
||||
CompleteCanonicalEntities = 20,
|
||||
CompleteHostProjection = 21,
|
||||
ChatIdentity = 22,
|
||||
PlayerSnapshots = 23,
|
||||
PlayerIdentity = 24,
|
||||
Complete = 25,
|
||||
/// <summary>
|
||||
/// Campaign QJ (2026-08-21): the journal is PER-CHARACTER, so carrying it
|
||||
/// across a reconnect would show one character another's notes. The host
|
||||
/// saves before the reset and loads again after it.
|
||||
/// </summary>
|
||||
Journal = 17,
|
||||
BeginEntityRetirement = 18,
|
||||
RetireEntities = 19,
|
||||
DrainHostProjection = 20,
|
||||
CompleteCanonicalEntities = 21,
|
||||
CompleteHostProjection = 22,
|
||||
ChatIdentity = 23,
|
||||
PlayerSnapshots = 24,
|
||||
PlayerIdentity = 25,
|
||||
Complete = 26,
|
||||
}
|
||||
|
||||
public readonly record struct RuntimeGenerationResetSnapshot(
|
||||
|
|
@ -138,6 +144,7 @@ public sealed class RuntimeGenerationReset
|
|||
private readonly RuntimeAllegianceState _allegiance;
|
||||
private readonly RuntimeTradeState _trade;
|
||||
private readonly RuntimeContractState _contracts;
|
||||
private readonly RuntimeJournalState _journal;
|
||||
private readonly RuntimeHouseState _house;
|
||||
private ResetState? _state;
|
||||
private RuntimeGenerationToken _lastCompletedGeneration;
|
||||
|
|
@ -158,7 +165,8 @@ public sealed class RuntimeGenerationReset
|
|||
RuntimeAllegianceState allegiance,
|
||||
RuntimeTradeState trade,
|
||||
RuntimeHouseState house,
|
||||
RuntimeContractState contracts)
|
||||
RuntimeContractState contracts,
|
||||
RuntimeJournalState journal)
|
||||
{
|
||||
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
|
||||
_communication = communication
|
||||
|
|
@ -182,6 +190,7 @@ public sealed class RuntimeGenerationReset
|
|||
_house = house ?? throw new ArgumentNullException(nameof(house));
|
||||
_contracts = contracts
|
||||
?? throw new ArgumentNullException(nameof(contracts));
|
||||
_journal = journal ?? throw new ArgumentNullException(nameof(journal));
|
||||
}
|
||||
|
||||
public RuntimeGenerationToken? ActiveRetiringGeneration =>
|
||||
|
|
@ -364,6 +373,9 @@ public sealed class RuntimeGenerationReset
|
|||
case RuntimeGenerationResetStage.Contracts:
|
||||
Advance(state, _contracts.ResetSession);
|
||||
break;
|
||||
case RuntimeGenerationResetStage.Journal:
|
||||
Advance(state, _journal.ResetSession);
|
||||
break;
|
||||
case RuntimeGenerationResetStage.BeginEntityRetirement:
|
||||
_ = _entityObjects.BeginSessionClear();
|
||||
state.Retirements = _entityObjects
|
||||
|
|
|
|||
175
tests/AcDream.Core.Tests/Journal/JournalFileTests.cs
Normal file
175
tests/AcDream.Core.Tests/Journal/JournalFileTests.cs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Journal;
|
||||
|
||||
namespace AcDream.Core.Tests.Journal;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign QJ slice QJ1: retail's journal file
|
||||
/// (<c>gmJournalUI::LoadPages @0x00496AC0</c> /
|
||||
/// <c>SavePages @0x00497270</c>).
|
||||
/// </summary>
|
||||
public sealed class JournalFileTests
|
||||
{
|
||||
[Fact]
|
||||
public void APageRoundTripsThroughTheFile()
|
||||
{
|
||||
var page = new JournalPage(
|
||||
Label: "Aerlinthe",
|
||||
Title: "Recall ring",
|
||||
Notes: "Talk to the archmage first.",
|
||||
TimerDays: 1,
|
||||
TimerHours: 2,
|
||||
TimerMinutes: 3,
|
||||
LocationX: 12.5f,
|
||||
LocationY: -8.25f,
|
||||
HasLocation: true,
|
||||
RunningTimerSeconds: 90d);
|
||||
|
||||
JournalReadResult result = JournalFile.Read(JournalFile.Write([page]));
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Equal(page, Assert.Single(result.Pages));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeveralPagesKeepTheirFileOrder()
|
||||
{
|
||||
// <PNUM> is written but page order IS file order — a reader that
|
||||
// trusted the number would reshuffle a hand-edited file.
|
||||
List<JournalPage> pages =
|
||||
[
|
||||
new(Label: "one"), new(Label: "two"), new(Label: "three"),
|
||||
];
|
||||
|
||||
JournalReadResult result = JournalFile.Read(JournalFile.Write(pages));
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "one", "two", "three" },
|
||||
result.Pages.Select(p => p.Label).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AFileThatDoesNotOpenWithAPageMarkerIsRefused()
|
||||
{
|
||||
// Retail's own strictness, and its own message. Accepting this would
|
||||
// scatter the first page's text into no page at all.
|
||||
JournalReadResult result = JournalFile.Read("<TITL> orphaned\n<NEWP>\n");
|
||||
|
||||
Assert.Equal(JournalFile.MalformedFileMessage, result.Error);
|
||||
Assert.Empty(result.Pages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnEmptyFileIsACharacterWhoHasWrittenNothing()
|
||||
{
|
||||
// Absence is not corruption: refusing here would show an error to
|
||||
// every character opening the journal for the first time.
|
||||
JournalReadResult result = JournalFile.Read(string.Empty);
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Empty(result.Pages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnUnknownTagIsSkippedRatherThanRefused()
|
||||
{
|
||||
// A newer client's tag must not make this one reject the file.
|
||||
JournalReadResult result = JournalFile.Read(
|
||||
"<NEWP>\n<TITL> kept\n<ZZZZ> whatever\n");
|
||||
|
||||
Assert.Null(result.Error);
|
||||
Assert.Equal("kept", Assert.Single(result.Pages).Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheWrittenFormIsRetailsTagOrder()
|
||||
{
|
||||
string text = JournalFile.Write([new JournalPage(Label: "L", Title: "T")]);
|
||||
|
||||
string[] tags = text
|
||||
.Split('\n', System.StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line => line.Length >= 6 ? line[..6] : line)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"<NEWP>", "<PNUM>", "<LABE>", "<TITL>", "<NOTE>",
|
||||
"<DAYS>", "<HOUR>", "<MINU>", "<TIME>",
|
||||
},
|
||||
tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARecordedLocationOfZeroIsStillARecordedLocation()
|
||||
{
|
||||
// (0, 0) is a real place. Writing the tags only when the numbers are
|
||||
// non-zero would lose the distinction between "recorded there" and
|
||||
// "never recorded".
|
||||
var page = new JournalPage(LocationX: 0f, LocationY: 0f, HasLocation: true);
|
||||
|
||||
JournalPage read = Assert.Single(JournalFile.Read(JournalFile.Write([page])).Pages);
|
||||
|
||||
Assert.True(read.HasLocation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void APageWithNoLocationDoesNotGainOne()
|
||||
{
|
||||
JournalPage read = Assert.Single(
|
||||
JournalFile.Read(JournalFile.Write([new JournalPage(Title: "T")])).Pages);
|
||||
|
||||
Assert.False(read.HasLocation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANewlineInsideNotesCannotSplitThePage()
|
||||
{
|
||||
// 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 — or worse, land inside the next page.
|
||||
var page = new JournalPage(Notes: "first line\nsecond line", Title: "kept");
|
||||
|
||||
JournalReadResult result = JournalFile.Read(JournalFile.Write([page]));
|
||||
|
||||
JournalPage read = Assert.Single(result.Pages);
|
||||
Assert.Equal("first line second line", read.Notes);
|
||||
Assert.Equal("kept", read.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverlongFieldsAreClippedToTheirAuthoredMaximums()
|
||||
{
|
||||
// The edit boxes enforce these; a hand-edited file does not.
|
||||
string text =
|
||||
"<NEWP>\n"
|
||||
+ "<LABE> " + new string('a', 100) + "\n"
|
||||
+ "<TITL> " + new string('b', 100) + "\n";
|
||||
|
||||
JournalPage page = Assert.Single(JournalFile.Read(text).Pages);
|
||||
|
||||
Assert.Equal(JournalPage.MaxLabelLength, page.Label.Length);
|
||||
Assert.Equal(JournalPage.MaxTitleLength, page.Title.Length);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Frostfell", "Acdream", "Journal-Frostfell-Acdream.txt")]
|
||||
// A name is outside data and must not be able to redirect a write.
|
||||
[InlineData("a/b", "c\\d", "Journal-a_b-c_d.txt")]
|
||||
public void TheFileNameFollowsRetailsPattern(
|
||||
string server, string character, string expected)
|
||||
=> Assert.Equal(expected, JournalFile.FileNameFor(server, character));
|
||||
|
||||
[Fact]
|
||||
public void NumbersAreCultureInvariant()
|
||||
{
|
||||
// A comma decimal separator would write a file this reader cannot read
|
||||
// back — and the user's locale is not the file's.
|
||||
string text = JournalFile.Write(
|
||||
[new JournalPage(LocationX: 1.5f, LocationY: 2.25f, HasLocation: true)]);
|
||||
|
||||
Assert.Contains("<LOCX> 1.5", text);
|
||||
Assert.DoesNotContain(",", text);
|
||||
}
|
||||
}
|
||||
|
|
@ -244,6 +244,7 @@ public sealed class GameRuntimeTests
|
|||
// its three sibling J-owners, before the identity and entity
|
||||
// foundations.
|
||||
GameRuntimeTeardownStage.ContractsDisposed,
|
||||
GameRuntimeTeardownStage.JournalDisposed,
|
||||
GameRuntimeTeardownStage.IdentityDisposed,
|
||||
GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
];
|
||||
|
|
|
|||
282
tests/AcDream.Runtime.Tests/Gameplay/RuntimeJournalStateTests.cs
Normal file
282
tests/AcDream.Runtime.Tests/Gameplay/RuntimeJournalStateTests.cs
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Journal;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Gameplay;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign QJ slice QJ2: the per-character journal owner.
|
||||
/// </summary>
|
||||
public sealed class RuntimeJournalStateTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 8, 21, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static RuntimeJournalState WithPages(params string[] labels)
|
||||
{
|
||||
var state = new RuntimeJournalState();
|
||||
state.Load(labels.Select(l => new JournalPage(Label: l)).ToArray());
|
||||
return state;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AFreshJournalHasNoPagesAndNoCurrentPage()
|
||||
{
|
||||
using var state = new RuntimeJournalState();
|
||||
|
||||
RuntimeJournalSnapshot snapshot = state.View.Snapshot;
|
||||
|
||||
Assert.Equal(0, snapshot.PageCount);
|
||||
Assert.Equal(0, snapshot.CurrentPage);
|
||||
Assert.Equal(JournalPage.Empty, state.View.Current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadingOpensOnTheFirstPageAndIsNotAnEdit()
|
||||
{
|
||||
// Pages are 1-BASED, matching retail's m_CurrentPage and the "~ 1 ~"
|
||||
// the panel shows. And a load must not mark the journal dirty — the
|
||||
// file already says exactly this, so saving straight back would be
|
||||
// pure churn.
|
||||
using RuntimeJournalState state = WithPages("a", "b");
|
||||
|
||||
Assert.Equal(1, state.View.Snapshot.CurrentPage);
|
||||
Assert.Equal("a", state.View.Current.Label);
|
||||
Assert.False(state.IsDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewPageAppendsAndMovesToIt()
|
||||
{
|
||||
using RuntimeJournalState state = WithPages("a");
|
||||
|
||||
state.NewPage();
|
||||
|
||||
Assert.Equal(2, state.View.Snapshot.PageCount);
|
||||
Assert.Equal(2, state.View.Snapshot.CurrentPage);
|
||||
Assert.Equal(JournalPage.Empty, state.View.Current);
|
||||
Assert.True(state.IsDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EditsLandOnTheCurrentPageOnly()
|
||||
{
|
||||
using RuntimeJournalState state = WithPages("a", "b");
|
||||
state.GotoPage(2);
|
||||
|
||||
state.UpdateCurrent("label", "title", "notes");
|
||||
|
||||
Assert.Equal("a", state.View.Pages[0].Label);
|
||||
Assert.Equal("label", state.View.Pages[1].Label);
|
||||
Assert.Equal("title", state.View.Pages[1].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnEditThatChangesNothingDoesNotDirtyTheJournal()
|
||||
{
|
||||
// The panel commits the edit boxes on every page change and hide, so a
|
||||
// no-op commit must not make a file write look necessary.
|
||||
using RuntimeJournalState state = WithPages("a");
|
||||
state.UpdateCurrent("a", string.Empty, string.Empty);
|
||||
|
||||
Assert.False(state.IsDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EditsAreClippedToTheAuthoredMaximums()
|
||||
{
|
||||
using RuntimeJournalState state = WithPages("a");
|
||||
|
||||
state.UpdateCurrent(new string('x', 100), new string('y', 100), "notes");
|
||||
|
||||
Assert.Equal(JournalPage.MaxLabelLength, state.View.Current.Label.Length);
|
||||
Assert.Equal(JournalPage.MaxTitleLength, state.View.Current.Title.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeletingTheLastRemainingPageEmptiesTheJournal()
|
||||
{
|
||||
// Inventing a replacement blank page would make the journal impossible
|
||||
// to empty.
|
||||
using RuntimeJournalState state = WithPages("only");
|
||||
|
||||
Assert.True(state.DeletePage(1));
|
||||
|
||||
Assert.Equal(0, state.View.Snapshot.PageCount);
|
||||
Assert.Equal(0, state.View.Snapshot.CurrentPage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeletingPastTheEndPullsTheCurrentPageBack()
|
||||
{
|
||||
using RuntimeJournalState state = WithPages("a", "b", "c");
|
||||
state.GotoPage(3);
|
||||
|
||||
state.DeletePage(3);
|
||||
|
||||
Assert.Equal(2, state.View.Snapshot.CurrentPage);
|
||||
Assert.Equal("b", state.View.Current.Label);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(4)]
|
||||
[InlineData(-1)]
|
||||
public void AnOutOfRangePageIsRefusedRatherThanClamped(int page)
|
||||
{
|
||||
// Clamping would move the player somewhere they did not ask for.
|
||||
using RuntimeJournalState state = WithPages("a", "b", "c");
|
||||
|
||||
Assert.False(state.GotoPage(page));
|
||||
Assert.False(state.DeletePage(page));
|
||||
Assert.Equal(1, state.View.Snapshot.CurrentPage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsLastPageTracksTheCurrentPage()
|
||||
{
|
||||
using RuntimeJournalState state = WithPages("a", "b");
|
||||
|
||||
Assert.False(state.IsLastPage);
|
||||
state.GotoPage(2);
|
||||
Assert.True(state.IsLastPage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordingALocationOfZeroStillCountsAsRecorded()
|
||||
{
|
||||
using RuntimeJournalState state = WithPages("a");
|
||||
|
||||
state.RecordLocation(0f, 0f);
|
||||
|
||||
Assert.True(state.View.Current.HasLocation);
|
||||
}
|
||||
|
||||
// ── the timer ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void StartingATimerNeedsADurationToCountDown()
|
||||
{
|
||||
using RuntimeJournalState state = WithPages("a");
|
||||
|
||||
Assert.False(state.StartTimer(Now));
|
||||
|
||||
state.SetTimer(0, 0, 5);
|
||||
Assert.True(state.StartTimer(Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheCountdownRunsAgainstTheClock()
|
||||
{
|
||||
using RuntimeJournalState state = WithPages("a");
|
||||
state.SetTimer(0, 1, 0);
|
||||
state.StartTimer(Now);
|
||||
|
||||
Assert.Equal(3600d, state.View.RemainingTimerSeconds(Now));
|
||||
Assert.Equal(3540d, state.View.RemainingTimerSeconds(Now.AddMinutes(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnExpiredCountdownStopsAtZeroRatherThanGoingNegative()
|
||||
{
|
||||
using RuntimeJournalState state = WithPages("a");
|
||||
state.SetTimer(0, 0, 1);
|
||||
state.StartTimer(Now);
|
||||
|
||||
Assert.Equal(0d, state.View.RemainingTimerSeconds(Now.AddHours(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeavingThePageStopsItsCountdown()
|
||||
{
|
||||
// The countdown belongs to the page it was started on; carrying it to
|
||||
// another page would attribute one page's timer to another.
|
||||
using RuntimeJournalState state = WithPages("a", "b");
|
||||
state.SetTimer(0, 1, 0);
|
||||
state.StartTimer(Now);
|
||||
|
||||
state.GotoPage(2);
|
||||
|
||||
Assert.Equal(0d, state.View.RemainingTimerSeconds(Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheSaveSnapshotFoldsInTheLiveCountdown()
|
||||
{
|
||||
// The page record holds the value at START; what belongs in the file
|
||||
// is what is left NOW, or a reload would resurrect the full duration.
|
||||
using RuntimeJournalState state = WithPages("a");
|
||||
state.SetTimer(0, 1, 0);
|
||||
state.StartTimer(Now);
|
||||
|
||||
IReadOnlyList<JournalPage> saved = state.CaptureForSave(Now.AddMinutes(30));
|
||||
|
||||
Assert.Equal(1800d, Assert.Single(saved).RunningTimerSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkSavedClearsTheDirtyFlag()
|
||||
{
|
||||
using RuntimeJournalState state = WithPages("a");
|
||||
state.NewPage();
|
||||
Assert.True(state.IsDirty);
|
||||
|
||||
state.MarkSaved();
|
||||
|
||||
Assert.False(state.IsDirty);
|
||||
}
|
||||
|
||||
// ── lifetime ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ResetSessionClearsBecauseTheJournalIsPerCharacter()
|
||||
{
|
||||
// Carrying it across a reconnect would show one character another's
|
||||
// notes.
|
||||
using RuntimeJournalState state = WithPages("a", "b");
|
||||
|
||||
state.ResetSession();
|
||||
|
||||
Assert.Equal(0, state.View.Snapshot.PageCount);
|
||||
Assert.Equal(0, state.View.Snapshot.CurrentPage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryMutationAdvancesTheRevision()
|
||||
{
|
||||
using RuntimeJournalState state = WithPages("a");
|
||||
long start = state.View.Snapshot.Revision;
|
||||
|
||||
state.NewPage();
|
||||
long afterNew = state.View.Snapshot.Revision;
|
||||
state.UpdateCurrent("x", "y", "z");
|
||||
|
||||
Assert.True(afterNew > start);
|
||||
Assert.True(state.View.Snapshot.Revision > afterNew);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OwnershipConvergesOnlyAfterDisposal()
|
||||
{
|
||||
RuntimeJournalState state = WithPages("a");
|
||||
Assert.False(state.CaptureOwnership().IsConverged);
|
||||
|
||||
state.Dispose();
|
||||
|
||||
Assert.True(state.CaptureOwnership().IsConverged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MutationsAfterDisposalAreIgnoredRatherThanThrowing()
|
||||
{
|
||||
RuntimeJournalState state = WithPages("a");
|
||||
state.Dispose();
|
||||
|
||||
state.NewPage();
|
||||
state.UpdateCurrent("x", "y", "z");
|
||||
state.Load([new JournalPage(Label: "b")]);
|
||||
|
||||
Assert.True(state.CaptureOwnership().IsConverged);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue