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