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

@ -34,8 +34,11 @@ public enum GameRuntimeTeardownStage
CharacterDisposed = 1 << 6,
InventoryDisposed = 1 << 7,
CommunicationDisposed = 1 << 8,
IdentityDisposed = 1 << 9,
EntityObjectsDisposed = 1 << 10,
// Campaign FA slice FA2 (2026-08-12): the two sibling J-owners.
FellowshipDisposed = 1 << 9,
AllegianceDisposed = 1 << 10,
IdentityDisposed = 1 << 11,
EntityObjectsDisposed = 1 << 12,
Complete =
HostLeasesReleased
| EventsDetached
@ -46,6 +49,8 @@ public enum GameRuntimeTeardownStage
| CharacterDisposed
| InventoryDisposed
| CommunicationDisposed
| FellowshipDisposed
| AllegianceDisposed
| IdentityDisposed
| EntityObjectsDisposed,
}
@ -87,6 +92,8 @@ internal enum GameRuntimeConstructionPoint
InventoryCreated,
CharacterCreated,
CommunicationCreated,
FellowshipCreated,
AllegianceCreated,
MovementCreated,
ActionsCreated,
EnvironmentCreated,
@ -102,6 +109,8 @@ internal sealed class GameRuntimeConstructionContext
public RuntimeInventoryState? Inventory { get; set; }
public RuntimeCharacterState? Character { get; set; }
public RuntimeCommunicationState? Communication { get; set; }
public RuntimeFellowshipState? Fellowship { get; set; }
public RuntimeAllegianceState? Allegiance { get; set; }
public RuntimeLocalPlayerMovementState? Movement { get; set; }
public RuntimeActionState? Actions { get; set; }
public GameRuntimeEventHub? Events { get; set; }
@ -117,7 +126,7 @@ public sealed class GameRuntime
IRuntimeEventSource,
IDisposable
{
private const int TeardownStageCount = 11;
private const int TeardownStageCount = 13;
private readonly object _lifetimeGate = new();
private readonly Dictionary<long, string> _hostLeases = [];
@ -213,6 +222,24 @@ public sealed class GameRuntime
context,
faultInjection);
// Campaign FA slice FA2 (2026-08-12): two sibling J-owners, not
// children of Communication — the reset semantics differ
// (fellowship is session-scoped, allegiance survives reconnect;
// docs/research/2026-08-11-fa-acdream-seams.md §1.3).
context.Fellowship = new RuntimeFellowshipState();
construction.Own(context.Fellowship);
Fault(
GameRuntimeConstructionPoint.FellowshipCreated,
context,
faultInjection);
context.Allegiance = new RuntimeAllegianceState();
construction.Own(context.Allegiance);
Fault(
GameRuntimeConstructionPoint.AllegianceCreated,
context,
faultInjection);
context.Movement = new RuntimeLocalPlayerMovementState();
// Campaign CH slice CH2: local jump refusals (CommenceJump/
// DoJump's WeenieError family — research doc §4.2/§6.4) reach
@ -265,7 +292,8 @@ public sealed class GameRuntime
context.Movement,
context.EntityObjects,
context.Character,
context.PlayerIdentity);
context.PlayerIdentity,
context.Fellowship);
context.Movement.AttachPhysicsPublication(
new RuntimeLocalPlayerPhysicsPublicationState(
@ -318,6 +346,8 @@ public sealed class GameRuntime
InventoryOwner = context.Inventory;
CharacterOwner = context.Character;
CommunicationOwner = context.Communication;
FellowshipOwner = context.Fellowship;
AllegianceOwner = context.Allegiance;
MovementOwner = context.Movement;
ActionOwner = context.Actions;
EnvironmentOwner = environment;
@ -421,6 +451,8 @@ public sealed class GameRuntime
public RuntimeInventoryState InventoryOwner { get; }
public RuntimeCharacterState CharacterOwner { get; }
public RuntimeCommunicationState CommunicationOwner { get; }
public RuntimeFellowshipState FellowshipOwner { get; }
public RuntimeAllegianceState AllegianceOwner { get; }
public RuntimeActionState ActionOwner { get; }
public RuntimeLocalPlayerMovementState MovementOwner { get; }
internal RuntimeLocalPlayerPhysicsPublicationState
@ -464,6 +496,8 @@ public sealed class GameRuntime
public IRuntimeCharacterView Character => CharacterOwner.View;
public IRuntimeSocialView Social => CommunicationOwner.SocialView;
public IRuntimeChatView Chat => CommunicationOwner.View;
public IRuntimeFellowshipView Fellowship => FellowshipOwner.View;
public IRuntimeAllegianceView Allegiance => AllegianceOwner.View;
public IRuntimeActionView Actions => ActionOwner.View;
public IRuntimeMovementView Movement => MovementOwner.View;
public IRuntimeWorldEnvironmentView Environment => EnvironmentOwner;
@ -490,7 +524,9 @@ public sealed class GameRuntime
Environment.Snapshot,
Environment.Ownership,
Portal.Snapshot,
Portal.Ownership);
Portal.Ownership,
Fellowship.Snapshot,
Allegiance.Snapshot);
public RuntimeLocalPlayerFrameController CreateLocalPlayerFrameController(
IRuntimeLocalPlayerFrameHost host,
@ -560,7 +596,9 @@ public sealed class GameRuntime
CharacterOwner,
CommunicationOwner,
ActionOwner,
MovementOwner),
MovementOwner,
FellowshipOwner,
AllegianceOwner),
EnvironmentOwner.CaptureOwnership(),
TransitOwner.CaptureOwnership(),
GenerationReset.CaptureSnapshot(),
@ -675,10 +713,24 @@ public sealed class GameRuntime
| GameRuntimeTeardownStage.MovementDisposed
| GameRuntimeTeardownStage.CharacterDisposed
| GameRuntimeTeardownStage.InventoryDisposed,
9 => GameRuntimeTeardownStage.Complete
9 => GameRuntimeTeardownStage.HostLeasesReleased
| GameRuntimeTeardownStage.EventsDetached
| GameRuntimeTeardownStage.SessionDisposed
| GameRuntimeTeardownStage.TransitReset
| GameRuntimeTeardownStage.ActionsDisposed
| GameRuntimeTeardownStage.MovementDisposed
| GameRuntimeTeardownStage.CharacterDisposed
| GameRuntimeTeardownStage.InventoryDisposed
| GameRuntimeTeardownStage.CommunicationDisposed
| GameRuntimeTeardownStage.FellowshipDisposed,
10 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.AllegianceDisposed
& ~GameRuntimeTeardownStage.IdentityDisposed
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
10 => GameRuntimeTeardownStage.Complete
11 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.IdentityDisposed
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
12 => GameRuntimeTeardownStage.Complete
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
_ => GameRuntimeTeardownStage.Complete,
};
@ -724,9 +776,15 @@ public sealed class GameRuntime
CommunicationOwner.Dispose();
return CommunicationOwner.CaptureOwnership().IsConverged;
case 9:
FellowshipOwner.Dispose();
return FellowshipOwner.CaptureOwnership().IsConverged;
case 10:
AllegianceOwner.Dispose();
return AllegianceOwner.CaptureOwnership().IsConverged;
case 11:
PlayerIdentity.Dispose();
return PlayerIdentity.CaptureOwnership().IsConverged;
case 10:
case 12:
EntityObjects.Dispose();
return EntityObjects.CaptureOwnership().IsConverged
&& EntityObjects.Physics.CaptureOwnership().IsConverged;
@ -747,8 +805,10 @@ public sealed class GameRuntime
6 => CharacterOwner.CaptureOwnership().IsConverged,
7 => InventoryOwner.CaptureOwnership().IsConverged,
8 => CommunicationOwner.CaptureOwnership().IsConverged,
9 => PlayerIdentity.CaptureOwnership().IsConverged,
10 => EntityObjects.CaptureOwnership().IsConverged
9 => FellowshipOwner.CaptureOwnership().IsConverged,
10 => AllegianceOwner.CaptureOwnership().IsConverged,
11 => PlayerIdentity.CaptureOwnership().IsConverged,
12 => EntityObjects.CaptureOwnership().IsConverged
&& EntityObjects.Physics.CaptureOwnership().IsConverged,
_ => true,
};

