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:
parent
1c40104896
commit
369729f06a
19 changed files with 1949 additions and 35 deletions
309
src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs
Normal file
309
src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
295
src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs
Normal file
295
src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
public readonly record struct RuntimeFellowshipOwnershipSnapshot(
|
||||
bool IsDisposed,
|
||||
bool IsInFellowship,
|
||||
int MemberCount)
|
||||
{
|
||||
public bool IsConverged =>
|
||||
IsDisposed
|
||||
&& !IsInFellowship
|
||||
&& MemberCount == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical presentation-independent owner for the local player's
|
||||
/// fellowship roster — Campaign FA slice FA2 (2026-08-12). Session-scoped:
|
||||
/// a disconnect drops you from the fellowship server-side, so this clears
|
||||
/// at generation reset exactly like the external-container precedent
|
||||
/// (<see cref="RuntimeInventoryState.ResetExternalContainer"/>), unlike
|
||||
/// <see cref="RuntimeAllegianceState"/> which survives reconnect
|
||||
/// (docs/research/2026-08-11-fa-acdream-seams.md §1.3).
|
||||
///
|
||||
/// <para>
|
||||
/// Assembled from the FA1 parsers: a full update (<c>0x02BE</c>) REPLACES
|
||||
/// the whole roster; an incremental fellow update (<c>0x02C0</c>) upserts
|
||||
/// one member by guid; a quit/dismiss notice (<c>0x00A3</c>/<c>0x00A4</c>)
|
||||
/// removes one member, or clears the whole snapshot when the removed guid
|
||||
/// is the local player's own (self-removal); a disband (<c>0x02BF</c>)
|
||||
/// always clears. Every mutation bumps <see cref="View"/>'s monotonic
|
||||
/// <c>Snapshot.Revision</c> — consumers (bots, future panel UI) poll it
|
||||
/// rather than subscribing to a push event (D2 — no
|
||||
/// <see cref="IRuntimeEventObserver"/> member).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RuntimeFellowshipState : IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<uint, GameEvents.FellowMember> _members = [];
|
||||
private string _name = string.Empty;
|
||||
private uint _leaderGuid;
|
||||
private bool _shareXp;
|
||||
private bool _evenXpSplit;
|
||||
private bool _isOpen;
|
||||
private bool _locked;
|
||||
private bool _isInFellowship;
|
||||
private long _revision;
|
||||
private bool _disposed;
|
||||
|
||||
public RuntimeFellowshipState() => View = new FellowshipView(this);
|
||||
|
||||
public IRuntimeFellowshipView View { get; }
|
||||
|
||||
public bool IsDisposed
|
||||
{
|
||||
get { lock (_gate) return _disposed; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x02BE FellowshipFullUpdate</c> — a complete authoritative
|
||||
/// replace of the roster and fellowship-level flags (name, leader,
|
||||
/// shareXp/evenXpSplit/open/locked). Fires on join, on recruit success
|
||||
/// (both the recruiter's and the recruit's own client), and whenever
|
||||
/// ACE decides a full resync is cheaper than an incremental.
|
||||
/// </summary>
|
||||
public void ApplyFullUpdate(GameEvents.FellowshipFullUpdate update)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||
lock (_gate)
|
||||
{
|
||||
_members.Clear();
|
||||
foreach (GameEvents.FellowMember member in update.Members)
|
||||
_members[member.Guid] = member;
|
||||
_name = update.Name;
|
||||
_leaderGuid = update.LeaderGuid;
|
||||
_shareXp = update.ShareXp;
|
||||
_evenXpSplit = update.EvenXpSplit;
|
||||
_isOpen = update.OpenFellow;
|
||||
_locked = update.Locked;
|
||||
_isInFellowship = true;
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x02C0 FellowshipUpdateFellow</c> — upserts exactly one member by
|
||||
/// guid (vitals/level/name refresh). A no-op before any full update has
|
||||
/// ever established that the local player IS in a fellowship — retail
|
||||
/// never sends this to a client outside one either.
|
||||
/// </summary>
|
||||
public void ApplyUpdateFellow(GameEvents.FellowshipUpdateFellow update)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_isInFellowship) return;
|
||||
_members[update.MemberGuid] = update.Member;
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x00A3 FellowshipQuit</c> (S→C direction) — sent both to the
|
||||
/// quitter and to every remaining member. Self-removal (<paramref
|
||||
/// name="quitterGuid"/> == <paramref name="selfGuid"/>) clears the
|
||||
/// whole snapshot; otherwise the named member is removed from the
|
||||
/// roster.
|
||||
/// </summary>
|
||||
public void ApplyQuit(uint quitterGuid, uint selfGuid)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_isInFellowship) return;
|
||||
if (quitterGuid == selfGuid)
|
||||
{
|
||||
ClearLocked();
|
||||
return;
|
||||
}
|
||||
if (_members.Remove(quitterGuid))
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x00A4 FellowshipDismiss</c> (S→C direction) — same self-vs-other
|
||||
/// removal rule as <see cref="ApplyQuit"/>.
|
||||
/// </summary>
|
||||
public void ApplyDismiss(uint dismissedGuid, uint selfGuid)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_isInFellowship) return;
|
||||
if (dismissedGuid == selfGuid)
|
||||
{
|
||||
ClearLocked();
|
||||
return;
|
||||
}
|
||||
if (_members.Remove(dismissedGuid))
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary><c>0x02BF FellowshipDisband</c> — always clears.</summary>
|
||||
public void ApplyDisband()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||
lock (_gate) ClearLocked();
|
||||
}
|
||||
|
||||
/// <summary>The local player's own current leader guid, or 0 when not in a fellowship.</summary>
|
||||
public uint LeaderGuid
|
||||
{
|
||||
get { lock (_gate) return _isInFellowship ? _leaderGuid : 0u; }
|
||||
}
|
||||
|
||||
public bool IsInFellowship
|
||||
{
|
||||
get { lock (_gate) return _isInFellowship; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's Quit-button leader hand-off rule (lane B §2.5/§3.6): the
|
||||
/// current leader quitting WITHOUT disbanding must first assign
|
||||
/// leadership to any other fellow. Returns <see langword="true"/> with
|
||||
/// a candidate guid exactly when that hand-off is required; the caller
|
||||
/// (the command adapters) sends <c>0x0290</c> before <c>0x00A3</c> when
|
||||
/// this returns true.
|
||||
/// </summary>
|
||||
public bool RequiresLeaderHandoffBeforeQuit(
|
||||
uint selfGuid,
|
||||
bool disband,
|
||||
out uint newLeaderGuid)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (disband || !_isInFellowship || _leaderGuid != selfGuid)
|
||||
{
|
||||
newLeaderGuid = 0u;
|
||||
return false;
|
||||
}
|
||||
foreach (uint guid in _members.Keys)
|
||||
{
|
||||
if (guid == selfGuid) continue;
|
||||
newLeaderGuid = guid;
|
||||
return true;
|
||||
}
|
||||
newLeaderGuid = 0u;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public RuntimeFellowshipOwnershipSnapshot CaptureOwnership()
|
||||
{
|
||||
lock (_gate)
|
||||
return new RuntimeFellowshipOwnershipSnapshot(
|
||||
_disposed,
|
||||
_isInFellowship,
|
||||
_members.Count);
|
||||
}
|
||||
|
||||
/// <summary>Session-scoped: cleared at every generation reset (reconnect).</summary>
|
||||
public void ResetSession()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||
lock (_gate) ClearLocked();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed) return;
|
||||
ClearLocked();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearLocked()
|
||||
{
|
||||
bool changed = _members.Count != 0
|
||||
|| _isInFellowship
|
||||
|| _name.Length != 0
|
||||
|| _leaderGuid != 0u
|
||||
|| _shareXp
|
||||
|| _evenXpSplit
|
||||
|| _isOpen
|
||||
|| _locked;
|
||||
_members.Clear();
|
||||
_name = string.Empty;
|
||||
_leaderGuid = 0u;
|
||||
_shareXp = false;
|
||||
_evenXpSplit = false;
|
||||
_isOpen = false;
|
||||
_locked = false;
|
||||
_isInFellowship = false;
|
||||
if (changed) Bump();
|
||||
}
|
||||
|
||||
private void Bump() => _revision++;
|
||||
|
||||
private sealed class FellowshipView(RuntimeFellowshipState owner)
|
||||
: IRuntimeFellowshipView
|
||||
{
|
||||
public RuntimeFellowshipSnapshot Snapshot
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (owner._gate)
|
||||
return new RuntimeFellowshipSnapshot(
|
||||
owner._revision,
|
||||
owner._isInFellowship,
|
||||
owner._name,
|
||||
owner._leaderGuid,
|
||||
owner._shareXp,
|
||||
owner._evenXpSplit,
|
||||
owner._isOpen,
|
||||
owner._locked,
|
||||
owner._members.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetMember(uint guid, out RuntimeFellowMemberSnapshot member)
|
||||
{
|
||||
lock (owner._gate)
|
||||
{
|
||||
if (!owner._members.TryGetValue(guid, out GameEvents.FellowMember raw))
|
||||
{
|
||||
member = default;
|
||||
return false;
|
||||
}
|
||||
member = ToSnapshot(raw);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static RuntimeFellowMemberSnapshot ToSnapshot(
|
||||
GameEvents.FellowMember raw) =>
|
||||
new(
|
||||
raw.Guid,
|
||||
raw.Name,
|
||||
raw.Level,
|
||||
raw.MaxHealth,
|
||||
raw.MaxStamina,
|
||||
raw.MaxMana,
|
||||
raw.CurrentHealth,
|
||||
raw.CurrentStamina,
|
||||
raw.CurrentMana,
|
||||
// D5 (lane B §4.1): the raw wire u32 is != 0, NEVER == 1 —
|
||||
// ACE encodes it two mutually-inconsistent ways.
|
||||
raw.ShareLoot != 0u);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,14 +10,19 @@ public readonly record struct RuntimeGameplayOwnershipSnapshot(
|
|||
RuntimeCharacterOwnershipSnapshot Character,
|
||||
RuntimeCommunicationOwnershipSnapshot Communication,
|
||||
RuntimeActionOwnershipSnapshot Actions,
|
||||
RuntimeLocalMovementOwnershipSnapshot Movement)
|
||||
RuntimeLocalMovementOwnershipSnapshot Movement,
|
||||
// Campaign FA slice FA2 (2026-08-12): the two sibling J-owners.
|
||||
RuntimeFellowshipOwnershipSnapshot Fellowship,
|
||||
RuntimeAllegianceOwnershipSnapshot Allegiance)
|
||||
{
|
||||
public bool IsConverged =>
|
||||
Inventory.IsConverged
|
||||
&& Character.IsConverged
|
||||
&& Communication.IsConverged
|
||||
&& Actions.IsConverged
|
||||
&& Movement.IsConverged;
|
||||
&& Movement.IsConverged
|
||||
&& Fellowship.IsConverged
|
||||
&& Allegiance.IsConverged;
|
||||
}
|
||||
|
||||
public static class RuntimeGameplayOwnership
|
||||
|
|
@ -27,18 +32,24 @@ public static class RuntimeGameplayOwnership
|
|||
RuntimeCharacterState character,
|
||||
RuntimeCommunicationState communication,
|
||||
RuntimeActionState actions,
|
||||
RuntimeLocalPlayerMovementState movement)
|
||||
RuntimeLocalPlayerMovementState movement,
|
||||
RuntimeFellowshipState fellowship,
|
||||
RuntimeAllegianceState allegiance)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inventory);
|
||||
ArgumentNullException.ThrowIfNull(character);
|
||||
ArgumentNullException.ThrowIfNull(communication);
|
||||
ArgumentNullException.ThrowIfNull(actions);
|
||||
ArgumentNullException.ThrowIfNull(movement);
|
||||
ArgumentNullException.ThrowIfNull(fellowship);
|
||||
ArgumentNullException.ThrowIfNull(allegiance);
|
||||
return new RuntimeGameplayOwnershipSnapshot(
|
||||
inventory.CaptureOwnership(),
|
||||
character.CaptureOwnership(),
|
||||
communication.CaptureOwnership(),
|
||||
actions.CaptureOwnership(),
|
||||
movement.CaptureOwnership());
|
||||
movement.CaptureOwnership(),
|
||||
fellowship.CaptureOwnership(),
|
||||
allegiance.CaptureOwnership());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue