Applies both MUST-FIX items and the code-facing SHOULD-FIX items from the dual-lens FA1 review (docs/research/2026-08-12-fa1-review-mechanism.md, docs/research/2026-08-12-fa1-review-blast.md): Mechanism MF-1 / blast SF-2: AllegianceHierarchy::Add @0x005B6E90 wraps its entire body in `if (_id != 0)` -- a record whose own id is zero discards the WHOLE message, for both the monarch and a child record, and this is also what makes treeParent == 0 unconditionally fatal for a non-monarch record. ReadAllegianceProfileBody now rejects CharacterId == 0 on both paths; four new boundary tests in AllegianceProfileVersionGateTests.cs (zero-id monarch, zero-id child, zero treeParent, plus the existing orphan/self-parent/duplicate trio). Mechanism MF-2: added the missing 0x001F AllegianceUpdateRequest builder -- the structural twin of the fellowship 0x00A6 this slice already repaired -- with golden-vector tests for both on/off. Mechanism SF-1 / blast SF-3: UnPack's last act before returning success forces the monarch's MayPassupExperience to false regardless of the wire bit or the HasPackedLevel-absent legacy-compat fallback. Ported at the end of the record loop; the pre-existing HasPackedLevel-absent test moved off the monarch record (which the new clear makes indistinguishable from "the fallback never fired") onto a vassal record, and a new test proves the monarch clear fires even when the wire bit explicitly asks for true. Mechanism SF-2: removed ParseFellowshipDisband's invented body-length validation -- retail's DispatchUI_Disband reads only the opcode and never inspects a trailing body. The parser now always succeeds; the matching test flips from asserting rejection to asserting acceptance. Mechanism SF-3: added the D5 `<<1` shareLoot-shape test at the 0x02C0 FellowshipUpdateFellow site -- previously only pinned at 0x02BE, so a future split of the shared ReadFellow helper could silently reintroduce a bool read on this leg undetected. Mechanism SF-5: renumbered the version-gate comments in ReadAllegianceProfileBody to the true AllegianceVersion enum values (1-11, matching acclient.h's SpokespersonAdded..ApprovedVassal) instead of wire-appearance order, which only reached 10 and silently dropped gate 5 (BannedCharactersAdded, which is real but gates nothing in UnPack -- now called out explicitly). Fixed the stale "lane B §12" citation in SocialActions.cs to the actual master-table row. Blast SF-1: pinned the two retail-faithful but user-visible behavior changes FA1 made to the ALREADY-LIVE `@allegiance info` command -- reversed vassal print order (3-vassal test through FormatAllegianceInfoLines) and malformed-tree silent-drop (test at the GameEventWiring registration layer, which is `if (info is null) return;`). Blast SF-4: fixed a doc comment citing a nonexistent `ConfirmationResponseTests` class; the actual class is `ConfirmationTripleTests`. Blast SF-5: cross-referenced the confirmation-triple discriminator's split representation (ConfirmationType on the response leg only; bare uint on the two inbound legs production actually reads) at both sites, so FA4 inherits a stated decision rather than an unexplained inconsistency. Full Release suite: 13,158 passed / 4 skipped / 0 failed (13,162 total), up from the pre-fix-round 13,149/4/0 (+9 tests this round). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
640 lines
26 KiB
C#
640 lines
26 KiB
C#
using System.Collections.Generic;
|
|
using AcDream.Core.Net.Messages;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Net.Tests.Messages;
|
|
|
|
/// <summary>
|
|
/// Campaign FA slice FA1 (2026-08-11): conformance tests for the ELEVEN
|
|
/// <c>AllegianceHierarchy::UnPack</c> version gates
|
|
/// (docs/research/2026-08-11-fa-allegiance-wire.md, lane C, §4.2) and the
|
|
/// §4.4 tree-assembly rules (orphan treeParent discards the whole
|
|
/// message; sibling order reverses on assembly), plus the new
|
|
/// <c>ParseAllegianceUpdate</c> (0x0020) sharing the extended
|
|
/// <c>ParseAllegianceInfoResponse</c> (0x027C) profile reader.
|
|
///
|
|
/// <para>
|
|
/// Every version-gate test builds TWO fixtures — <c>oldVersion = N-1</c>
|
|
/// (field OFF) and <c>oldVersion = N</c> (field ON) — for each of the
|
|
/// eleven <c>AllegianceVersion</c> thresholds (1 through 11), proving both
|
|
/// that the monarch record downstream still parses correctly (the
|
|
/// structural proof: a wrong gate offset garbles every field after it)
|
|
/// and, where a field is surfaced on the public record, that its
|
|
/// presence/default flips exactly at the threshold.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class AllegianceProfileVersionGateTests
|
|
{
|
|
private const uint MonarchGuid = 0x50000001u;
|
|
|
|
private static readonly List<(uint characterId, uint parentGuid, bool loggedIn, string name)> OneMonarch =
|
|
new() { (MonarchGuid, 0u, true, "Monarch") };
|
|
|
|
/// <summary>
|
|
/// Builds one <c>AllegianceProfile</c> wire body (0x027C/0x0020 shape,
|
|
/// leading discriminator u32 supplied by the caller) at a given
|
|
/// <c>oldVersion</c>, writing ONLY the fields that version's gates
|
|
/// admit — mirrors <c>ACE/Source/.../AllegianceHierarchy.cs</c>'s own
|
|
/// writer field order, cross-checked against lane C §4.1-§4.2.
|
|
/// </summary>
|
|
private static byte[] BuildProfileWire(
|
|
uint leadingField,
|
|
ushort oldVersion,
|
|
List<(uint characterId, uint parentGuid, bool loggedIn, string name)> records,
|
|
int officerEntries = 0,
|
|
int officerTitleEntries = 0,
|
|
string motd = "TheMotd",
|
|
string motdSetBy = "MotdSetter",
|
|
uint chatRoomId = 0x77770000u,
|
|
string allegianceName = "TestAllegiance",
|
|
uint nameLastSetTime = 0x88880000u,
|
|
bool isLocked = true,
|
|
uint approvedVassal = 0x99990000u)
|
|
{
|
|
var w = new AceWireWriter()
|
|
.Write(leadingField)
|
|
.Write((uint)records.Count) // totalMembers
|
|
.Write((uint)0) // totalVassals
|
|
.Write((ushort)records.Count) // recordCount
|
|
.Write(oldVersion);
|
|
|
|
// §4.2 gates 1/2.
|
|
if (oldVersion >= 6)
|
|
{
|
|
w.Write((ushort)officerEntries).Write((ushort)256);
|
|
for (int i = 0; i < officerEntries; i++)
|
|
w.Write(0x60000000u + (uint)i).Write((uint)2);
|
|
}
|
|
else if (oldVersion >= 1)
|
|
{
|
|
w.Write(0xDEADBEEFu); // old single spokesperson id
|
|
}
|
|
|
|
// §4.2 gate 3.
|
|
if (oldVersion >= 9)
|
|
{
|
|
w.Write((uint)officerTitleEntries);
|
|
for (int i = 0; i < officerTitleEntries; i++)
|
|
w.WriteString16L($"Title{i}");
|
|
}
|
|
|
|
// §4.2 gate 4 (PoolsAdded block, 4 counters).
|
|
if (oldVersion >= 2)
|
|
w.Write((uint)1).Write((uint)2).Write((uint)3).Write((uint)4);
|
|
|
|
// §4.2 gate 5 (MotdAdded).
|
|
if (oldVersion >= 3)
|
|
w.WriteString16L(motd).WriteString16L(motdSetBy);
|
|
|
|
// §4.2 gate 6 (ChatRoomIDAdded).
|
|
if (oldVersion >= 4)
|
|
w.Write(chatRoomId);
|
|
|
|
// §4.2 gate 7 (Bindstones) — 32-byte Position.
|
|
if (oldVersion >= 7)
|
|
{
|
|
w.Write((uint)0)
|
|
.Write(1f).Write(2f).Write(3f)
|
|
.Write(1f).Write(0f).Write(0f).Write(0f);
|
|
}
|
|
|
|
// §4.2 gate 8 (AllegianceName).
|
|
if (oldVersion >= 8)
|
|
w.WriteString16L(allegianceName).Write(nameLastSetTime);
|
|
|
|
// §4.2 gate 9 (LockedState).
|
|
if (oldVersion >= 10)
|
|
w.Write(isLocked ? 1u : 0u);
|
|
|
|
// §4.2 gate 10 (ApprovedVassal).
|
|
if (oldVersion >= 11)
|
|
w.Write(approvedVassal);
|
|
|
|
for (int i = 0; i < records.Count; i++)
|
|
{
|
|
(uint characterId, uint parentGuid, bool loggedIn, string name) = records[i];
|
|
if (i > 0)
|
|
w.Write(parentGuid);
|
|
|
|
uint bitfield = 0x4u | 0x8u; // HasAllegianceAge | HasPackedLevel
|
|
if (loggedIn) bitfield |= 0x1u;
|
|
|
|
w.Write(characterId)
|
|
.Write((uint)0).Write((uint)0) // cpCached, cpTithed
|
|
.Write(bitfield)
|
|
.Write((byte)0).Write((byte)0) // gender, heritage
|
|
.Write((ushort)1) // rank
|
|
.Write((uint)5) // level
|
|
.Write((ushort)0).Write((ushort)0) // loyalty, leadership
|
|
.Write((uint)0).Write((uint)0) // timeOnline, allegianceAge
|
|
.WriteString16L(name);
|
|
}
|
|
|
|
return w.ToArray();
|
|
}
|
|
|
|
private static ClientCommandResponses.AllegianceInfoResponse ParseAt(
|
|
ushort oldVersion,
|
|
List<(uint, uint, bool, string)> records,
|
|
int officerEntries = 0,
|
|
int officerTitleEntries = 0)
|
|
{
|
|
byte[] wire = BuildProfileWire(
|
|
MonarchGuid, oldVersion, records,
|
|
officerEntries: officerEntries, officerTitleEntries: officerTitleEntries);
|
|
var parsed = ClientCommandResponses.ParseAllegianceInfoResponse(wire);
|
|
Assert.NotNull(parsed);
|
|
return parsed!.Value;
|
|
}
|
|
|
|
// ── Gate 1/2 boundary: v0 (neither) → v1 (old spokesperson 4-byte skip) ──
|
|
|
|
[Fact]
|
|
public void VersionGate_0to1_OldSpokespersonSkipTurnsOn()
|
|
{
|
|
var v0 = ParseAt(0, OneMonarch);
|
|
var v1 = ParseAt(1, OneMonarch);
|
|
|
|
Assert.Equal((ushort)0, v0.OldVersion);
|
|
Assert.Equal((ushort)1, v1.OldVersion);
|
|
Assert.Equal("Monarch", v0.Monarch!.Value.Name);
|
|
Assert.Equal("Monarch", v1.Monarch!.Value.Name);
|
|
Assert.Equal(MonarchGuid, v0.Monarch!.Value.CharacterId);
|
|
Assert.Equal(MonarchGuid, v1.Monarch!.Value.CharacterId);
|
|
}
|
|
|
|
// ── Gate: v1 → v2 (PoolsAdded — 4 broadcast counters) ────────────────────
|
|
|
|
[Fact]
|
|
public void VersionGate_1to2_BroadcastCountersTurnOn()
|
|
{
|
|
var v1 = ParseAt(1, OneMonarch);
|
|
var v2 = ParseAt(2, OneMonarch);
|
|
|
|
// Not surfaced on the public record (lane C §5.1: ACE never
|
|
// assigns them) — the structural proof (monarch survives intact)
|
|
// is what confirms the gate consumed exactly the right bytes.
|
|
Assert.Equal("Monarch", v1.Monarch!.Value.Name);
|
|
Assert.Equal("Monarch", v2.Monarch!.Value.Name);
|
|
}
|
|
|
|
// ── Gate 5: v2 → v3 (MotdAdded) ───────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void VersionGate_2to3_MotdTurnsOn()
|
|
{
|
|
var v2 = ParseAt(2, OneMonarch);
|
|
var v3 = ParseAt(3, OneMonarch);
|
|
|
|
Assert.Equal("", v2.Motd);
|
|
Assert.Equal("", v2.MotdSetBy);
|
|
Assert.Equal("TheMotd", v3.Motd);
|
|
Assert.Equal("MotdSetter", v3.MotdSetBy);
|
|
Assert.Equal("Monarch", v3.Monarch!.Value.Name);
|
|
}
|
|
|
|
// ── Gate 6: v3 → v4 (ChatRoomIDAdded) ─────────────────────────────────
|
|
|
|
[Fact]
|
|
public void VersionGate_3to4_ChatRoomIdTurnsOn()
|
|
{
|
|
var v3 = ParseAt(3, OneMonarch);
|
|
var v4 = ParseAt(4, OneMonarch);
|
|
|
|
Assert.Equal(0u, v3.ChatRoomId);
|
|
Assert.Equal(0x77770000u, v4.ChatRoomId);
|
|
Assert.Equal("Monarch", v4.Monarch!.Value.Name);
|
|
}
|
|
|
|
// ── v4 → v5: BannedCharactersAdded (version 5) gates NOTHING in UnPack ──
|
|
// (lane C §4.2 note) — this is the negative proof: v5 must parse
|
|
// structurally IDENTICALLY to v4 since no new field exists between them.
|
|
|
|
[Fact]
|
|
public void VersionGate_4to5_BannedCharactersAddedGatesNothing()
|
|
{
|
|
var v4 = ParseAt(4, OneMonarch);
|
|
var v5 = ParseAt(5, OneMonarch);
|
|
|
|
Assert.Equal(v4.ChatRoomId, v5.ChatRoomId);
|
|
Assert.Equal(v4.Motd, v5.Motd);
|
|
Assert.Equal("Monarch", v5.Monarch!.Value.Name);
|
|
}
|
|
|
|
// ── Gate 1 (the real one): v5 → v6 (MultipleAllegianceOfficersAdded) ────
|
|
// The officers PHashTable REPLACES the 4-byte spokesperson skip.
|
|
// officerEntries=2 at v6 proves the table's non-trivial byte count is
|
|
// consumed correctly (a wrong gate would garble the monarch that follows).
|
|
|
|
[Fact]
|
|
public void VersionGate_5to6_OfficersTableReplacesSpokespersonSkip()
|
|
{
|
|
var v5 = ParseAt(5, OneMonarch);
|
|
var v6 = ParseAt(6, OneMonarch, officerEntries: 2);
|
|
|
|
Assert.Equal((ushort)5, v5.OldVersion);
|
|
Assert.Equal((ushort)6, v6.OldVersion);
|
|
Assert.Equal("Monarch", v5.Monarch!.Value.Name);
|
|
Assert.Equal("Monarch", v6.Monarch!.Value.Name);
|
|
}
|
|
|
|
// ── Gate: v6 → v7 (Bindstones — 32-byte Position) ────────────────────────
|
|
|
|
[Fact]
|
|
public void VersionGate_6to7_BindPointTurnsOn()
|
|
{
|
|
var v6 = ParseAt(6, OneMonarch);
|
|
var v7 = ParseAt(7, OneMonarch);
|
|
|
|
// Not surfaced on the public record (deliberate FA1 scope
|
|
// decision — see AllegianceProfileBody's class doc); the
|
|
// structural proof is the monarch surviving both 0 and 32 extra
|
|
// bytes correctly.
|
|
Assert.Equal("Monarch", v6.Monarch!.Value.Name);
|
|
Assert.Equal("Monarch", v7.Monarch!.Value.Name);
|
|
}
|
|
|
|
// ── Gate 8: v7 → v8 (AllegianceName + NameLastSetTime) ───────────────────
|
|
|
|
[Fact]
|
|
public void VersionGate_7to8_AllegianceNameTurnsOn()
|
|
{
|
|
var v7 = ParseAt(7, OneMonarch);
|
|
var v8 = ParseAt(8, OneMonarch);
|
|
|
|
Assert.Equal("", v7.AllegianceName);
|
|
Assert.Equal(0u, v7.NameLastSetTime);
|
|
Assert.Equal("TestAllegiance", v8.AllegianceName);
|
|
Assert.Equal(0x88880000u, v8.NameLastSetTime);
|
|
Assert.Equal("Monarch", v8.Monarch!.Value.Name);
|
|
}
|
|
|
|
// ── Gate 3: v8 → v9 (OfficersTitlesAdded) ────────────────────────────────
|
|
|
|
[Fact]
|
|
public void VersionGate_8to9_OfficerTitlesTurnOn()
|
|
{
|
|
var v8 = ParseAt(8, OneMonarch);
|
|
var v9 = ParseAt(9, OneMonarch, officerTitleEntries: 2);
|
|
|
|
Assert.Equal("Monarch", v8.Monarch!.Value.Name);
|
|
Assert.Equal("Monarch", v9.Monarch!.Value.Name);
|
|
}
|
|
|
|
// ── Gate 9: v9 → v10 (LockedState) ───────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void VersionGate_9to10_IsLockedTurnsOn()
|
|
{
|
|
var v9 = ParseAt(9, OneMonarch);
|
|
var v10 = ParseAt(10, OneMonarch);
|
|
|
|
Assert.False(v9.IsLocked);
|
|
Assert.True(v10.IsLocked);
|
|
Assert.Equal("Monarch", v10.Monarch!.Value.Name);
|
|
}
|
|
|
|
// ── Gate 10: v10 → v11 (ApprovedVassal) ──────────────────────────────────
|
|
|
|
[Fact]
|
|
public void VersionGate_10to11_ApprovedVassalTurnsOn()
|
|
{
|
|
var v10 = ParseAt(10, OneMonarch);
|
|
var v11 = ParseAt(11, OneMonarch);
|
|
|
|
Assert.Equal(0u, v10.ApprovedVassal);
|
|
Assert.Equal(0x99990000u, v11.ApprovedVassal);
|
|
Assert.Equal("Monarch", v11.Monarch!.Value.Name);
|
|
}
|
|
|
|
// ── §4.4 tree-assembly rules ──────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void TreeAssembly_OrphanTreeParent_DiscardsWholeMessage()
|
|
{
|
|
// AllegianceHierarchy::Add @0x005B6E90: a record whose treeParent
|
|
// has not already been added returns 0, which makes UnPack return
|
|
// 0 and the client drop the ENTIRE message.
|
|
var records = new List<(uint, uint, bool, string)>
|
|
{
|
|
(MonarchGuid, 0u, true, "Monarch"),
|
|
(0x50000005u, 0x5000FFFFu /* never-seen parent */, true, "Orphan"),
|
|
};
|
|
byte[] wire = BuildProfileWire(MonarchGuid, 11, records);
|
|
|
|
Assert.Null(ClientCommandResponses.ParseAllegianceInfoResponse(wire));
|
|
}
|
|
|
|
[Fact]
|
|
public void TreeAssembly_SelfParent_DiscardsWholeMessage()
|
|
{
|
|
var records = new List<(uint, uint, bool, string)>
|
|
{
|
|
(MonarchGuid, 0u, true, "Monarch"),
|
|
(0x50000006u, 0x50000006u /* itself */, true, "SelfParent"),
|
|
};
|
|
byte[] wire = BuildProfileWire(MonarchGuid, 11, records);
|
|
|
|
Assert.Null(ClientCommandResponses.ParseAllegianceInfoResponse(wire));
|
|
}
|
|
|
|
[Fact]
|
|
public void TreeAssembly_DuplicateId_DiscardsWholeMessage()
|
|
{
|
|
var records = new List<(uint, uint, bool, string)>
|
|
{
|
|
(MonarchGuid, 0u, true, "Monarch"),
|
|
(0x50000007u, MonarchGuid, true, "First"),
|
|
(0x50000007u, MonarchGuid, true, "DuplicateAgain"),
|
|
};
|
|
byte[] wire = BuildProfileWire(MonarchGuid, 11, records);
|
|
|
|
Assert.Null(ClientCommandResponses.ParseAllegianceInfoResponse(wire));
|
|
}
|
|
|
|
// MF-1 (mechanism review): AllegianceHierarchy::Add @0x005B6E90 wraps
|
|
// its ENTIRE body in `if (_id != 0)` — a record whose own id is zero
|
|
// falls straight out to `return 0`, which UnPack turns into "discard
|
|
// the whole message", for BOTH the monarch and a child record. This
|
|
// is a FOURTH rejection rule distinct from orphan/self-parent/
|
|
// duplicate above, and it is also what makes `treeParent == 0`
|
|
// unconditionally fatal for a non-monarch record — 0 can never be a
|
|
// knownId once the id==0 monarch case is rejected.
|
|
|
|
[Fact]
|
|
public void TreeAssembly_ZeroIdMonarch_DiscardsWholeMessage()
|
|
{
|
|
var records = new List<(uint, uint, bool, string)>
|
|
{
|
|
(0u /* zero id */, 0u, true, "ZeroIdMonarch"),
|
|
};
|
|
byte[] wire = BuildProfileWire(MonarchGuid, 11, records);
|
|
|
|
Assert.Null(ClientCommandResponses.ParseAllegianceInfoResponse(wire));
|
|
}
|
|
|
|
[Fact]
|
|
public void TreeAssembly_ZeroIdChildRecord_DiscardsWholeMessage()
|
|
{
|
|
var records = new List<(uint, uint, bool, string)>
|
|
{
|
|
(MonarchGuid, 0u, true, "Monarch"),
|
|
(0u /* zero id */, MonarchGuid, true, "ZeroIdChild"),
|
|
};
|
|
byte[] wire = BuildProfileWire(MonarchGuid, 11, records);
|
|
|
|
Assert.Null(ClientCommandResponses.ParseAllegianceInfoResponse(wire));
|
|
}
|
|
|
|
[Fact]
|
|
public void TreeAssembly_ZeroTreeParent_OnNonMonarchRecord_DiscardsWholeMessage()
|
|
{
|
|
// treeParent == 0 for anything but the (implicit) monarch slot can
|
|
// never resolve — 0 is never added to knownIds because a zero-id
|
|
// record is itself rejected (the two tests above), so this is the
|
|
// orphan rule specialized to the zero case retail's own Search
|
|
// (arg2 != 0) guard names explicitly.
|
|
var records = new List<(uint, uint, bool, string)>
|
|
{
|
|
(MonarchGuid, 0u, true, "Monarch"),
|
|
(0x50000008u, 0u /* treeParent == 0, not the monarch */, true, "ZeroParent"),
|
|
};
|
|
byte[] wire = BuildProfileWire(MonarchGuid, 11, records);
|
|
|
|
Assert.Null(ClientCommandResponses.ParseAllegianceInfoResponse(wire));
|
|
}
|
|
|
|
[Fact]
|
|
public void TreeAssembly_ValidChain_ParentBeforeChild_Succeeds()
|
|
{
|
|
var records = new List<(uint, uint, bool, string)>
|
|
{
|
|
(MonarchGuid, 0u, true, "Monarch"),
|
|
(0x50000002u, MonarchGuid, true, "Patron"),
|
|
(0x50000003u, 0x50000002u, true, "Self"),
|
|
};
|
|
byte[] wire = BuildProfileWire(MonarchGuid, 11, records);
|
|
|
|
var parsed = ClientCommandResponses.ParseAllegianceInfoResponse(wire);
|
|
Assert.NotNull(parsed);
|
|
Assert.Equal(2, parsed!.Value.Records.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void TreeAssembly_SiblingOrder_ReversesOnAssembly()
|
|
{
|
|
// AllegianceHierarchy::Add prepends each new record to its
|
|
// parent's vassal list ("_peer = parent->_vassal; parent->_vassal
|
|
// = node"), so GetFirstVassal/GetNextVassal — FindVassals here —
|
|
// walks in REVERSE wire order: the record parsed LAST under a
|
|
// given parent renders FIRST (lane C §4.4 point 3).
|
|
var records = new List<(uint, uint, bool, string)>
|
|
{
|
|
(MonarchGuid, 0u, true, "Monarch"),
|
|
(0x50000010u, MonarchGuid, true, "VassalA"),
|
|
(0x50000011u, MonarchGuid, true, "VassalB"),
|
|
(0x50000012u, MonarchGuid, true, "VassalC"),
|
|
};
|
|
byte[] wire = BuildProfileWire(MonarchGuid, 11, records);
|
|
|
|
var parsed = ClientCommandResponses.ParseAllegianceInfoResponse(wire);
|
|
Assert.NotNull(parsed);
|
|
|
|
var vassalNames = new List<string>();
|
|
foreach (var v in parsed!.Value.FindVassals(MonarchGuid))
|
|
vassalNames.Add(v.Name);
|
|
|
|
Assert.Equal(new[] { "VassalC", "VassalB", "VassalA" }, vassalNames);
|
|
}
|
|
|
|
// ── Surfaced per-record fields (lane C §7.2's "panel needs" list) ────────
|
|
|
|
[Fact]
|
|
public void ReadAllegianceData_SurfacesRankLevelLoyaltyLeadershipCpGenderHeritage()
|
|
{
|
|
var w = new AceWireWriter()
|
|
.Write(MonarchGuid) // targetGuid
|
|
.Write((uint)1).Write((uint)0) // totalMembers, totalVassals
|
|
.Write((ushort)1).Write((ushort)11) // recordCount, oldVersion=11 (newest)
|
|
.Write((ushort)0).Write((ushort)256) // officers: empty
|
|
.Write((uint)0) // officerTitles: empty
|
|
.Write((uint)0).Write((uint)0).Write((uint)0).Write((uint)0) // broadcast counters
|
|
.WriteString16L("").WriteString16L("") // motd, motdSetBy
|
|
.Write((uint)0) // chatRoomID
|
|
.Write((uint)0).Write(0f).Write(0f).Write(0f).Write(1f).Write(0f).Write(0f).Write(0f) // bindPoint
|
|
.WriteString16L("Alle") // allegianceName
|
|
.Write((uint)0) // nameLastSetTime
|
|
.Write((uint)0) // isLocked
|
|
.Write((uint)0) // approvedVassal
|
|
// monarch AllegianceData
|
|
.Write(MonarchGuid)
|
|
.Write((uint)12345) // cpCached
|
|
.Write((uint)67890) // cpTithed
|
|
.Write((uint)(0x4u | 0x8u | 0x1u)) // HasAllegianceAge | HasPackedLevel | LoggedIn
|
|
.Write((byte)2) // gender
|
|
.Write((byte)3) // heritage group
|
|
.Write((ushort)7) // rank
|
|
.Write((uint)42) // level (HasPackedLevel set)
|
|
.Write((ushort)200) // loyalty
|
|
.Write((ushort)150) // leadership
|
|
.Write((uint)0).Write((uint)0) // timeOnline, allegianceAge
|
|
.WriteString16L("Monarch");
|
|
|
|
var parsed = ClientCommandResponses.ParseAllegianceInfoResponse(w.ToArray());
|
|
|
|
Assert.NotNull(parsed);
|
|
var monarch = parsed!.Value.Monarch!.Value;
|
|
Assert.Equal((ushort)7, monarch.Rank);
|
|
Assert.Equal(42u, monarch.Level);
|
|
Assert.Equal((ushort)200, monarch.Loyalty);
|
|
Assert.Equal((ushort)150, monarch.Leadership);
|
|
Assert.Equal(12345u, monarch.CpCached);
|
|
Assert.Equal(67890u, monarch.CpTithed);
|
|
Assert.Equal((byte)2, monarch.Gender);
|
|
Assert.Equal((byte)3, monarch.HeritageGroup);
|
|
// HasPackedLevel IS set here, so MayPassupExperience follows the
|
|
// wire bit (0x10, unset in this fixture) rather than the
|
|
// legacy-compat override.
|
|
Assert.False(monarch.MayPassupExperience);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadAllegianceData_MayPassupExperience_SetWhenHasPackedLevelAbsent()
|
|
{
|
|
// Lane C §4.1 point 1: when HasPackedLevel (0x8) is absent, retail
|
|
// sets MayPassupExperience itself regardless of the wire bit.
|
|
//
|
|
// FA1 review round (SF-1/SF-3): the record under test here MUST be
|
|
// a non-monarch record. UnPack's LAST act unconditionally forces
|
|
// the monarch's MayPassupExperience back to false regardless of
|
|
// how it was computed (see
|
|
// ReadAllegianceData_MonarchMayPassupExperience_ForcedFalseRegardlessOfWireBit),
|
|
// so putting the legacy-compat fixture on the monarch (as this
|
|
// test originally did) could never distinguish "the fallback
|
|
// fired" from "the monarch clear fired" — both produce the same
|
|
// observed value on that record. A vassal record is untouched by
|
|
// the monarch-only clear and isolates the fallback.
|
|
var w = new AceWireWriter()
|
|
.Write(MonarchGuid)
|
|
.Write((uint)2).Write((uint)0)
|
|
.Write((ushort)2).Write((ushort)11)
|
|
.Write((ushort)0).Write((ushort)256)
|
|
.Write((uint)0)
|
|
.Write((uint)0).Write((uint)0).Write((uint)0).Write((uint)0)
|
|
.WriteString16L("").WriteString16L("")
|
|
.Write((uint)0)
|
|
.Write((uint)0).Write(0f).Write(0f).Write(0f).Write(1f).Write(0f).Write(0f).Write(0f)
|
|
.WriteString16L("Alle")
|
|
.Write((uint)0)
|
|
.Write((uint)0)
|
|
.Write((uint)0)
|
|
// monarch — ordinary HasPackedLevel-set record, not under test.
|
|
.Write(MonarchGuid)
|
|
.Write((uint)0).Write((uint)0)
|
|
.Write((uint)(0x4u | 0x8u))
|
|
.Write((byte)0).Write((byte)0)
|
|
.Write((ushort)0)
|
|
.Write((uint)0) // level (HasPackedLevel set)
|
|
.Write((ushort)0).Write((ushort)0)
|
|
.Write((uint)0).Write((uint)0)
|
|
.WriteString16L("Monarch")
|
|
// vassal — the record under test: HasPackedLevel absent.
|
|
.Write(MonarchGuid) // treeParent
|
|
.Write(0x50000005u)
|
|
.Write((uint)0).Write((uint)0)
|
|
.Write((uint)0x4u) // HasAllegianceAge only — NO HasPackedLevel, NO MayPassupExperience bit
|
|
.Write((byte)0).Write((byte)0)
|
|
.Write((ushort)0)
|
|
// no level field (HasPackedLevel unset)
|
|
.Write((ushort)0).Write((ushort)0)
|
|
.Write((uint)0).Write((uint)0)
|
|
.WriteString16L("Vassal");
|
|
|
|
var parsed = ClientCommandResponses.ParseAllegianceInfoResponse(w.ToArray());
|
|
|
|
Assert.NotNull(parsed);
|
|
Assert.False(parsed!.Value.Monarch!.Value.MayPassupExperience); // always forced false
|
|
Assert.Single(parsed.Value.Records);
|
|
Assert.True(parsed.Value.Records[0].MayPassupExperience); // legacy-compat fallback fired
|
|
Assert.Equal(0u, parsed.Value.Records[0].Level); // never read — HasPackedLevel unset
|
|
}
|
|
|
|
// SF-1/SF-3 (review): AllegianceHierarchy::UnPack's LAST act before
|
|
// returning success is SetMayPassupExperience(&monarch->_data, 0) —
|
|
// the monarch can never pass up, whatever the wire bit says. Build a
|
|
// monarch record with the wire bit explicitly SET (0x10) and prove
|
|
// the parsed record still comes back false.
|
|
[Fact]
|
|
public void ReadAllegianceData_MonarchMayPassupExperience_ForcedFalseRegardlessOfWireBit()
|
|
{
|
|
var w = new AceWireWriter()
|
|
.Write(MonarchGuid) // targetGuid
|
|
.Write((uint)1).Write((uint)0) // totalMembers, totalVassals
|
|
.Write((ushort)1).Write((ushort)11) // recordCount, oldVersion=11 (newest)
|
|
.Write((ushort)0).Write((ushort)256) // officers: empty
|
|
.Write((uint)0) // officerTitles: empty
|
|
.Write((uint)0).Write((uint)0).Write((uint)0).Write((uint)0) // broadcast counters
|
|
.WriteString16L("").WriteString16L("") // motd, motdSetBy
|
|
.Write((uint)0) // chatRoomID
|
|
.Write((uint)0).Write(0f).Write(0f).Write(0f).Write(1f).Write(0f).Write(0f).Write(0f) // bindPoint
|
|
.WriteString16L("Alle") // allegianceName
|
|
.Write((uint)0) // nameLastSetTime
|
|
.Write((uint)0) // isLocked
|
|
.Write((uint)0) // approvedVassal
|
|
// monarch AllegianceData — HasAllegianceAge | HasPackedLevel |
|
|
// MayPassupExperience (0x4|0x8|0x10 = 0x1C), i.e. the wire bit
|
|
// asks for MayPassupExperience = true.
|
|
.Write(MonarchGuid)
|
|
.Write((uint)0).Write((uint)0)
|
|
.Write((uint)0x1Cu)
|
|
.Write((byte)0).Write((byte)0)
|
|
.Write((ushort)1)
|
|
.Write((uint)5) // level (HasPackedLevel set)
|
|
.Write((ushort)0).Write((ushort)0)
|
|
.Write((uint)0).Write((uint)0)
|
|
.WriteString16L("Monarch");
|
|
|
|
var parsed = ClientCommandResponses.ParseAllegianceInfoResponse(w.ToArray());
|
|
|
|
Assert.NotNull(parsed);
|
|
Assert.False(parsed!.Value.Monarch!.Value.MayPassupExperience);
|
|
}
|
|
|
|
// ── 0x0020 AllegianceUpdate shares the same profile reader ─────────────
|
|
|
|
[Fact]
|
|
public void ParseAllegianceUpdate_RankLeading_SharesProfileReaderWithInfoResponse()
|
|
{
|
|
var records = new List<(uint, uint, bool, string)>
|
|
{
|
|
(MonarchGuid, 0u, true, "Monarch"),
|
|
(0x50000002u, MonarchGuid, true, "Vassal"),
|
|
};
|
|
const uint rank = 3u;
|
|
byte[] wire = BuildProfileWire(rank, 11, records);
|
|
|
|
var update = ClientCommandResponses.ParseAllegianceUpdate(wire);
|
|
|
|
Assert.NotNull(update);
|
|
Assert.Equal(rank, update!.Value.Rank);
|
|
Assert.Equal("Monarch", update.Value.Monarch!.Value.Name);
|
|
Assert.Single(update.Value.Records);
|
|
Assert.Equal("Vassal", update.Value.Records[0].Name);
|
|
Assert.Equal("TestAllegiance", update.Value.AllegianceName);
|
|
Assert.True(update.Value.IsLocked);
|
|
Assert.Equal(0x99990000u, update.Value.ApprovedVassal);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseAllegianceUpdate_TreeAssemblyRulesApply_OrphanDiscardsWholeMessage()
|
|
{
|
|
var records = new List<(uint, uint, bool, string)>
|
|
{
|
|
(MonarchGuid, 0u, true, "Monarch"),
|
|
(0x50000005u, 0x5000FFFFu, true, "Orphan"),
|
|
};
|
|
byte[] wire = BuildProfileWire(3u /* rank */, 11, records);
|
|
|
|
Assert.Null(ClientCommandResponses.ParseAllegianceUpdate(wire));
|
|
}
|
|
}
|