From fa266aaa03a22b7469bab636d9efc75e2253dc46 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Apr 2026 10:26:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(net):=20SocialActions=20=E2=80=94=20query?= =?UTF-8?q?=20/=20fellowship=20/=20channel=20/=20options=20outbound?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broad batch of GameActions for features the UI will wire to buttons + hotkeys: /hp query, ping keepalive, fellowship full lifecycle, character-options persist, chat channel subscribe/unsubscribe. Wire layer: - QueryHealth (0x01BF): u32 targetGuid — server replies UpdateHealth (0x01C0, already parsed by Phase F.1 dispatcher + routed to CombatState). - PingRequest (0x01E9): u32 clientId — server echoes PingResponse (0x01EA) with matching id. Keepalive use. - FellowshipCreate (0x00A2): string16L name + 2 u8 bools. - FellowshipQuit (0x00A3): u8 disband. - FellowshipDismiss (0x00A4) / FellowshipRecruit (0x00A5): u32 guid. - FellowshipUpdate (0x00A6): u8 open. - SetCharacterOptions (0x01A1): u32 options bitmap. - AddChannel (0x0145) / RemoveChannel (0x0146): string16L channelName. Tests (10 new): byte-exact wire encoding for each action. Build green, 182 Core.Net tests pass (up from 172). Ref: r08 §3 rows 0x01BF / 0x01E9 / 0x00A2-0x00A6 / 0x01A1 / 0x0145 / 0x0146. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Messages/SocialActions.cs | 174 ++++++++++++++++++ .../Messages/SocialActionsTests.cs | 111 +++++++++++ 2 files changed, 285 insertions(+) create mode 100644 src/AcDream.Core.Net/Messages/SocialActions.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs diff --git a/src/AcDream.Core.Net/Messages/SocialActions.cs b/src/AcDream.Core.Net/Messages/SocialActions.cs new file mode 100644 index 0000000..86f7524 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/SocialActions.cs @@ -0,0 +1,174 @@ +using System; +using System.Buffers.Binary; +using System.Text; + +namespace AcDream.Core.Net.Messages; + +/// +/// Outbound social + query GameActions. These share a common pattern: +/// single-target / single-parameter GameActions inside the 0xF7B1 +/// envelope. +/// +/// +/// Wire format: +/// +/// u32 0xF7B1 envelope +/// u32 gameActionSequence +/// u32 subOpcode +/// <payload> per-action +/// +/// +/// +/// +/// References: r08 §3 rows for each opcode. +/// +/// +public static class SocialActions +{ + public const uint GameActionEnvelope = 0xF7B1u; + + // Queries + public const uint QueryHealthOpcode = 0x01BFu; // u32 targetGuid + public const uint PingRequestOpcode = 0x01E9u; // u32 clientId + + // Fellowship + public const uint FellowshipCreateOpcode = 0x00A2u; // string16L name, bool openness, bool shareXP + public const uint FellowshipQuitOpcode = 0x00A3u; // bool disband + public const uint FellowshipDismissOpcode = 0x00A4u; // u32 guid + public const uint FellowshipRecruitOpcode = 0x00A5u; // u32 guid + public const uint FellowshipUpdateOpcode = 0x00A6u; // bool open + + // Character options + public const uint SetCharacterOptionsOpcode = 0x01A1u; // u32 options bitmap + + // Chat channels + public const uint AddChannelOpcode = 0x0145u; // string16L channelName + public const uint RemoveChannelOpcode = 0x0146u; // string16L channelName + + /// Query a target's health — server replies with UpdateHealth (0x01C0). + public static byte[] BuildQueryHealth(uint seq, uint targetGuid) + { + byte[] body = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), QueryHealthOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid); + return body; + } + + /// Keepalive ping — server echoes with PingResponse (0x01EA). + public static byte[] BuildPingRequest(uint seq, uint clientId) + { + byte[] body = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), PingRequestOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), clientId); + return body; + } + + /// Create a fellowship with a chosen name + options. + public static byte[] BuildFellowshipCreate( + uint seq, string fellowshipName, bool openness, bool shareXp) + { + byte[] name = PackString16L(fellowshipName); + // 2 bools consume 2 bytes + alignment pad to 4. + int boolBlock = 2; + int pad = (4 - ((name.Length + boolBlock) & 3)) & 3; + byte[] body = new byte[12 + name.Length + boolBlock + pad]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipCreateOpcode); + Array.Copy(name, 0, body, 12, name.Length); + body[12 + name.Length] = openness ? (byte)1 : (byte)0; + body[12 + name.Length + 1] = shareXp ? (byte)1 : (byte)0; + return body; + } + + /// Quit your current fellowship (optionally disband if leader). + public static byte[] BuildFellowshipQuit(uint seq, bool disband) + { + byte[] body = new byte[16]; // envelope + 1 byte bool aligned to 4 + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipQuitOpcode); + body[12] = disband ? (byte)1 : (byte)0; + return body; + } + + /// Dismiss a specific vassal from your fellowship. + public static byte[] BuildFellowshipDismiss(uint seq, uint targetGuid) + => SingleGuid(seq, FellowshipDismissOpcode, targetGuid); + + /// Recruit a target into your fellowship. + public static byte[] BuildFellowshipRecruit(uint seq, uint targetGuid) + => SingleGuid(seq, FellowshipRecruitOpcode, targetGuid); + + /// Toggle fellowship open / closed recruiting. + public static byte[] BuildFellowshipUpdate(uint seq, bool open) + { + byte[] body = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipUpdateOpcode); + body[12] = open ? (byte)1 : (byte)0; + return body; + } + + /// Push the client's character-options bitmap to the server. + public static byte[] BuildSetCharacterOptions(uint seq, uint optionsBitmap) + { + byte[] body = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SetCharacterOptionsOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), optionsBitmap); + return body; + } + + /// Subscribe to a named chat channel. + public static byte[] BuildAddChannel(uint seq, string channelName) + => SingleString(seq, AddChannelOpcode, channelName); + + /// Unsubscribe from a named chat channel. + public static byte[] BuildRemoveChannel(uint seq, string channelName) + => SingleString(seq, RemoveChannelOpcode, channelName); + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static byte[] SingleGuid(uint seq, uint sub, uint guid) + { + byte[] body = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), sub); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), guid); + return body; + } + + private static byte[] SingleString(uint seq, uint sub, string s) + { + byte[] str = PackString16L(s); + byte[] body = new byte[12 + str.Length]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), sub); + Array.Copy(str, 0, body, 12, str.Length); + return body; + } + + private static byte[] PackString16L(string s) + { + ArgumentNullException.ThrowIfNull(s); + byte[] data = Encoding.ASCII.GetBytes(s); + if (data.Length > ushort.MaxValue) + throw new ArgumentException("String too long for 16-bit length prefix.", nameof(s)); + + int recordSize = 2 + data.Length; + int padding = (4 - (recordSize & 3)) & 3; + byte[] result = new byte[recordSize + padding]; + BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length); + Array.Copy(data, 0, result, 2, data.Length); + return result; + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs new file mode 100644 index 0000000..3018ae1 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs @@ -0,0 +1,111 @@ +using System; +using System.Buffers.Binary; +using System.Text; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +public sealed class SocialActionsTests +{ + [Fact] + public void BuildQueryHealth_HasOpcode0x01BFAndGuid() + { + byte[] body = SocialActions.BuildQueryHealth(seq: 3, targetGuid: 0xCAFE); + Assert.Equal(SocialActions.QueryHealthOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0xCAFEu, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + } + + [Fact] + public void BuildPingRequest_HasOpcode0x01E9() + { + byte[] body = SocialActions.BuildPingRequest(seq: 1, clientId: 42); + Assert.Equal(SocialActions.PingRequestOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(42u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + } + + [Fact] + public void BuildFellowshipCreate_StringThenBools() + { + byte[] body = SocialActions.BuildFellowshipCreate( + seq: 1, fellowshipName: "Team", openness: true, shareXp: false); + + Assert.Equal(SocialActions.FellowshipCreateOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + // String at offset 12: u16 length = 4 + ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(12)); + Assert.Equal(4, len); + Assert.Equal("Team", Encoding.ASCII.GetString(body.AsSpan(14, 4))); + // string16L record = 2+4=6, pad 2 → advance by 8. + // Then 2 bools at offset 20,21. + Assert.Equal(1, body[20]); // openness true + Assert.Equal(0, body[21]); // shareXp false + } + + [Fact] + public void BuildFellowshipQuit_CarriesDisbandFlag() + { + byte[] body = SocialActions.BuildFellowshipQuit(seq: 1, disband: true); + Assert.Equal(SocialActions.FellowshipQuitOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(1, body[12]); + } + + [Fact] + public void BuildFellowshipDismiss_HasGuid() + { + byte[] body = SocialActions.BuildFellowshipDismiss(seq: 1, targetGuid: 0xDEAD); + Assert.Equal(SocialActions.FellowshipDismissOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0xDEADu, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + } + + [Fact] + public void BuildFellowshipRecruit_HasGuid() + { + byte[] body = SocialActions.BuildFellowshipRecruit(seq: 1, targetGuid: 0xBEEF); + Assert.Equal(SocialActions.FellowshipRecruitOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + } + + [Fact] + public void BuildFellowshipUpdate_HasOpenBool() + { + byte[] body = SocialActions.BuildFellowshipUpdate(seq: 1, open: true); + Assert.Equal(SocialActions.FellowshipUpdateOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(1, body[12]); + } + + [Fact] + public void BuildSetCharacterOptions_HasBitmap() + { + byte[] body = SocialActions.BuildSetCharacterOptions(seq: 1, optionsBitmap: 0xDEADBEEFu); + Assert.Equal(0xDEADBEEFu, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + } + + [Fact] + public void BuildAddChannel_ContainsName() + { + byte[] body = SocialActions.BuildAddChannel(seq: 1, channelName: "Trade"); + Assert.Equal(SocialActions.AddChannelOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(12)); + Assert.Equal(5, len); + Assert.Equal("Trade", Encoding.ASCII.GetString(body.AsSpan(14, 5))); + } + + [Fact] + public void BuildRemoveChannel_HasOpcode0x0146() + { + byte[] body = SocialActions.BuildRemoveChannel(seq: 1, channelName: "General"); + Assert.Equal(SocialActions.RemoveChannelOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + } +}