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