From 369729f06a3bbc4dc8f1d1298df2fd1643fc05c4 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 12 Aug 2026 01:31:54 +0200 Subject: [PATCH] feat(runtime): FA2 -- RuntimeFellowshipState + RuntimeAllegianceState sibling J-owners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/AcDream.Runtime/GameRuntime.cs | 82 ++++- src/AcDream.Runtime/GameRuntimeCommands.cs | 74 +++++ src/AcDream.Runtime/GameRuntimeEvents.cs | 8 + .../GameRuntimeGameplayViews.cs | 84 +++++ src/AcDream.Runtime/GameRuntimeViews.cs | 10 +- .../Gameplay/RuntimeAllegianceState.cs | 309 ++++++++++++++++++ .../Gameplay/RuntimeFellowshipState.cs | 295 +++++++++++++++++ .../Gameplay/RuntimeGameplayOwnership.cs | 19 +- src/AcDream.Runtime/RuntimeGenerationReset.cs | 35 +- .../RuntimeSimulationOwnership.cs | 8 +- .../DirectGameRuntimeCommandAdapter.cs | 260 +++++++++++++++ .../Session/LiveSessionEventRouter.cs | 37 ++- .../GameRuntimeContractTests.cs | 2 + .../AcDream.Runtime.Tests/GameRuntimeTests.cs | 8 + .../Gameplay/RuntimeAllegianceStateTests.cs | 185 +++++++++++ .../Gameplay/RuntimeFellowshipStateTests.cs | 288 ++++++++++++++++ .../Gameplay/RuntimeGameplayOwnershipTests.cs | 32 +- .../RuntimeGenerationResetTests.cs | 44 +++ .../DirectGameRuntimeCommandAdapterTests.cs | 204 ++++++++++++ 19 files changed, 1949 insertions(+), 35 deletions(-) create mode 100644 src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs create mode 100644 src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/RuntimeAllegianceStateTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/RuntimeFellowshipStateTests.cs diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index 0cafe69f..293cc12c 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -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 _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, }; diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs index 877e1e43..f20db0f2 100644 --- a/src/AcDream.Runtime/GameRuntimeCommands.cs +++ b/src/AcDream.Runtime/GameRuntimeCommands.cs @@ -299,6 +299,76 @@ public interface IRuntimeSocialCommands in RuntimeSquelchCommand command); } +// ── Fellowship / Allegiance (Campaign FA slice FA2, 2026-08-12) ──────────── + +/// +/// Generation-gated fellowship outbound actions. +/// implements retail's leader hand-off rule itself (lane B §2.5/§3.6): the +/// current leader quitting WITHOUT disbanding sends 0x0290 (assign +/// a non-leader fellow) BEFORE 0x00A3 — see +/// RuntimeFellowshipState.RequiresLeaderHandoffBeforeQuit. +/// is D4's 0x00A6 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. +/// +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); +} + +/// Generation-gated allegiance outbound actions. +public interface IRuntimeAllegianceCommands +{ + RuntimeCommandResult Swear( + RuntimeGenerationToken expectedGeneration, + uint patronGuid); + + RuntimeCommandResult Break( + RuntimeGenerationToken expectedGeneration, + uint targetGuid); + + RuntimeCommandResult Kick( + RuntimeGenerationToken expectedGeneration, + uint vassalGuid); + + /// 0x027B — empty name queries self. + RuntimeCommandResult RequestInfo( + RuntimeGenerationToken expectedGeneration, + string playerName); + + /// 0x001F — the allegiance panel's subscribe/unsubscribe toggle. + 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; } } diff --git a/src/AcDream.Runtime/GameRuntimeEvents.cs b/src/AcDream.Runtime/GameRuntimeEvents.cs index 6183ea23..b729f183 100644 --- a/src/AcDream.Runtime/GameRuntimeEvents.cs +++ b/src/AcDream.Runtime/GameRuntimeEvents.cs @@ -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}:" + diff --git a/src/AcDream.Runtime/GameRuntimeGameplayViews.cs b/src/AcDream.Runtime/GameRuntimeGameplayViews.cs index 56f2b14d..831f5283 100644 --- a/src/AcDream.Runtime/GameRuntimeGameplayViews.cs +++ b/src/AcDream.Runtime/GameRuntimeGameplayViews.cs @@ -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); + + /// + /// Port of retail's GetFirstVassal/GetNextVassal walk — + /// reverse wire order, exactly like AllegianceProfileLookups. + /// FindVassals (lane C §4.4 point 3; + /// ClientCommandResponses.cs:280-294). + /// + IEnumerable GetVassals(uint guid); +} diff --git a/src/AcDream.Runtime/GameRuntimeViews.cs b/src/AcDream.Runtime/GameRuntimeViews.cs index 924651dd..3a18ec28 100644 --- a/src/AcDream.Runtime/GameRuntimeViews.cs +++ b/src/AcDream.Runtime/GameRuntimeViews.cs @@ -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; } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs b/src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs new file mode 100644 index 00000000..ca059010 --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs @@ -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; +} + +/// +/// Canonical presentation-independent owner for the local player's +/// allegiance profile — Campaign FA slice FA2 (2026-08-12). Survives +/// reconnect (unlike , which is +/// session-scoped): it is NOT a +/// stage, and its data persists across +/// a generation boundary the same way a re-login does not sever your +/// character's allegiance membership. The +/// latch is a - +/// 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 . +/// +/// +/// Seeded by 0x0020 AllegianceUpdate (the unsolicited/subscribed +/// push — always about the local player's own tree) and, self-gated, by +/// 0x027C AllegianceInfoResponse when the response's TargetGuid is +/// the local player's own guid (an explicit @allegiance info +/// self-query; a by-name query against another player's tree is NOT +/// applied here — see 's registration). +/// Wraps ClientCommandResponses.AllegianceMemberRecord — the +/// FA1-assembled flat vassal list + monarch/patron/self blocks +/// (AllegianceTree 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). +/// +/// +public sealed class RuntimeAllegianceState : IDisposable +{ + private readonly object _gate = new(); + private ClientCommandResponses.AllegianceMemberRecord? _monarch; + private IReadOnlyList _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; } + } + + /// + /// Has any real allegiance push landed since construction? A one-way + /// latch — see the class doc. Never cleared by + /// wiring (this owner has none); only clears it. + /// + public bool HasServerSeed + { + get { lock (_gate) return _hasServerSeed; } + } + + /// 0x0020 AllegianceUpdate — the unsolicited/subscribed profile push. + 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(); + } + } + + /// + /// 0x027C AllegianceInfoResponse, self-gated by the caller + /// ('s registration checks + /// TargetGuid == playerGuid() before invoking this). Carries no + /// rank field on the wire — the last known rank (if any) is retained. + /// + 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(); + } + } + + /// + /// 0x027A AllegianceLoginNotification — 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 + /// (gmAllegianceUI::RecvNotice_AllegianceLogin @0x00492220 + /// 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. + /// + public void ApplyLoginNotification(uint characterGuid, bool isLoggedIn) + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + lock (_gate) Bump(); + } + + /// 0x01C8 AllegianceUpdateDone — clears the panel busy latch; carries the WeenieError for a failed swear/break. + public void ApplyUpdateDone(uint weenieError) + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + lock (_gate) Bump(); + } + + /// 0x0003 AllegianceUpdateAborted — declared by retail but never actually sent by ACE; parsed for forward-compat. + 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 GetVassals(uint guid) + { + List 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(); + 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); + } +} diff --git a/src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs b/src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs new file mode 100644 index 00000000..c6f907fb --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs @@ -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; +} + +/// +/// 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 +/// (), unlike +/// which survives reconnect +/// (docs/research/2026-08-11-fa-acdream-seams.md §1.3). +/// +/// +/// Assembled from the FA1 parsers: a full update (0x02BE) REPLACES +/// the whole roster; an incremental fellow update (0x02C0) upserts +/// one member by guid; a quit/dismiss notice (0x00A3/0x00A4) +/// removes one member, or clears the whole snapshot when the removed guid +/// is the local player's own (self-removal); a disband (0x02BF) +/// always clears. Every mutation bumps 's monotonic +/// Snapshot.Revision — consumers (bots, future panel UI) poll it +/// rather than subscribing to a push event (D2 — no +/// member). +/// +/// +public sealed class RuntimeFellowshipState : IDisposable +{ + private readonly object _gate = new(); + private readonly Dictionary _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; } + } + + /// + /// 0x02BE FellowshipFullUpdate — 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. + /// + 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(); + } + } + + /// + /// 0x02C0 FellowshipUpdateFellow — 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. + /// + public void ApplyUpdateFellow(GameEvents.FellowshipUpdateFellow update) + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + lock (_gate) + { + if (!_isInFellowship) return; + _members[update.MemberGuid] = update.Member; + Bump(); + } + } + + /// + /// 0x00A3 FellowshipQuit (S→C direction) — sent both to the + /// quitter and to every remaining member. Self-removal ( == ) clears the + /// whole snapshot; otherwise the named member is removed from the + /// roster. + /// + 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(); + } + } + + /// + /// 0x00A4 FellowshipDismiss (S→C direction) — same self-vs-other + /// removal rule as . + /// + 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(); + } + } + + /// 0x02BF FellowshipDisband — always clears. + public void ApplyDisband() + { + ObjectDisposedException.ThrowIf(IsDisposed, this); + lock (_gate) ClearLocked(); + } + + /// The local player's own current leader guid, or 0 when not in a fellowship. + public uint LeaderGuid + { + get { lock (_gate) return _isInFellowship ? _leaderGuid : 0u; } + } + + public bool IsInFellowship + { + get { lock (_gate) return _isInFellowship; } + } + + /// + /// 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 with + /// a candidate guid exactly when that hand-off is required; the caller + /// (the command adapters) sends 0x0290 before 0x00A3 when + /// this returns true. + /// + 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); + } + + /// Session-scoped: cleared at every generation reset (reconnect). + 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); + } +} diff --git a/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs b/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs index bfa70952..771fdd58 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs @@ -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()); } } diff --git a/src/AcDream.Runtime/RuntimeGenerationReset.cs b/src/AcDream.Runtime/RuntimeGenerationReset.cs index 705b5776..c2f1d055 100644 --- a/src/AcDream.Runtime/RuntimeGenerationReset.cs +++ b/src/AcDream.Runtime/RuntimeGenerationReset.cs @@ -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, + /// + /// 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 's class doc). + /// + 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 diff --git a/src/AcDream.Runtime/RuntimeSimulationOwnership.cs b/src/AcDream.Runtime/RuntimeSimulationOwnership.cs index 7e05e4c7..57b4ce46 100644 --- a/src/AcDream.Runtime/RuntimeSimulationOwnership.cs +++ b/src/AcDream.Runtime/RuntimeSimulationOwnership.cs @@ -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)); } } diff --git a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs index e307d8d7..bc07ca53 100644 --- a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs +++ b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs @@ -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); + } + + /// + /// Retail's Quit-button leader hand-off (lane B §2.5/§3.6): the current + /// leader quitting WITHOUT disbanding sends 0x0290 (assign a + /// non-leader fellow) BEFORE 0x00A3 disband=0. + /// + 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 diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index fba2724f..9427eb87 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -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? AddText = null); + Action? 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); /// /// 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 — diff --git a/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs b/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs index 7dfa551b..b56f9289 100644 --- a/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs +++ b/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs @@ -230,6 +230,8 @@ public sealed class GameRuntimeContractTests typeof(RuntimeCombatTargetState), typeof(RuntimeCombatModeState), typeof(RuntimeSpellCastState), + typeof(RuntimeFellowshipState), + typeof(RuntimeAllegianceState), ]; foreach (Type owner in owners) diff --git a/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs b/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs index 9c7ce21b..e2bdc415 100644 --- a/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs +++ b/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs @@ -19,6 +19,8 @@ public sealed class GameRuntimeTests Assert.Same(runtime.CharacterOwner.View, runtime.Character); Assert.Same(runtime.CommunicationOwner.View, runtime.Chat); Assert.Same(runtime.CommunicationOwner.SocialView, runtime.Social); + Assert.Same(runtime.FellowshipOwner.View, runtime.Fellowship); + Assert.Same(runtime.AllegianceOwner.View, runtime.Allegiance); Assert.Same(runtime.ActionOwner.View, runtime.Actions); Assert.Same(runtime.MovementOwner.View, runtime.Movement); Assert.Same(runtime.EnvironmentOwner, runtime.Environment); @@ -110,6 +112,8 @@ public sealed class GameRuntimeTests [InlineData((int)GameRuntimeConstructionPoint.InventoryCreated)] [InlineData((int)GameRuntimeConstructionPoint.CharacterCreated)] [InlineData((int)GameRuntimeConstructionPoint.CommunicationCreated)] + [InlineData((int)GameRuntimeConstructionPoint.FellowshipCreated)] + [InlineData((int)GameRuntimeConstructionPoint.AllegianceCreated)] [InlineData((int)GameRuntimeConstructionPoint.MovementCreated)] [InlineData((int)GameRuntimeConstructionPoint.ActionsCreated)] [InlineData((int)GameRuntimeConstructionPoint.EnvironmentCreated)] @@ -147,6 +151,10 @@ public sealed class GameRuntimeTests Assert.True(captured.Inventory.CaptureOwnership().IsConverged); if (captured.Communication is not null) Assert.True(captured.Communication.CaptureOwnership().IsConverged); + if (captured.Fellowship is not null) + Assert.True(captured.Fellowship.CaptureOwnership().IsConverged); + if (captured.Allegiance is not null) + Assert.True(captured.Allegiance.CaptureOwnership().IsConverged); if (captured.EntityObjects is not null) { Assert.True(captured.EntityObjects.CaptureOwnership().IsConverged); diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeAllegianceStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeAllegianceStateTests.cs new file mode 100644 index 00000000..588a7d1a --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeAllegianceStateTests.cs @@ -0,0 +1,185 @@ +using AcDream.Core.Net.Messages; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Gameplay; + +/// +/// Campaign FA slice FA2 (2026-08-12): lifecycle rules for +/// — seeding from the unsolicited +/// AllegianceUpdate push and from a self-gated +/// AllegianceInfoResponse, the HasServerSeed-style latch, the +/// monarch/patron/vassal walk, revision monotonicity, and the "survives +/// reconnect" ownership contract (no RuntimeGenerationReset stage — +/// see for the sibling assertion +/// that Fellowship IS a reset stage and Allegiance is not). +/// +public sealed class RuntimeAllegianceStateTests +{ + private const uint MonarchGuid = 0x50000001u; + private const uint PatronGuid = 0x50000002u; + private const uint SelfGuid = 0x50000003u; + private const uint VassalAGuid = 0x50000004u; + private const uint VassalBGuid = 0x50000005u; + + private static ClientCommandResponses.AllegianceMemberRecord Record( + uint id, + uint parent, + string name, + bool loggedIn = true) => + new(id, parent, loggedIn, name); + + private static ClientCommandResponses.AllegianceUpdate Update(uint rank = 3u) => + new( + rank, + TotalMembers: 5u, + TotalVassals: 3u, + RecordCount: 5, + AllegianceName: "The Order", + Monarch: Record(MonarchGuid, 0u, "Monarch"), + Records: + [ + Record(PatronGuid, MonarchGuid, "Patron"), + Record(SelfGuid, PatronGuid, "Self"), + Record(VassalAGuid, SelfGuid, "VassalA"), + Record(VassalBGuid, SelfGuid, "VassalB"), + ]); + + [Fact] + public void ApplyUpdate_SeedsTheProfileAndArmsHasServerSeed() + { + var state = new RuntimeAllegianceState(); + Assert.False(state.HasServerSeed); + + state.ApplyUpdate(Update(rank: 7u)); + + RuntimeAllegianceSnapshot snapshot = state.View.Snapshot; + Assert.True(state.HasServerSeed); + Assert.True(snapshot.HasServerSeed); + Assert.True(snapshot.HasProfile); + Assert.Equal(7u, snapshot.Rank); + Assert.Equal("The Order", snapshot.AllegianceName); + Assert.Equal(MonarchGuid, snapshot.MonarchGuid); + Assert.True(snapshot.HasMonarch); + Assert.Equal(4, snapshot.RecordCount); + } + + [Fact] + public void ApplyInfoResponseSelf_SeedsTheProfileButLeavesRankUntouched() + { + var state = new RuntimeAllegianceState(); + state.ApplyUpdate(Update(rank: 9u)); + + var response = new ClientCommandResponses.AllegianceInfoResponse( + TargetGuid: SelfGuid, + TotalMembers: 5u, + TotalVassals: 3u, + RecordCount: 5, + AllegianceName: "The Order", + Monarch: Record(MonarchGuid, 0u, "Monarch"), + Records: + [ + Record(PatronGuid, MonarchGuid, "Patron"), + Record(SelfGuid, PatronGuid, "Self"), + ]); + + state.ApplyInfoResponseSelf(response); + + RuntimeAllegianceSnapshot snapshot = state.View.Snapshot; + Assert.True(snapshot.HasProfile); + // AllegianceInfoResponse carries no rank field on the wire — the + // last known rank from the earlier AllegianceUpdate is retained. + Assert.Equal(9u, snapshot.Rank); + Assert.Equal(2, snapshot.RecordCount); + } + + [Fact] + public void TryGetMonarchPatronAndVassals_WalkTheFlatRecordList() + { + var state = new RuntimeAllegianceState(); + state.ApplyUpdate(Update()); + + Assert.True(state.View.TryGetMonarch(out RuntimeAllegianceMemberSnapshot monarch)); + Assert.Equal("Monarch", monarch.Name); + + Assert.True(state.View.TryGetPatron(SelfGuid, out RuntimeAllegianceMemberSnapshot patron)); + Assert.Equal(PatronGuid, patron.CharacterId); + + // The monarch has no patron. + Assert.False(state.View.TryGetPatron(MonarchGuid, out _)); + + var vassals = state.View.GetVassals(SelfGuid).ToList(); + Assert.Equal(2, vassals.Count); + Assert.Contains(vassals, v => v.CharacterId == VassalAGuid); + Assert.Contains(vassals, v => v.CharacterId == VassalBGuid); + + Assert.True(state.View.TryGetMember(VassalAGuid, out RuntimeAllegianceMemberSnapshot vassalA)); + Assert.Equal("VassalA", vassalA.Name); + Assert.False(state.View.TryGetMember(0x99999999u, out _)); + } + + [Fact] + public void GetVassals_VisitsSiblingsInReverseWireOrder() + { + // Lane C §4.4 point 3: each new record is PREPENDED to its parent's + // vassal list on assembly — the record parsed LAST under a given + // parent renders FIRST. + var state = new RuntimeAllegianceState(); + state.ApplyUpdate(Update()); + + var vassals = state.View.GetVassals(SelfGuid).ToList(); + Assert.Equal(VassalBGuid, vassals[0].CharacterId); + Assert.Equal(VassalAGuid, vassals[1].CharacterId); + } + + [Fact] + public void Revision_IsMonotonicAcrossEveryEventKind() + { + var state = new RuntimeAllegianceState(); + long r0 = state.View.Snapshot.Revision; + + state.ApplyUpdate(Update()); + long r1 = state.View.Snapshot.Revision; + Assert.True(r1 > r0); + + state.ApplyLoginNotification(VassalAGuid, isLoggedIn: true); + long r2 = state.View.Snapshot.Revision; + Assert.True(r2 > r1); + + state.ApplyUpdateDone(weenieError: 0u); + long r3 = state.View.Snapshot.Revision; + Assert.True(r3 > r2); + + state.ApplyUpdateAborted(weenieError: 0u); + long r4 = state.View.Snapshot.Revision; + Assert.True(r4 > r3); + } + + [Fact] + public void CaptureOwnership_ConvergesOnlyAfterDispose() + { + var state = new RuntimeAllegianceState(); + state.ApplyUpdate(Update()); + + Assert.False(state.CaptureOwnership().IsConverged); + + state.Dispose(); + + RuntimeAllegianceOwnershipSnapshot retired = state.CaptureOwnership(); + Assert.True(retired.IsConverged); + Assert.True(retired.IsDisposed); + Assert.False(retired.HasProfile); + Assert.Equal(0, retired.RecordCount); + Assert.False(state.HasServerSeed); + } + + [Fact] + public void MutatingAfterDispose_Throws() + { + var state = new RuntimeAllegianceState(); + state.Dispose(); + + Assert.Throws(() => state.ApplyUpdate(Update())); + Assert.Throws( + () => state.ApplyLoginNotification(SelfGuid, true)); + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeFellowshipStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeFellowshipStateTests.cs new file mode 100644 index 00000000..d7359332 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeFellowshipStateTests.cs @@ -0,0 +1,288 @@ +using AcDream.Core.Net.Messages; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Gameplay; + +/// +/// Campaign FA slice FA2 (2026-08-12): lifecycle rules for +/// — full-update assembly, incremental +/// upsert, self-vs-other quit/dismiss removal, disband clear, revision +/// monotonicity, and ownership convergence. +/// +public sealed class RuntimeFellowshipStateTests +{ + private const uint SelfGuid = 0x50000001u; + private const uint LeaderGuid = 0x50000002u; + private const uint OtherGuid = 0x50000003u; + + private static GameEvents.FellowMember Member( + uint guid, + string name = "Fellow", + uint currentHealth = 100u, + uint shareLoot = 0u) => + new( + guid, + CpCache: 0u, + LumCache: 0u, + Level: 10u, + MaxHealth: 100u, + MaxStamina: 100u, + MaxMana: 100u, + CurrentHealth: currentHealth, + CurrentStamina: 100u, + CurrentMana: 100u, + ShareLoot: shareLoot, + Name: name); + + private static GameEvents.FellowshipFullUpdate FullUpdate( + params GameEvents.FellowMember[] members) => + new( + members, + Name: "The Fellows", + LeaderGuid: LeaderGuid, + ShareXp: true, + EvenXpSplit: false, + OpenFellow: true, + Locked: false, + Departed: []); + + [Fact] + public void ApplyFullUpdate_ReplacesTheWholeRosterAndFlags() + { + var state = new RuntimeFellowshipState(); + + state.ApplyFullUpdate(FullUpdate( + Member(SelfGuid, "Self"), + Member(LeaderGuid, "Leader"), + Member(OtherGuid, "Other"))); + + RuntimeFellowshipSnapshot snapshot = state.View.Snapshot; + Assert.True(snapshot.IsInFellowship); + Assert.Equal("The Fellows", snapshot.Name); + Assert.Equal(LeaderGuid, snapshot.LeaderGuid); + Assert.True(snapshot.ShareXp); + Assert.False(snapshot.EvenXpSplit); + Assert.True(snapshot.IsOpen); + Assert.False(snapshot.Locked); + Assert.Equal(3, snapshot.MemberCount); + Assert.True(state.View.TryGetMember(OtherGuid, out RuntimeFellowMemberSnapshot other)); + Assert.Equal("Other", other.Name); + + // A SECOND full update REPLACES, not merges — the departed member + // must disappear. + state.ApplyFullUpdate(FullUpdate(Member(SelfGuid, "Self"))); + Assert.Equal(1, state.View.Snapshot.MemberCount); + Assert.False(state.View.TryGetMember(OtherGuid, out _)); + } + + [Fact] + public void ApplyUpdateFellow_UpsertsExactlyOneMemberByGuid() + { + var state = new RuntimeFellowshipState(); + state.ApplyFullUpdate(FullUpdate( + Member(SelfGuid, "Self", currentHealth: 100u), + Member(LeaderGuid, "Leader"))); + + state.ApplyUpdateFellow(new GameEvents.FellowshipUpdateFellow( + SelfGuid, + Member(SelfGuid, "Self", currentHealth: 42u), + UpdateType: 3u)); + + Assert.True(state.View.TryGetMember(SelfGuid, out RuntimeFellowMemberSnapshot self)); + Assert.Equal(42u, self.CurrentHealth); + // The other member is untouched. + Assert.True(state.View.TryGetMember(LeaderGuid, out RuntimeFellowMemberSnapshot leader)); + Assert.Equal("Leader", leader.Name); + Assert.Equal(2, state.View.Snapshot.MemberCount); + } + + [Fact] + public void ApplyUpdateFellow_BeforeAnyFullUpdate_IsANoOp() + { + var state = new RuntimeFellowshipState(); + long before = state.View.Snapshot.Revision; + + state.ApplyUpdateFellow(new GameEvents.FellowshipUpdateFellow( + SelfGuid, Member(SelfGuid), UpdateType: 1u)); + + Assert.Equal(before, state.View.Snapshot.Revision); + Assert.False(state.View.Snapshot.IsInFellowship); + } + + [Theory] + [InlineData(false)] // Quit + [InlineData(true)] // Dismiss + public void SelfRemoval_ClearsTheWholeSnapshot(bool viaDismiss) + { + var state = new RuntimeFellowshipState(); + state.ApplyFullUpdate(FullUpdate( + Member(SelfGuid), + Member(LeaderGuid), + Member(OtherGuid))); + + if (viaDismiss) + state.ApplyDismiss(SelfGuid, SelfGuid); + else + state.ApplyQuit(SelfGuid, SelfGuid); + + RuntimeFellowshipSnapshot snapshot = state.View.Snapshot; + Assert.False(snapshot.IsInFellowship); + Assert.Equal(0, snapshot.MemberCount); + Assert.Equal(string.Empty, snapshot.Name); + Assert.Equal(0u, snapshot.LeaderGuid); + } + + [Theory] + [InlineData(false)] // Quit + [InlineData(true)] // Dismiss + public void OtherMemberRemoval_RemovesOnlyThatMember(bool viaDismiss) + { + var state = new RuntimeFellowshipState(); + state.ApplyFullUpdate(FullUpdate( + Member(SelfGuid), + Member(LeaderGuid), + Member(OtherGuid))); + + if (viaDismiss) + state.ApplyDismiss(OtherGuid, SelfGuid); + else + state.ApplyQuit(OtherGuid, SelfGuid); + + RuntimeFellowshipSnapshot snapshot = state.View.Snapshot; + Assert.True(snapshot.IsInFellowship); + Assert.Equal(2, snapshot.MemberCount); + Assert.False(state.View.TryGetMember(OtherGuid, out _)); + Assert.True(state.View.TryGetMember(SelfGuid, out _)); + } + + [Fact] + public void ApplyDisband_AlwaysClears() + { + var state = new RuntimeFellowshipState(); + state.ApplyFullUpdate(FullUpdate(Member(SelfGuid), Member(LeaderGuid))); + + state.ApplyDisband(); + + Assert.False(state.View.Snapshot.IsInFellowship); + Assert.Equal(0, state.View.Snapshot.MemberCount); + } + + [Fact] + public void RequiresLeaderHandoffBeforeQuit_OnlyWhenSelfIsLeaderAndNotDisbanding() + { + var state = new RuntimeFellowshipState(); + state.ApplyFullUpdate(FullUpdate( + Member(SelfGuid), + Member(LeaderGuid), + Member(OtherGuid))); + + // Not the leader (SelfGuid isn't the leader here) — no handoff. + Assert.False(state.RequiresLeaderHandoffBeforeQuit(SelfGuid, disband: false, out _)); + + // The leader disbanding — no handoff needed (the fellowship dissolves). + Assert.False(state.RequiresLeaderHandoffBeforeQuit(LeaderGuid, disband: true, out _)); + + // The leader quitting WITHOUT disbanding — hand off to a non-leader fellow. + Assert.True(state.RequiresLeaderHandoffBeforeQuit(LeaderGuid, disband: false, out uint newLeader)); + Assert.NotEqual(LeaderGuid, newLeader); + Assert.True(newLeader is SelfGuid or OtherGuid); + + // Sole member (no one else to hand off to) — no handoff, even as leader. + var solo = new RuntimeFellowshipState(); + solo.ApplyFullUpdate(new GameEvents.FellowshipFullUpdate( + [Member(LeaderGuid)], + "Solo", + LeaderGuid, + ShareXp: false, + EvenXpSplit: false, + OpenFellow: false, + Locked: false, + Departed: [])); + Assert.False(solo.RequiresLeaderHandoffBeforeQuit(LeaderGuid, disband: false, out _)); + } + + [Fact] + public void Revision_IsMonotonicAcrossEveryMutationKind() + { + var state = new RuntimeFellowshipState(); + long r0 = state.View.Snapshot.Revision; + + state.ApplyFullUpdate(FullUpdate(Member(SelfGuid), Member(LeaderGuid))); + long r1 = state.View.Snapshot.Revision; + Assert.True(r1 > r0); + + state.ApplyUpdateFellow(new GameEvents.FellowshipUpdateFellow( + SelfGuid, Member(SelfGuid, currentHealth: 1u), UpdateType: 3u)); + long r2 = state.View.Snapshot.Revision; + Assert.True(r2 > r1); + + state.ApplyQuit(LeaderGuid, SelfGuid); + long r3 = state.View.Snapshot.Revision; + Assert.True(r3 > r2); + + state.ApplyDisband(); + long r4 = state.View.Snapshot.Revision; + Assert.True(r4 > r3); + } + + [Fact] + public void ShareLoot_ReadsTheRawWireBitAsNonZero_NeverEqualsOne() + { + // D5 (lane B §4.1): ACE encodes ShareLoot two mutually-inconsistent + // ways (0x10 full update, <<1 incremental) — the only safe read is + // != 0. + var state = new RuntimeFellowshipState(); + state.ApplyFullUpdate(FullUpdate(Member(SelfGuid, shareLoot: 0x10u))); + + Assert.True(state.View.TryGetMember(SelfGuid, out RuntimeFellowMemberSnapshot member)); + Assert.True(member.ShareLoot); + } + + [Fact] + public void CaptureOwnership_ConvergesOnlyAfterDisposeAndEmptyRoster() + { + var state = new RuntimeFellowshipState(); + state.ApplyFullUpdate(FullUpdate(Member(SelfGuid), Member(LeaderGuid))); + + Assert.False(state.CaptureOwnership().IsConverged); + + state.Dispose(); + + RuntimeFellowshipOwnershipSnapshot retired = state.CaptureOwnership(); + Assert.True(retired.IsConverged); + Assert.True(retired.IsDisposed); + Assert.False(retired.IsInFellowship); + Assert.Equal(0, retired.MemberCount); + } + + [Fact] + public void ResetSession_ClearsSessionScopedRoster_MatchingTheExternalContainerPrecedent() + { + // D2: fellowship is session-scoped — cleared at generation reset + // exactly like RuntimeInventoryState.ResetExternalContainer, unlike + // RuntimeAllegianceState which survives reconnect. + var state = new RuntimeFellowshipState(); + state.ApplyFullUpdate(FullUpdate(Member(SelfGuid), Member(LeaderGuid))); + + state.ResetSession(); + + RuntimeFellowshipSnapshot snapshot = state.View.Snapshot; + Assert.False(snapshot.IsInFellowship); + Assert.Equal(0, snapshot.MemberCount); + Assert.False(state.IsDisposed); + } + + [Fact] + public void MutatingAfterDispose_Throws() + { + var state = new RuntimeFellowshipState(); + state.Dispose(); + + Assert.Throws( + () => state.ApplyFullUpdate(FullUpdate(Member(SelfGuid)))); + Assert.Throws( + () => state.ApplyQuit(SelfGuid, SelfGuid)); + Assert.Throws(state.ApplyDisband); + Assert.Throws(state.ResetSession); + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs index 26f0839e..241a8d48 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeGameplayOwnershipTests.cs @@ -18,6 +18,8 @@ public sealed class RuntimeGameplayOwnershipTests var communication = new RuntimeCommunicationState(); var actions = RuntimeActionTestFactory.Create(inventory.Transactions); var movement = new RuntimeLocalPlayerMovementState(); + var fellowship = new RuntimeFellowshipState(); + var allegiance = new RuntimeAllegianceState(); movement.Execute(RuntimeMovementCommand.ToggleRunLock); inventory.Transactions.IncrementBusyCount(); @@ -34,7 +36,9 @@ public sealed class RuntimeGameplayOwnershipTests character, communication, actions, - movement); + movement, + fellowship, + allegiance); Assert.False(populated.IsConverged); movement.Dispose(); @@ -42,6 +46,8 @@ public sealed class RuntimeGameplayOwnershipTests communication.Dispose(); character.Dispose(); inventory.Dispose(); + fellowship.Dispose(); + allegiance.Dispose(); entities.Dispose(); RuntimeSimulationOwnershipSnapshot retired = @@ -51,7 +57,9 @@ public sealed class RuntimeGameplayOwnershipTests character, communication, actions, - movement); + movement, + fellowship, + allegiance); Assert.True(retired.IsConverged); Assert.True(retired.EntityObjects.IsDisposed); Assert.True(retired.Physics.IsDisposed); @@ -66,6 +74,8 @@ public sealed class RuntimeGameplayOwnershipTests var communication = new RuntimeCommunicationState(); var actions = RuntimeActionTestFactory.Create(inventory.Transactions); var movement = new RuntimeLocalPlayerMovementState(); + var fellowship = new RuntimeFellowshipState(); + var allegiance = new RuntimeAllegianceState(); movement.Execute(RuntimeMovementCommand.ToggleRunLock); inventory.Shortcuts.Changed += static () => { }; inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]); @@ -127,7 +137,9 @@ public sealed class RuntimeGameplayOwnershipTests character, communication, actions, - movement); + movement, + fellowship, + allegiance); Assert.False(populated.IsConverged); Assert.Equal(1, populated.Inventory.ShortcutCount); @@ -147,6 +159,8 @@ public sealed class RuntimeGameplayOwnershipTests communication.Dispose(); character.Dispose(); inventory.Dispose(); + fellowship.Dispose(); + allegiance.Dispose(); subscription.Dispose(); RuntimeGameplayOwnershipSnapshot retired = @@ -155,7 +169,9 @@ public sealed class RuntimeGameplayOwnershipTests character, communication, actions, - movement); + movement, + fellowship, + allegiance); Assert.True(retired.IsConverged); Assert.Equal(0, retired.Inventory.ShortcutSubscriberCount); @@ -173,6 +189,8 @@ public sealed class RuntimeGameplayOwnershipTests var communication = new RuntimeCommunicationState(); var actions = RuntimeActionTestFactory.Create(inventory.Transactions); var movement = new RuntimeLocalPlayerMovementState(); + var fellowship = new RuntimeFellowshipState(); + var allegiance = new RuntimeAllegianceState(); inventory.ExternalContainers.RequestOpen(0x70000001u); inventory.ExternalContainers.ApplyViewContents(0x70000001u); @@ -195,6 +213,8 @@ public sealed class RuntimeGameplayOwnershipTests Assert.Throws(inventory.Dispose); Assert.Throws(character.Dispose); communication.Dispose(); + fellowship.Dispose(); + allegiance.Dispose(); RuntimeGameplayOwnershipSnapshot retired = RuntimeGameplayOwnership.Capture( @@ -202,7 +222,9 @@ public sealed class RuntimeGameplayOwnershipTests character, communication, actions, - movement); + movement, + fellowship, + allegiance); Assert.True(retired.IsConverged); Assert.Equal(1, retired.Communication.DispatchFailureCount); diff --git a/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs b/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs index e66ae29c..a13eebed 100644 --- a/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs +++ b/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs @@ -199,6 +199,50 @@ public sealed class RuntimeGenerationResetTests Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive); } + [Fact] + public void FellowshipClearsAtResetButAllegianceSurvivesReconnect() + { + // Campaign FA slice FA2 (2026-08-12), D2: fellowship is + // session-scoped (cleared at every generation reset, matching the + // ExternalContainer precedent); allegiance is NOT a reset stage at + // all — it survives reconnect exactly like a real disconnect does + // not sever your character's allegiance membership. + using var runtime = Create(); + runtime.PlayerIdentity.ServerGuid = 0x50000001u; + runtime.FellowshipOwner.ApplyFullUpdate(new GameEvents.FellowshipFullUpdate( + [new GameEvents.FellowMember( + 0x50000001u, 0u, 0u, 1u, 100u, 100u, 100u, 100u, 100u, 100u, 0u, "Self")], + "The Fellows", + LeaderGuid: 0x50000001u, + ShareXp: true, + EvenXpSplit: false, + OpenFellow: true, + Locked: false, + Departed: [])); + runtime.AllegianceOwner.ApplyUpdate(new ClientCommandResponses.AllegianceUpdate( + Rank: 2u, + TotalMembers: 1u, + TotalVassals: 0u, + RecordCount: 1, + AllegianceName: "The Order", + Monarch: new ClientCommandResponses.AllegianceMemberRecord( + 0x50000001u, 0u, true, "Self"), + Records: [])); + + Assert.True(runtime.Fellowship.Snapshot.IsInFellowship); + Assert.True(runtime.Allegiance.Snapshot.HasProfile); + + var host = new RecordingResetHost(runtime); + runtime.ResetGeneration(new RuntimeGenerationToken(3), host); + + Assert.False(runtime.Fellowship.Snapshot.IsInFellowship); + Assert.Equal(0, runtime.Fellowship.Snapshot.MemberCount); + // Allegiance is untouched by the reset — the data survives. + Assert.True(runtime.Allegiance.Snapshot.HasProfile); + Assert.True(runtime.AllegianceOwner.HasServerSeed); + Assert.Equal("The Order", runtime.Allegiance.Snapshot.AllegianceName); + } + private static GameRuntime Create() { var operations = new Operations(); diff --git a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs index 77249f14..cdb95ec3 100644 --- a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs @@ -490,6 +490,210 @@ public sealed class DirectGameRuntimeCommandAdapterTests runtime.Dispose(); } + // ── Fellowship / Allegiance (Campaign FA slice FA2, 2026-08-12) ──────── + + private static uint ReadOpcode(byte[] gameAction) => + System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian( + gameAction.AsSpan(8)); + + [Fact] + public void Fellowship_Create_SendsTheCreateOpcodeWithNameAndShareXp() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + + RuntimeCommandResult result = adapter.Fellowship.Create( + runtime.Generation, "TestFellowship", shareXp: true); + + Assert.True(result.Accepted); + byte[] sent = Assert.Single(gameActions); + Assert.Equal(SocialActions.FellowshipCreateOpcode, ReadOpcode(sent)); + runtime.Dispose(); + } + + [Fact] + public void Fellowship_Create_RejectsAnEmptyName_WithoutSendingAnything() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + + RuntimeCommandResult result = adapter.Fellowship.Create( + runtime.Generation, string.Empty, shareXp: false); + + Assert.Equal(RuntimeCommandStatus.Rejected, result.Status); + Assert.Empty(gameActions); + runtime.Dispose(); + } + + [Fact] + public void Fellowship_RecruitAndDismiss_SendTheirOpcodesWithTheTargetGuid() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + + adapter.Fellowship.Recruit(runtime.Generation, 0x50000005u); + adapter.Fellowship.Dismiss(runtime.Generation, 0x50000006u); + + Assert.Equal(2, gameActions.Count); + Assert.Equal(SocialActions.FellowshipRecruitOpcode, ReadOpcode(gameActions[0])); + Assert.Equal(SocialActions.FellowshipDismissOpcode, ReadOpcode(gameActions[1])); + runtime.Dispose(); + } + + [Fact] + public void Fellowship_Quit_NotLeader_SendsOnlyTheQuitOpcode() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + runtime.PlayerIdentity.ServerGuid = 0x50000001u; + SeedFellowship(runtime, leader: 0x50000002u, self: 0x50000001u, others: 0x50000003u); + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + + adapter.Fellowship.Quit(runtime.Generation, disband: false); + + byte[] sent = Assert.Single(gameActions); + Assert.Equal(SocialActions.FellowshipQuitOpcode, ReadOpcode(sent)); + runtime.Dispose(); + } + + [Fact] + public void Fellowship_Quit_LeaderWithoutDisbanding_SendsAssignNewLeaderBeforeQuit() + { + // Retail's Quit-button leader hand-off (lane B §2.5/§3.6). + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + runtime.PlayerIdentity.ServerGuid = 0x50000001u; + SeedFellowship(runtime, leader: 0x50000001u, self: 0x50000001u, others: 0x50000003u); + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + + adapter.Fellowship.Quit(runtime.Generation, disband: false); + + Assert.Equal(2, gameActions.Count); + Assert.Equal(SocialActions.FellowshipAssignNewLeaderOpcode, ReadOpcode(gameActions[0])); + Assert.Equal(SocialActions.FellowshipQuitOpcode, ReadOpcode(gameActions[1])); + runtime.Dispose(); + } + + [Fact] + public void Fellowship_Quit_LeaderDisbanding_SendsOnlyTheQuitOpcode() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + runtime.PlayerIdentity.ServerGuid = 0x50000001u; + SeedFellowship(runtime, leader: 0x50000001u, self: 0x50000001u, others: 0x50000003u); + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + + adapter.Fellowship.Quit(runtime.Generation, disband: true); + + byte[] sent = Assert.Single(gameActions); + Assert.Equal(SocialActions.FellowshipQuitOpcode, ReadOpcode(sent)); + runtime.Dispose(); + } + + [Fact] + public void Fellowship_AssignLeaderSetOpenSetPanelOpen_SendTheirOpcodes() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + + adapter.Fellowship.AssignLeader(runtime.Generation, 0x50000009u); + adapter.Fellowship.SetOpen(runtime.Generation, isOpen: true); + adapter.Fellowship.SetPanelOpen(runtime.Generation, panelOpen: true); + + Assert.Equal(3, gameActions.Count); + Assert.Equal(SocialActions.FellowshipAssignNewLeaderOpcode, ReadOpcode(gameActions[0])); + Assert.Equal(SocialActions.FellowshipChangeOpennessOpcode, ReadOpcode(gameActions[1])); + Assert.Equal(SocialActions.FellowshipUpdateRequestOpcode, ReadOpcode(gameActions[2])); + runtime.Dispose(); + } + + [Fact] + public void Allegiance_SwearBreakKick_SendTheirOpcodesWithTheTargetGuid() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + + adapter.Allegiance.Swear(runtime.Generation, 0x50000010u); + adapter.Allegiance.Break(runtime.Generation, 0x50000011u); + adapter.Allegiance.Kick(runtime.Generation, 0x50000012u); + + Assert.Equal(3, gameActions.Count); + Assert.Equal(AllegianceRequests.SwearOpcode, ReadOpcode(gameActions[0])); + Assert.Equal(AllegianceRequests.BreakOpcode, ReadOpcode(gameActions[1])); + Assert.Equal(AllegianceRequests.BreakOpcode, ReadOpcode(gameActions[2])); + runtime.Dispose(); + } + + [Fact] + public void Allegiance_RequestInfoAndSetUpdateSubscription_SendTheirOpcodes() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + + adapter.Allegiance.RequestInfo(runtime.Generation, string.Empty); + adapter.Allegiance.SetUpdateSubscription(runtime.Generation, on: true); + + Assert.Equal(2, gameActions.Count); + Assert.Equal(ClientCommandRequests.AllegianceInfoRequestOpcode, ReadOpcode(gameActions[0])); + Assert.Equal(AllegianceRequests.AllegianceUpdateRequestOpcode, ReadOpcode(gameActions[1])); + runtime.Dispose(); + } + + [Fact] + public void Fellowship_And_Allegiance_Commands_RejectAStaleGeneration() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + RuntimeGenerationToken stale = runtime.Generation; + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + adapter.Session.Reconnect(runtime.Generation); + + RuntimeCommandResult fellowshipResult = + adapter.Fellowship.Create(stale, "Stale", false); + RuntimeCommandResult allegianceResult = + adapter.Allegiance.Swear(stale, 0x50000001u); + + Assert.Equal(RuntimeCommandStatus.StaleGeneration, fellowshipResult.Status); + Assert.Equal(RuntimeCommandStatus.StaleGeneration, allegianceResult.Status); + Assert.Empty(gameActions); + runtime.Dispose(); + } + + private static void SeedFellowship( + GameRuntime runtime, + uint leader, + uint self, + uint others) + { + GameEvents.FellowMember Member(uint guid, string name) => new( + guid, 0u, 0u, 1u, 100u, 100u, 100u, 100u, 100u, 100u, 0u, name); + runtime.FellowshipOwner.ApplyFullUpdate(new GameEvents.FellowshipFullUpdate( + [Member(leader, "Leader"), Member(self, "Self"), Member(others, "Other")], + "The Fellows", + leader, + ShareXp: true, + EvenXpSplit: false, + OpenFellow: true, + Locked: false, + Departed: [])); + } + private sealed class ManualTimeProvider : TimeProvider { private DateTimeOffset _now = new(2026, 8, 11, 0, 0, 0, TimeSpan.Zero);