diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index 8edd4d0a..3ffeea3f 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -50,6 +50,21 @@ internal sealed record LiveSessionCommandBindings( // research §2.3-§2.7. No-ops when the batched module is clean, matching // retail's CPlayerModule::SaveToServer(force: 0). Action SaveCharacterOptions, + // Campaign FA slice FA2 (2026-08-12): fellowship + allegiance send + // wrappers, the App-bus twin of DirectGameRuntimeCommandAdapter's + // direct session.SendXxx calls. + Action SendFellowshipCreate, + Action SendFellowshipRecruit, + Action SendFellowshipDismiss, + Action SendFellowshipQuit, + Action SendFellowshipAssignNewLeader, + Action SendFellowshipChangeOpenness, + Action SendFellowshipUpdateRequest, + Action SendAllegianceSwear, + Action SendAllegianceBreak, + Action SendAllegianceKick, + Action SendAllegianceInfoRequest, + Action SendAllegianceUpdateRequest, Action? Log = null); internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry); @@ -91,6 +106,22 @@ internal readonly record struct ModifyGlobalSquelchRuntimeCmd( bool Add, uint MessageType); +// ── Fellowship / Allegiance (Campaign FA slice FA2, 2026-08-12) ──────────── +internal readonly record struct FellowshipCreateRuntimeCmd( + string FellowshipName, + bool ShareXp); +internal readonly record struct FellowshipRecruitRuntimeCmd(uint TargetGuid); +internal readonly record struct FellowshipDismissRuntimeCmd(uint TargetGuid); +internal readonly record struct FellowshipQuitRuntimeCmd(bool Disband); +internal readonly record struct FellowshipAssignNewLeaderRuntimeCmd(uint NewLeaderGuid); +internal readonly record struct FellowshipChangeOpennessRuntimeCmd(bool IsOpen); +internal readonly record struct FellowshipUpdateRequestRuntimeCmd(bool PanelOpen); +internal readonly record struct AllegianceSwearRuntimeCmd(uint PatronGuid); +internal readonly record struct AllegianceBreakRuntimeCmd(uint TargetGuid); +internal readonly record struct AllegianceKickRuntimeCmd(uint VassalGuid); +internal readonly record struct AllegianceInfoRequestRuntimeCmd(string PlayerName); +internal readonly record struct AllegianceUpdateRequestRuntimeCmd(bool On); + /// /// Owns the command surface for one exact live-session generation. The router /// itself is the published bus, so a retained reference becomes inert before @@ -200,6 +231,43 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting command => SendIfActive(() => bindings.ModifyGlobalSquelch( command.Add, command.MessageType))); + commands.Register( + command => SendIfActive(() => bindings.SendFellowshipCreate( + command.FellowshipName, + command.ShareXp))); + commands.Register( + command => SendIfActive(() => + bindings.SendFellowshipRecruit(command.TargetGuid))); + commands.Register( + command => SendIfActive(() => + bindings.SendFellowshipDismiss(command.TargetGuid))); + commands.Register( + command => SendIfActive(() => + bindings.SendFellowshipQuit(command.Disband))); + commands.Register( + command => SendIfActive(() => + bindings.SendFellowshipAssignNewLeader(command.NewLeaderGuid))); + commands.Register( + command => SendIfActive(() => + bindings.SendFellowshipChangeOpenness(command.IsOpen))); + commands.Register( + command => SendIfActive(() => + bindings.SendFellowshipUpdateRequest(command.PanelOpen))); + commands.Register( + command => SendIfActive(() => + bindings.SendAllegianceSwear(command.PatronGuid))); + commands.Register( + command => SendIfActive(() => + bindings.SendAllegianceBreak(command.TargetGuid))); + commands.Register( + command => SendIfActive(() => + bindings.SendAllegianceKick(command.VassalGuid))); + commands.Register( + command => SendIfActive(() => + bindings.SendAllegianceInfoRequest(command.PlayerName))); + commands.Register( + command => SendIfActive(() => + bindings.SendAllegianceUpdateRequest(command.On))); _commands = commands; } diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index d84c0fea..f0b778f4 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -258,7 +258,9 @@ internal sealed class LiveSessionRuntimeFactory _domain.Communication.TurbineChat, _domain.Communication.Friends, _domain.Communication.Squelch, - (text, type) => _domain.Communication.AddText(text, type))); + (text, type) => _domain.Communication.AddText(text, type), + Fellowship: _domain.Runtime.FellowshipOwner, + Allegiance: _domain.Runtime.AllegianceOwner)); return new GraphicalSessionEventRoute( route, _domain.Runtime, @@ -591,6 +593,20 @@ internal sealed class LiveSessionRuntimeFactory CharacterState: _domain.Character, SendSingleCharacterOption: SendSingleCharacterOption, SaveCharacterOptions: SaveCharacterOptionsIfDirty, + // Campaign FA slice FA2 (2026-08-12): fellowship + allegiance send + // wrappers, matching the WorldSession.SendXxx methods FA2 added. + SendFellowshipCreate: session.SendFellowshipCreate, + SendFellowshipRecruit: session.SendFellowshipRecruit, + SendFellowshipDismiss: session.SendFellowshipDismiss, + SendFellowshipQuit: session.SendFellowshipQuit, + SendFellowshipAssignNewLeader: session.SendFellowshipAssignNewLeader, + SendFellowshipChangeOpenness: session.SendFellowshipChangeOpenness, + SendFellowshipUpdateRequest: session.SendFellowshipUpdateRequest, + SendAllegianceSwear: session.SendAllegianceSwear, + SendAllegianceBreak: session.SendAllegianceBreak, + SendAllegianceKick: session.SendAllegianceKick, + SendAllegianceInfoRequest: session.SendAllegianceInfoRequest, + SendAllegianceUpdateRequest: session.SendAllegianceUpdateRequest, Log: _log); } diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index 372d5c6b..e11df45d 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -49,6 +49,7 @@ internal sealed class CurrentGameRuntimeAdapter runtime.CharacterOwner, runtime.ActionOwner, runtime.MovementOwner, + runtime.FellowshipOwner, selection, runtime.EventSink); } @@ -88,6 +89,8 @@ internal sealed class CurrentGameRuntimeAdapter public IRuntimeCharacterView Character => _runtime.Character; public IRuntimeSocialView Social => _runtime.Social; public IRuntimeChatView Chat => _runtime.Chat; + public IRuntimeFellowshipView Fellowship => _runtime.Fellowship; + public IRuntimeAllegianceView Allegiance => _runtime.Allegiance; public IRuntimeActionView Actions => _runtime.Actions; public IRuntimeMovementView Movement => _runtime.Movement; public IRuntimeWorldEnvironmentView Environment => _runtime.Environment; @@ -112,6 +115,10 @@ internal sealed class CurrentGameRuntimeAdapter IRuntimeCharacterCommands IGameRuntimeCommands.Character => _commands; public IRuntimeSocialCommands SocialCommands => _commands; IRuntimeSocialCommands IGameRuntimeCommands.Social => _commands; + public IRuntimeFellowshipCommands FellowshipCommands => _commands; + IRuntimeFellowshipCommands IGameRuntimeCommands.Fellowship => _commands; + public IRuntimeAllegianceCommands AllegianceCommands => _commands; + IRuntimeAllegianceCommands IGameRuntimeCommands.Allegiance => _commands; public RuntimeStateCheckpoint CaptureCheckpoint() { diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs index dd4d87df..7ec92c68 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs @@ -26,7 +26,9 @@ internal sealed class CurrentGameRuntimeCommandAdapter IRuntimeInventoryStateCommands, IRuntimeSpellbookCommands, IRuntimeCharacterCommands, - IRuntimeSocialCommands + IRuntimeSocialCommands, + IRuntimeFellowshipCommands, + IRuntimeAllegianceCommands { private readonly LiveSessionController _session; private readonly LiveSessionHost _sessionHost; @@ -36,6 +38,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter private readonly RuntimeCharacterState _character; private readonly RuntimeActionState _actions; private readonly RuntimeLocalPlayerMovementState _movement; + private readonly RuntimeFellowshipState _fellowship; private readonly SelectionInteractionController _selection; private readonly IGameRuntimeEventSink _events; @@ -48,6 +51,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter RuntimeCharacterState character, RuntimeActionState actions, RuntimeLocalPlayerMovementState movement, + RuntimeFellowshipState fellowship, SelectionInteractionController selection, IGameRuntimeEventSink events) { @@ -61,6 +65,8 @@ internal sealed class CurrentGameRuntimeCommandAdapter ?? throw new ArgumentNullException(nameof(actions)); _movement = movement ?? throw new ArgumentNullException(nameof(movement)); + _fellowship = fellowship + ?? throw new ArgumentNullException(nameof(fellowship)); _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _events = events ?? throw new ArgumentNullException(nameof(events)); } @@ -793,6 +799,249 @@ internal sealed class CurrentGameRuntimeCommandAdapter command.Name); } + // ── Fellowship / Allegiance (Campaign FA slice FA2, 2026-08-12) ──────── + + public RuntimeCommandResult Create( + RuntimeGenerationToken expectedGeneration, + string fellowshipName, + bool shareXp) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if (string.IsNullOrWhiteSpace(fellowshipName)) + { + return EmitResult( + RuntimeCommandDomain.Fellowship, + operation: 0, + RuntimeCommandStatus.Rejected); + } + _commands.Publish(new FellowshipCreateRuntimeCmd(fellowshipName, shareXp)); + return EmitResult( + RuntimeCommandDomain.Fellowship, + operation: 0, + RuntimeCommandStatus.Accepted); + } + + public RuntimeCommandResult Recruit( + RuntimeGenerationToken expectedGeneration, + uint targetGuid) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if (targetGuid == 0u) + { + return EmitResult( + RuntimeCommandDomain.Fellowship, + operation: 1, + RuntimeCommandStatus.Rejected, + targetGuid); + } + _commands.Publish(new FellowshipRecruitRuntimeCmd(targetGuid)); + return EmitResult( + RuntimeCommandDomain.Fellowship, + operation: 1, + RuntimeCommandStatus.Accepted, + targetGuid); + } + + public RuntimeCommandResult Dismiss( + RuntimeGenerationToken expectedGeneration, + uint targetGuid) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if (targetGuid == 0u) + { + return EmitResult( + RuntimeCommandDomain.Fellowship, + operation: 2, + RuntimeCommandStatus.Rejected, + targetGuid); + } + _commands.Publish(new FellowshipDismissRuntimeCmd(targetGuid)); + return EmitResult( + RuntimeCommandDomain.Fellowship, + operation: 2, + RuntimeCommandStatus.Accepted, + targetGuid); + } + + /// + /// Retail's Quit-button leader hand-off (lane B §2.5/§3.6) — see + /// 's identical rule. + /// + public RuntimeCommandResult Quit( + RuntimeGenerationToken expectedGeneration, + bool disband) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if (_fellowship.RequiresLeaderHandoffBeforeQuit( + _view.Lifecycle.PlayerGuid, + disband, + out uint newLeaderGuid)) + { + _commands.Publish(new FellowshipAssignNewLeaderRuntimeCmd(newLeaderGuid)); + } + _commands.Publish(new FellowshipQuitRuntimeCmd(disband)); + return EmitResult( + RuntimeCommandDomain.Fellowship, + operation: 3, + RuntimeCommandStatus.Accepted); + } + + public RuntimeCommandResult AssignLeader( + RuntimeGenerationToken expectedGeneration, + uint newLeaderGuid) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if (newLeaderGuid == 0u) + { + return EmitResult( + RuntimeCommandDomain.Fellowship, + operation: 4, + RuntimeCommandStatus.Rejected, + newLeaderGuid); + } + _commands.Publish(new FellowshipAssignNewLeaderRuntimeCmd(newLeaderGuid)); + return EmitResult( + RuntimeCommandDomain.Fellowship, + operation: 4, + RuntimeCommandStatus.Accepted, + newLeaderGuid); + } + + public RuntimeCommandResult SetOpen( + RuntimeGenerationToken expectedGeneration, + bool isOpen) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + _commands.Publish(new FellowshipChangeOpennessRuntimeCmd(isOpen)); + return EmitResult( + RuntimeCommandDomain.Fellowship, + operation: 5, + RuntimeCommandStatus.Accepted); + } + + public RuntimeCommandResult SetPanelOpen( + RuntimeGenerationToken expectedGeneration, + bool panelOpen) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + _commands.Publish(new FellowshipUpdateRequestRuntimeCmd(panelOpen)); + return EmitResult( + RuntimeCommandDomain.Fellowship, + operation: 6, + RuntimeCommandStatus.Accepted); + } + + public RuntimeCommandResult Swear( + RuntimeGenerationToken expectedGeneration, + uint patronGuid) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if (patronGuid == 0u) + { + return EmitResult( + RuntimeCommandDomain.Allegiance, + operation: 0, + RuntimeCommandStatus.Rejected, + patronGuid); + } + _commands.Publish(new AllegianceSwearRuntimeCmd(patronGuid)); + return EmitResult( + RuntimeCommandDomain.Allegiance, + operation: 0, + RuntimeCommandStatus.Accepted, + patronGuid); + } + + public RuntimeCommandResult Break( + RuntimeGenerationToken expectedGeneration, + uint targetGuid) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if (targetGuid == 0u) + { + return EmitResult( + RuntimeCommandDomain.Allegiance, + operation: 1, + RuntimeCommandStatus.Rejected, + targetGuid); + } + _commands.Publish(new AllegianceBreakRuntimeCmd(targetGuid)); + return EmitResult( + RuntimeCommandDomain.Allegiance, + operation: 1, + RuntimeCommandStatus.Accepted, + targetGuid); + } + + public RuntimeCommandResult Kick( + RuntimeGenerationToken expectedGeneration, + uint vassalGuid) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if (vassalGuid == 0u) + { + return EmitResult( + RuntimeCommandDomain.Allegiance, + operation: 2, + RuntimeCommandStatus.Rejected, + vassalGuid); + } + _commands.Publish(new AllegianceKickRuntimeCmd(vassalGuid)); + return EmitResult( + RuntimeCommandDomain.Allegiance, + operation: 2, + RuntimeCommandStatus.Accepted, + vassalGuid); + } + + public RuntimeCommandResult RequestInfo( + RuntimeGenerationToken expectedGeneration, + string playerName) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + _commands.Publish(new AllegianceInfoRequestRuntimeCmd(playerName ?? string.Empty)); + return EmitResult( + RuntimeCommandDomain.Allegiance, + operation: 3, + RuntimeCommandStatus.Accepted); + } + + public RuntimeCommandResult SetUpdateSubscription( + RuntimeGenerationToken expectedGeneration, + bool on) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + _commands.Publish(new AllegianceUpdateRequestRuntimeCmd(on)); + return EmitResult( + RuntimeCommandDomain.Allegiance, + operation: 4, + RuntimeCommandStatus.Accepted); + } + private RuntimeCommandStatus Validate( RuntimeGenerationToken expectedGeneration, bool requireWorld) diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index d4c53dde..62a58cec 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -782,7 +782,9 @@ internal sealed class HeadlessSessionHost : IDisposable Runtime.CommunicationOwner.TurbineChat, Runtime.CommunicationOwner.Friends, Runtime.CommunicationOwner.Squelch, - (text, type) => Runtime.CommunicationOwner.AddText(text, type))); + (text, type) => Runtime.CommunicationOwner.AddText(text, type), + Fellowship: Runtime.FellowshipOwner, + Allegiance: Runtime.AllegianceOwner)); var eventRoute = new HeadlessSessionEventRoute( route, Runtime, diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs index af117b27..6600398c 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs @@ -234,6 +234,8 @@ public sealed class InteractionUiRuntimeSourcesTests public IRuntimeCharacterView Character => null!; public IRuntimeSocialView Social => null!; public IRuntimeChatView Chat => null!; + public IRuntimeFellowshipView Fellowship => null!; + public IRuntimeAllegianceView Allegiance => null!; public IRuntimeActionView Actions => null!; public IRuntimeMovementView Movement => null!; public AcDream.Runtime.World.IRuntimeWorldEnvironmentView Environment => @@ -250,6 +252,8 @@ public sealed class InteractionUiRuntimeSourcesTests IRuntimeSpellbookCommands IGameRuntimeCommands.Spellbook => this; IRuntimeCharacterCommands IGameRuntimeCommands.Character => this; IRuntimeSocialCommands IGameRuntimeCommands.Social => null!; + IRuntimeFellowshipCommands IGameRuntimeCommands.Fellowship => null!; + IRuntimeAllegianceCommands IGameRuntimeCommands.Allegiance => null!; public RuntimeStateCheckpoint CaptureCheckpoint() => default; diff --git a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs index a1f8e74e..e74d6031 100644 --- a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs @@ -227,6 +227,8 @@ public sealed class GameplayInputCommandControllerTests throw new NotSupportedException(); public IRuntimeSocialView Social => throw new NotSupportedException(); public IRuntimeChatView Chat => throw new NotSupportedException(); + public IRuntimeFellowshipView Fellowship => throw new NotSupportedException(); + public IRuntimeAllegianceView Allegiance => throw new NotSupportedException(); public IRuntimeActionView Actions => throw new NotSupportedException(); public IRuntimeMovementView Movement => throw new NotSupportedException(); public AcDream.Runtime.World.IRuntimeWorldEnvironmentView Environment => diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index d3d69480..af25bf2d 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -617,6 +617,18 @@ public sealed class LiveSessionCommandRouterTests CharacterState: characterState ?? new RuntimeCharacterState(), SendSingleCharacterOption: sendSingleCharacterOption ?? ((_, _) => { }), SaveCharacterOptions: saveCharacterOptions ?? (() => { }), + SendFellowshipCreate: (_, _) => { }, + SendFellowshipRecruit: _ => { }, + SendFellowshipDismiss: _ => { }, + SendFellowshipQuit: _ => { }, + SendFellowshipAssignNewLeader: _ => { }, + SendFellowshipChangeOpenness: _ => { }, + SendFellowshipUpdateRequest: _ => { }, + SendAllegianceSwear: _ => { }, + SendAllegianceBreak: _ => { }, + SendAllegianceKick: _ => { }, + SendAllegianceInfoRequest: _ => { }, + SendAllegianceUpdateRequest: _ => { }, Log: log)); [MethodImpl(MethodImplOptions.NoInlining)]