acdream/src/AcDream.Runtime/RuntimeGenerationReset.cs
Erik 536d17456d 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>
2026-08-21 15:31:47 +02:00

506 lines
20 KiB
C#

using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.World;
namespace AcDream.Runtime;
/// <summary>
/// The only host-specific edge in a character-generation reset. The host
/// retires presentation borrowed from an exact Runtime incarnation; Runtime
/// retains the retirement set, cursor, canonical cleanup, and generation.
/// </summary>
public interface IRuntimeGenerationResetHost
{
void RetireEntityProjection(RuntimeEntityRecord entity);
void DrainEntityProjectionBoundary();
void CompleteEntityProjectionRetirement();
}
public enum RuntimeGenerationResetStage
{
None = 0,
Transit = 1,
CommandTargets = 2,
ExternalContainer = 3,
Actions = 4,
Movement = 5,
ObjectTable = 6,
Character = 7,
ItemMana = 8,
Friends = 9,
Squelch = 10,
NegotiatedChannels = 11,
/// <summary>
/// Campaign FA slice FA2 (2026-08-12): the fellowship roster is
/// session-scoped (a disconnect drops you from the fellowship
/// server-side) — clear it here, alongside the other social-list
/// stages.
/// </summary>
Fellowship = 12,
/// <summary>
/// FA2 fix-round MUST-FIX 1 (2026-08-12,
/// docs/research/2026-08-12-fa2-review-mechanism.md MF-1): the
/// allegiance profile is ALSO cleared here — retail's
/// <c>ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0</c>
/// tail-calls <c>AllegianceProfile::Clear</c> at exactly this
/// per-character-session boundary, and the precedent this owner cites
/// (<see cref="RuntimeCharacterOptionsState.ResetSession"/>) clears AND
/// re-latches, it does not persist. See
/// <see cref="RuntimeAllegianceState"/>'s class doc for the full
/// correction (the original "survives reconnect" design was inverted
/// from the precedent it named).
/// </summary>
Allegiance = 13,
/// <summary>
/// Secure trade (2026-08-14): the trade window is session-scoped — a
/// disconnect closes the trade server-side (ACE tears the negotiation
/// down with the session), so the third sibling J-owner clears here
/// beside its fellowship/allegiance precedents.
/// </summary>
Trade = 14,
/// <summary>
/// Batch C (Map/House toolbar panel, 2026-08-17): the House tab's
/// query result is session-scoped like fellowship/allegiance/trade
/// above — a fresh login must not show a previous character's house
/// data. See <see cref="RuntimeHouseState"/>'s class doc for why this
/// owner is lighter-weight than its three siblings (no disposal, no
/// construction-transaction Fault() point).
/// </summary>
House = 15,
/// <summary>
/// Campaign QT (2026-08-21): the contract tracker is a projection of
/// server state and nothing else — the retail client stores no quest
/// state of its own. A fresh session opens with a full 0x0314
/// replacement, so clearing here cannot lose anything the next login
/// will not immediately restate, while NOT clearing would show a
/// previous character's quests.
/// </summary>
Contracts = 16,
/// <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(
bool IsActive,
bool IsExecuting,
RuntimeGenerationToken RetiringGeneration,
RuntimeGenerationToken LastCompletedGeneration,
RuntimeGenerationResetStage Stage,
int RetirementCount,
int RetirementCursor,
bool CurrentProjectionAcknowledged,
long TransactionId)
{
public bool IsConverged =>
!IsActive
&& !IsExecuting;
}
public sealed class RuntimeGenerationResetStageException(
RuntimeGenerationToken retiringGeneration,
RuntimeGenerationResetStage stage,
Exception innerException) : Exception(
$"Runtime generation {retiringGeneration.Value} reset stage "
+ $"'{stage}' did not converge.",
innerException)
{
public RuntimeGenerationToken RetiringGeneration { get; } =
retiringGeneration;
public RuntimeGenerationResetStage Stage { get; } = stage;
}
/// <summary>
/// One persisted, retryable reset transaction for every canonical owner in a
/// reusable <see cref="GameRuntime"/>. A failed call retains the exact suffix:
/// completed stages and exact-incarnation retirements never replay.
/// </summary>
public sealed class RuntimeGenerationReset
{
private readonly RuntimeWorldTransitState _transit;
private readonly RuntimeCommunicationState _communication;
private readonly RuntimeInventoryState _inventory;
private readonly RuntimeActionState _actions;
private readonly RuntimeLocalPlayerMovementState _movement;
private readonly RuntimeEntityObjectLifetime _entityObjects;
private readonly RuntimeCharacterState _character;
private readonly RuntimeLocalPlayerIdentityState _identity;
private readonly RuntimeFellowshipState _fellowship;
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;
private bool _hasCompletedGeneration;
private bool _executing;
private long _nextTransactionId;
internal RuntimeGenerationReset(
RuntimeWorldTransitState transit,
RuntimeCommunicationState communication,
RuntimeInventoryState inventory,
RuntimeActionState actions,
RuntimeLocalPlayerMovementState movement,
RuntimeEntityObjectLifetime entityObjects,
RuntimeCharacterState character,
RuntimeLocalPlayerIdentityState identity,
RuntimeFellowshipState fellowship,
RuntimeAllegianceState allegiance,
RuntimeTradeState trade,
RuntimeHouseState house,
RuntimeContractState contracts,
RuntimeJournalState journal)
{
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
_communication = communication
?? throw new ArgumentNullException(nameof(communication));
_inventory = inventory
?? throw new ArgumentNullException(nameof(inventory));
_actions = actions ?? throw new ArgumentNullException(nameof(actions));
_movement = movement
?? throw new ArgumentNullException(nameof(movement));
_entityObjects = entityObjects
?? throw new ArgumentNullException(nameof(entityObjects));
_character = character
?? throw new ArgumentNullException(nameof(character));
_identity = identity
?? throw new ArgumentNullException(nameof(identity));
_fellowship = fellowship
?? throw new ArgumentNullException(nameof(fellowship));
_allegiance = allegiance
?? throw new ArgumentNullException(nameof(allegiance));
_trade = trade ?? throw new ArgumentNullException(nameof(trade));
_house = house ?? throw new ArgumentNullException(nameof(house));
_contracts = contracts
?? throw new ArgumentNullException(nameof(contracts));
_journal = journal ?? throw new ArgumentNullException(nameof(journal));
}
public RuntimeGenerationToken? ActiveRetiringGeneration =>
_state?.Generation;
public RuntimeGenerationResetSnapshot CaptureSnapshot()
{
ResetState? state = _state;
return state is null
? new RuntimeGenerationResetSnapshot(
false,
_executing,
default,
_lastCompletedGeneration,
_hasCompletedGeneration
? RuntimeGenerationResetStage.Complete
: RuntimeGenerationResetStage.None,
0,
0,
false,
0)
: new RuntimeGenerationResetSnapshot(
true,
_executing,
state.Generation,
_lastCompletedGeneration,
state.Stage,
state.Retirements?.Length ?? 0,
state.RetirementCursor,
state.CurrentProjectionAcknowledged,
state.TransactionId);
}
public void Reset(
RuntimeGenerationToken retiringGeneration,
IRuntimeGenerationResetHost host)
{
ArgumentNullException.ThrowIfNull(host);
if (_executing)
{
throw new InvalidOperationException(
"Runtime generation reset cannot run concurrently or reentrantly.");
}
ResetState state = AcquireState(retiringGeneration, host);
if (state.Stage is RuntimeGenerationResetStage.Complete)
return;
_executing = true;
try
{
Drain(state);
}
catch (Exception error)
{
throw new RuntimeGenerationResetStageException(
state.Generation,
state.Stage,
error);
}
finally
{
_executing = false;
}
}
internal void DrainPending()
{
ResetState? state = _state;
if (state is null)
return;
Reset(state.Generation, state.Host);
}
private ResetState AcquireState(
RuntimeGenerationToken generation,
IRuntimeGenerationResetHost host)
{
if (_state is { } pending)
{
if (pending.Generation != generation)
{
throw new InvalidOperationException(
$"Runtime generation {pending.Generation.Value} reset "
+ $"must converge before generation {generation.Value} can reset.");
}
if (!ReferenceEquals(pending.Host, host))
{
throw new InvalidOperationException(
"An in-progress Runtime generation reset cannot replace "
+ "its borrowed projection host.");
}
return pending;
}
if (_hasCompletedGeneration)
{
if (generation == _lastCompletedGeneration)
return ResetState.Completed(generation, host);
if (generation.Value < _lastCompletedGeneration.Value)
{
throw new InvalidOperationException(
$"Runtime generation {generation.Value} reset is stale; "
+ $"generation {_lastCompletedGeneration.Value} already converged.");
}
}
var created = new ResetState(
generation,
host,
checked(++_nextTransactionId));
_state = created;
return created;
}
private void Drain(ResetState state)
{
while (state.Stage is not RuntimeGenerationResetStage.Complete)
{
switch (state.Stage)
{
case RuntimeGenerationResetStage.Transit:
Advance(state, _transit.ResetSession);
break;
case RuntimeGenerationResetStage.CommandTargets:
Advance(state, _communication.ResetCommandTargets);
break;
case RuntimeGenerationResetStage.ExternalContainer:
// Slice 5.3: the vendor browse session shares the
// external-container stage rather than claiming a new
// enum ordinal — both are client-local "open server
// object" sessions torn down uniformly at session
// reset/portal-out/logout (research doc §C.2's
// "generation/lifecycle contract every other J4/J5 child
// follows").
Advance(state, () =>
{
_inventory.ResetExternalContainer();
_inventory.ResetVendor();
});
break;
case RuntimeGenerationResetStage.Actions:
Advance(state, _actions.ResetSession);
break;
case RuntimeGenerationResetStage.Movement:
Advance(state, _movement.ResetSession);
break;
case RuntimeGenerationResetStage.ObjectTable:
Advance(state, _entityObjects.ClearObjects);
break;
case RuntimeGenerationResetStage.Character:
Advance(state, _character.ResetSession);
break;
case RuntimeGenerationResetStage.ItemMana:
Advance(state, _inventory.ResetItemMana);
break;
case RuntimeGenerationResetStage.Friends:
Advance(state, _communication.ResetFriends);
break;
case RuntimeGenerationResetStage.Squelch:
Advance(state, _communication.ResetSquelch);
break;
case RuntimeGenerationResetStage.NegotiatedChannels:
Advance(
state,
_communication.ResetNegotiatedChannels);
break;
case RuntimeGenerationResetStage.Fellowship:
Advance(state, _fellowship.ResetSession);
break;
case RuntimeGenerationResetStage.Allegiance:
Advance(state, _allegiance.ResetSession);
break;
case RuntimeGenerationResetStage.Trade:
Advance(state, _trade.Clear);
break;
case RuntimeGenerationResetStage.House:
Advance(state, _house.ResetSession);
break;
case RuntimeGenerationResetStage.Contracts:
Advance(state, _contracts.ResetSession);
break;
case RuntimeGenerationResetStage.Journal:
Advance(state, _journal.ResetSession);
break;
case RuntimeGenerationResetStage.BeginEntityRetirement:
_ = _entityObjects.BeginSessionClear();
state.Retirements = _entityObjects
.CaptureSessionClearRetirements()
.ToArray();
state.Stage = RuntimeGenerationResetStage.RetireEntities;
break;
case RuntimeGenerationResetStage.RetireEntities:
RetireCurrentEntity(state);
break;
case RuntimeGenerationResetStage.DrainHostProjection:
state.Host.DrainEntityProjectionBoundary();
state.Stage =
RuntimeGenerationResetStage.CompleteCanonicalEntities;
break;
case RuntimeGenerationResetStage.CompleteCanonicalEntities:
if (!_entityObjects.CompleteSessionClearIfConverged())
{
throw new InvalidOperationException(
"Canonical entity/object lifetime still owns an "
+ "unacknowledged session retirement.");
}
state.Stage =
RuntimeGenerationResetStage.CompleteHostProjection;
break;
case RuntimeGenerationResetStage.CompleteHostProjection:
state.Host.CompleteEntityProjectionRetirement();
state.Stage = RuntimeGenerationResetStage.ChatIdentity;
break;
case RuntimeGenerationResetStage.ChatIdentity:
// CH2 REJECT-review rework (SHOULD-FIX 1,
// docs/research/2026-08-09-ch2-review-findings.md):
// RuntimeCommunicationState.ResetSpewBox was dead code —
// no caller reset the transient SpewBox queue at
// generation boundaries even though ResetChatIdentity
// (the chat transcript's identity/dedup reset) already
// ran here every generation. They share this stage
// because they're the same lifetime boundary — a fresh
// generation must not resurrect a stale refusal line —
// even though they differ in WHAT they reset:
// ResetChatIdentity preserves the visible transcript,
// ResetSpewBox clears it (see RuntimeCommunicationState's
// own doc comments on each).
Advance(state, () =>
{
_communication.ResetChatIdentity();
_communication.ResetSpewBox();
});
break;
case RuntimeGenerationResetStage.PlayerSnapshots:
Advance(state, _inventory.ResetPlayerSnapshots);
break;
case RuntimeGenerationResetStage.PlayerIdentity:
_identity.ResetSession();
Complete(state);
break;
default:
throw new InvalidOperationException(
$"Unsupported Runtime generation reset stage {state.Stage}.");
}
}
}
private void RetireCurrentEntity(ResetState state)
{
RuntimeEntityRecord[] retirements = state.Retirements ?? [];
if (state.RetirementCursor >= retirements.Length)
{
state.Stage = RuntimeGenerationResetStage.DrainHostProjection;
return;
}
RuntimeEntityRecord current = retirements[state.RetirementCursor];
if (!state.CurrentProjectionAcknowledged)
{
state.Host.RetireEntityProjection(current);
state.CurrentProjectionAcknowledged = true;
}
_entityObjects.CompleteSessionEntityRetirement(current);
state.CurrentProjectionAcknowledged = false;
state.RetirementCursor++;
}
private static void Advance(ResetState state, Action action)
{
action();
state.Stage++;
}
private void Complete(ResetState state)
{
state.Stage = RuntimeGenerationResetStage.Complete;
_lastCompletedGeneration = state.Generation;
_hasCompletedGeneration = true;
_state = null;
}
private sealed class ResetState
{
public ResetState(
RuntimeGenerationToken generation,
IRuntimeGenerationResetHost host,
long transactionId)
{
Generation = generation;
Host = host;
TransactionId = transactionId;
Stage = RuntimeGenerationResetStage.Transit;
}
public RuntimeGenerationToken Generation { get; }
public IRuntimeGenerationResetHost Host { get; }
public long TransactionId { get; }
public RuntimeGenerationResetStage Stage { get; set; }
public RuntimeEntityRecord[]? Retirements { get; set; }
public int RetirementCursor { get; set; }
public bool CurrentProjectionAcknowledged { get; set; }
public static ResetState Completed(
RuntimeGenerationToken generation,
IRuntimeGenerationResetHost host) =>
new(generation, host, 0)
{
Stage = RuntimeGenerationResetStage.Complete,
};
}
}