feat(runtime): FA2 -- RuntimeFellowshipState + RuntimeAllegianceState sibling J-owners
Two new sibling Runtime owners under GameRuntime per the Slice-J pattern (D2): RuntimeFellowshipState is session-scoped (a new RuntimeGenerationResetStage.Fellowship clears it at every generation reset, matching the ExternalContainer precedent); RuntimeAllegianceState survives reconnect behind a HasServerSeed-style one-way latch and participates in NO reset stage (its data persists like a real disconnect does not sever allegiance membership). Fellowship: full-update REPLACE, incremental-fellow UPSERT, self-vs- other quit/dismiss removal (self clears the whole snapshot), disband clear, and retail's leader hand-off rule for the Quit button (RequiresLeaderHandoffBeforeQuit -- the current leader quitting WITHOUT disbanding must send 0x0290 AssignNewLeader before 0x00A3, lane B §2.5/§3.6). Allegiance: seeded by AllegianceUpdate (0x0020, always self) and, self-gated on TargetGuid == playerGuid(), by AllegianceInfoResponse (0x027C); wraps the FA1-assembled flat AllegianceMemberRecord list directly (AllegianceTree was deleted at FA1 -- nothing left to wrap). Both apply the full 8-edit J-owner template: construction + fault points + Owner/View properties + CaptureOwnership + a new GameRuntimeTeardownStage pair (FellowshipDisposed/AllegianceDisposed, stage count 11->13) + RuntimeGameplayOwnershipSnapshot inclusion + RuntimeStateCheckpoint/trace fields. IRuntimeFellowshipCommands/ IRuntimeAllegianceCommands added to IGameRuntimeCommands and implemented on DirectGameRuntimeCommandAdapter. No IRuntimeEventObserver member added (D2) -- consumers poll Snapshot.Revision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1c40104896
commit
369729f06a
19 changed files with 1949 additions and 35 deletions
|
|
@ -34,8 +34,11 @@ public enum GameRuntimeTeardownStage
|
||||||
CharacterDisposed = 1 << 6,
|
CharacterDisposed = 1 << 6,
|
||||||
InventoryDisposed = 1 << 7,
|
InventoryDisposed = 1 << 7,
|
||||||
CommunicationDisposed = 1 << 8,
|
CommunicationDisposed = 1 << 8,
|
||||||
IdentityDisposed = 1 << 9,
|
// Campaign FA slice FA2 (2026-08-12): the two sibling J-owners.
|
||||||
EntityObjectsDisposed = 1 << 10,
|
FellowshipDisposed = 1 << 9,
|
||||||
|
AllegianceDisposed = 1 << 10,
|
||||||
|
IdentityDisposed = 1 << 11,
|
||||||
|
EntityObjectsDisposed = 1 << 12,
|
||||||
Complete =
|
Complete =
|
||||||
HostLeasesReleased
|
HostLeasesReleased
|
||||||
| EventsDetached
|
| EventsDetached
|
||||||
|
|
@ -46,6 +49,8 @@ public enum GameRuntimeTeardownStage
|
||||||
| CharacterDisposed
|
| CharacterDisposed
|
||||||
| InventoryDisposed
|
| InventoryDisposed
|
||||||
| CommunicationDisposed
|
| CommunicationDisposed
|
||||||
|
| FellowshipDisposed
|
||||||
|
| AllegianceDisposed
|
||||||
| IdentityDisposed
|
| IdentityDisposed
|
||||||
| EntityObjectsDisposed,
|
| EntityObjectsDisposed,
|
||||||
}
|
}
|
||||||
|
|
@ -87,6 +92,8 @@ internal enum GameRuntimeConstructionPoint
|
||||||
InventoryCreated,
|
InventoryCreated,
|
||||||
CharacterCreated,
|
CharacterCreated,
|
||||||
CommunicationCreated,
|
CommunicationCreated,
|
||||||
|
FellowshipCreated,
|
||||||
|
AllegianceCreated,
|
||||||
MovementCreated,
|
MovementCreated,
|
||||||
ActionsCreated,
|
ActionsCreated,
|
||||||
EnvironmentCreated,
|
EnvironmentCreated,
|
||||||
|
|
@ -102,6 +109,8 @@ internal sealed class GameRuntimeConstructionContext
|
||||||
public RuntimeInventoryState? Inventory { get; set; }
|
public RuntimeInventoryState? Inventory { get; set; }
|
||||||
public RuntimeCharacterState? Character { get; set; }
|
public RuntimeCharacterState? Character { get; set; }
|
||||||
public RuntimeCommunicationState? Communication { get; set; }
|
public RuntimeCommunicationState? Communication { get; set; }
|
||||||
|
public RuntimeFellowshipState? Fellowship { get; set; }
|
||||||
|
public RuntimeAllegianceState? Allegiance { get; set; }
|
||||||
public RuntimeLocalPlayerMovementState? Movement { get; set; }
|
public RuntimeLocalPlayerMovementState? Movement { get; set; }
|
||||||
public RuntimeActionState? Actions { get; set; }
|
public RuntimeActionState? Actions { get; set; }
|
||||||
public GameRuntimeEventHub? Events { get; set; }
|
public GameRuntimeEventHub? Events { get; set; }
|
||||||
|
|
@ -117,7 +126,7 @@ public sealed class GameRuntime
|
||||||
IRuntimeEventSource,
|
IRuntimeEventSource,
|
||||||
IDisposable
|
IDisposable
|
||||||
{
|
{
|
||||||
private const int TeardownStageCount = 11;
|
private const int TeardownStageCount = 13;
|
||||||
|
|
||||||
private readonly object _lifetimeGate = new();
|
private readonly object _lifetimeGate = new();
|
||||||
private readonly Dictionary<long, string> _hostLeases = [];
|
private readonly Dictionary<long, string> _hostLeases = [];
|
||||||
|
|
@ -213,6 +222,24 @@ public sealed class GameRuntime
|
||||||
context,
|
context,
|
||||||
faultInjection);
|
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();
|
context.Movement = new RuntimeLocalPlayerMovementState();
|
||||||
// Campaign CH slice CH2: local jump refusals (CommenceJump/
|
// Campaign CH slice CH2: local jump refusals (CommenceJump/
|
||||||
// DoJump's WeenieError family — research doc §4.2/§6.4) reach
|
// DoJump's WeenieError family — research doc §4.2/§6.4) reach
|
||||||
|
|
@ -265,7 +292,8 @@ public sealed class GameRuntime
|
||||||
context.Movement,
|
context.Movement,
|
||||||
context.EntityObjects,
|
context.EntityObjects,
|
||||||
context.Character,
|
context.Character,
|
||||||
context.PlayerIdentity);
|
context.PlayerIdentity,
|
||||||
|
context.Fellowship);
|
||||||
|
|
||||||
context.Movement.AttachPhysicsPublication(
|
context.Movement.AttachPhysicsPublication(
|
||||||
new RuntimeLocalPlayerPhysicsPublicationState(
|
new RuntimeLocalPlayerPhysicsPublicationState(
|
||||||
|
|
@ -318,6 +346,8 @@ public sealed class GameRuntime
|
||||||
InventoryOwner = context.Inventory;
|
InventoryOwner = context.Inventory;
|
||||||
CharacterOwner = context.Character;
|
CharacterOwner = context.Character;
|
||||||
CommunicationOwner = context.Communication;
|
CommunicationOwner = context.Communication;
|
||||||
|
FellowshipOwner = context.Fellowship;
|
||||||
|
AllegianceOwner = context.Allegiance;
|
||||||
MovementOwner = context.Movement;
|
MovementOwner = context.Movement;
|
||||||
ActionOwner = context.Actions;
|
ActionOwner = context.Actions;
|
||||||
EnvironmentOwner = environment;
|
EnvironmentOwner = environment;
|
||||||
|
|
@ -421,6 +451,8 @@ public sealed class GameRuntime
|
||||||
public RuntimeInventoryState InventoryOwner { get; }
|
public RuntimeInventoryState InventoryOwner { get; }
|
||||||
public RuntimeCharacterState CharacterOwner { get; }
|
public RuntimeCharacterState CharacterOwner { get; }
|
||||||
public RuntimeCommunicationState CommunicationOwner { get; }
|
public RuntimeCommunicationState CommunicationOwner { get; }
|
||||||
|
public RuntimeFellowshipState FellowshipOwner { get; }
|
||||||
|
public RuntimeAllegianceState AllegianceOwner { get; }
|
||||||
public RuntimeActionState ActionOwner { get; }
|
public RuntimeActionState ActionOwner { get; }
|
||||||
public RuntimeLocalPlayerMovementState MovementOwner { get; }
|
public RuntimeLocalPlayerMovementState MovementOwner { get; }
|
||||||
internal RuntimeLocalPlayerPhysicsPublicationState
|
internal RuntimeLocalPlayerPhysicsPublicationState
|
||||||
|
|
@ -464,6 +496,8 @@ public sealed class GameRuntime
|
||||||
public IRuntimeCharacterView Character => CharacterOwner.View;
|
public IRuntimeCharacterView Character => CharacterOwner.View;
|
||||||
public IRuntimeSocialView Social => CommunicationOwner.SocialView;
|
public IRuntimeSocialView Social => CommunicationOwner.SocialView;
|
||||||
public IRuntimeChatView Chat => CommunicationOwner.View;
|
public IRuntimeChatView Chat => CommunicationOwner.View;
|
||||||
|
public IRuntimeFellowshipView Fellowship => FellowshipOwner.View;
|
||||||
|
public IRuntimeAllegianceView Allegiance => AllegianceOwner.View;
|
||||||
public IRuntimeActionView Actions => ActionOwner.View;
|
public IRuntimeActionView Actions => ActionOwner.View;
|
||||||
public IRuntimeMovementView Movement => MovementOwner.View;
|
public IRuntimeMovementView Movement => MovementOwner.View;
|
||||||
public IRuntimeWorldEnvironmentView Environment => EnvironmentOwner;
|
public IRuntimeWorldEnvironmentView Environment => EnvironmentOwner;
|
||||||
|
|
@ -490,7 +524,9 @@ public sealed class GameRuntime
|
||||||
Environment.Snapshot,
|
Environment.Snapshot,
|
||||||
Environment.Ownership,
|
Environment.Ownership,
|
||||||
Portal.Snapshot,
|
Portal.Snapshot,
|
||||||
Portal.Ownership);
|
Portal.Ownership,
|
||||||
|
Fellowship.Snapshot,
|
||||||
|
Allegiance.Snapshot);
|
||||||
|
|
||||||
public RuntimeLocalPlayerFrameController CreateLocalPlayerFrameController(
|
public RuntimeLocalPlayerFrameController CreateLocalPlayerFrameController(
|
||||||
IRuntimeLocalPlayerFrameHost host,
|
IRuntimeLocalPlayerFrameHost host,
|
||||||
|
|
@ -560,7 +596,9 @@ public sealed class GameRuntime
|
||||||
CharacterOwner,
|
CharacterOwner,
|
||||||
CommunicationOwner,
|
CommunicationOwner,
|
||||||
ActionOwner,
|
ActionOwner,
|
||||||
MovementOwner),
|
MovementOwner,
|
||||||
|
FellowshipOwner,
|
||||||
|
AllegianceOwner),
|
||||||
EnvironmentOwner.CaptureOwnership(),
|
EnvironmentOwner.CaptureOwnership(),
|
||||||
TransitOwner.CaptureOwnership(),
|
TransitOwner.CaptureOwnership(),
|
||||||
GenerationReset.CaptureSnapshot(),
|
GenerationReset.CaptureSnapshot(),
|
||||||
|
|
@ -675,10 +713,24 @@ public sealed class GameRuntime
|
||||||
| GameRuntimeTeardownStage.MovementDisposed
|
| GameRuntimeTeardownStage.MovementDisposed
|
||||||
| GameRuntimeTeardownStage.CharacterDisposed
|
| GameRuntimeTeardownStage.CharacterDisposed
|
||||||
| GameRuntimeTeardownStage.InventoryDisposed,
|
| 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.IdentityDisposed
|
||||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||||
10 => GameRuntimeTeardownStage.Complete
|
11 => GameRuntimeTeardownStage.Complete
|
||||||
|
& ~GameRuntimeTeardownStage.IdentityDisposed
|
||||||
|
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||||
|
12 => GameRuntimeTeardownStage.Complete
|
||||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||||
_ => GameRuntimeTeardownStage.Complete,
|
_ => GameRuntimeTeardownStage.Complete,
|
||||||
};
|
};
|
||||||
|
|
@ -724,9 +776,15 @@ public sealed class GameRuntime
|
||||||
CommunicationOwner.Dispose();
|
CommunicationOwner.Dispose();
|
||||||
return CommunicationOwner.CaptureOwnership().IsConverged;
|
return CommunicationOwner.CaptureOwnership().IsConverged;
|
||||||
case 9:
|
case 9:
|
||||||
|
FellowshipOwner.Dispose();
|
||||||
|
return FellowshipOwner.CaptureOwnership().IsConverged;
|
||||||
|
case 10:
|
||||||
|
AllegianceOwner.Dispose();
|
||||||
|
return AllegianceOwner.CaptureOwnership().IsConverged;
|
||||||
|
case 11:
|
||||||
PlayerIdentity.Dispose();
|
PlayerIdentity.Dispose();
|
||||||
return PlayerIdentity.CaptureOwnership().IsConverged;
|
return PlayerIdentity.CaptureOwnership().IsConverged;
|
||||||
case 10:
|
case 12:
|
||||||
EntityObjects.Dispose();
|
EntityObjects.Dispose();
|
||||||
return EntityObjects.CaptureOwnership().IsConverged
|
return EntityObjects.CaptureOwnership().IsConverged
|
||||||
&& EntityObjects.Physics.CaptureOwnership().IsConverged;
|
&& EntityObjects.Physics.CaptureOwnership().IsConverged;
|
||||||
|
|
@ -747,8 +805,10 @@ public sealed class GameRuntime
|
||||||
6 => CharacterOwner.CaptureOwnership().IsConverged,
|
6 => CharacterOwner.CaptureOwnership().IsConverged,
|
||||||
7 => InventoryOwner.CaptureOwnership().IsConverged,
|
7 => InventoryOwner.CaptureOwnership().IsConverged,
|
||||||
8 => CommunicationOwner.CaptureOwnership().IsConverged,
|
8 => CommunicationOwner.CaptureOwnership().IsConverged,
|
||||||
9 => PlayerIdentity.CaptureOwnership().IsConverged,
|
9 => FellowshipOwner.CaptureOwnership().IsConverged,
|
||||||
10 => EntityObjects.CaptureOwnership().IsConverged
|
10 => AllegianceOwner.CaptureOwnership().IsConverged,
|
||||||
|
11 => PlayerIdentity.CaptureOwnership().IsConverged,
|
||||||
|
12 => EntityObjects.CaptureOwnership().IsConverged
|
||||||
&& EntityObjects.Physics.CaptureOwnership().IsConverged,
|
&& EntityObjects.Physics.CaptureOwnership().IsConverged,
|
||||||
_ => true,
|
_ => true,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,76 @@ public interface IRuntimeSocialCommands
|
||||||
in RuntimeSquelchCommand command);
|
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
|
public interface IGameRuntimeCommands
|
||||||
{
|
{
|
||||||
IRuntimeSessionCommands Session { get; }
|
IRuntimeSessionCommands Session { get; }
|
||||||
|
|
@ -322,4 +392,8 @@ public interface IGameRuntimeCommands
|
||||||
IRuntimeCharacterCommands Character { get; }
|
IRuntimeCharacterCommands Character { get; }
|
||||||
|
|
||||||
IRuntimeSocialCommands Social { get; }
|
IRuntimeSocialCommands Social { get; }
|
||||||
|
|
||||||
|
IRuntimeFellowshipCommands Fellowship { get; }
|
||||||
|
|
||||||
|
IRuntimeAllegianceCommands Allegiance { get; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ public enum RuntimeCommandDomain
|
||||||
Character = 8,
|
Character = 8,
|
||||||
Social = 9,
|
Social = 9,
|
||||||
Magic = 10,
|
Magic = 10,
|
||||||
|
Fellowship = 11,
|
||||||
|
Allegiance = 12,
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly record struct RuntimeLifecycleDelta(
|
public readonly record struct RuntimeLifecycleDelta(
|
||||||
|
|
@ -179,6 +181,12 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver
|
||||||
$"social={checkpoint.Social.FriendsRevision}:" +
|
$"social={checkpoint.Social.FriendsRevision}:" +
|
||||||
$"{checkpoint.Social.FriendCount}:" +
|
$"{checkpoint.Social.FriendCount}:" +
|
||||||
$"{checkpoint.Social.SquelchRevision};" +
|
$"{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}:" +
|
$"actions={checkpoint.Actions.SelectionRevision}:" +
|
||||||
$"{checkpoint.Actions.SelectedObjectId:X8}:" +
|
$"{checkpoint.Actions.SelectedObjectId:X8}:" +
|
||||||
$"{checkpoint.Actions.CombatRevision}:" +
|
$"{checkpoint.Actions.CombatRevision}:" +
|
||||||
|
|
|
||||||
|
|
@ -108,3 +108,87 @@ public interface IRuntimeSocialView
|
||||||
|
|
||||||
bool TryGetFriend(uint characterId, out RuntimeFriendSnapshot friend);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,11 @@ public readonly record struct RuntimeStateCheckpoint(
|
||||||
RuntimeWorldEnvironmentSnapshot Environment,
|
RuntimeWorldEnvironmentSnapshot Environment,
|
||||||
RuntimeWorldEnvironmentOwnershipSnapshot EnvironmentOwnership,
|
RuntimeWorldEnvironmentOwnershipSnapshot EnvironmentOwnership,
|
||||||
RuntimePortalSnapshot Portal,
|
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
|
public interface IGameRuntimeView
|
||||||
{
|
{
|
||||||
|
|
@ -260,6 +264,10 @@ public interface IGameRuntimeView
|
||||||
|
|
||||||
IRuntimeChatView Chat { get; }
|
IRuntimeChatView Chat { get; }
|
||||||
|
|
||||||
|
IRuntimeFellowshipView Fellowship { get; }
|
||||||
|
|
||||||
|
IRuntimeAllegianceView Allegiance { get; }
|
||||||
|
|
||||||
IRuntimeActionView Actions { get; }
|
IRuntimeActionView Actions { get; }
|
||||||
|
|
||||||
IRuntimeMovementView Movement { get; }
|
IRuntimeMovementView Movement { get; }
|
||||||
|
|
|
||||||
309
src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs
Normal file
309
src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
|
namespace AcDream.Runtime.Gameplay;
|
||||||
|
|
||||||
|
public readonly record struct RuntimeAllegianceOwnershipSnapshot(
|
||||||
|
bool IsDisposed,
|
||||||
|
bool HasProfile,
|
||||||
|
int RecordCount)
|
||||||
|
{
|
||||||
|
public bool IsConverged =>
|
||||||
|
IsDisposed
|
||||||
|
&& !HasProfile
|
||||||
|
&& RecordCount == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical presentation-independent owner for the local player's
|
||||||
|
/// allegiance profile — Campaign FA slice FA2 (2026-08-12). Survives
|
||||||
|
/// reconnect (unlike <see cref="RuntimeFellowshipState"/>, which is
|
||||||
|
/// session-scoped): it is NOT a
|
||||||
|
/// <see cref="RuntimeGenerationReset"/> stage, and its data persists across
|
||||||
|
/// a generation boundary the same way a re-login does not sever your
|
||||||
|
/// character's allegiance membership. The <see cref="HasServerSeed"/>
|
||||||
|
/// latch is a <see cref="RuntimeCharacterOptionsState.HasServerSeed"/>-
|
||||||
|
/// style one-way flag distinguishing "no profile has ever arrived" from
|
||||||
|
/// "genuinely no allegiance" (a real push with a null monarch) — it only
|
||||||
|
/// clears at terminal <see cref="Dispose"/>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Seeded by <c>0x0020 AllegianceUpdate</c> (the unsolicited/subscribed
|
||||||
|
/// push — always about the local player's own tree) and, self-gated, by
|
||||||
|
/// <c>0x027C AllegianceInfoResponse</c> when the response's TargetGuid is
|
||||||
|
/// the local player's own guid (an explicit <c>@allegiance info</c>
|
||||||
|
/// self-query; a by-name query against another player's tree is NOT
|
||||||
|
/// applied here — see <see cref="GameEventWiring"/>'s registration).
|
||||||
|
/// Wraps <c>ClientCommandResponses.AllegianceMemberRecord</c> — the
|
||||||
|
/// FA1-assembled flat vassal list + monarch/patron/self blocks
|
||||||
|
/// (<c>AllegianceTree</c> was DELETED at FA1; there is nothing left to
|
||||||
|
/// wrap — see the seam-map addendum,
|
||||||
|
/// docs/research/2026-08-11-fa-acdream-seams.md §8).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RuntimeAllegianceState : IDisposable
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private ClientCommandResponses.AllegianceMemberRecord? _monarch;
|
||||||
|
private IReadOnlyList<ClientCommandResponses.AllegianceMemberRecord> _records =
|
||||||
|
[];
|
||||||
|
private string _allegianceName = string.Empty;
|
||||||
|
private uint _totalMembers;
|
||||||
|
private uint _totalVassals;
|
||||||
|
private uint _rank;
|
||||||
|
private bool _hasProfile;
|
||||||
|
private bool _hasServerSeed;
|
||||||
|
private long _revision;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public RuntimeAllegianceState() => View = new AllegianceView(this);
|
||||||
|
|
||||||
|
public IRuntimeAllegianceView View { get; }
|
||||||
|
|
||||||
|
public bool IsDisposed
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _disposed; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Has any real allegiance push landed since construction? A one-way
|
||||||
|
/// latch — see the class doc. Never cleared by <see cref="ResetSession"/>
|
||||||
|
/// wiring (this owner has none); only <see cref="Dispose"/> clears it.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasServerSeed
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _hasServerSeed; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>0x0020 AllegianceUpdate</c> — the unsolicited/subscribed profile push.</summary>
|
||||||
|
public void ApplyUpdate(ClientCommandResponses.AllegianceUpdate update)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_monarch = update.Monarch;
|
||||||
|
_records = update.Records;
|
||||||
|
_allegianceName = update.AllegianceName;
|
||||||
|
_totalMembers = update.TotalMembers;
|
||||||
|
_totalVassals = update.TotalVassals;
|
||||||
|
_rank = update.Rank;
|
||||||
|
_hasProfile = true;
|
||||||
|
_hasServerSeed = true;
|
||||||
|
Bump();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>0x027C AllegianceInfoResponse</c>, self-gated by the caller
|
||||||
|
/// (<see cref="GameEventWiring"/>'s registration checks
|
||||||
|
/// <c>TargetGuid == playerGuid()</c> before invoking this). Carries no
|
||||||
|
/// rank field on the wire — the last known rank (if any) is retained.
|
||||||
|
/// </summary>
|
||||||
|
public void ApplyInfoResponseSelf(
|
||||||
|
ClientCommandResponses.AllegianceInfoResponse response)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_monarch = response.Monarch;
|
||||||
|
_records = response.Records;
|
||||||
|
_allegianceName = response.AllegianceName;
|
||||||
|
_totalMembers = response.TotalMembers;
|
||||||
|
_totalVassals = response.TotalVassals;
|
||||||
|
_hasProfile = true;
|
||||||
|
_hasServerSeed = true;
|
||||||
|
Bump();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>0x027A AllegianceLoginNotification</c> — bumps the revision so a
|
||||||
|
/// polling consumer can observe the event happened; the retail-faithful
|
||||||
|
/// two-line chat text this notice carries is NOT emitted here. Its
|
||||||
|
/// literal retail string could not be verified from primary source
|
||||||
|
/// (<c>gmAllegianceUI::RecvNotice_AllegianceLogin @0x00492220</c>
|
||||||
|
/// resolves its two candidate strings through Binary Ninja symbols that
|
||||||
|
/// collide with unrelated vtable slot names — a DAT string-table
|
||||||
|
/// lookup is needed before this can be added faithfully; see FA2's
|
||||||
|
/// final report). Deliberately does not gate on the "already known"
|
||||||
|
/// filter retail itself applies (lane C §1.6) — that is a display-time
|
||||||
|
/// concern for the text this owner does not yet produce.
|
||||||
|
/// </summary>
|
||||||
|
public void ApplyLoginNotification(uint characterGuid, bool isLoggedIn)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||||
|
lock (_gate) Bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>0x01C8 AllegianceUpdateDone</c> — clears the panel busy latch; carries the WeenieError for a failed swear/break.</summary>
|
||||||
|
public void ApplyUpdateDone(uint weenieError)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||||
|
lock (_gate) Bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>0x0003 AllegianceUpdateAborted</c> — declared by retail but never actually sent by ACE; parsed for forward-compat.</summary>
|
||||||
|
public void ApplyUpdateAborted(uint weenieError)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||||
|
lock (_gate) Bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
public RuntimeAllegianceOwnershipSnapshot CaptureOwnership()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
return new RuntimeAllegianceOwnershipSnapshot(
|
||||||
|
_disposed,
|
||||||
|
_hasProfile,
|
||||||
|
_records.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_monarch = null;
|
||||||
|
_records = [];
|
||||||
|
_allegianceName = string.Empty;
|
||||||
|
_totalMembers = 0u;
|
||||||
|
_totalVassals = 0u;
|
||||||
|
_rank = 0u;
|
||||||
|
_hasProfile = false;
|
||||||
|
_hasServerSeed = false;
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Bump() => _revision++;
|
||||||
|
|
||||||
|
private sealed class AllegianceView(RuntimeAllegianceState owner)
|
||||||
|
: IRuntimeAllegianceView
|
||||||
|
{
|
||||||
|
public RuntimeAllegianceSnapshot Snapshot
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (owner._gate)
|
||||||
|
return new RuntimeAllegianceSnapshot(
|
||||||
|
owner._revision,
|
||||||
|
owner._hasServerSeed,
|
||||||
|
owner._hasProfile,
|
||||||
|
owner._rank,
|
||||||
|
owner._totalMembers,
|
||||||
|
owner._totalVassals,
|
||||||
|
owner._allegianceName,
|
||||||
|
owner._monarch?.CharacterId ?? 0u,
|
||||||
|
owner._records.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetMonarch(out RuntimeAllegianceMemberSnapshot monarch)
|
||||||
|
{
|
||||||
|
lock (owner._gate)
|
||||||
|
{
|
||||||
|
if (owner._monarch is not { } record)
|
||||||
|
{
|
||||||
|
monarch = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
monarch = ToSnapshot(record);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetMember(uint guid, out RuntimeAllegianceMemberSnapshot member)
|
||||||
|
{
|
||||||
|
lock (owner._gate)
|
||||||
|
{
|
||||||
|
if (owner._monarch is { } monarch && monarch.CharacterId == guid)
|
||||||
|
{
|
||||||
|
member = ToSnapshot(monarch);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
foreach (ClientCommandResponses.AllegianceMemberRecord record in owner._records)
|
||||||
|
{
|
||||||
|
if (record.CharacterId != guid) continue;
|
||||||
|
member = ToSnapshot(record);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
member = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetPatron(uint guid, out RuntimeAllegianceMemberSnapshot patron)
|
||||||
|
{
|
||||||
|
lock (owner._gate)
|
||||||
|
{
|
||||||
|
if (owner._monarch is { } monarch && monarch.CharacterId == guid)
|
||||||
|
{
|
||||||
|
// Port of AllegianceProfile::GetPatron: the monarch has no patron.
|
||||||
|
patron = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach (ClientCommandResponses.AllegianceMemberRecord record in owner._records)
|
||||||
|
{
|
||||||
|
if (record.CharacterId != guid) continue;
|
||||||
|
return TryGetMemberLocked(record.ParentGuid, out patron);
|
||||||
|
}
|
||||||
|
patron = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<RuntimeAllegianceMemberSnapshot> GetVassals(uint guid)
|
||||||
|
{
|
||||||
|
List<RuntimeAllegianceMemberSnapshot> result;
|
||||||
|
lock (owner._gate)
|
||||||
|
{
|
||||||
|
// Lane C §4.4 point 3: each new record is PREPENDED to its
|
||||||
|
// parent's vassal list on assembly, so the walk visits
|
||||||
|
// siblings in REVERSE wire order.
|
||||||
|
result = new List<RuntimeAllegianceMemberSnapshot>();
|
||||||
|
for (int i = owner._records.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
ClientCommandResponses.AllegianceMemberRecord record = owner._records[i];
|
||||||
|
if (record.ParentGuid == guid)
|
||||||
|
result.Add(ToSnapshot(record));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetMemberLocked(
|
||||||
|
uint guid,
|
||||||
|
out RuntimeAllegianceMemberSnapshot member)
|
||||||
|
{
|
||||||
|
if (owner._monarch is { } monarch && monarch.CharacterId == guid)
|
||||||
|
{
|
||||||
|
member = ToSnapshot(monarch);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
foreach (ClientCommandResponses.AllegianceMemberRecord record in owner._records)
|
||||||
|
{
|
||||||
|
if (record.CharacterId != guid) continue;
|
||||||
|
member = ToSnapshot(record);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
member = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RuntimeAllegianceMemberSnapshot ToSnapshot(
|
||||||
|
ClientCommandResponses.AllegianceMemberRecord record) =>
|
||||||
|
new(
|
||||||
|
record.CharacterId,
|
||||||
|
record.ParentGuid,
|
||||||
|
record.IsLoggedIn,
|
||||||
|
record.Name,
|
||||||
|
record.Rank,
|
||||||
|
record.Level,
|
||||||
|
record.Loyalty,
|
||||||
|
record.Leadership,
|
||||||
|
record.CpCached,
|
||||||
|
record.CpTithed,
|
||||||
|
record.Gender,
|
||||||
|
record.HeritageGroup,
|
||||||
|
record.MayPassupExperience);
|
||||||
|
}
|
||||||
|
}
|
||||||
295
src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs
Normal file
295
src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
|
namespace AcDream.Runtime.Gameplay;
|
||||||
|
|
||||||
|
public readonly record struct RuntimeFellowshipOwnershipSnapshot(
|
||||||
|
bool IsDisposed,
|
||||||
|
bool IsInFellowship,
|
||||||
|
int MemberCount)
|
||||||
|
{
|
||||||
|
public bool IsConverged =>
|
||||||
|
IsDisposed
|
||||||
|
&& !IsInFellowship
|
||||||
|
&& MemberCount == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical presentation-independent owner for the local player's
|
||||||
|
/// fellowship roster — Campaign FA slice FA2 (2026-08-12). Session-scoped:
|
||||||
|
/// a disconnect drops you from the fellowship server-side, so this clears
|
||||||
|
/// at generation reset exactly like the external-container precedent
|
||||||
|
/// (<see cref="RuntimeInventoryState.ResetExternalContainer"/>), unlike
|
||||||
|
/// <see cref="RuntimeAllegianceState"/> which survives reconnect
|
||||||
|
/// (docs/research/2026-08-11-fa-acdream-seams.md §1.3).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Assembled from the FA1 parsers: a full update (<c>0x02BE</c>) REPLACES
|
||||||
|
/// the whole roster; an incremental fellow update (<c>0x02C0</c>) upserts
|
||||||
|
/// one member by guid; a quit/dismiss notice (<c>0x00A3</c>/<c>0x00A4</c>)
|
||||||
|
/// removes one member, or clears the whole snapshot when the removed guid
|
||||||
|
/// is the local player's own (self-removal); a disband (<c>0x02BF</c>)
|
||||||
|
/// always clears. Every mutation bumps <see cref="View"/>'s monotonic
|
||||||
|
/// <c>Snapshot.Revision</c> — consumers (bots, future panel UI) poll it
|
||||||
|
/// rather than subscribing to a push event (D2 — no
|
||||||
|
/// <see cref="IRuntimeEventObserver"/> member).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RuntimeFellowshipState : IDisposable
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly Dictionary<uint, GameEvents.FellowMember> _members = [];
|
||||||
|
private string _name = string.Empty;
|
||||||
|
private uint _leaderGuid;
|
||||||
|
private bool _shareXp;
|
||||||
|
private bool _evenXpSplit;
|
||||||
|
private bool _isOpen;
|
||||||
|
private bool _locked;
|
||||||
|
private bool _isInFellowship;
|
||||||
|
private long _revision;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public RuntimeFellowshipState() => View = new FellowshipView(this);
|
||||||
|
|
||||||
|
public IRuntimeFellowshipView View { get; }
|
||||||
|
|
||||||
|
public bool IsDisposed
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _disposed; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>0x02BE FellowshipFullUpdate</c> — a complete authoritative
|
||||||
|
/// replace of the roster and fellowship-level flags (name, leader,
|
||||||
|
/// shareXp/evenXpSplit/open/locked). Fires on join, on recruit success
|
||||||
|
/// (both the recruiter's and the recruit's own client), and whenever
|
||||||
|
/// ACE decides a full resync is cheaper than an incremental.
|
||||||
|
/// </summary>
|
||||||
|
public void ApplyFullUpdate(GameEvents.FellowshipFullUpdate update)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_members.Clear();
|
||||||
|
foreach (GameEvents.FellowMember member in update.Members)
|
||||||
|
_members[member.Guid] = member;
|
||||||
|
_name = update.Name;
|
||||||
|
_leaderGuid = update.LeaderGuid;
|
||||||
|
_shareXp = update.ShareXp;
|
||||||
|
_evenXpSplit = update.EvenXpSplit;
|
||||||
|
_isOpen = update.OpenFellow;
|
||||||
|
_locked = update.Locked;
|
||||||
|
_isInFellowship = true;
|
||||||
|
Bump();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>0x02C0 FellowshipUpdateFellow</c> — upserts exactly one member by
|
||||||
|
/// guid (vitals/level/name refresh). A no-op before any full update has
|
||||||
|
/// ever established that the local player IS in a fellowship — retail
|
||||||
|
/// never sends this to a client outside one either.
|
||||||
|
/// </summary>
|
||||||
|
public void ApplyUpdateFellow(GameEvents.FellowshipUpdateFellow update)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_isInFellowship) return;
|
||||||
|
_members[update.MemberGuid] = update.Member;
|
||||||
|
Bump();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>0x00A3 FellowshipQuit</c> (S→C direction) — sent both to the
|
||||||
|
/// quitter and to every remaining member. Self-removal (<paramref
|
||||||
|
/// name="quitterGuid"/> == <paramref name="selfGuid"/>) clears the
|
||||||
|
/// whole snapshot; otherwise the named member is removed from the
|
||||||
|
/// roster.
|
||||||
|
/// </summary>
|
||||||
|
public void ApplyQuit(uint quitterGuid, uint selfGuid)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_isInFellowship) return;
|
||||||
|
if (quitterGuid == selfGuid)
|
||||||
|
{
|
||||||
|
ClearLocked();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_members.Remove(quitterGuid))
|
||||||
|
Bump();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>0x00A4 FellowshipDismiss</c> (S→C direction) — same self-vs-other
|
||||||
|
/// removal rule as <see cref="ApplyQuit"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void ApplyDismiss(uint dismissedGuid, uint selfGuid)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_isInFellowship) return;
|
||||||
|
if (dismissedGuid == selfGuid)
|
||||||
|
{
|
||||||
|
ClearLocked();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_members.Remove(dismissedGuid))
|
||||||
|
Bump();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>0x02BF FellowshipDisband</c> — always clears.</summary>
|
||||||
|
public void ApplyDisband()
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||||
|
lock (_gate) ClearLocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The local player's own current leader guid, or 0 when not in a fellowship.</summary>
|
||||||
|
public uint LeaderGuid
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _isInFellowship ? _leaderGuid : 0u; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsInFellowship
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _isInFellowship; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's Quit-button leader hand-off rule (lane B §2.5/§3.6): the
|
||||||
|
/// current leader quitting WITHOUT disbanding must first assign
|
||||||
|
/// leadership to any other fellow. Returns <see langword="true"/> with
|
||||||
|
/// a candidate guid exactly when that hand-off is required; the caller
|
||||||
|
/// (the command adapters) sends <c>0x0290</c> before <c>0x00A3</c> when
|
||||||
|
/// this returns true.
|
||||||
|
/// </summary>
|
||||||
|
public bool RequiresLeaderHandoffBeforeQuit(
|
||||||
|
uint selfGuid,
|
||||||
|
bool disband,
|
||||||
|
out uint newLeaderGuid)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (disband || !_isInFellowship || _leaderGuid != selfGuid)
|
||||||
|
{
|
||||||
|
newLeaderGuid = 0u;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach (uint guid in _members.Keys)
|
||||||
|
{
|
||||||
|
if (guid == selfGuid) continue;
|
||||||
|
newLeaderGuid = guid;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
newLeaderGuid = 0u;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public RuntimeFellowshipOwnershipSnapshot CaptureOwnership()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
return new RuntimeFellowshipOwnershipSnapshot(
|
||||||
|
_disposed,
|
||||||
|
_isInFellowship,
|
||||||
|
_members.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Session-scoped: cleared at every generation reset (reconnect).</summary>
|
||||||
|
public void ResetSession()
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||||
|
lock (_gate) ClearLocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
ClearLocked();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearLocked()
|
||||||
|
{
|
||||||
|
bool changed = _members.Count != 0
|
||||||
|
|| _isInFellowship
|
||||||
|
|| _name.Length != 0
|
||||||
|
|| _leaderGuid != 0u
|
||||||
|
|| _shareXp
|
||||||
|
|| _evenXpSplit
|
||||||
|
|| _isOpen
|
||||||
|
|| _locked;
|
||||||
|
_members.Clear();
|
||||||
|
_name = string.Empty;
|
||||||
|
_leaderGuid = 0u;
|
||||||
|
_shareXp = false;
|
||||||
|
_evenXpSplit = false;
|
||||||
|
_isOpen = false;
|
||||||
|
_locked = false;
|
||||||
|
_isInFellowship = false;
|
||||||
|
if (changed) Bump();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Bump() => _revision++;
|
||||||
|
|
||||||
|
private sealed class FellowshipView(RuntimeFellowshipState owner)
|
||||||
|
: IRuntimeFellowshipView
|
||||||
|
{
|
||||||
|
public RuntimeFellowshipSnapshot Snapshot
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (owner._gate)
|
||||||
|
return new RuntimeFellowshipSnapshot(
|
||||||
|
owner._revision,
|
||||||
|
owner._isInFellowship,
|
||||||
|
owner._name,
|
||||||
|
owner._leaderGuid,
|
||||||
|
owner._shareXp,
|
||||||
|
owner._evenXpSplit,
|
||||||
|
owner._isOpen,
|
||||||
|
owner._locked,
|
||||||
|
owner._members.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetMember(uint guid, out RuntimeFellowMemberSnapshot member)
|
||||||
|
{
|
||||||
|
lock (owner._gate)
|
||||||
|
{
|
||||||
|
if (!owner._members.TryGetValue(guid, out GameEvents.FellowMember raw))
|
||||||
|
{
|
||||||
|
member = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
member = ToSnapshot(raw);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RuntimeFellowMemberSnapshot ToSnapshot(
|
||||||
|
GameEvents.FellowMember raw) =>
|
||||||
|
new(
|
||||||
|
raw.Guid,
|
||||||
|
raw.Name,
|
||||||
|
raw.Level,
|
||||||
|
raw.MaxHealth,
|
||||||
|
raw.MaxStamina,
|
||||||
|
raw.MaxMana,
|
||||||
|
raw.CurrentHealth,
|
||||||
|
raw.CurrentStamina,
|
||||||
|
raw.CurrentMana,
|
||||||
|
// D5 (lane B §4.1): the raw wire u32 is != 0, NEVER == 1 —
|
||||||
|
// ACE encodes it two mutually-inconsistent ways.
|
||||||
|
raw.ShareLoot != 0u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,14 +10,19 @@ public readonly record struct RuntimeGameplayOwnershipSnapshot(
|
||||||
RuntimeCharacterOwnershipSnapshot Character,
|
RuntimeCharacterOwnershipSnapshot Character,
|
||||||
RuntimeCommunicationOwnershipSnapshot Communication,
|
RuntimeCommunicationOwnershipSnapshot Communication,
|
||||||
RuntimeActionOwnershipSnapshot Actions,
|
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 =>
|
public bool IsConverged =>
|
||||||
Inventory.IsConverged
|
Inventory.IsConverged
|
||||||
&& Character.IsConverged
|
&& Character.IsConverged
|
||||||
&& Communication.IsConverged
|
&& Communication.IsConverged
|
||||||
&& Actions.IsConverged
|
&& Actions.IsConverged
|
||||||
&& Movement.IsConverged;
|
&& Movement.IsConverged
|
||||||
|
&& Fellowship.IsConverged
|
||||||
|
&& Allegiance.IsConverged;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class RuntimeGameplayOwnership
|
public static class RuntimeGameplayOwnership
|
||||||
|
|
@ -27,18 +32,24 @@ public static class RuntimeGameplayOwnership
|
||||||
RuntimeCharacterState character,
|
RuntimeCharacterState character,
|
||||||
RuntimeCommunicationState communication,
|
RuntimeCommunicationState communication,
|
||||||
RuntimeActionState actions,
|
RuntimeActionState actions,
|
||||||
RuntimeLocalPlayerMovementState movement)
|
RuntimeLocalPlayerMovementState movement,
|
||||||
|
RuntimeFellowshipState fellowship,
|
||||||
|
RuntimeAllegianceState allegiance)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(inventory);
|
ArgumentNullException.ThrowIfNull(inventory);
|
||||||
ArgumentNullException.ThrowIfNull(character);
|
ArgumentNullException.ThrowIfNull(character);
|
||||||
ArgumentNullException.ThrowIfNull(communication);
|
ArgumentNullException.ThrowIfNull(communication);
|
||||||
ArgumentNullException.ThrowIfNull(actions);
|
ArgumentNullException.ThrowIfNull(actions);
|
||||||
ArgumentNullException.ThrowIfNull(movement);
|
ArgumentNullException.ThrowIfNull(movement);
|
||||||
|
ArgumentNullException.ThrowIfNull(fellowship);
|
||||||
|
ArgumentNullException.ThrowIfNull(allegiance);
|
||||||
return new RuntimeGameplayOwnershipSnapshot(
|
return new RuntimeGameplayOwnershipSnapshot(
|
||||||
inventory.CaptureOwnership(),
|
inventory.CaptureOwnership(),
|
||||||
character.CaptureOwnership(),
|
character.CaptureOwnership(),
|
||||||
communication.CaptureOwnership(),
|
communication.CaptureOwnership(),
|
||||||
actions.CaptureOwnership(),
|
actions.CaptureOwnership(),
|
||||||
movement.CaptureOwnership());
|
movement.CaptureOwnership(),
|
||||||
|
fellowship.CaptureOwnership(),
|
||||||
|
allegiance.CaptureOwnership());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,15 +32,23 @@ public enum RuntimeGenerationResetStage
|
||||||
Friends = 9,
|
Friends = 9,
|
||||||
Squelch = 10,
|
Squelch = 10,
|
||||||
NegotiatedChannels = 11,
|
NegotiatedChannels = 11,
|
||||||
BeginEntityRetirement = 12,
|
/// <summary>
|
||||||
RetireEntities = 13,
|
/// Campaign FA slice FA2 (2026-08-12): the fellowship roster is
|
||||||
DrainHostProjection = 14,
|
/// session-scoped (a disconnect drops you from the fellowship
|
||||||
CompleteCanonicalEntities = 15,
|
/// server-side) — clear it here, alongside the other social-list
|
||||||
CompleteHostProjection = 16,
|
/// stages. Allegiance is deliberately NOT a reset stage — it survives
|
||||||
ChatIdentity = 17,
|
/// reconnect (see <see cref="RuntimeAllegianceState"/>'s class doc).
|
||||||
PlayerSnapshots = 18,
|
/// </summary>
|
||||||
PlayerIdentity = 19,
|
Fellowship = 12,
|
||||||
Complete = 20,
|
BeginEntityRetirement = 13,
|
||||||
|
RetireEntities = 14,
|
||||||
|
DrainHostProjection = 15,
|
||||||
|
CompleteCanonicalEntities = 16,
|
||||||
|
CompleteHostProjection = 17,
|
||||||
|
ChatIdentity = 18,
|
||||||
|
PlayerSnapshots = 19,
|
||||||
|
PlayerIdentity = 20,
|
||||||
|
Complete = 21,
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly record struct RuntimeGenerationResetSnapshot(
|
public readonly record struct RuntimeGenerationResetSnapshot(
|
||||||
|
|
@ -88,6 +96,7 @@ public sealed class RuntimeGenerationReset
|
||||||
private readonly RuntimeEntityObjectLifetime _entityObjects;
|
private readonly RuntimeEntityObjectLifetime _entityObjects;
|
||||||
private readonly RuntimeCharacterState _character;
|
private readonly RuntimeCharacterState _character;
|
||||||
private readonly RuntimeLocalPlayerIdentityState _identity;
|
private readonly RuntimeLocalPlayerIdentityState _identity;
|
||||||
|
private readonly RuntimeFellowshipState _fellowship;
|
||||||
private ResetState? _state;
|
private ResetState? _state;
|
||||||
private RuntimeGenerationToken _lastCompletedGeneration;
|
private RuntimeGenerationToken _lastCompletedGeneration;
|
||||||
private bool _hasCompletedGeneration;
|
private bool _hasCompletedGeneration;
|
||||||
|
|
@ -102,7 +111,8 @@ public sealed class RuntimeGenerationReset
|
||||||
RuntimeLocalPlayerMovementState movement,
|
RuntimeLocalPlayerMovementState movement,
|
||||||
RuntimeEntityObjectLifetime entityObjects,
|
RuntimeEntityObjectLifetime entityObjects,
|
||||||
RuntimeCharacterState character,
|
RuntimeCharacterState character,
|
||||||
RuntimeLocalPlayerIdentityState identity)
|
RuntimeLocalPlayerIdentityState identity,
|
||||||
|
RuntimeFellowshipState fellowship)
|
||||||
{
|
{
|
||||||
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
|
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
|
||||||
_communication = communication
|
_communication = communication
|
||||||
|
|
@ -118,6 +128,8 @@ public sealed class RuntimeGenerationReset
|
||||||
?? throw new ArgumentNullException(nameof(character));
|
?? throw new ArgumentNullException(nameof(character));
|
||||||
_identity = identity
|
_identity = identity
|
||||||
?? throw new ArgumentNullException(nameof(identity));
|
?? throw new ArgumentNullException(nameof(identity));
|
||||||
|
_fellowship = fellowship
|
||||||
|
?? throw new ArgumentNullException(nameof(fellowship));
|
||||||
}
|
}
|
||||||
|
|
||||||
public RuntimeGenerationToken? ActiveRetiringGeneration =>
|
public RuntimeGenerationToken? ActiveRetiringGeneration =>
|
||||||
|
|
@ -285,6 +297,9 @@ public sealed class RuntimeGenerationReset
|
||||||
state,
|
state,
|
||||||
_communication.ResetNegotiatedChannels);
|
_communication.ResetNegotiatedChannels);
|
||||||
break;
|
break;
|
||||||
|
case RuntimeGenerationResetStage.Fellowship:
|
||||||
|
Advance(state, _fellowship.ResetSession);
|
||||||
|
break;
|
||||||
case RuntimeGenerationResetStage.BeginEntityRetirement:
|
case RuntimeGenerationResetStage.BeginEntityRetirement:
|
||||||
_ = _entityObjects.BeginSessionClear();
|
_ = _entityObjects.BeginSessionClear();
|
||||||
state.Retirements = _entityObjects
|
state.Retirements = _entityObjects
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,9 @@ public static class RuntimeSimulationOwnership
|
||||||
RuntimeCharacterState character,
|
RuntimeCharacterState character,
|
||||||
RuntimeCommunicationState communication,
|
RuntimeCommunicationState communication,
|
||||||
RuntimeActionState actions,
|
RuntimeActionState actions,
|
||||||
RuntimeLocalPlayerMovementState movement)
|
RuntimeLocalPlayerMovementState movement,
|
||||||
|
RuntimeFellowshipState fellowship,
|
||||||
|
RuntimeAllegianceState allegiance)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(entityObjects);
|
ArgumentNullException.ThrowIfNull(entityObjects);
|
||||||
return new RuntimeSimulationOwnershipSnapshot(
|
return new RuntimeSimulationOwnershipSnapshot(
|
||||||
|
|
@ -39,6 +41,8 @@ public static class RuntimeSimulationOwnership
|
||||||
character,
|
character,
|
||||||
communication,
|
communication,
|
||||||
actions,
|
actions,
|
||||||
movement));
|
movement,
|
||||||
|
fellowship,
|
||||||
|
allegiance));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ public sealed class DirectGameRuntimeCommandAdapter
|
||||||
IRuntimeSpellbookCommands,
|
IRuntimeSpellbookCommands,
|
||||||
IRuntimeCharacterCommands,
|
IRuntimeCharacterCommands,
|
||||||
IRuntimeSocialCommands,
|
IRuntimeSocialCommands,
|
||||||
|
IRuntimeFellowshipCommands,
|
||||||
|
IRuntimeAllegianceCommands,
|
||||||
IRuntimeInteractionTransport
|
IRuntimeInteractionTransport
|
||||||
{
|
{
|
||||||
private sealed class CommandRoute(
|
private sealed class CommandRoute(
|
||||||
|
|
@ -80,6 +82,8 @@ public sealed class DirectGameRuntimeCommandAdapter
|
||||||
public IRuntimeSpellbookCommands Spellbook => this;
|
public IRuntimeSpellbookCommands Spellbook => this;
|
||||||
public IRuntimeCharacterCommands Character => this;
|
public IRuntimeCharacterCommands Character => this;
|
||||||
public IRuntimeSocialCommands Social => this;
|
public IRuntimeSocialCommands Social => this;
|
||||||
|
public IRuntimeFellowshipCommands Fellowship => this;
|
||||||
|
public IRuntimeAllegianceCommands Allegiance => this;
|
||||||
|
|
||||||
public ILiveSessionCommandRouting CreateRoute(WorldSession session)
|
public ILiveSessionCommandRouting CreateRoute(WorldSession session)
|
||||||
{
|
{
|
||||||
|
|
@ -795,6 +799,262 @@ public sealed class DirectGameRuntimeCommandAdapter
|
||||||
command.Name);
|
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
|
bool IRuntimeInteractionTransport.IsInWorld
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,13 @@ public sealed record LiveSocialSessionBindings(
|
||||||
// existing caller (including tests that build a bare ChatLog with no
|
// existing caller (including tests that build a bare ChatLog with no
|
||||||
// owning RuntimeCommunicationState) compiles unchanged; GameEventWiring
|
// owning RuntimeCommunicationState) compiles unchanged; GameEventWiring
|
||||||
// falls back to its pre-CH2 chat-only behavior when this is null.
|
// 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>
|
/// <summary>
|
||||||
/// Owns every inbound subscription for one exact live session. Domain state
|
/// Owns every inbound subscription for one exact live session. Domain state
|
||||||
|
|
@ -228,7 +234,34 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
||||||
externalContainers: inventory.ExternalContainers,
|
externalContainers: inventory.ExternalContainers,
|
||||||
vendor: inventory.Vendor,
|
vendor: inventory.Vendor,
|
||||||
onInterfaceText: social.AddText,
|
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();
|
ConstructionCheckpoint();
|
||||||
|
|
||||||
// Campaign P Slice P1 (2026-07-30): burden recompute triggers —
|
// Campaign P Slice P1 (2026-07-30): burden recompute triggers —
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,8 @@ public sealed class GameRuntimeContractTests
|
||||||
typeof(RuntimeCombatTargetState),
|
typeof(RuntimeCombatTargetState),
|
||||||
typeof(RuntimeCombatModeState),
|
typeof(RuntimeCombatModeState),
|
||||||
typeof(RuntimeSpellCastState),
|
typeof(RuntimeSpellCastState),
|
||||||
|
typeof(RuntimeFellowshipState),
|
||||||
|
typeof(RuntimeAllegianceState),
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach (Type owner in owners)
|
foreach (Type owner in owners)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ public sealed class GameRuntimeTests
|
||||||
Assert.Same(runtime.CharacterOwner.View, runtime.Character);
|
Assert.Same(runtime.CharacterOwner.View, runtime.Character);
|
||||||
Assert.Same(runtime.CommunicationOwner.View, runtime.Chat);
|
Assert.Same(runtime.CommunicationOwner.View, runtime.Chat);
|
||||||
Assert.Same(runtime.CommunicationOwner.SocialView, runtime.Social);
|
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.ActionOwner.View, runtime.Actions);
|
||||||
Assert.Same(runtime.MovementOwner.View, runtime.Movement);
|
Assert.Same(runtime.MovementOwner.View, runtime.Movement);
|
||||||
Assert.Same(runtime.EnvironmentOwner, runtime.Environment);
|
Assert.Same(runtime.EnvironmentOwner, runtime.Environment);
|
||||||
|
|
@ -110,6 +112,8 @@ public sealed class GameRuntimeTests
|
||||||
[InlineData((int)GameRuntimeConstructionPoint.InventoryCreated)]
|
[InlineData((int)GameRuntimeConstructionPoint.InventoryCreated)]
|
||||||
[InlineData((int)GameRuntimeConstructionPoint.CharacterCreated)]
|
[InlineData((int)GameRuntimeConstructionPoint.CharacterCreated)]
|
||||||
[InlineData((int)GameRuntimeConstructionPoint.CommunicationCreated)]
|
[InlineData((int)GameRuntimeConstructionPoint.CommunicationCreated)]
|
||||||
|
[InlineData((int)GameRuntimeConstructionPoint.FellowshipCreated)]
|
||||||
|
[InlineData((int)GameRuntimeConstructionPoint.AllegianceCreated)]
|
||||||
[InlineData((int)GameRuntimeConstructionPoint.MovementCreated)]
|
[InlineData((int)GameRuntimeConstructionPoint.MovementCreated)]
|
||||||
[InlineData((int)GameRuntimeConstructionPoint.ActionsCreated)]
|
[InlineData((int)GameRuntimeConstructionPoint.ActionsCreated)]
|
||||||
[InlineData((int)GameRuntimeConstructionPoint.EnvironmentCreated)]
|
[InlineData((int)GameRuntimeConstructionPoint.EnvironmentCreated)]
|
||||||
|
|
@ -147,6 +151,10 @@ public sealed class GameRuntimeTests
|
||||||
Assert.True(captured.Inventory.CaptureOwnership().IsConverged);
|
Assert.True(captured.Inventory.CaptureOwnership().IsConverged);
|
||||||
if (captured.Communication is not null)
|
if (captured.Communication is not null)
|
||||||
Assert.True(captured.Communication.CaptureOwnership().IsConverged);
|
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)
|
if (captured.EntityObjects is not null)
|
||||||
{
|
{
|
||||||
Assert.True(captured.EntityObjects.CaptureOwnership().IsConverged);
|
Assert.True(captured.EntityObjects.CaptureOwnership().IsConverged);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Runtime.Gameplay;
|
||||||
|
|
||||||
|
namespace AcDream.Runtime.Tests.Gameplay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FA slice FA2 (2026-08-12): lifecycle rules for
|
||||||
|
/// <see cref="RuntimeAllegianceState"/> — seeding from the unsolicited
|
||||||
|
/// <c>AllegianceUpdate</c> push and from a self-gated
|
||||||
|
/// <c>AllegianceInfoResponse</c>, the <c>HasServerSeed</c>-style latch, the
|
||||||
|
/// monarch/patron/vassal walk, revision monotonicity, and the "survives
|
||||||
|
/// reconnect" ownership contract (no <c>RuntimeGenerationReset</c> stage —
|
||||||
|
/// see <see cref="RuntimeGenerationResetTests"/> for the sibling assertion
|
||||||
|
/// that Fellowship IS a reset stage and Allegiance is not).
|
||||||
|
/// </summary>
|
||||||
|
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<ObjectDisposedException>(() => state.ApplyUpdate(Update()));
|
||||||
|
Assert.Throws<ObjectDisposedException>(
|
||||||
|
() => state.ApplyLoginNotification(SelfGuid, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Runtime.Gameplay;
|
||||||
|
|
||||||
|
namespace AcDream.Runtime.Tests.Gameplay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FA slice FA2 (2026-08-12): lifecycle rules for
|
||||||
|
/// <see cref="RuntimeFellowshipState"/> — full-update assembly, incremental
|
||||||
|
/// upsert, self-vs-other quit/dismiss removal, disband clear, revision
|
||||||
|
/// monotonicity, and ownership convergence.
|
||||||
|
/// </summary>
|
||||||
|
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<ObjectDisposedException>(
|
||||||
|
() => state.ApplyFullUpdate(FullUpdate(Member(SelfGuid))));
|
||||||
|
Assert.Throws<ObjectDisposedException>(
|
||||||
|
() => state.ApplyQuit(SelfGuid, SelfGuid));
|
||||||
|
Assert.Throws<ObjectDisposedException>(state.ApplyDisband);
|
||||||
|
Assert.Throws<ObjectDisposedException>(state.ResetSession);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,8 @@ public sealed class RuntimeGameplayOwnershipTests
|
||||||
var communication = new RuntimeCommunicationState();
|
var communication = new RuntimeCommunicationState();
|
||||||
var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
|
var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
|
||||||
var movement = new RuntimeLocalPlayerMovementState();
|
var movement = new RuntimeLocalPlayerMovementState();
|
||||||
|
var fellowship = new RuntimeFellowshipState();
|
||||||
|
var allegiance = new RuntimeAllegianceState();
|
||||||
|
|
||||||
movement.Execute(RuntimeMovementCommand.ToggleRunLock);
|
movement.Execute(RuntimeMovementCommand.ToggleRunLock);
|
||||||
inventory.Transactions.IncrementBusyCount();
|
inventory.Transactions.IncrementBusyCount();
|
||||||
|
|
@ -34,7 +36,9 @@ public sealed class RuntimeGameplayOwnershipTests
|
||||||
character,
|
character,
|
||||||
communication,
|
communication,
|
||||||
actions,
|
actions,
|
||||||
movement);
|
movement,
|
||||||
|
fellowship,
|
||||||
|
allegiance);
|
||||||
Assert.False(populated.IsConverged);
|
Assert.False(populated.IsConverged);
|
||||||
|
|
||||||
movement.Dispose();
|
movement.Dispose();
|
||||||
|
|
@ -42,6 +46,8 @@ public sealed class RuntimeGameplayOwnershipTests
|
||||||
communication.Dispose();
|
communication.Dispose();
|
||||||
character.Dispose();
|
character.Dispose();
|
||||||
inventory.Dispose();
|
inventory.Dispose();
|
||||||
|
fellowship.Dispose();
|
||||||
|
allegiance.Dispose();
|
||||||
entities.Dispose();
|
entities.Dispose();
|
||||||
|
|
||||||
RuntimeSimulationOwnershipSnapshot retired =
|
RuntimeSimulationOwnershipSnapshot retired =
|
||||||
|
|
@ -51,7 +57,9 @@ public sealed class RuntimeGameplayOwnershipTests
|
||||||
character,
|
character,
|
||||||
communication,
|
communication,
|
||||||
actions,
|
actions,
|
||||||
movement);
|
movement,
|
||||||
|
fellowship,
|
||||||
|
allegiance);
|
||||||
Assert.True(retired.IsConverged);
|
Assert.True(retired.IsConverged);
|
||||||
Assert.True(retired.EntityObjects.IsDisposed);
|
Assert.True(retired.EntityObjects.IsDisposed);
|
||||||
Assert.True(retired.Physics.IsDisposed);
|
Assert.True(retired.Physics.IsDisposed);
|
||||||
|
|
@ -66,6 +74,8 @@ public sealed class RuntimeGameplayOwnershipTests
|
||||||
var communication = new RuntimeCommunicationState();
|
var communication = new RuntimeCommunicationState();
|
||||||
var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
|
var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
|
||||||
var movement = new RuntimeLocalPlayerMovementState();
|
var movement = new RuntimeLocalPlayerMovementState();
|
||||||
|
var fellowship = new RuntimeFellowshipState();
|
||||||
|
var allegiance = new RuntimeAllegianceState();
|
||||||
movement.Execute(RuntimeMovementCommand.ToggleRunLock);
|
movement.Execute(RuntimeMovementCommand.ToggleRunLock);
|
||||||
inventory.Shortcuts.Changed += static () => { };
|
inventory.Shortcuts.Changed += static () => { };
|
||||||
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
|
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
|
||||||
|
|
@ -127,7 +137,9 @@ public sealed class RuntimeGameplayOwnershipTests
|
||||||
character,
|
character,
|
||||||
communication,
|
communication,
|
||||||
actions,
|
actions,
|
||||||
movement);
|
movement,
|
||||||
|
fellowship,
|
||||||
|
allegiance);
|
||||||
|
|
||||||
Assert.False(populated.IsConverged);
|
Assert.False(populated.IsConverged);
|
||||||
Assert.Equal(1, populated.Inventory.ShortcutCount);
|
Assert.Equal(1, populated.Inventory.ShortcutCount);
|
||||||
|
|
@ -147,6 +159,8 @@ public sealed class RuntimeGameplayOwnershipTests
|
||||||
communication.Dispose();
|
communication.Dispose();
|
||||||
character.Dispose();
|
character.Dispose();
|
||||||
inventory.Dispose();
|
inventory.Dispose();
|
||||||
|
fellowship.Dispose();
|
||||||
|
allegiance.Dispose();
|
||||||
subscription.Dispose();
|
subscription.Dispose();
|
||||||
|
|
||||||
RuntimeGameplayOwnershipSnapshot retired =
|
RuntimeGameplayOwnershipSnapshot retired =
|
||||||
|
|
@ -155,7 +169,9 @@ public sealed class RuntimeGameplayOwnershipTests
|
||||||
character,
|
character,
|
||||||
communication,
|
communication,
|
||||||
actions,
|
actions,
|
||||||
movement);
|
movement,
|
||||||
|
fellowship,
|
||||||
|
allegiance);
|
||||||
|
|
||||||
Assert.True(retired.IsConverged);
|
Assert.True(retired.IsConverged);
|
||||||
Assert.Equal(0, retired.Inventory.ShortcutSubscriberCount);
|
Assert.Equal(0, retired.Inventory.ShortcutSubscriberCount);
|
||||||
|
|
@ -173,6 +189,8 @@ public sealed class RuntimeGameplayOwnershipTests
|
||||||
var communication = new RuntimeCommunicationState();
|
var communication = new RuntimeCommunicationState();
|
||||||
var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
|
var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
|
||||||
var movement = new RuntimeLocalPlayerMovementState();
|
var movement = new RuntimeLocalPlayerMovementState();
|
||||||
|
var fellowship = new RuntimeFellowshipState();
|
||||||
|
var allegiance = new RuntimeAllegianceState();
|
||||||
|
|
||||||
inventory.ExternalContainers.RequestOpen(0x70000001u);
|
inventory.ExternalContainers.RequestOpen(0x70000001u);
|
||||||
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
|
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
|
||||||
|
|
@ -195,6 +213,8 @@ public sealed class RuntimeGameplayOwnershipTests
|
||||||
Assert.Throws<AggregateException>(inventory.Dispose);
|
Assert.Throws<AggregateException>(inventory.Dispose);
|
||||||
Assert.Throws<AggregateException>(character.Dispose);
|
Assert.Throws<AggregateException>(character.Dispose);
|
||||||
communication.Dispose();
|
communication.Dispose();
|
||||||
|
fellowship.Dispose();
|
||||||
|
allegiance.Dispose();
|
||||||
|
|
||||||
RuntimeGameplayOwnershipSnapshot retired =
|
RuntimeGameplayOwnershipSnapshot retired =
|
||||||
RuntimeGameplayOwnership.Capture(
|
RuntimeGameplayOwnership.Capture(
|
||||||
|
|
@ -202,7 +222,9 @@ public sealed class RuntimeGameplayOwnershipTests
|
||||||
character,
|
character,
|
||||||
communication,
|
communication,
|
||||||
actions,
|
actions,
|
||||||
movement);
|
movement,
|
||||||
|
fellowship,
|
||||||
|
allegiance);
|
||||||
|
|
||||||
Assert.True(retired.IsConverged);
|
Assert.True(retired.IsConverged);
|
||||||
Assert.Equal(1, retired.Communication.DispatchFailureCount);
|
Assert.Equal(1, retired.Communication.DispatchFailureCount);
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,50 @@ public sealed class RuntimeGenerationResetTests
|
||||||
Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive);
|
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()
|
private static GameRuntime Create()
|
||||||
{
|
{
|
||||||
var operations = new Operations();
|
var operations = new Operations();
|
||||||
|
|
|
||||||
|
|
@ -490,6 +490,210 @@ public sealed class DirectGameRuntimeCommandAdapterTests
|
||||||
runtime.Dispose();
|
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<byte[]>();
|
||||||
|
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<byte[]>();
|
||||||
|
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<byte[]>();
|
||||||
|
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<byte[]>();
|
||||||
|
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<byte[]>();
|
||||||
|
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<byte[]>();
|
||||||
|
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<byte[]>();
|
||||||
|
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<byte[]>();
|
||||||
|
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<byte[]>();
|
||||||
|
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<byte[]>();
|
||||||
|
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 sealed class ManualTimeProvider : TimeProvider
|
||||||
{
|
{
|
||||||
private DateTimeOffset _now = new(2026, 8, 11, 0, 0, 0, TimeSpan.Zero);
|
private DateTimeOffset _now = new(2026, 8, 11, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue