acdream/tests/AcDream.Core.Net.Tests/Messages/ConfirmationTripleTests.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

80 lines
3.4 KiB
C#

using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Campaign FA slice FA1 (2026-08-11), D6: completes Core.Net's typed
/// representation of the shared confirmation triple
/// (<c>0x0274</c>/<c>0x0275</c>/<c>0x0276</c>). Grepping the tree before
/// this slice showed <c>0x0274</c> (<see cref="GameEvents.ParseCharacterConfirmationRequest"/>)
/// and <c>0x0276</c> (<see cref="GameEvents.ParseCharacterConfirmationDone"/>)
/// already had typed record + parser pairs; <c>0x0275</c> — the CLIENT→
/// SERVER leg — already had a byte-correct builder
/// (<c>ClientCommandRequests.BuildConfirmationResponse</c>, lane C §3.3)
/// but no typed record, no <see cref="GameEvents.ConfirmationType"/>
/// enum, and no parser. This file pins the completed triple: the new
/// <see cref="GameEvents.ConfirmationType"/> enum (1 = SwearAllegiance,
/// 4 = Fellowship — D6), and <see cref="GameEvents.ParseConfirmationResponse"/>
/// round-tripping against the EXISTING builder byte-for-byte.
/// </summary>
public sealed class ConfirmationTripleTests
{
[Fact]
public void ConfirmationType_SwearAllegianceIsOne_FellowshipIsFour()
{
// Handle_Character__ConfirmationRequest @0x005640A0 switch vs ACE
// ConfirmationType.cs:5-12 — identical (lane B §3.15).
Assert.Equal(1u, (uint)GameEvents.ConfirmationType.SwearAllegiance);
Assert.Equal(4u, (uint)GameEvents.ConfirmationType.Fellowship);
}
[Fact]
public void ParseConfirmationResponse_RoundTripsAgainstExistingBuilder()
{
byte[] wire = ClientCommandRequests.BuildConfirmationResponse(
sequence: 5,
confirmationType: (uint)GameEvents.ConfirmationType.Fellowship,
contextId: 0x1234u,
accepted: true);
// Strip the 12-byte envelope/seq/opcode header the same way every
// other GameEvents.Parse* function receives its payload (header
// already stripped by the dispatcher) — here we strip the GameACTION
// header by hand since BuildConfirmationResponse is a C→S builder.
var response = GameEvents.ParseConfirmationResponse(wire.AsSpan(12));
Assert.NotNull(response);
Assert.Equal(GameEvents.ConfirmationType.Fellowship, response.Value.Type);
Assert.Equal(0x1234u, response.Value.ContextId);
Assert.True(response.Value.Accepted);
}
[Fact]
public void ParseConfirmationResponse_GoldenByteVector_SwearAllegianceDeclined()
{
// Hand-computed from CM_Character::Event_ConfirmationResponse
// @0x006A1210 (lane B §3.15 / lane C §3.3):
// [u32 confirmType][u32 context][u32 accepted].
byte[] payload =
[
0x01, 0x00, 0x00, 0x00, // confirmType = 1 (SwearAllegiance)
0x99, 0x00, 0x00, 0x00, // context = 0x99
0x00, 0x00, 0x00, 0x00, // accepted = 0 (declined)
];
var response = GameEvents.ParseConfirmationResponse(payload);
Assert.NotNull(response);
Assert.Equal(GameEvents.ConfirmationType.SwearAllegiance, response.Value.Type);
Assert.Equal(0x99u, response.Value.ContextId);
Assert.False(response.Value.Accepted);
}
[Fact]
public void ParseConfirmationResponse_TruncatedPayload_ReturnsNull()
{
Assert.Null(GameEvents.ParseConfirmationResponse(new byte[8]));
}
}