feat(allegiance): Phase H.2 AllegianceRequests + AllegianceTree model
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) <noreply@anthropic.com>
This commit is contained in:
parent
3bea646c62
commit
62cf755e7d
4 changed files with 385 additions and 0 deletions
57
src/AcDream.Core.Net/Messages/AllegianceRequests.cs
Normal file
57
src/AcDream.Core.Net/Messages/AllegianceRequests.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Outbound allegiance GameActions. Both Swear and Break carry a
|
||||
/// single <c>uint32</c> target-guid payload inside the standard
|
||||
/// <c>0xF7B1</c> GameAction envelope.
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout (r11 §2.1 / §2.3):
|
||||
/// <code>
|
||||
/// u32 0xF7B1
|
||||
/// u32 gameActionSequence
|
||||
/// u32 subOpcode (0x001D or 0x001E)
|
||||
/// u32 targetGuid
|
||||
/// </code>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Server replies with <c>GameEventAllegianceUpdate</c> (0x0020) and
|
||||
/// <c>GameEventAllegianceAllegianceUpdateDone</c> (0x01C8) on success,
|
||||
/// or a <c>WeenieError</c> on failure (already sworn, already maxed
|
||||
/// vassals, target not online, etc — see r11 §2.1 for the full list).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AllegianceRequests
|
||||
{
|
||||
public const uint GameActionEnvelope = 0xF7B1u;
|
||||
public const uint SwearOpcode = 0x001Du;
|
||||
public const uint BreakOpcode = 0x001Eu;
|
||||
|
||||
/// <summary>Pledge yourself to the given patron.</summary>
|
||||
public static byte[] BuildSwear(uint gameActionSequence, uint patronGuid)
|
||||
{
|
||||
return Build(gameActionSequence, SwearOpcode, patronGuid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Break your pledge to <paramref name="targetGuid"/>. Target can be
|
||||
/// your patron (breaking from) OR your vassal (breaking them away).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
183
src/AcDream.Core/Allegiance/AllegianceTree.cs
Normal file
183
src/AcDream.Core/Allegiance/AllegianceTree.cs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Allegiance;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
///
|
||||
/// <para>
|
||||
/// Nodes know their own name + rank + allegiance XP pass-through
|
||||
/// history. The root (monarch) has <see cref="PatronGuid"/> = 0.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<uint> VassalGuids { get; } = new(); // direct vassals only
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client-side mirror of the player's allegiance tree. Updates come
|
||||
/// from <see cref="AcDream.Core.Net.Messages.GameEventType.AllegianceUpdate"/>
|
||||
/// (0x0020). The full wire blob is complex — r11 §5 — so this class
|
||||
/// exposes a minimal API the UI can grow into:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// <c>UpsertNode</c> — called by the AllegianceUpdate handler
|
||||
/// when the server pushes a node refresh.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>SetMonarch</c> — establishes the root.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>RemoveNode</c> — handles break / leave.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>GetAncestors</c> — walks up to the monarch.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>GetDescendants</c> — flattens the subtree.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Thread safety: single-threaded use from the render thread. Clone
|
||||
/// before reading from another thread.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AllegianceTree
|
||||
{
|
||||
private readonly Dictionary<uint, AllegianceNode> _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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk from <paramref name="guid"/> 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).
|
||||
/// </summary>
|
||||
public IEnumerable<AllegianceNode> GetAncestors(uint guid)
|
||||
{
|
||||
var seen = new HashSet<uint>();
|
||||
uint cursor = guid;
|
||||
while (cursor != 0 && seen.Add(cursor) && _byGuid.TryGetValue(cursor, out var node))
|
||||
{
|
||||
yield return node;
|
||||
cursor = node.PatronGuid;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flatten the subtree rooted at <paramref name="rootGuid"/> in BFS
|
||||
/// order. Includes the root itself.
|
||||
/// </summary>
|
||||
public IEnumerable<AllegianceNode> GetDescendants(uint rootGuid)
|
||||
{
|
||||
if (!_byGuid.TryGetValue(rootGuid, out var root)) yield break;
|
||||
var queue = new Queue<AllegianceNode>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail XP passup formula (r11 §3.2). Vassal passes up a fraction of
|
||||
/// their earned XP each level:
|
||||
/// <code>
|
||||
/// passup = ((50 + 22.5 × loyaltyLevel) / 291) ×
|
||||
/// (1 + realTimeDays/730 × inGameDays/720) × earnedXp
|
||||
/// </code>
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue