feat(net): FA1 -- S->C parsers for fellowship/allegiance, confirmation triple, allegiance version gates

Campaign FA slice FA1: pure parse functions + typed records only,
UNWIRED (FA2 registers them against the new RuntimeFellowshipState/
RuntimeAllegianceState owners -- see docs/research/2026-08-11-fa-acdream-seams.md
§2).

Fellowship family (GameEvents.cs), field orders from lane B §3.8-§3.13,
guid-first on 0x02C0 per the resolved Chorizite disagreement:
FellowshipFullUpdate (0x02BE), FellowshipUpdateFellow (0x02C0),
FellowshipQuitNotice/FellowshipDismissNotice (S->C 0x00A3/0x00A4),
FellowshipDisband (0x02BF, empty body), and the dead
FellowshipFellowUpdateDone/FellowshipFellowStatsDone (0x01C9/0x01CA,
parse-and-ignore, must never fail per lane B §2.7). ShareLoot is modeled
as a raw uint (D5) -- ACE encodes it two incompatible ways (0x10 in full
updates, <<1 incremental), so `!= 0` is the only safe read, never `== 1`.

Confirmation triple (D6): grepping the tree showed 0x0274/0x0276 already
had typed parsers in Core.Net; 0x0275 (client-authored) already had a
byte-correct builder but no typed representation. Added the
ConfirmationType enum (1 SwearAllegiance, 4 Fellowship, matching retail's
Handle_Character__ConfirmationRequest switch and ACE's enum verbatim) and
ParseConfirmationResponse, completing Core.Net's typed coverage of all
three legs and round-tripping against the existing
ClientCommandRequests.BuildConfirmationResponse byte-for-byte.

Allegiance small events (GameEvents.cs): AllegianceLoginNotification
(0x027A), AllegianceUpdateDone (0x01C8), AllegianceUpdateAborted (0x0003,
declared but never sent by ACE).

The heavyweight AllegianceUpdate (0x0020) extends
ClientCommandResponses.ParseAllegianceInfoResponse (0x027C) rather than a
second parser, per lane C §7.2's explicit reuse verdict -- both messages
now share ReadAllegianceProfileBody, which the discriminating leading u32
(targetGuid vs rank) is read around. That shared reader implements:

- The ELEVEN AllegianceHierarchy::UnPack version gates (lane C §4.2) --
  officers/spokesperson-skip, officer titles, the four broadcast
  counters, motd/motdSetBy, chatRoomId, bind point, allegianceName,
  isLocked, approvedVassal, each behind its own oldVersion threshold.
  AllegianceProfileVersionGateTests.cs pins all eleven with a
  boundary-crossing pair per gate (N-1 OFF vs N ON), including the
  negative proof that version 5 (BannedCharactersAdded) gates nothing
  in UnPack.
- The §4.4 tree-assembly rules: a record whose treeParent is not already
  in the tree (orphan), equals its own id (self-parent), or duplicates an
  id already seen makes AllegianceHierarchy::Add fail, which the whole
  parse now mirrors by returning null for the ENTIRE message -- not a
  partial tree. Sibling order REVERSES on assembly (each new record is
  prepended to its parent's vassal list), so FindVassals now walks
  records in reverse wire order; both rules have dedicated tests.
- AllegianceMemberRecord gained the panel-needed columns lane C §7.2
  names (rank, level, loyalty, leadership, cpCached, cpTithed, gender,
  heritage, MayPassupExperience) with defaulted trailing parameters so
  existing 4-arg positional construction sites keep compiling. Officers/
  officer titles/bind point are read (so every later field lands at the
  right offset) but deliberately left unsurfaced -- ACE always zeroes/
  empties them anyway (lane C §5.1), and bind point is a 32-byte Position
  the retail chat renderer never uses either; a future panel slice can
  extend the record without re-deriving the parse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 00:13:10 +02:00
parent 7be86f47f6
commit 6bedbc4772
6 changed files with 1570 additions and 107 deletions

View file

@ -0,0 +1,522 @@
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));
}
[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.
var w = new AceWireWriter()
.Write(MonarchGuid)
.Write((uint)1).Write((uint)0)
.Write((ushort)1).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)
.Write(MonarchGuid)
.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("Monarch");
var parsed = ClientCommandResponses.ParseAllegianceInfoResponse(w.ToArray());
Assert.NotNull(parsed);
Assert.True(parsed!.Value.Monarch!.Value.MayPassupExperience);
Assert.Equal(0u, parsed.Value.Monarch!.Value.Level); // never read — HasPackedLevel unset
}
// ── 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));
}
}