View file

@ -299,6 +299,76 @@ public interface IRuntimeSocialCommands
in RuntimeSquelchCommand command);
}
// ── Fellowship / Allegiance (Campaign FA slice FA2, 2026-08-12) ────────────
/// <summary>
/// Generation-gated fellowship outbound actions. <see cref="Quit"/>
/// implements retail's leader hand-off rule itself (lane B §2.5/§3.6): the
/// current leader quitting WITHOUT disbanding sends <c>0x0290</c> (assign
/// a non-leader fellow) BEFORE <c>0x00A3</c> — see
/// <c>RuntimeFellowshipState.RequiresLeaderHandoffBeforeQuit</c>.
/// <see cref="SetPanelOpen"/> is D4's <c>0x00A6</c> panel-visibility
/// declaration, the command the fellowship page's show/hide seam calls
/// (FA3+); without it ACE freezes the roster's vitals stream at join.
/// </summary>
public interface IRuntimeFellowshipCommands
{
RuntimeCommandResult Create(
RuntimeGenerationToken expectedGeneration,
string fellowshipName,
bool shareXp);
RuntimeCommandResult Recruit(
RuntimeGenerationToken expectedGeneration,
uint targetGuid);
RuntimeCommandResult Dismiss(
RuntimeGenerationToken expectedGeneration,
uint targetGuid);
RuntimeCommandResult Quit(
RuntimeGenerationToken expectedGeneration,
bool disband);
RuntimeCommandResult AssignLeader(
RuntimeGenerationToken expectedGeneration,
uint newLeaderGuid);
RuntimeCommandResult SetOpen(
RuntimeGenerationToken expectedGeneration,
bool isOpen);
RuntimeCommandResult SetPanelOpen(
RuntimeGenerationToken expectedGeneration,
bool panelOpen);
}
/// <summary>Generation-gated allegiance outbound actions.</summary>
public interface IRuntimeAllegianceCommands
{
RuntimeCommandResult Swear(
RuntimeGenerationToken expectedGeneration,
uint patronGuid);
RuntimeCommandResult Break(
RuntimeGenerationToken expectedGeneration,
uint targetGuid);
RuntimeCommandResult Kick(
RuntimeGenerationToken expectedGeneration,
uint vassalGuid);
/// <summary><c>0x027B</c> — empty name queries self.</summary>
RuntimeCommandResult RequestInfo(
RuntimeGenerationToken expectedGeneration,
string playerName);
/// <summary><c>0x001F</c> — the allegiance panel's subscribe/unsubscribe toggle.</summary>
RuntimeCommandResult SetUpdateSubscription(
RuntimeGenerationToken expectedGeneration,
bool on);
}
public interface IGameRuntimeCommands
{
IRuntimeSessionCommands Session { get; }
@ -322,4 +392,8 @@ public interface IGameRuntimeCommands
IRuntimeCharacterCommands Character { get; }
IRuntimeSocialCommands Social { get; }
IRuntimeFellowshipCommands Fellowship { get; }
IRuntimeAllegianceCommands Allegiance { get; }
}

