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
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
110
tests/AcDream.Core.Tests/Allegiance/AllegianceTreeTests.cs
Normal file
110
tests/AcDream.Core.Tests/Allegiance/AllegianceTreeTests.cs
Normal file
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue