acdream/tests/AcDream.Core.Net.Tests/Messages/FellowshipEventsTests.cs
Erik 6bedbc4772 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>
2026-08-12 00:13:10 +02:00

234 lines
9.3 KiB
C#

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