feat(runtime): FA2 -- RuntimeFellowshipState + RuntimeAllegianceState sibling J-owners

Two new sibling Runtime owners under GameRuntime per the Slice-J
pattern (D2): RuntimeFellowshipState is session-scoped (a new
RuntimeGenerationResetStage.Fellowship clears it at every generation
reset, matching the ExternalContainer precedent); RuntimeAllegianceState
survives reconnect behind a HasServerSeed-style one-way latch and
participates in NO reset stage (its data persists like a real
disconnect does not sever allegiance membership).

Fellowship: full-update REPLACE, incremental-fellow UPSERT, self-vs-
other quit/dismiss removal (self clears the whole snapshot), disband
clear, and retail's leader hand-off rule for the Quit button
(RequiresLeaderHandoffBeforeQuit -- the current leader quitting WITHOUT
disbanding must send 0x0290 AssignNewLeader before 0x00A3, lane B
§2.5/§3.6).

Allegiance: seeded by AllegianceUpdate (0x0020, always self) and,
self-gated on TargetGuid == playerGuid(), by AllegianceInfoResponse
(0x027C); wraps the FA1-assembled flat AllegianceMemberRecord list
directly (AllegianceTree was deleted at FA1 -- nothing left to wrap).

Both apply the full 8-edit J-owner template: construction + fault
points + Owner/View properties + CaptureOwnership + a new
GameRuntimeTeardownStage pair (FellowshipDisposed/AllegianceDisposed,
stage count 11->13) + RuntimeGameplayOwnershipSnapshot inclusion +
RuntimeStateCheckpoint/trace fields. IRuntimeFellowshipCommands/
IRuntimeAllegianceCommands added to IGameRuntimeCommands and
implemented on DirectGameRuntimeCommandAdapter. No IRuntimeEventObserver
member added (D2) -- consumers poll Snapshot.Revision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 01:31:54 +02:00
parent 1c40104896
commit 369729f06a
19 changed files with 1949 additions and 35 deletions

View file

