acdream/src/AcDream.Core/Allegiance/AllegianceTree.cs
Erik 62cf755e7d 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>
2026-04-18 17:17:45 +02:00

183 lines
6.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}