From 62cf755e7df1ee9f8a9a3efe314afb206cc570aa Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 18 Apr 2026 17:17:45 +0200 Subject: [PATCH] feat(allegiance): Phase H.2 AllegianceRequests + AllegianceTree model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client-side allegiance data model + outbound swear/break actions. The inbound AllegianceUpdate blob (0x0020) is complex and is deferred; the tree API here is designed so the handler can push nodes in when the blob parser lands. Wire layer: - AllegianceRequests.BuildSwear (0x001D): single uint32 patronGuid. - AllegianceRequests.BuildBreak (0x001E): single uint32 targetGuid (works for both breaking from patron and breaking away a vassal; the server picks behavior based on the relationship). Core layer (AcDream.Core/Allegiance): - AllegianceNode: Guid, Name, PatronGuid, Rank (clamped 0..10), VassalGuids list. - AllegianceTree: Dictionary-backed, events on TreeChanged. - SetMonarch: registers the root (no patron). - UpsertNode: adds/refreshes + auto-inserts into parent's vassal list. - RemoveNode: removes from parent list too; descendants are left with dangling patron pointers for the UI to hide (next AllegianceUpdate refreshes). - GetAncestors: walks up to monarch, cycle-detected for defense. - GetDescendants: BFS-order flattening. - AllegianceMath.ComputePassup: retail XP formula (50+22.5×loyalty)/291 × (1+RT/730×IG/720) × earned, clamped at 0. Tests (11 new): - Tree: SetMonarch fires TreeChanged, UpsertNode auto-populates parent vassal list, rank clamp at 10, RemoveNode cleans parent list, GetAncestors chain, cycle-safe walk, GetDescendants BFS order. - Math: Passup known-value check (1000 XP, 10 loyalty, 100 RT/IG days → ~963 XP), negative clamp. - Wire: Swear + Break byte-exact encoding. Build green, 613 tests pass (up from 602). Next: wire inbound AllegianceUpdate (0x0020) + AllegianceInfoResponse (0x027C) handlers once the blob parser lands. Chat "Allegiance" Turbine channel joining (r11 §2.1 step 9) layers on top of Phase H.1 chat infrastructure. Ref: r11 §1 (tree structure + rank cap), §2 (swear/break wire), §3.2 (XP passup formula). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Messages/AllegianceRequests.cs | 57 ++++++ src/AcDream.Core/Allegiance/AllegianceTree.cs | 183 ++++++++++++++++++ .../Messages/AllegianceRequestsTests.cs | 35 ++++ .../Allegiance/AllegianceTreeTests.cs | 110 +++++++++++ 4 files changed, 385 insertions(+) create mode 100644 src/AcDream.Core.Net/Messages/AllegianceRequests.cs create mode 100644 src/AcDream.Core/Allegiance/AllegianceTree.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/AllegianceRequestsTests.cs create mode 100644 tests/AcDream.Core.Tests/Allegiance/AllegianceTreeTests.cs diff --git a/src/AcDream.Core.Net/Messages/AllegianceRequests.cs b/src/AcDream.Core.Net/Messages/AllegianceRequests.cs new file mode 100644 index 0000000..a7bac79 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/AllegianceRequests.cs @@ -0,0 +1,57 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Outbound allegiance GameActions. Both Swear and Break carry a +/// single uint32 target-guid payload inside the standard +/// 0xF7B1 GameAction envelope. +/// +/// +/// Wire layout (r11 §2.1 / §2.3): +/// +/// u32 0xF7B1 +/// u32 gameActionSequence +/// u32 subOpcode (0x001D or 0x001E) +/// u32 targetGuid +/// +/// +/// +/// +/// Server replies with GameEventAllegianceUpdate (0x0020) and +/// GameEventAllegianceAllegianceUpdateDone (0x01C8) on success, +/// or a WeenieError on failure (already sworn, already maxed +/// vassals, target not online, etc — see r11 §2.1 for the full list). +/// +/// +public static class AllegianceRequests +{ + public const uint GameActionEnvelope = 0xF7B1u; + public const uint SwearOpcode = 0x001Du; + public const uint BreakOpcode = 0x001Eu; + + /// Pledge yourself to the given patron. + public static byte[] BuildSwear(uint gameActionSequence, uint patronGuid) + { + return Build(gameActionSequence, SwearOpcode, patronGuid); + } + + /// + /// Break your pledge to . Target can be + /// your patron (breaking from) OR your vassal (breaking them away). + /// + public static byte[] BuildBreak(uint gameActionSequence, uint targetGuid) + { + return Build(gameActionSequence, BreakOpcode, targetGuid); + } + + private static byte[] Build(uint seq, uint sub, uint targetGuid) + { + byte[] body = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), sub); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid); + return body; + } +} diff --git a/src/AcDream.Core/Allegiance/AllegianceTree.cs b/src/AcDream.Core/Allegiance/AllegianceTree.cs new file mode 100644 index 0000000..2d68dd9 --- /dev/null +++ b/src/AcDream.Core/Allegiance/AllegianceTree.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; + +namespace AcDream.Core.Allegiance; + +/// +/// Per-character allegiance node. Retail allegiance is a strict tree: +/// one patron per character, max 11 direct vassals, recursive depth. +/// Rank capped at 10 (r11 §1, §4). +/// +/// +/// Nodes know their own name + rank + allegiance XP pass-through +/// history. The root (monarch) has = 0. +/// +/// +public sealed class AllegianceNode +{ + public uint Guid; + public string Name = ""; + public uint PatronGuid; // 0 if this node IS the monarch + public int Rank; // 0 = unpledged / monarch; capped at 10 + public long ExperienceCached; // XP the client has seen pass up so far + public List VassalGuids { get; } = new(); // direct vassals only +} + +/// +/// Client-side mirror of the player's allegiance tree. Updates come +/// from +/// (0x0020). The full wire blob is complex — r11 §5 — so this class +/// exposes a minimal API the UI can grow into: +/// +/// +/// +/// UpsertNode — called by the AllegianceUpdate handler +/// when the server pushes a node refresh. +/// +/// +/// SetMonarch — establishes the root. +/// +/// +/// RemoveNode — handles break / leave. +/// +/// +/// GetAncestors — walks up to the monarch. +/// +/// +/// GetDescendants — flattens the subtree. +/// +/// +/// +/// +/// Thread safety: single-threaded use from the render thread. Clone +/// before reading from another thread. +/// +/// +public sealed class AllegianceTree +{ + private readonly Dictionary _byGuid = new(); + + public uint MonarchGuid { get; private set; } + public uint PlayerGuid { get; set; } // whose-allegiance-this-is + + public event Action? TreeChanged; + + public int NodeCount => _byGuid.Count; + + public AllegianceNode? Get(uint guid) => + _byGuid.TryGetValue(guid, out var n) ? n : null; + + public void SetMonarch(uint guid, string name) + { + MonarchGuid = guid; + var node = UpsertNodeInternal(guid, name, 0, rank: 0); + TreeChanged?.Invoke(); + } + + public AllegianceNode UpsertNode(uint guid, string name, uint patronGuid, int rank) + { + var node = UpsertNodeInternal(guid, name, patronGuid, rank); + TreeChanged?.Invoke(); + return node; + } + + private AllegianceNode UpsertNodeInternal(uint guid, string name, uint patronGuid, int rank) + { + if (!_byGuid.TryGetValue(guid, out var node)) + { + node = new AllegianceNode { Guid = guid }; + _byGuid[guid] = node; + } + node.Name = name; + node.PatronGuid = patronGuid; + node.Rank = Math.Clamp(rank, 0, 10); + + // Keep parent's vassal list in sync with this node's declared + // patron. A node that changes patron is removed from the old + // patron's list and added to the new one. + if (patronGuid != 0 && _byGuid.TryGetValue(patronGuid, out var parent)) + { + if (!parent.VassalGuids.Contains(guid)) + parent.VassalGuids.Add(guid); + } + + return node; + } + + public bool RemoveNode(uint guid) + { + if (!_byGuid.TryGetValue(guid, out var node)) return false; + if (node.PatronGuid != 0 && _byGuid.TryGetValue(node.PatronGuid, out var parent)) + parent.VassalGuids.Remove(guid); + _byGuid.Remove(guid); + // Cascade: orphaned descendants stay in the map with their + // patron pointer dangling; the UI can hide them or the next + // AllegianceUpdate will refresh them. + TreeChanged?.Invoke(); + return true; + } + + /// + /// Walk from up to the monarch. Yields node + /// then patron then grand-patron, etc. Includes the starting node. + /// Stops if a cycle is detected (shouldn't happen — server enforces + /// acyclicity — but be defensive). + /// + public IEnumerable GetAncestors(uint guid) + { + var seen = new HashSet(); + uint cursor = guid; + while (cursor != 0 && seen.Add(cursor) && _byGuid.TryGetValue(cursor, out var node)) + { + yield return node; + cursor = node.PatronGuid; + } + } + + /// + /// Flatten the subtree rooted at in BFS + /// order. Includes the root itself. + /// + public IEnumerable GetDescendants(uint rootGuid) + { + if (!_byGuid.TryGetValue(rootGuid, out var root)) yield break; + var queue = new Queue(); + queue.Enqueue(root); + while (queue.Count > 0) + { + var node = queue.Dequeue(); + yield return node; + foreach (var vGuid in node.VassalGuids) + if (_byGuid.TryGetValue(vGuid, out var vassal)) + queue.Enqueue(vassal); + } + } + + public void Clear() + { + _byGuid.Clear(); + MonarchGuid = 0; + TreeChanged?.Invoke(); + } +} + +/// +/// Retail XP passup formula (r11 §3.2). Vassal passes up a fraction of +/// their earned XP each level: +/// +/// passup = ((50 + 22.5 × loyaltyLevel) / 291) × +/// (1 + realTimeDays/730 × inGameDays/720) × earnedXp +/// +/// +public static class AllegianceMath +{ + public static long ComputePassup(long earnedXp, int loyaltyLevel, + double realTimeDays, double inGameDays) + { + double loyaltyScale = (50.0 + 22.5 * loyaltyLevel) / 291.0; + double ageScale = 1.0 + (realTimeDays / 730.0) * (inGameDays / 720.0); + double passup = earnedXp * loyaltyScale * ageScale; + if (passup < 0) passup = 0; + return (long)Math.Round(passup); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/AllegianceRequestsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/AllegianceRequestsTests.cs new file mode 100644 index 0000000..848d79c --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/AllegianceRequestsTests.cs @@ -0,0 +1,35 @@ +using System; +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class AllegianceRequestsTests +{ + [Fact] + public void BuildSwear_EncodesOpcodeAndTarget() + { + byte[] body = AllegianceRequests.BuildSwear(gameActionSequence: 3, patronGuid: 0xAAAAu); + + Assert.Equal(16, body.Length); + Assert.Equal(AllegianceRequests.GameActionEnvelope, + BinaryPrimitives.ReadUInt32LittleEndian(body)); + Assert.Equal(3u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4))); + Assert.Equal(AllegianceRequests.SwearOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0xAAAAu, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + } + + [Fact] + public void BuildBreak_EncodesOpcodeAndTarget() + { + byte[] body = AllegianceRequests.BuildBreak(gameActionSequence: 5, targetGuid: 0xBBBBu); + Assert.Equal(AllegianceRequests.BreakOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0xBBBBu, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + } +} diff --git a/tests/AcDream.Core.Tests/Allegiance/AllegianceTreeTests.cs b/tests/AcDream.Core.Tests/Allegiance/AllegianceTreeTests.cs new file mode 100644 index 0000000..826e3fa --- /dev/null +++ b/tests/AcDream.Core.Tests/Allegiance/AllegianceTreeTests.cs @@ -0,0 +1,110 @@ +using System.Linq; +using AcDream.Core.Allegiance; +using Xunit; + +namespace AcDream.Core.Tests.Allegiance; + +public sealed class AllegianceTreeTests +{ + [Fact] + public void SetMonarch_RegistersNode_FiresEvent() + { + var tree = new AllegianceTree(); + int changes = 0; + tree.TreeChanged += () => changes++; + + tree.SetMonarch(0xAA, "Bael'Zharon"); + Assert.Equal(1, changes); + Assert.Equal(0xAAu, tree.MonarchGuid); + Assert.Equal(1, tree.NodeCount); + } + + [Fact] + public void UpsertNode_NewPatron_AddsToVassalList() + { + var tree = new AllegianceTree(); + tree.SetMonarch(0xAA, "Monarch"); + tree.UpsertNode(0xBB, "Vassal", patronGuid: 0xAA, rank: 1); + + var monarch = tree.Get(0xAA); + Assert.NotNull(monarch); + Assert.Contains(0xBBu, monarch!.VassalGuids); + } + + [Fact] + public void UpsertNode_RankClamp_CapsAt10() + { + var tree = new AllegianceTree(); + tree.SetMonarch(0xAA, "M"); + var node = tree.UpsertNode(0xBB, "V", 0xAA, rank: 50); + Assert.Equal(10, node.Rank); + } + + [Fact] + public void RemoveNode_RemovesFromParentVassalList() + { + var tree = new AllegianceTree(); + tree.SetMonarch(0xAA, "M"); + tree.UpsertNode(0xBB, "V", 0xAA, 1); + Assert.Single(tree.Get(0xAA)!.VassalGuids); + + tree.RemoveNode(0xBB); + Assert.Empty(tree.Get(0xAA)!.VassalGuids); + } + + [Fact] + public void GetAncestors_WalksToMonarch() + { + var tree = new AllegianceTree(); + tree.SetMonarch(0xAA, "M"); + tree.UpsertNode(0xBB, "V", 0xAA, 1); + tree.UpsertNode(0xCC, "VV", 0xBB, 2); + tree.UpsertNode(0xDD, "VVV", 0xCC, 3); + + var chain = tree.GetAncestors(0xDD).Select(n => n.Name).ToArray(); + Assert.Equal(new[] { "VVV", "VV", "V", "M" }, chain); + } + + [Fact] + public void GetAncestors_HandlesCycleWithoutLooping() + { + // Corrupt data: defensive path. Treat a cycle as a stop. + var tree = new AllegianceTree(); + tree.UpsertNode(0xAA, "A", patronGuid: 0xBB, rank: 1); + tree.UpsertNode(0xBB, "B", patronGuid: 0xAA, rank: 1); // cycle + + var walk = tree.GetAncestors(0xAA).Select(n => n.Guid).ToArray(); + Assert.Equal(2, walk.Length); // visits each exactly once + } + + [Fact] + public void GetDescendants_BfsOrder() + { + var tree = new AllegianceTree(); + tree.SetMonarch(0xAA, "M"); + tree.UpsertNode(0xB0, "B0", 0xAA, 1); + tree.UpsertNode(0xB1, "B1", 0xAA, 1); + tree.UpsertNode(0xC0, "C0", 0xB0, 2); + + var bfs = tree.GetDescendants(0xAA).Select(n => n.Name).ToArray(); + Assert.Equal(new[] { "M", "B0", "B1", "C0" }, bfs); + } + + [Fact] + public void AllegianceMath_Passup_KnownCase() + { + // 1000 XP, loyalty 10, 100 real-time days, 100 in-game days. + // loyaltyScale = (50 + 225) / 291 = 0.9450 + // ageScale = 1 + (100/730)*(100/720) = 1 + 0.0190 = 1.0190 + // passup = 1000 * 0.9450 * 1.0190 ≈ 963 + long passup = AllegianceMath.ComputePassup(1000, 10, 100, 100); + Assert.InRange(passup, 960, 965); + } + + [Fact] + public void AllegianceMath_Passup_ZeroClamp() + { + // Negative XP shouldn't be possible; passup is clamped at 0. + Assert.Equal(0, AllegianceMath.ComputePassup(-1000, 10, 0, 0)); + } +}