View file

@ -21,6 +21,8 @@ public enum RuntimeCommandDomain
Character = 8,
Social = 9,
Magic = 10,
Fellowship = 11,
Allegiance = 12,
}
public readonly record struct RuntimeLifecycleDelta(
@ -179,6 +181,12 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver
$"social={checkpoint.Social.FriendsRevision}:" +
$"{checkpoint.Social.FriendCount}:" +
$"{checkpoint.Social.SquelchRevision};" +
$"fellowship={checkpoint.Fellowship.Revision}:" +
$"{checkpoint.Fellowship.IsInFellowship}:" +
$"{checkpoint.Fellowship.MemberCount};" +
$"allegiance={checkpoint.Allegiance.Revision}:" +
$"{checkpoint.Allegiance.HasProfile}:" +
$"{checkpoint.Allegiance.RecordCount};" +
$"actions={checkpoint.Actions.SelectionRevision}:" +
$"{checkpoint.Actions.SelectedObjectId:X8}:" +
$"{checkpoint.Actions.CombatRevision}:" +

View file

@ -108,3 +108,87 @@ public interface IRuntimeSocialView
bool TryGetFriend(uint characterId, out RuntimeFriendSnapshot friend);
}
// ── Fellowship / Allegiance (Campaign FA slice FA2, 2026-08-12) ────────────
// D2: two sibling J-owners, session-scoped Fellowship vs reconnect-surviving
// Allegiance (docs/research/2026-08-11-fa-acdream-seams.md §1.3). Consumers
// poll Snapshot.Revision — no IRuntimeEventObserver member is added (would
// break all 5 bot policies + the trace recorder, per the same doc §1.3).
public readonly record struct RuntimeFellowMemberSnapshot(
uint Guid,
string Name,
uint Level,
uint MaxHealth,
uint MaxStamina,
uint MaxMana,
uint CurrentHealth,
uint CurrentStamina,
uint CurrentMana,
bool ShareLoot);
public readonly record struct RuntimeFellowshipSnapshot(
long Revision,
bool IsInFellowship,
string Name,
uint LeaderGuid,
bool ShareXp,
bool EvenXpSplit,
bool IsOpen,
bool Locked,
int MemberCount);
public interface IRuntimeFellowshipView
{
RuntimeFellowshipSnapshot Snapshot { get; }
bool TryGetMember(uint guid, out RuntimeFellowMemberSnapshot member);
}
public readonly record struct RuntimeAllegianceMemberSnapshot(
uint CharacterId,
uint ParentGuid,
bool IsLoggedIn,
string Name,
ushort Rank,
uint Level,
ushort Loyalty,
ushort Leadership,
uint CpCached,
uint CpTithed,
byte Gender,
byte HeritageGroup,
bool MayPassupExperience);
public readonly record struct RuntimeAllegianceSnapshot(
long Revision,
bool HasServerSeed,
bool HasProfile,
uint Rank,
uint TotalMembers,
uint TotalVassals,
string AllegianceName,
uint MonarchGuid,
int RecordCount)
{
public bool HasMonarch => MonarchGuid != 0u;
}
public interface IRuntimeAllegianceView
{
RuntimeAllegianceSnapshot Snapshot { get; }
bool TryGetMonarch(out RuntimeAllegianceMemberSnapshot monarch);
bool TryGetMember(uint guid, out RuntimeAllegianceMemberSnapshot member);
bool TryGetPatron(uint guid, out RuntimeAllegianceMemberSnapshot patron);
/// <summary>
/// Port of retail's <c>GetFirstVassal</c>/<c>GetNextVassal</c> walk —
/// reverse wire order, exactly like <c>AllegianceProfileLookups.
/// FindVassals</c> (lane C §4.4 point 3;
/// <c>ClientCommandResponses.cs:280-294</c>).
/// </summary>
IEnumerable<RuntimeAllegianceMemberSnapshot> GetVassals(uint guid);
}

