From 1c40104896f3958fff084ed8fbbfb19f47e53fc1 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 12 Aug 2026 01:31:18 +0200 Subject: [PATCH] feat(net): FA2 -- fellowship/allegiance outbound wrappers + inbound wiring Adds the missing WorldSession.Send* link for every FA1 fellowship/ allegiance builder (SendFellowshipCreate/Quit/Dismiss/Recruit/ UpdateRequest/AssignNewLeader/ChangeOpenness, SendAllegianceSwear/ Break/Kick/UpdateRequest) and 15 new GameEventWiring.WireAll delegate holes covering the 11 S->C fellowship/allegiance events. Delegate holes (not state-object params) because Core.Net cannot reference AcDream.Runtime, matching the onCharacterOptions/onConfirmationRequest precedent. Fixes a real bug found during implementation: GameEventDispatcher. Dispatch invokes only the single most-recently-registered handler per GameEventType (RegisterOwned REPLACES, it does not chain-invoke) -- contradicts the seam doc's "the dispatcher supports multiple owned handlers per type" claim. A literal second registrar.Register call for AllegianceInfoResponse would have silently killed the already-live `@allegiance info` chat-text output the moment a caller supplied the new self-gated Runtime callback. Both behaviors are folded into the ONE existing registration instead. Co-Authored-By: Claude Fable 5 --- src/AcDream.Core.Net/GameEventWiring.cs | 123 +++++++++- src/AcDream.Core.Net/WorldSession.cs | 88 +++++++ .../GameEventWiringTests.cs | 227 ++++++++++++++++++ 3 files changed, 437 insertions(+), 1 deletion(-) diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 7f084dc5..32ee8252 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -96,7 +96,28 @@ public static class GameEventWiring // WeenieErrorMessages table, just without the SpewBox split) so // every existing caller compiles and behaves unchanged. Action? onInterfaceText = null, - Func? accepting = null) + Func? accepting = null, + // Campaign FA slice FA2 (2026-08-12): fellowship + allegiance + // delegate holes. RuntimeFellowshipState/RuntimeAllegianceState are + // AcDream.Runtime types — Core.Net cannot reference AcDream.Runtime + // directly, so these are delegate holes exactly like every other + // Runtime-owned sink above (docs/research/2026-08-11-fa-acdream-seams.md + // §2.2). All optional/nullable so every existing caller compiles + // unchanged. + Action? onFellowshipFullUpdate = null, + Action? onFellowshipUpdateFellow = null, + Action? onFellowshipQuit = null, + Action? onFellowshipDismiss = null, + Action? onFellowshipDisband = null, + Action? onAllegianceUpdate = null, + // Self-gated: fires only when the response's TargetGuid is the + // local player's own guid (see the registration below) — a + // by-name @allegiance info query on ANOTHER player must not + // overwrite the Runtime allegiance owner's own-tree snapshot. + Action? onAllegianceInfoResponseSelf = null, + Action? onAllegianceUpdateDone = null, + Action? onAllegianceUpdateAborted = null, + Action? onAllegianceLoginNotification = null) { ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(items); @@ -189,14 +210,114 @@ public static class GameEventWiring foreach (string line in ClientCommandResponses.FormatAvailableHousesLines(houses.Value)) chat.OnSystemMessage(line, chatType: 0u); }); + // Campaign FA slice FA2 (2026-08-12) correction: GameEventDispatcher. + // Dispatch invokes ONLY the single most-recently-registered handler + // per GameEventType (GameEventDispatcher.cs:95-117) — a second + // registrar.Register(GameEventType.AllegianceInfoResponse, ...) + // call does NOT chain-invoke the first; it REPLACES it (the + // superseded handler only comes back if the newer registration's + // token is later disposed). The seam doc's "the dispatcher supports + // multiple owned handlers per type — both fire" claim + // (docs/research/2026-08-11-fa-acdream-seams.md §2.3) does not hold + // against the actual dispatcher; a literal second Register call + // here would have silently killed the already-live `@allegiance + // info` chat-text output the moment a caller supplied + // onAllegianceInfoResponseSelf. Both behaviors are folded into this + // ONE registration instead. Self-gated on TargetGuid == playerGuid() + // so a by-name query against ANOTHER player's allegiance never + // overwrites the Runtime owner's own-tree snapshot; skipped + // entirely when no playerGuid resolver was supplied (matches every + // other playerGuid-gated site above). registrar.Register(GameEventType.AllegianceInfoResponse, e => { var info = ClientCommandResponses.ParseAllegianceInfoResponse(e.Payload.Span); if (info is null) return; foreach (string line in ClientCommandResponses.FormatAllegianceInfoLines(info.Value)) chat.OnSystemMessage(line, chatType: 0u); + if (onAllegianceInfoResponseSelf is not null + && playerGuid is not null + && info.Value.TargetGuid == playerGuid()) + { + onAllegianceInfoResponseSelf(info.Value); + } }); + // ── Fellowship (Campaign FA slice FA2, 2026-08-12) ────────────── + if (onFellowshipFullUpdate is not null) + { + registrar.Register(GameEventType.FellowshipFullUpdate, e => + { + var update = GameEvents.ParseFellowshipFullUpdate(e.Payload.Span); + if (update is not null) onFellowshipFullUpdate(update.Value); + }); + } + if (onFellowshipUpdateFellow is not null) + { + registrar.Register(GameEventType.FellowshipUpdateFellow, e => + { + var update = GameEvents.ParseFellowshipUpdateFellow(e.Payload.Span); + if (update is not null) onFellowshipUpdateFellow(update.Value); + }); + } + if (onFellowshipQuit is not null) + { + registrar.Register(GameEventType.FellowshipQuit, e => + { + var quit = GameEvents.ParseFellowshipQuit(e.Payload.Span); + if (quit is not null) onFellowshipQuit(quit.Value.QuitterGuid); + }); + } + if (onFellowshipDismiss is not null) + { + registrar.Register(GameEventType.FellowshipDismiss, e => + { + var dismiss = GameEvents.ParseFellowshipDismiss(e.Payload.Span); + if (dismiss is not null) onFellowshipDismiss(dismiss.Value.DismissedGuid); + }); + } + if (onFellowshipDisband is not null) + { + registrar.Register(GameEventType.FellowshipDisband, e => + { + if (GameEvents.ParseFellowshipDisband(e.Payload.Span)) + onFellowshipDisband(); + }); + } + + // ── Allegiance (Campaign FA slice FA2, 2026-08-12) ────────────── + if (onAllegianceUpdate is not null) + { + registrar.Register(GameEventType.AllegianceUpdate, e => + { + var update = ClientCommandResponses.ParseAllegianceUpdate(e.Payload.Span); + if (update is not null) onAllegianceUpdate(update.Value); + }); + } + if (onAllegianceUpdateDone is not null) + { + registrar.Register(GameEventType.AllegianceUpdateDone, e => + { + var code = GameEvents.ParseAllegianceUpdateDone(e.Payload.Span); + if (code is not null) onAllegianceUpdateDone(code.Value); + }); + } + if (onAllegianceUpdateAborted is not null) + { + registrar.Register(GameEventType.AllegianceUpdateAborted, e => + { + var code = GameEvents.ParseAllegianceUpdateAborted(e.Payload.Span); + if (code is not null) onAllegianceUpdateAborted(code.Value); + }); + } + if (onAllegianceLoginNotification is not null) + { + registrar.Register(GameEventType.AllegianceLoginNotification, e => + { + var notice = GameEvents.ParseAllegianceLoginNotification(e.Payload.Span); + if (notice is not null) onAllegianceLoginNotification(notice.Value); + }); + } + if (onConfirmationRequest is not null) { registrar.Register(GameEventType.CharacterConfirmationRequest, e => diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 79a387fb..a57eace0 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -2315,6 +2315,94 @@ public sealed class WorldSession : IDisposable SendGameAction(ClientCommandRequests.BuildAllegianceInfoRequest(seq, playerName)); } + // ── Campaign FA slice FA2 (2026-08-12): fellowship + allegiance + // outbound wrappers. SocialActions/AllegianceRequests ship the byte + // builders (repaired/added in FA1); this is the missing + // NextGameActionSequence() + SendGameAction() link every other + // outbound family already has (docs/research/2026-08-11-fa-acdream-seams.md + // §3.2). + + /// Send retail fellowship create (0x00A2). + public void SendFellowshipCreate(string fellowshipName, bool shareXp) + { + uint seq = NextGameActionSequence(); + SendGameAction(SocialActions.BuildFellowshipCreate(seq, fellowshipName, shareXp)); + } + + /// Send retail fellowship quit / disband (0x00A3). + public void SendFellowshipQuit(bool disband) + { + uint seq = NextGameActionSequence(); + SendGameAction(SocialActions.BuildFellowshipQuit(seq, disband)); + } + + /// Send retail fellowship dismiss (0x00A4). + public void SendFellowshipDismiss(uint targetGuid) + { + uint seq = NextGameActionSequence(); + SendGameAction(SocialActions.BuildFellowshipDismiss(seq, targetGuid)); + } + + /// Send retail fellowship recruit (0x00A5). + public void SendFellowshipRecruit(uint targetGuid) + { + uint seq = NextGameActionSequence(); + SendGameAction(SocialActions.BuildFellowshipRecruit(seq, targetGuid)); + } + + /// + /// Send retail fellowship-panel visibility declaration (0x00A6) — D4: + /// gates ACE's 0x02C0 member-vitals stream (docs/research/ + /// 2026-08-11-fa-fellowship-wire.md §4.5). + /// + public void SendFellowshipUpdateRequest(bool panelOpen) + { + uint seq = NextGameActionSequence(); + SendGameAction(SocialActions.BuildFellowshipUpdateRequest(seq, panelOpen)); + } + + /// Send retail fellowship leadership transfer (0x0290). + public void SendFellowshipAssignNewLeader(uint newLeaderGuid) + { + uint seq = NextGameActionSequence(); + SendGameAction(SocialActions.BuildFellowshipAssignNewLeader(seq, newLeaderGuid)); + } + + /// Send retail fellowship openness toggle (0x0291). + public void SendFellowshipChangeOpenness(bool isOpen) + { + uint seq = NextGameActionSequence(); + SendGameAction(SocialActions.BuildFellowshipChangeOpenness(seq, isOpen)); + } + + /// Send retail allegiance swear (0x001D). + public void SendAllegianceSwear(uint patronGuid) + { + uint seq = NextGameActionSequence(); + SendGameAction(AllegianceRequests.BuildSwear(seq, patronGuid)); + } + + /// Send retail allegiance break (0x001E) — targets your own patron. + public void SendAllegianceBreak(uint targetGuid) + { + uint seq = NextGameActionSequence(); + SendGameAction(AllegianceRequests.BuildBreak(seq, targetGuid)); + } + + /// Send retail allegiance kick (0x001E) — targets a vassal. + public void SendAllegianceKick(uint vassalGuid) + { + uint seq = NextGameActionSequence(); + SendGameAction(AllegianceRequests.BuildKick(seq, vassalGuid)); + } + + /// Send retail allegiance-panel subscribe/unsubscribe (0x001F). + public void SendAllegianceUpdateRequest(bool on) + { + uint seq = NextGameActionSequence(); + SendGameAction(AllegianceRequests.BuildAllegianceUpdateRequest(seq, on)); + } + /// Send retail @hslist <type> (0x0270). public void SendListAvailableHouses(uint houseType) { diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index c132fb95..30982524 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -7,6 +7,7 @@ using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; +using AcDream.Core.Net.Tests.Messages; using AcDream.Core.Player; using AcDream.Core.Spells; using AcDream.Core.Social; @@ -1544,6 +1545,232 @@ public sealed class GameEventWiringTests Assert.Equal((0x50C4A54Au, 0x948700u, false), observed); } + // ── Fellowship / Allegiance (Campaign FA slice FA2, 2026-08-12) ──────── + // Wiring-plumbing tests: proves GameEventWiring.WireAll's NEW delegate + // holes register against the SAME already-golden-vector-tested FA1 + // parsers (FellowshipEventsTests.cs / AllegianceSmallEventsTests.cs / + // AllegianceProfileVersionGateTests.cs) and reach their callback. Not a + // re-test of parser correctness — a proof the registration site is + // wired at all. + + [Fact] + public void WireAll_FellowshipFullUpdate_ReachesTheCallback() + { + var dispatcher = new GameEventDispatcher(); + GameEvents.FellowshipFullUpdate? observed = null; + GameEventWiring.WireAll( + dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(), + onFellowshipFullUpdate: update => observed = update); + + byte[] wire = new AceWireWriter() + .Write((ushort)0).Write((ushort)16) + .WriteString16L("TestFellowship") + .Write((uint)0) // leaderGuid + .Write((uint)1) // shareXp + .Write((uint)0) // evenXpSplit + .Write((uint)0) // openFellow + .Write((uint)0) // locked + .Write((ushort)0).Write((ushort)32) + .ToArray(); + dispatcher.Dispatch(GameEventEnvelope.TryParse( + WrapEnvelope(GameEventType.FellowshipFullUpdate, wire))!.Value); + + Assert.NotNull(observed); + Assert.Equal("TestFellowship", observed.Value.Name); + Assert.True(observed.Value.ShareXp); + } + + [Fact] + public void WireAll_FellowshipUpdateFellow_ReachesTheCallback() + { + var dispatcher = new GameEventDispatcher(); + GameEvents.FellowshipUpdateFellow? observed = null; + GameEventWiring.WireAll( + dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(), + onFellowshipUpdateFellow: update => observed = update); + + byte[] wire = new AceWireWriter() + .Write(0x50000001u) + .Write((uint)0).Write((uint)0).Write((uint)1) + .Write((uint)100).Write((uint)100).Write((uint)100) + .Write((uint)42).Write((uint)100).Write((uint)100) + .Write((uint)0) + .WriteString16L("Self") + .Write((uint)3) + .ToArray(); + dispatcher.Dispatch(GameEventEnvelope.TryParse( + WrapEnvelope(GameEventType.FellowshipUpdateFellow, wire))!.Value); + + Assert.NotNull(observed); + Assert.Equal(0x50000001u, observed.Value.MemberGuid); + Assert.Equal(42u, observed.Value.Member.CurrentHealth); + Assert.Equal(3u, observed.Value.UpdateType); + } + + [Fact] + public void WireAll_FellowshipQuit_ReachesTheCallbackWithQuitterGuid() + { + var dispatcher = new GameEventDispatcher(); + uint? observed = null; + GameEventWiring.WireAll( + dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(), + onFellowshipQuit: guid => observed = guid); + + byte[] wire = new AceWireWriter().Write(0x50000042u).ToArray(); + dispatcher.Dispatch(GameEventEnvelope.TryParse( + WrapEnvelope(GameEventType.FellowshipQuit, wire))!.Value); + + Assert.Equal(0x50000042u, observed); + } + + [Fact] + public void WireAll_FellowshipDismiss_ReachesTheCallbackWithDismissedGuid() + { + var dispatcher = new GameEventDispatcher(); + uint? observed = null; + GameEventWiring.WireAll( + dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(), + onFellowshipDismiss: guid => observed = guid); + + byte[] wire = new AceWireWriter().Write(0x50000043u).ToArray(); + dispatcher.Dispatch(GameEventEnvelope.TryParse( + WrapEnvelope(GameEventType.FellowshipDismiss, wire))!.Value); + + Assert.Equal(0x50000043u, observed); + } + + [Fact] + public void WireAll_FellowshipDisband_ReachesTheCallback() + { + var dispatcher = new GameEventDispatcher(); + bool fired = false; + GameEventWiring.WireAll( + dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(), + onFellowshipDisband: () => fired = true); + + dispatcher.Dispatch(GameEventEnvelope.TryParse( + WrapEnvelope(GameEventType.FellowshipDisband, []))!.Value); + + Assert.True(fired); + } + + /// Minimal valid AllegianceProfile body at oldVersion=0 — no gated fields, one monarch record only. + private static byte[] BuildMinimalAllegianceProfile( + uint leadingField, + uint monarchGuid, + string monarchName = "Monarch") + { + return new AceWireWriter() + .Write(leadingField) + .Write((uint)1) // totalMembers + .Write((uint)0) // totalVassals + .Write((ushort)1) // recordCount + .Write((ushort)0) // oldVersion — no gates fire + .Write(monarchGuid) + .Write((uint)0).Write((uint)0) // cpCached, cpTithed + .Write(0x4u | 0x8u) // bitfield: HasAllegianceAge | HasPackedLevel + .Write((byte)0).Write((byte)0) // gender, heritageGroup + .Write((ushort)1) // rank + .Write((uint)10) // level (HasPackedLevel) + .Write((ushort)0).Write((ushort)0) // loyalty, leadership + .Write((uint)0).Write((uint)0) // timeOnline, allegianceAge (HasAllegianceAge) + .WriteString16L(monarchName) + .ToArray(); + } + + [Fact] + public void WireAll_AllegianceUpdate_ReachesTheCallback() + { + var dispatcher = new GameEventDispatcher(); + ClientCommandResponses.AllegianceUpdate? observed = null; + GameEventWiring.WireAll( + dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(), + onAllegianceUpdate: update => observed = update); + + byte[] wire = BuildMinimalAllegianceProfile(leadingField: 5u, monarchGuid: 0x50000001u); + dispatcher.Dispatch(GameEventEnvelope.TryParse( + WrapEnvelope(GameEventType.AllegianceUpdate, wire))!.Value); + + Assert.NotNull(observed); + Assert.Equal(5u, observed.Value.Rank); + Assert.Equal(0x50000001u, observed.Value.Monarch!.Value.CharacterId); + Assert.Equal("Monarch", observed.Value.Monarch.Value.Name); + } + + [Fact] + public void WireAll_AllegianceInfoResponse_SelfGated_FiresOnlyForOwnGuid() + { + const uint self = 0x50000001u; + const uint other = 0x50000002u; + var dispatcher = new GameEventDispatcher(); + ClientCommandResponses.AllegianceInfoResponse? observed = null; + int chatLines = 0; + var chat = new ChatLog(); + chat.EntryAppended += _ => chatLines++; + GameEventWiring.WireAll( + dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), chat, + playerGuid: () => self, + onAllegianceInfoResponseSelf: response => observed = response); + + // A response about ANOTHER player: the self-gated Runtime callback + // must NOT fire, but the pre-existing `@allegiance info` chat-text + // handler (unconditional) must still fire unchanged. + byte[] otherWire = BuildMinimalAllegianceProfile(leadingField: other, monarchGuid: other, monarchName: "Other"); + dispatcher.Dispatch(GameEventEnvelope.TryParse( + WrapEnvelope(GameEventType.AllegianceInfoResponse, otherWire))!.Value); + Assert.Null(observed); + Assert.True(chatLines > 0); + + chatLines = 0; + byte[] selfWire = BuildMinimalAllegianceProfile(leadingField: self, monarchGuid: self, monarchName: "Self"); + dispatcher.Dispatch(GameEventEnvelope.TryParse( + WrapEnvelope(GameEventType.AllegianceInfoResponse, selfWire))!.Value); + Assert.NotNull(observed); + Assert.Equal(self, observed.Value.TargetGuid); + // The chat-text handler fires for EVERY response, self or not. + Assert.True(chatLines > 0); + } + + [Fact] + public void WireAll_AllegianceUpdateDoneAndAborted_ReachTheirCallbacks() + { + var dispatcher = new GameEventDispatcher(); + uint? done = null; + uint? aborted = null; + GameEventWiring.WireAll( + dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(), + onAllegianceUpdateDone: code => done = code, + onAllegianceUpdateAborted: code => aborted = code); + + dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope( + GameEventType.AllegianceUpdateDone, + new AceWireWriter().Write(0x0561u).ToArray()))!.Value); + dispatcher.Dispatch(GameEventEnvelope.TryParse(WrapEnvelope( + GameEventType.AllegianceUpdateAborted, + new AceWireWriter().Write(0x0562u).ToArray()))!.Value); + + Assert.Equal(0x0561u, done); + Assert.Equal(0x0562u, aborted); + } + + [Fact] + public void WireAll_AllegianceLoginNotification_ReachesTheCallback() + { + var dispatcher = new GameEventDispatcher(); + GameEvents.AllegianceLoginNotification? observed = null; + GameEventWiring.WireAll( + dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(), + onAllegianceLoginNotification: notice => observed = notice); + + byte[] wire = new AceWireWriter().Write(0x50000042u).Write((uint)1).ToArray(); + dispatcher.Dispatch(GameEventEnvelope.TryParse( + WrapEnvelope(GameEventType.AllegianceLoginNotification, wire))!.Value); + + Assert.NotNull(observed); + Assert.Equal(0x50000042u, observed.Value.CharacterGuid); + Assert.True(observed.Value.IsLoggedIn); + } + private static byte[] BuildEnchantment( ushort spellId, ushort layer,