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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 01:31:18 +02:00
parent 1bb707e248
commit 1c40104896
3 changed files with 437 additions and 1 deletions

View file

@ -96,7 +96,28 @@ public static class GameEventWiring
// WeenieErrorMessages table, just without the SpewBox split) so // WeenieErrorMessages table, just without the SpewBox split) so
// every existing caller compiles and behaves unchanged. // every existing caller compiles and behaves unchanged.
Action<string, RetailLogTextType>? onInterfaceText = null, Action<string, RetailLogTextType>? onInterfaceText = null,
Func<bool>? accepting = null) Func<bool>? 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<GameEvents.FellowshipFullUpdate>? onFellowshipFullUpdate = null,
Action<GameEvents.FellowshipUpdateFellow>? onFellowshipUpdateFellow = null,
Action<uint /*quitterGuid*/>? onFellowshipQuit = null,
Action<uint /*dismissedGuid*/>? onFellowshipDismiss = null,
Action? onFellowshipDisband = null,
Action<ClientCommandResponses.AllegianceUpdate>? 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<ClientCommandResponses.AllegianceInfoResponse>? onAllegianceInfoResponseSelf = null,
Action<uint /*weenieError*/>? onAllegianceUpdateDone = null,
Action<uint /*weenieError*/>? onAllegianceUpdateAborted = null,
Action<GameEvents.AllegianceLoginNotification>? onAllegianceLoginNotification = null)
{ {
ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items); ArgumentNullException.ThrowIfNull(items);
@ -189,14 +210,114 @@ public static class GameEventWiring
foreach (string line in ClientCommandResponses.FormatAvailableHousesLines(houses.Value)) foreach (string line in ClientCommandResponses.FormatAvailableHousesLines(houses.Value))
chat.OnSystemMessage(line, chatType: 0u); 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 => registrar.Register(GameEventType.AllegianceInfoResponse, e =>
{ {
var info = ClientCommandResponses.ParseAllegianceInfoResponse(e.Payload.Span); var info = ClientCommandResponses.ParseAllegianceInfoResponse(e.Payload.Span);
if (info is null) return; if (info is null) return;
foreach (string line in ClientCommandResponses.FormatAllegianceInfoLines(info.Value)) foreach (string line in ClientCommandResponses.FormatAllegianceInfoLines(info.Value))
chat.OnSystemMessage(line, chatType: 0u); 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) if (onConfirmationRequest is not null)
{ {
registrar.Register(GameEventType.CharacterConfirmationRequest, e => registrar.Register(GameEventType.CharacterConfirmationRequest, e =>

View file

@ -2315,6 +2315,94 @@ public sealed class WorldSession : IDisposable
SendGameAction(ClientCommandRequests.BuildAllegianceInfoRequest(seq, playerName)); 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).
/// <summary>Send retail fellowship create (0x00A2).</summary>
public void SendFellowshipCreate(string fellowshipName, bool shareXp)
{
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildFellowshipCreate(seq, fellowshipName, shareXp));
}
/// <summary>Send retail fellowship quit / disband (0x00A3).</summary>
public void SendFellowshipQuit(bool disband)
{
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildFellowshipQuit(seq, disband));
}
/// <summary>Send retail fellowship dismiss (0x00A4).</summary>
public void SendFellowshipDismiss(uint targetGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildFellowshipDismiss(seq, targetGuid));
}
/// <summary>Send retail fellowship recruit (0x00A5).</summary>
public void SendFellowshipRecruit(uint targetGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildFellowshipRecruit(seq, targetGuid));
}
/// <summary>
/// Send retail fellowship-panel visibility declaration (0x00A6) — D4:
/// gates ACE's <c>0x02C0</c> member-vitals stream (docs/research/
/// 2026-08-11-fa-fellowship-wire.md §4.5).
/// </summary>
public void SendFellowshipUpdateRequest(bool panelOpen)
{
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildFellowshipUpdateRequest(seq, panelOpen));
}
/// <summary>Send retail fellowship leadership transfer (0x0290).</summary>
public void SendFellowshipAssignNewLeader(uint newLeaderGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildFellowshipAssignNewLeader(seq, newLeaderGuid));
}
/// <summary>Send retail fellowship openness toggle (0x0291).</summary>
public void SendFellowshipChangeOpenness(bool isOpen)
{
uint seq = NextGameActionSequence();
SendGameAction(SocialActions.BuildFellowshipChangeOpenness(seq, isOpen));
}
/// <summary>Send retail allegiance swear (0x001D).</summary>
public void SendAllegianceSwear(uint patronGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(AllegianceRequests.BuildSwear(seq, patronGuid));
}
/// <summary>Send retail allegiance break (0x001E) — targets your own patron.</summary>
public void SendAllegianceBreak(uint targetGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(AllegianceRequests.BuildBreak(seq, targetGuid));
}
/// <summary>Send retail allegiance kick (0x001E) — targets a vassal.</summary>
public void SendAllegianceKick(uint vassalGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(AllegianceRequests.BuildKick(seq, vassalGuid));
}
/// <summary>Send retail allegiance-panel subscribe/unsubscribe (0x001F).</summary>
public void SendAllegianceUpdateRequest(bool on)
{
uint seq = NextGameActionSequence();
SendGameAction(AllegianceRequests.BuildAllegianceUpdateRequest(seq, on));
}
/// <summary>Send retail @hslist &lt;type&gt; (0x0270).</summary> /// <summary>Send retail @hslist &lt;type&gt; (0x0270).</summary>
public void SendListAvailableHouses(uint houseType) public void SendListAvailableHouses(uint houseType)
{ {

View file

@ -7,6 +7,7 @@ using AcDream.Core.Combat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Tests.Messages;
using AcDream.Core.Player; using AcDream.Core.Player;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Core.Social; using AcDream.Core.Social;
@ -1544,6 +1545,232 @@ public sealed class GameEventWiringTests
Assert.Equal((0x50C4A54Au, 0x948700u, false), observed); 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);
}
/// <summary>Minimal valid <c>AllegianceProfile</c> body at <c>oldVersion=0</c> — no gated fields, one monarch record only.</summary>
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( private static byte[] BuildEnchantment(
ushort spellId, ushort spellId,
ushort layer, ushort layer,