View file

@ -238,7 +238,11 @@ public readonly record struct RuntimeStateCheckpoint(
RuntimeWorldEnvironmentSnapshot Environment,
RuntimeWorldEnvironmentOwnershipSnapshot EnvironmentOwnership,
RuntimePortalSnapshot Portal,
RuntimeWorldTransitOwnershipSnapshot TransitOwnership);
RuntimeWorldTransitOwnershipSnapshot TransitOwnership,
// Campaign FA slice FA2 (2026-08-12): default so every existing
// positional construction site (tests) compiles unchanged.
RuntimeFellowshipSnapshot Fellowship = default,
RuntimeAllegianceSnapshot Allegiance = default);
public interface IGameRuntimeView
{
@ -260,6 +264,10 @@ public interface IGameRuntimeView
IRuntimeChatView Chat { get; }
IRuntimeFellowshipView Fellowship { get; }
IRuntimeAllegianceView Allegiance { get; }
IRuntimeActionView Actions { get; }
IRuntimeMovementView Movement { get; }

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);
}
}

View 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);
}
}

View file

@ -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());
}
}

View file

@ -32,15 +32,23 @@ public enum RuntimeGenerationResetStage
Friends = 9,
Squelch = 10,
NegotiatedChannels = 11,
BeginEntityRetirement = 12,
RetireEntities = 13,
DrainHostProjection = 14,
CompleteCanonicalEntities = 15,
CompleteHostProjection = 16,
ChatIdentity = 17,
PlayerSnapshots = 18,
PlayerIdentity = 19,
Complete = 20,
/// <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. Allegiance is deliberately NOT a reset stage — it survives
/// reconnect (see <see cref="RuntimeAllegianceState"/>'s class doc).
/// </summary>
Fellowship = 12,
BeginEntityRetirement = 13,
RetireEntities = 14,
DrainHostProjection = 15,
CompleteCanonicalEntities = 16,
CompleteHostProjection = 17,
ChatIdentity = 18,
PlayerSnapshots = 19,
PlayerIdentity = 20,
Complete = 21,
}
public readonly record struct RuntimeGenerationResetSnapshot(
@ -88,6 +96,7 @@ public sealed class RuntimeGenerationReset
private readonly RuntimeEntityObjectLifetime _entityObjects;
private readonly RuntimeCharacterState _character;
private readonly RuntimeLocalPlayerIdentityState _identity;
private readonly RuntimeFellowshipState _fellowship;
private ResetState? _state;
private RuntimeGenerationToken _lastCompletedGeneration;
private bool _hasCompletedGeneration;
@ -102,7 +111,8 @@ public sealed class RuntimeGenerationReset
RuntimeLocalPlayerMovementState movement,
RuntimeEntityObjectLifetime entityObjects,
RuntimeCharacterState character,
RuntimeLocalPlayerIdentityState identity)
RuntimeLocalPlayerIdentityState identity,
RuntimeFellowshipState fellowship)
{
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
_communication = communication
@ -118,6 +128,8 @@ public sealed class RuntimeGenerationReset
?? throw new ArgumentNullException(nameof(character));
_identity = identity
?? throw new ArgumentNullException(nameof(identity));
_fellowship = fellowship
?? throw new ArgumentNullException(nameof(fellowship));
}
public RuntimeGenerationToken? ActiveRetiringGeneration =>
@ -285,6 +297,9 @@ public sealed class RuntimeGenerationReset
state,
_communication.ResetNegotiatedChannels);
break;
case RuntimeGenerationResetStage.Fellowship:
Advance(state, _fellowship.ResetSession);
break;
case RuntimeGenerationResetStage.BeginEntityRetirement:
_ = _entityObjects.BeginSessionClear();
state.Retirements = _entityObjects

