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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue