feat(net): SocialActions — query / fellowship / channel / options outbound
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) <noreply@anthropic.com>
This commit is contained in:
parent
a9f366718d
commit
fa266aaa03
2 changed files with 285 additions and 0 deletions
174
src/AcDream.Core.Net/Messages/SocialActions.cs
Normal file
174
src/AcDream.Core.Net/Messages/SocialActions.cs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Outbound social + query GameActions. These share a common pattern:
|
||||
/// single-target / single-parameter GameActions inside the <c>0xF7B1</c>
|
||||
/// envelope.
|
||||
///
|
||||
/// <para>
|
||||
/// Wire format:
|
||||
/// <code>
|
||||
/// u32 0xF7B1 envelope
|
||||
/// u32 gameActionSequence
|
||||
/// u32 subOpcode
|
||||
/// <payload> per-action
|
||||
/// </code>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// References: r08 §3 rows for each opcode.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>Query a target's health — server replies with UpdateHealth (0x01C0).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Keepalive ping — server echoes with PingResponse (0x01EA).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Create a fellowship with a chosen name + options.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Quit your current fellowship (optionally disband if leader).</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Dismiss a specific vassal from your fellowship.</summary>
|
||||
public static byte[] BuildFellowshipDismiss(uint seq, uint targetGuid)
|
||||
=> SingleGuid(seq, FellowshipDismissOpcode, targetGuid);
|
||||
|
||||
/// <summary>Recruit a target into your fellowship.</summary>
|
||||
public static byte[] BuildFellowshipRecruit(uint seq, uint targetGuid)
|
||||
=> SingleGuid(seq, FellowshipRecruitOpcode, targetGuid);
|
||||
|
||||
/// <summary>Toggle fellowship open / closed recruiting.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Push the client's character-options bitmap to the server.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Subscribe to a named chat channel.</summary>
|
||||
public static byte[] BuildAddChannel(uint seq, string channelName)
|
||||
=> SingleString(seq, AddChannelOpcode, channelName);
|
||||
|
||||
/// <summary>Unsubscribe from a named chat channel.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
111
tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs
Normal file
111
tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs
Normal file
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue