Applies both MUST-FIX items and the code-facing SHOULD-FIX items from the dual-lens FA1 review (docs/research/2026-08-12-fa1-review-mechanism.md, docs/research/2026-08-12-fa1-review-blast.md): Mechanism MF-1 / blast SF-2: AllegianceHierarchy::Add @0x005B6E90 wraps its entire body in `if (_id != 0)` -- a record whose own id is zero discards the WHOLE message, for both the monarch and a child record, and this is also what makes treeParent == 0 unconditionally fatal for a non-monarch record. ReadAllegianceProfileBody now rejects CharacterId == 0 on both paths; four new boundary tests in AllegianceProfileVersionGateTests.cs (zero-id monarch, zero-id child, zero treeParent, plus the existing orphan/self-parent/duplicate trio). Mechanism MF-2: added the missing 0x001F AllegianceUpdateRequest builder -- the structural twin of the fellowship 0x00A6 this slice already repaired -- with golden-vector tests for both on/off. Mechanism SF-1 / blast SF-3: UnPack's last act before returning success forces the monarch's MayPassupExperience to false regardless of the wire bit or the HasPackedLevel-absent legacy-compat fallback. Ported at the end of the record loop; the pre-existing HasPackedLevel-absent test moved off the monarch record (which the new clear makes indistinguishable from "the fallback never fired") onto a vassal record, and a new test proves the monarch clear fires even when the wire bit explicitly asks for true. Mechanism SF-2: removed ParseFellowshipDisband's invented body-length validation -- retail's DispatchUI_Disband reads only the opcode and never inspects a trailing body. The parser now always succeeds; the matching test flips from asserting rejection to asserting acceptance. Mechanism SF-3: added the D5 `<<1` shareLoot-shape test at the 0x02C0 FellowshipUpdateFellow site -- previously only pinned at 0x02BE, so a future split of the shared ReadFellow helper could silently reintroduce a bool read on this leg undetected. Mechanism SF-5: renumbered the version-gate comments in ReadAllegianceProfileBody to the true AllegianceVersion enum values (1-11, matching acclient.h's SpokespersonAdded..ApprovedVassal) instead of wire-appearance order, which only reached 10 and silently dropped gate 5 (BannedCharactersAdded, which is real but gates nothing in UnPack -- now called out explicitly). Fixed the stale "lane B §12" citation in SocialActions.cs to the actual master-table row. Blast SF-1: pinned the two retail-faithful but user-visible behavior changes FA1 made to the ALREADY-LIVE `@allegiance info` command -- reversed vassal print order (3-vassal test through FormatAllegianceInfoLines) and malformed-tree silent-drop (test at the GameEventWiring registration layer, which is `if (info is null) return;`). Blast SF-4: fixed a doc comment citing a nonexistent `ConfirmationResponseTests` class; the actual class is `ConfirmationTripleTests`. Blast SF-5: cross-referenced the confirmation-triple discriminator's split representation (ConfirmationType on the response leg only; bare uint on the two inbound legs production actually reads) at both sites, so FA4 inherits a stated decision rather than an unexplained inconsistency. Full Release suite: 13,158 passed / 4 skipped / 0 failed (13,162 total), up from the pre-fix-round 13,149/4/0 (+9 tests this round). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
266 lines
11 KiB
C#
266 lines
11 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);
|
|
}
|
|
|
|
// 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<byte>.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<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);
|
|
}
|
|
}
|