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>
234 lines
9.3 KiB
C#
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);
|
|
}
|
|
}
|