View file

@ -0,0 +1,74 @@
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Campaign FA slice FA1 (2026-08-11): the small fixed-shape allegiance
/// S→C events (everything except <c>0x0020 AllegianceUpdate</c> and
/// <c>0x027C AllegianceInfoResponse</c>, which reuse the extended
/// <c>ClientCommandResponses</c> profile reader — see
/// <c>AllegianceProfileVersionGateTests</c>). Field orders per
/// docs/research/2026-08-11-fa-allegiance-wire.md (lane C) §4.5. Unwired —
/// FA2 registers these against RuntimeAllegianceState.
/// </summary>
public sealed class AllegianceSmallEventsTests
{
[Fact]
public void ParseAllegianceLoginNotification_RoundTrips_LoggedIn()
{
byte[] wire = new AceWireWriter().Write(0x50000042u).Write((uint)1).ToArray();
var notice = GameEvents.ParseAllegianceLoginNotification(wire);
Assert.NotNull(notice);
Assert.Equal(0x50000042u, notice.Value.CharacterGuid);
Assert.True(notice.Value.IsLoggedIn);
}
[Fact]
public void ParseAllegianceLoginNotification_RoundTrips_LoggedOut()
{
byte[] wire = new AceWireWriter().Write(0x50000042u).Write((uint)0).ToArray();
var notice = GameEvents.ParseAllegianceLoginNotification(wire);
Assert.NotNull(notice);
Assert.False(notice.Value.IsLoggedIn);
}
[Fact]
public void ParseAllegianceLoginNotification_TruncatedPayload_ReturnsNull()
{
Assert.Null(GameEvents.ParseAllegianceLoginNotification(new byte[4]));
}
[Fact]
public void ParseAllegianceUpdateDone_ReadsWeenieError()
{
byte[] wire = new AceWireWriter().Write(0u).ToArray();
Assert.Equal(0u, GameEvents.ParseAllegianceUpdateDone(wire));
}
[Fact]
public void ParseAllegianceUpdateDone_NonZeroErrorCode_RoundTrips()
{
byte[] wire = new AceWireWriter().Write(0x40Bu).ToArray(); // AlreadySworn-style code
Assert.Equal(0x40Bu, GameEvents.ParseAllegianceUpdateDone(wire));
}
[Fact]
public void ParseAllegianceUpdateAborted_ReadsWeenieError()
{
// Never actually sent by ACE (lane C §5.4) — parsed for
// forward-compat/completeness only.
byte[] wire = new AceWireWriter().Write(0x40Cu).ToArray();
Assert.Equal(0x40Cu, GameEvents.ParseAllegianceUpdateAborted(wire));
}
[Fact]
public void ParseAllegianceUpdateDone_TruncatedPayload_ReturnsNull()
{
Assert.Null(GameEvents.ParseAllegianceUpdateDone(System.ReadOnlySpan<byte>.Empty));
}
}

View file

