acdream/tests/AcDream.Core.Net.Tests/Messages/FellowshipEventsTests.cs
Erik ed30808720 fix(net): FA1 review round -- zero-id tree rejection, monarch clear, 0x001F builder
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>
2026-08-12 00:46:42 +02:00

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