View file

@ -28,7 +28,9 @@ public static class RuntimeSimulationOwnership
RuntimeCharacterState character,
RuntimeCommunicationState communication,
RuntimeActionState actions,
RuntimeLocalPlayerMovementState movement)
RuntimeLocalPlayerMovementState movement,
RuntimeFellowshipState fellowship,
RuntimeAllegianceState allegiance)
{
ArgumentNullException.ThrowIfNull(entityObjects);
return new RuntimeSimulationOwnershipSnapshot(
@ -39,6 +41,8 @@ public static class RuntimeSimulationOwnership
character,
communication,
actions,
movement));
movement,
fellowship,
allegiance));
}
}

View file

@ -28,6 +28,8 @@ public sealed class DirectGameRuntimeCommandAdapter
IRuntimeSpellbookCommands,
IRuntimeCharacterCommands,
IRuntimeSocialCommands,
IRuntimeFellowshipCommands,
IRuntimeAllegianceCommands,
IRuntimeInteractionTransport
{
private sealed class CommandRoute(
@ -80,6 +82,8 @@ public sealed class DirectGameRuntimeCommandAdapter
public IRuntimeSpellbookCommands Spellbook => this;
public IRuntimeCharacterCommands Character => this;
public IRuntimeSocialCommands Social => this;
public IRuntimeFellowshipCommands Fellowship => this;
public IRuntimeAllegianceCommands Allegiance => this;
public ILiveSessionCommandRouting CreateRoute(WorldSession session)
{
@ -795,6 +799,262 @@ public sealed class DirectGameRuntimeCommandAdapter
command.Name);
}
// ── Fellowship / Allegiance (Campaign FA slice FA2, 2026-08-12) ────────
public RuntimeCommandResult Create(
RuntimeGenerationToken expectedGeneration,
string fellowshipName,
bool shareXp)
{
RuntimeCommandStatus gate =
Validate(expectedGeneration, out WorldSession? session);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if (string.IsNullOrWhiteSpace(fellowshipName))
{
return EmitUnsupported(
RuntimeCommandDomain.Fellowship,
operation: 0,
RuntimeCommandStatus.Rejected);
}
session!.SendFellowshipCreate(fellowshipName, shareXp);
return EmitResult(
RuntimeCommandDomain.Fellowship,
operation: 0,
RuntimeCommandStatus.Accepted);
}
public RuntimeCommandResult Recruit(
RuntimeGenerationToken expectedGeneration,
uint targetGuid)
{
RuntimeCommandStatus gate =
Validate(expectedGeneration, out WorldSession? session);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if (targetGuid == 0u)
{
return EmitUnsupported(
RuntimeCommandDomain.Fellowship,
operation: 1,
RuntimeCommandStatus.Rejected,
targetGuid);
}
session!.SendFellowshipRecruit(targetGuid);
return EmitResult(
RuntimeCommandDomain.Fellowship,
operation: 1,
RuntimeCommandStatus.Accepted,
targetGuid);
}
public RuntimeCommandResult Dismiss(
RuntimeGenerationToken expectedGeneration,
uint targetGuid)
{
RuntimeCommandStatus gate =
Validate(expectedGeneration, out WorldSession? session);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if (targetGuid == 0u)
{
return EmitUnsupported(
RuntimeCommandDomain.Fellowship,
operation: 2,
RuntimeCommandStatus.Rejected,
targetGuid);
}
session!.SendFellowshipDismiss(targetGuid);
return EmitResult(
RuntimeCommandDomain.Fellowship,
operation: 2,
RuntimeCommandStatus.Accepted,
targetGuid);
}
/// <summary>
/// Retail's Quit-button leader hand-off (lane B §2.5/§3.6): the current
/// leader quitting WITHOUT disbanding sends <c>0x0290</c> (assign a
/// non-leader fellow) BEFORE <c>0x00A3</c> disband=0.
/// </summary>
public RuntimeCommandResult Quit(
RuntimeGenerationToken expectedGeneration,
bool disband)
{
RuntimeCommandStatus gate =
Validate(expectedGeneration, out WorldSession? session);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if (_runtime.FellowshipOwner.RequiresLeaderHandoffBeforeQuit(
_runtime.PlayerIdentity.ServerGuid,
disband,
out uint newLeaderGuid))
{
session!.SendFellowshipAssignNewLeader(newLeaderGuid);
}
session!.SendFellowshipQuit(disband);
return EmitResult(
RuntimeCommandDomain.Fellowship,
operation: 3,
RuntimeCommandStatus.Accepted);
}
public RuntimeCommandResult AssignLeader(
RuntimeGenerationToken expectedGeneration,
uint newLeaderGuid)
{
RuntimeCommandStatus gate =
Validate(expectedGeneration, out WorldSession? session);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if (newLeaderGuid == 0u)
{
return EmitUnsupported(
RuntimeCommandDomain.Fellowship,
operation: 4,
RuntimeCommandStatus.Rejected,
newLeaderGuid);
}
session!.SendFellowshipAssignNewLeader(newLeaderGuid);
return EmitResult(
RuntimeCommandDomain.Fellowship,
operation: 4,
RuntimeCommandStatus.Accepted,
newLeaderGuid);
}
public RuntimeCommandResult SetOpen(
RuntimeGenerationToken expectedGeneration,
bool isOpen)
{
RuntimeCommandStatus gate =
Validate(expectedGeneration, out WorldSession? session);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
session!.SendFellowshipChangeOpenness(isOpen);
return EmitResult(
RuntimeCommandDomain.Fellowship,
operation: 5,
RuntimeCommandStatus.Accepted);
}
public RuntimeCommandResult SetPanelOpen(
RuntimeGenerationToken expectedGeneration,
bool panelOpen)
{
RuntimeCommandStatus gate =
Validate(expectedGeneration, out WorldSession? session);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
session!.SendFellowshipUpdateRequest(panelOpen);
return EmitResult(
RuntimeCommandDomain.Fellowship,
operation: 6,
RuntimeCommandStatus.Accepted);
}
public RuntimeCommandResult Swear(
RuntimeGenerationToken expectedGeneration,
uint patronGuid)
{
RuntimeCommandStatus gate =
Validate(expectedGeneration, out WorldSession? session);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if (patronGuid == 0u)
{
return EmitUnsupported(
RuntimeCommandDomain.Allegiance,
operation: 0,
RuntimeCommandStatus.Rejected,
patronGuid);
}
session!.SendAllegianceSwear(patronGuid);
return EmitResult(
RuntimeCommandDomain.Allegiance,
operation: 0,
RuntimeCommandStatus.Accepted,
patronGuid);
}
public RuntimeCommandResult Break(
RuntimeGenerationToken expectedGeneration,
uint targetGuid)
{
RuntimeCommandStatus gate =
Validate(expectedGeneration, out WorldSession? session);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if (targetGuid == 0u)
{
return EmitUnsupported(
RuntimeCommandDomain.Allegiance,
operation: 1,
RuntimeCommandStatus.Rejected,
targetGuid);
}
session!.SendAllegianceBreak(targetGuid);
return EmitResult(
RuntimeCommandDomain.Allegiance,
operation: 1,
RuntimeCommandStatus.Accepted,
targetGuid);
}
public RuntimeCommandResult Kick(
RuntimeGenerationToken expectedGeneration,
uint vassalGuid)
{
RuntimeCommandStatus gate =
Validate(expectedGeneration, out WorldSession? session);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
if (vassalGuid == 0u)
{
return EmitUnsupported(
RuntimeCommandDomain.Allegiance,
operation: 2,
RuntimeCommandStatus.Rejected,
vassalGuid);
}
session!.SendAllegianceKick(vassalGuid);
return EmitResult(
RuntimeCommandDomain.Allegiance,
operation: 2,
RuntimeCommandStatus.Accepted,
vassalGuid);
}
public RuntimeCommandResult RequestInfo(
RuntimeGenerationToken expectedGeneration,
string playerName)
{
RuntimeCommandStatus gate =
Validate(expectedGeneration, out WorldSession? session);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
session!.SendAllegianceInfoRequest(playerName ?? string.Empty);
return EmitResult(
RuntimeCommandDomain.Allegiance,
operation: 3,
RuntimeCommandStatus.Accepted);
}
public RuntimeCommandResult SetUpdateSubscription(
RuntimeGenerationToken expectedGeneration,
bool on)
{
RuntimeCommandStatus gate =
Validate(expectedGeneration, out WorldSession? session);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
session!.SendAllegianceUpdateRequest(on);
return EmitResult(
RuntimeCommandDomain.Allegiance,
operation: 4,
RuntimeCommandStatus.Accepted);
}
bool IRuntimeInteractionTransport.IsInWorld
{
get

View file

@ -79,7 +79,13 @@ public sealed record LiveSocialSessionBindings(
// existing caller (including tests that build a bare ChatLog with no
// owning RuntimeCommunicationState) compiles unchanged; GameEventWiring
// falls back to its pre-CH2 chat-only behavior when this is null.
Action<string, RetailLogTextType>? AddText = null);
Action<string, RetailLogTextType>? AddText = null,
// Campaign FA slice FA2 (2026-08-12): the two sibling J-owners.
// Trailing/optional so every existing positional caller (Headless)
// compiles unchanged (docs/research/2026-08-11-fa-acdream-seams.md
// §2.4 — "the established compatibility convention").
RuntimeFellowshipState? Fellowship = null,
RuntimeAllegianceState? Allegiance = null);
/// <summary>
/// Owns every inbound subscription for one exact live session. Domain state
@ -228,7 +234,34 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
externalContainers: inventory.ExternalContainers,
vendor: inventory.Vendor,
onInterfaceText: social.AddText,
accepting: IsAccepting));
accepting: IsAccepting,
// Campaign FA slice FA2 (2026-08-12): fellowship/allegiance
// sink registration — the ONE inbound registration site
// (docs/research/2026-08-11-fa-acdream-seams.md §2.1),
// reached identically by both hosts since LiveSessionEventRouter
// itself is shared (LiveSessionEventRouter.Attach, K-slice
// unification).
onFellowshipFullUpdate: update =>
social.Fellowship?.ApplyFullUpdate(update),
onFellowshipUpdateFellow: update =>
social.Fellowship?.ApplyUpdateFellow(update),
onFellowshipQuit: quitterGuid =>
social.Fellowship?.ApplyQuit(quitterGuid, inventory.PlayerGuid()),
onFellowshipDismiss: dismissedGuid =>
social.Fellowship?.ApplyDismiss(dismissedGuid, inventory.PlayerGuid()),
onFellowshipDisband: () => social.Fellowship?.ApplyDisband(),
onAllegianceUpdate: update =>
social.Allegiance?.ApplyUpdate(update),
onAllegianceInfoResponseSelf: response =>
social.Allegiance?.ApplyInfoResponseSelf(response),
onAllegianceUpdateDone: weenieError =>
social.Allegiance?.ApplyUpdateDone(weenieError),
onAllegianceUpdateAborted: weenieError =>
social.Allegiance?.ApplyUpdateAborted(weenieError),
onAllegianceLoginNotification: notice =>
social.Allegiance?.ApplyLoginNotification(
notice.CharacterGuid,
notice.IsLoggedIn)));
ConstructionCheckpoint();
// Campaign P Slice P1 (2026-07-30): burden recompute triggers —