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:
Erik 2026-08-21 15:31:47 +02:00
parent 45c964dbf0
commit 536d17456d
8 changed files with 1198 additions and 16 deletions

View file

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