@ -0,0 +1,309 @@
using AcDream.Core.Net.Messages;
namespace AcDream.Runtime.Gameplay;
public readonly record struct RuntimeAllegianceOwnershipSnapshot(
bool IsDisposed,
bool HasProfile,
int RecordCount)
{
public bool IsConverged =>
IsDisposed
&& !HasProfile
&& RecordCount == 0;
}
/// <summary>
/// Canonical presentation-independent owner for the local player's
/// allegiance profile — Campaign FA slice FA2 (2026-08-12). Survives
/// reconnect (unlike <see cref="RuntimeFellowshipState"/>, which is
/// session-scoped): it is NOT a
/// <see cref="RuntimeGenerationReset"/> stage, and its data persists across
/// a generation boundary the same way a re-login does not sever your
/// character's allegiance membership. The <see cref="HasServerSeed"/>
/// latch is a <see cref="RuntimeCharacterOptionsState.HasServerSeed"/>-
/// style one-way flag distinguishing "no profile has ever arrived" from
/// "genuinely no allegiance" (a real push with a null monarch) — it only
/// clears at terminal <see cref="Dispose"/>.
///
/// <para>
/// Seeded by <c>0x0020 AllegianceUpdate</c> (the unsolicited/subscribed
/// push — always about the local player's own tree) and, self-gated, by
/// <c>0x027C AllegianceInfoResponse</c> when the response's TargetGuid is
/// the local player's own guid (an explicit <c>@allegiance info</c>
/// self-query; a by-name query against another player's tree is NOT
/// applied here — see <see cref="GameEventWiring"/>'s registration).
/// Wraps <c>ClientCommandResponses.AllegianceMemberRecord</c> — the
/// FA1-assembled flat vassal list + monarch/patron/self blocks
/// (<c>AllegianceTree</c> was DELETED at FA1; there is nothing left to
/// wrap — see the seam-map addendum,
/// docs/research/2026-08-11-fa-acdream-seams.md §8).
/// </para>
/// </summary>
public sealed class RuntimeAllegianceState : IDisposable
{
private readonly object _gate = new();
private ClientCommandResponses.AllegianceMemberRecord? _monarch;
private IReadOnlyList<ClientCommandResponses.AllegianceMemberRecord> _records =
[];
private string _allegianceName = string.Empty;
private uint _totalMembers;
private uint _totalVassals;
private uint _rank;
private bool _hasProfile;
private bool _hasServerSeed;
private long _revision;
private bool _disposed;
public RuntimeAllegianceState() => View = new AllegianceView(this);
public IRuntimeAllegianceView View { get; }
public bool IsDisposed
{
get { lock (_gate) return _disposed; }
}
/// <summary>
/// Has any real allegiance push landed since construction? A one-way
/// latch — see the class doc. Never cleared by <see cref="ResetSession"/>
/// wiring (this owner has none); only <see cref="Dispose"/> clears it.
/// </summary>
public bool HasServerSeed
{
get { lock (_gate) return _hasServerSeed; }
}
/// <summary><c>0x0020 AllegianceUpdate</c> — the unsolicited/subscribed profile push.</summary>
public void ApplyUpdate(ClientCommandResponses.AllegianceUpdate update)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
lock (_gate)
{
_monarch = update.Monarch;
_records = update.Records;
_allegianceName = update.AllegianceName;
_totalMembers = update.TotalMembers;
_totalVassals = update.TotalVassals;
_rank = update.Rank;
_hasProfile = true;
_hasServerSeed = true;
Bump();
}
}
/// <summary>
/// <c>0x027C AllegianceInfoResponse</c>, self-gated by the caller
/// (<see cref="GameEventWiring"/>'s registration checks
/// <c>TargetGuid == playerGuid()</c> before invoking this). Carries no
/// rank field on the wire — the last known rank (if any) is retained.
/// </summary>
public void ApplyInfoResponseSelf(
ClientCommandResponses.AllegianceInfoResponse response)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
lock (_gate)
{
_monarch = response.Monarch;
_records = response.Records;
_allegianceName = response.AllegianceName;
_totalMembers = response.TotalMembers;
_totalVassals = response.TotalVassals;
_hasProfile = true;
_hasServerSeed = true;
Bump();
}
}
/// <summary>
/// <c>0x027A AllegianceLoginNotification</c> — bumps the revision so a
/// polling consumer can observe the event happened; the retail-faithful
/// two-line chat text this notice carries is NOT emitted here. Its
/// literal retail string could not be verified from primary source
/// (<c>gmAllegianceUI::RecvNotice_AllegianceLogin @0x00492220</c>
/// resolves its two candidate strings through Binary Ninja symbols that
/// collide with unrelated vtable slot names — a DAT string-table
/// lookup is needed before this can be added faithfully; see FA2's
/// final report). Deliberately does not gate on the "already known"
/// filter retail itself applies (lane C §1.6) — that is a display-time
/// concern for the text this owner does not yet produce.
/// </summary>
public void ApplyLoginNotification(uint characterGuid, bool isLoggedIn)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
lock (_gate) Bump();
}
/// <summary><c>0x01C8 AllegianceUpdateDone</c> — clears the panel busy latch; carries the WeenieError for a failed swear/break.</summary>
public void ApplyUpdateDone(uint weenieError)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
lock (_gate) Bump();
}
/// <summary><c>0x0003 AllegianceUpdateAborted</c> — declared by retail but never actually sent by ACE; parsed for forward-compat.</summary>
public void ApplyUpdateAborted(uint weenieError)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
lock (_gate) Bump();
}
public RuntimeAllegianceOwnershipSnapshot CaptureOwnership()
{
lock (_gate)
return new RuntimeAllegianceOwnershipSnapshot(
_disposed,
_hasProfile,
_records.Count);
}
public void Dispose()
{
lock (_gate)
{
if (_disposed) return;
_monarch = null;
_records = [];
_allegianceName = string.Empty;
_totalMembers = 0u;
_totalVassals = 0u;
_rank = 0u;
_hasProfile = false;
_hasServerSeed = false;
_disposed = true;
}
}
private void Bump() => _revision++;
private sealed class AllegianceView(RuntimeAllegianceState owner)
: IRuntimeAllegianceView
{
public RuntimeAllegianceSnapshot Snapshot
{
get
{
lock (owner._gate)
return new RuntimeAllegianceSnapshot(
owner._revision,
owner._hasServerSeed,
owner._hasProfile,
owner._rank,
owner._totalMembers,
owner._totalVassals,
owner._allegianceName,
owner._monarch?.CharacterId ?? 0u,
owner._records.Count);
}
}
public bool TryGetMonarch(out RuntimeAllegianceMemberSnapshot monarch)
{
lock (owner._gate)
{
if (owner._monarch is not { } record)
{
monarch = default;
return false;
}
monarch = ToSnapshot(record);
return true;
}
}
public bool TryGetMember(uint guid, out RuntimeAllegianceMemberSnapshot member)
{
lock (owner._gate)
{
if (owner._monarch is { } monarch && monarch.CharacterId == guid)
{
member = ToSnapshot(monarch);
return true;
}
foreach (ClientCommandResponses.AllegianceMemberRecord record in owner._records)
{
if (record.CharacterId != guid) continue;
member = ToSnapshot(record);
return true;
}
member = default;
return false;
}
}
public bool TryGetPatron(uint guid, out RuntimeAllegianceMemberSnapshot patron)
{
lock (owner._gate)
{
if (owner._monarch is { } monarch && monarch.CharacterId == guid)
{
// Port of AllegianceProfile::GetPatron: the monarch has no patron.
patron = default;
return false;
}
foreach (ClientCommandResponses.AllegianceMemberRecord record in owner._records)
{
if (record.CharacterId != guid) continue;
return TryGetMemberLocked(record.ParentGuid, out patron);
}
patron = default;
return false;
}
}
public IEnumerable<RuntimeAllegianceMemberSnapshot> GetVassals(uint guid)
{
List<RuntimeAllegianceMemberSnapshot> result;
lock (owner._gate)
{
// Lane C §4.4 point 3: each new record is PREPENDED to its
// parent's vassal list on assembly, so the walk visits
// siblings in REVERSE wire order.
result = new List<RuntimeAllegianceMemberSnapshot>();
for (int i = owner._records.Count - 1; i >= 0; i--)
{
ClientCommandResponses.AllegianceMemberRecord record = owner._records[i];
if (record.ParentGuid == guid)
result.Add(ToSnapshot(record));
}
}
return result;
}
private bool TryGetMemberLocked(
uint guid,
out RuntimeAllegianceMemberSnapshot member)
{
if (owner._monarch is { } monarch && monarch.CharacterId == guid)
{
member = ToSnapshot(monarch);
return true;
}
foreach (ClientCommandResponses.AllegianceMemberRecord record in owner._records)
{
if (record.CharacterId != guid) continue;
member = ToSnapshot(record);
return true;
}
member = default;
return false;
}
private static RuntimeAllegianceMemberSnapshot ToSnapshot(
ClientCommandResponses.AllegianceMemberRecord record) =>
new(
record.CharacterId,
record.ParentGuid,
record.IsLoggedIn,
record.Name,
record.Rank,
record.Level,
record.Loyalty,
record.Leadership,
record.CpCached,
record.CpTithed,
record.Gender,
record.HeritageGroup,
record.MayPassupExperience);
}
}