using System; using AcDream.Core.Net.Messages; using Xunit; namespace AcDream.Core.Net.Tests.Messages; /// /// Campaign FA slice FA1 (2026-08-11): golden-vector round-trip tests for /// the fellowship S→C parsers added to . Fixtures /// are built with (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. /// 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); } // D5/mechanism SF-3: the same raw-uint, never-a-bool shareLoot // requirement pinned for ParseFellowshipFullUpdate above // (ParseFellowshipFullUpdate_ShareLootIsRawNotBool_D5) must ALSO hold // at the 0x02C0 site — ReadFellow is shared by both parsers, but // nothing previously asserted the <<1 incremental-update shape here; // a future split of ReadFellow could silently reintroduce a bool read // on this leg without either test catching it. [Fact] public void ParseFellowshipUpdateFellow_ShareLootIsRawNotBool_D5() { 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)2) // shareLoot — ACE's incremental-update "<<1" shape .WriteString16L("Vitals") .Write((uint)3) // updateType = 3 UpdateVitals .ToArray(); var update = GameEvents.ParseFellowshipUpdateFellow(wire); Assert.NotNull(update); Assert.Equal(2u, update.Value.Member.ShareLoot); Assert.NotEqual(1u, update.Value.Member.ShareLoot); } // ── 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.Empty)); } // FA1 review round (mechanism SF-2): retail's DispatchUI_Disband // @0x006A5E80 reads only the opcode and never inspects a trailing // body — the pre-fix parser's `payload.Length == 0` check was an // invented validation retail does not perform. A non-empty body // (an ACE change, a trailing pad) must still succeed. [Fact] public void ParseFellowshipDisband_NonEmptyBody_StillReturnsTrue() { Assert.True(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.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.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); } }