@ -0,0 +1,80 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Campaign FA slice FA1 (2026-08-11), D6: completes Core.Net's typed
/// representation of the shared confirmation triple
/// (<c>0x0274</c>/<c>0x0275</c>/<c>0x0276</c>). Grepping the tree before
/// this slice showed <c>0x0274</c> (<see cref="GameEvents.ParseCharacterConfirmationRequest"/>)
/// and <c>0x0276</c> (<see cref="GameEvents.ParseCharacterConfirmationDone"/>)
/// already had typed record + parser pairs; <c>0x0275</c> — the CLIENT→
/// SERVER leg — already had a byte-correct builder
/// (<c>ClientCommandRequests.BuildConfirmationResponse</c>, lane C §3.3)
/// but no typed record, no <see cref="GameEvents.ConfirmationType"/>
/// enum, and no parser. This file pins the completed triple: the new
/// <see cref="GameEvents.ConfirmationType"/> enum (1 = SwearAllegiance,
/// 4 = Fellowship — D6), and <see cref="GameEvents.ParseConfirmationResponse"/>
/// round-tripping against the EXISTING builder byte-for-byte.
/// </summary>
public sealed class ConfirmationTripleTests
{
[Fact]
public void ConfirmationType_SwearAllegianceIsOne_FellowshipIsFour()
{
// Handle_Character__ConfirmationRequest @0x005640A0 switch vs ACE
// ConfirmationType.cs:5-12 — identical (lane B §3.15).
Assert.Equal(1u, (uint)GameEvents.ConfirmationType.SwearAllegiance);
Assert.Equal(4u, (uint)GameEvents.ConfirmationType.Fellowship);
}
[Fact]
public void ParseConfirmationResponse_RoundTripsAgainstExistingBuilder()
{
byte[] wire = ClientCommandRequests.BuildConfirmationResponse(
sequence: 5,
confirmationType: (uint)GameEvents.ConfirmationType.Fellowship,
contextId: 0x1234u,
accepted: true);
// Strip the 12-byte envelope/seq/opcode header the same way every
// other GameEvents.Parse* function receives its payload (header
// already stripped by the dispatcher) — here we strip the GameACTION
// header by hand since BuildConfirmationResponse is a C→S builder.
var response = GameEvents.ParseConfirmationResponse(wire.AsSpan(12));
Assert.NotNull(response);
Assert.Equal(GameEvents.ConfirmationType.Fellowship, response.Value.Type);
Assert.Equal(0x1234u, response.Value.ContextId);
Assert.True(response.Value.Accepted);
}
[Fact]
public void ParseConfirmationResponse_GoldenByteVector_SwearAllegianceDeclined()
{
// Hand-computed from CM_Character::Event_ConfirmationResponse
// @0x006A1210 (lane B §3.15 / lane C §3.3):
// [u32 confirmType][u32 context][u32 accepted].
byte[] payload =
[
0x01, 0x00, 0x00, 0x00, // confirmType = 1 (SwearAllegiance)
0x99, 0x00, 0x00, 0x00, // context = 0x99
0x00, 0x00, 0x00, 0x00, // accepted = 0 (declined)
];
var response = GameEvents.ParseConfirmationResponse(payload);
Assert.NotNull(response);
Assert.Equal(GameEvents.ConfirmationType.SwearAllegiance, response.Value.Type);
Assert.Equal(0x99u, response.Value.ContextId);
Assert.False(response.Value.Accepted);
}
[Fact]
public void ParseConfirmationResponse_TruncatedPayload_ReturnsNull()
{
Assert.Null(GameEvents.ParseConfirmationResponse(new byte[8]));
}
}

View file

@ -0,0 +1,234 @@
using System;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Campaign FA slice FA1 (2026-08-11): golden-vector round-trip tests for
/// the fellowship S→C parsers added to <see cref="GameEvents"/>. Fixtures
/// are built with <see cref="AceWireWriter"/> (the ACE-mirror writer) so a
/// pass proves agreement with the server's own algorithm, not just with
/// itself. Field orders per
/// docs/research/2026-08-11-fa-fellowship-wire.md (lane B) §3.8-§3.13.
/// These parsers are UNWIRED — FA2 registers them against
/// RuntimeFellowshipState.
/// </summary>
public sealed class FellowshipEventsTests
{
// ── 0x02BE FellowshipFullUpdate ──────────────────────────────────────
[Fact]
public void ParseFellowshipFullUpdate_RoundTrips_TwoMembersOneDeparted()
{
byte[] wire = new AceWireWriter()
.Write((ushort)2) // memberCount
.Write((ushort)16) // numBuckets — server-chosen, not consulted
// member 1
.Write(0x50000001u)
.Write((uint)100) // cpCache
.Write((uint)50) // lumCache
.Write((uint)20) // level
.Write((uint)100) // maxHealth
.Write((uint)100) // maxStamina
.Write((uint)100) // maxMana
.Write((uint)80) // currentHealth
.Write((uint)90) // currentStamina
.Write((uint)70) // currentMana
.Write((uint)0x10) // shareLoot — ACE's full-update "shares" sentinel
.WriteString16L("Leader")
// member 2
.Write(0x50000002u)
.Write((uint)0).Write((uint)0).Write((uint)15)
.Write((uint)90).Write((uint)90).Write((uint)90)
.Write((uint)90).Write((uint)90).Write((uint)90)
.Write((uint)0)
.WriteString16L("Second")
// fellowship-level fields
.WriteString16L("TestFellowship")
.Write(0x50000001u) // leaderGuid
.Write((uint)1) // shareXp
.Write((uint)1) // evenXpSplit
.Write((uint)0) // openFellow
.Write((uint)0) // locked
.Write((ushort)1) // departedCount
.Write((ushort)32) // numBuckets
.Write(0x50000099u)
.Write(1700000000)
.ToArray();
var update = GameEvents.ParseFellowshipFullUpdate(wire);
Assert.NotNull(update);
Assert.Equal(2, update.Value.Members.Count);
Assert.Equal(0x50000001u, update.Value.Members[0].Guid);
Assert.Equal("Leader", update.Value.Members[0].Name);
Assert.Equal(100u, update.Value.Members[0].CpCache);
Assert.Equal(50u, update.Value.Members[0].LumCache);
Assert.Equal(20u, update.Value.Members[0].Level);
Assert.Equal(80u, update.Value.Members[0].CurrentHealth);
Assert.Equal(0x10u, update.Value.Members[0].ShareLoot);
Assert.Equal("Second", update.Value.Members[1].Name);
Assert.Equal("TestFellowship", update.Value.Name);
Assert.Equal(0x50000001u, update.Value.LeaderGuid);
Assert.True(update.Value.ShareXp);
Assert.True(update.Value.EvenXpSplit);
Assert.False(update.Value.OpenFellow);
Assert.False(update.Value.Locked);
Assert.Single(update.Value.Departed);
Assert.Equal(0x50000099u, update.Value.Departed[0].Guid);
Assert.Equal(1700000000, update.Value.Departed[0].DepartedTimestamp);
}
// D5: shareLoot must be a raw uint, `!= 0` means "shares" — NEVER a
// ReadBool()-style `== 1` comparison. ACE's incremental-update
// encoding is `Convert.ToUInt32(shareLoot) << 1` (0 or 2), which a
// `== 1` reader would silently read as "never shares" (lane B §4.1).
[Fact]
public void ParseFellowshipFullUpdate_ShareLootIsRawNotBool_D5()
{
byte[] wire = BuildSingleMemberFullUpdate(shareLoot: 2u);
var update = GameEvents.ParseFellowshipFullUpdate(wire);
Assert.NotNull(update);
Assert.Equal(2u, update.Value.Members[0].ShareLoot);
Assert.NotEqual(1u, update.Value.Members[0].ShareLoot);
}
[Fact]
public void ParseFellowshipFullUpdate_NoMembersNoDeparted_ParsesEmpty()
{
byte[] wire = new AceWireWriter()
.Write((ushort)0).Write((ushort)16)
.WriteString16L("Empty")
.Write((uint)0).Write((uint)0).Write((uint)0).Write((uint)0).Write((uint)0)
.Write((ushort)0).Write((ushort)32)
.ToArray();
var update = GameEvents.ParseFellowshipFullUpdate(wire);
Assert.NotNull(update);
Assert.Empty(update.Value.Members);
Assert.Empty(update.Value.Departed);
Assert.Equal("Empty", update.Value.Name);
}
[Fact]
public void ParseFellowshipFullUpdate_TruncatedPayload_ReturnsNull()
{
byte[] wire = new AceWireWriter().Write((ushort)1).ToArray(); // missing everything else
Assert.Null(GameEvents.ParseFellowshipFullUpdate(wire));
}
private static byte[] BuildSingleMemberFullUpdate(uint shareLoot)
{
return new AceWireWriter()
.Write((ushort)1).Write((ushort)16)
.Write(0x50000001u)
.Write((uint)0).Write((uint)0).Write((uint)1)
.Write((uint)100).Write((uint)100).Write((uint)100)
.Write((uint)100).Write((uint)100).Write((uint)100)
.Write(shareLoot)
.WriteString16L("Solo")
.WriteString16L("Solo Fellowship")
.Write(0x50000001u)
.Write((uint)0).Write((uint)0).Write((uint)0).Write((uint)0)
.Write((ushort)0).Write((ushort)32)
.ToArray();
}
// ── 0x02C0 FellowshipUpdateFellow ────────────────────────────────────
// Lane B §3.10: "Reference disagreement, resolved" — Chorizite's
// generated Fellowship_UpdateFellow is missing the leading guid;
// retail + ACE + holtburger all put the guid first. Retail wins.
[Fact]
public void ParseFellowshipUpdateFellow_GuidFirst_RoundTrips()
{
byte[] wire = new AceWireWriter()
.Write(0x50000005u) // guid FIRST
.Write((uint)10).Write((uint)5).Write((uint)3)
.Write((uint)100).Write((uint)80).Write((uint)60)
.Write((uint)90).Write((uint)70).Write((uint)50)
.Write((uint)0)
.WriteString16L("Vitals")
.Write((uint)3) // updateType = 3 UpdateVitals
.ToArray();
var update = GameEvents.ParseFellowshipUpdateFellow(wire);
Assert.NotNull(update);
Assert.Equal(0x50000005u, update.Value.MemberGuid);
Assert.Equal(0x50000005u, update.Value.Member.Guid);
Assert.Equal("Vitals", update.Value.Member.Name);
Assert.Equal(90u, update.Value.Member.CurrentHealth);
Assert.Equal(3u, update.Value.UpdateType);
}
// ── 0x00A3/0x00A4 S→C ─────────────────────────────────────────────────
[Fact]
public void ParseFellowshipQuit_ReadsQuitterGuid()
{
byte[] wire = new AceWireWriter().Write(0x50000009u).ToArray();
var notice = GameEvents.ParseFellowshipQuit(wire);
Assert.NotNull(notice);
Assert.Equal(0x50000009u, notice.Value.QuitterGuid);
}
[Fact]
public void ParseFellowshipDismiss_ReadsDismissedGuid()
{
byte[] wire = new AceWireWriter().Write(0x5000000Au).ToArray();
var notice = GameEvents.ParseFellowshipDismiss(wire);
Assert.NotNull(notice);
Assert.Equal(0x5000000Au, notice.Value.DismissedGuid);
}
// ── 0x02BF FellowshipDisband ──────────────────────────────────────────
[Fact]
public void ParseFellowshipDisband_EmptyBody_ReturnsTrue()
{
Assert.True(GameEvents.ParseFellowshipDisband(ReadOnlySpan<byte>.Empty));
}
[Fact]
public void ParseFellowshipDisband_NonEmptyBody_ReturnsFalse()
{
Assert.False(GameEvents.ParseFellowshipDisband(new byte[] { 1 }));
}
// ── 0x01C9/0x01CA dead events — parse-and-ignore, must never fail ──────
[Fact]
public void ParseFellowshipFellowUpdateDone_EmptyPayload_ToleratedNullRaw()
{
var done = GameEvents.ParseFellowshipFellowUpdateDone(ReadOnlySpan<byte>.Empty);
Assert.Null(done.RawValue);
}
[Fact]
public void ParseFellowshipFellowUpdateDone_TrailingU32_CapturesRawValue()
{
byte[] wire = new AceWireWriter().Write(42u).ToArray();
var done = GameEvents.ParseFellowshipFellowUpdateDone(wire);
Assert.Equal(42u, done.RawValue);
}
[Fact]
public void ParseFellowshipFellowStatsDone_EmptyPayload_ToleratedNullRaw()
{
var done = GameEvents.ParseFellowshipFellowStatsDone(ReadOnlySpan<byte>.Empty);
Assert.Null(done.RawValue);
}
[Fact]
public void ParseFellowshipFellowStatsDone_TrailingU32_CapturesRawValue()
{
byte[] wire = new AceWireWriter().Write(7u).ToArray();
var done = GameEvents.ParseFellowshipFellowStatsDone(wire);
Assert.Equal(7u, done.RawValue);
}
}