feat(chat): port retail client command families
Expand the typed client-command boundary across travel, character queries, local UI and layout controls, AFK and consent, emotes, friends, squelch and filters, and fill-components. Preserve retail packet layouts and queue ownership, import the confirmation dialog, and keep authoritative social state in Core. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
5a45a7ac7f
commit
9ea579bdd0
47 changed files with 6659 additions and 99 deletions
|
|
@ -9,6 +9,7 @@ using AcDream.Core.Net;
|
|||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Core.Social;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests;
|
||||
|
|
@ -142,6 +143,72 @@ public sealed class GameEventWiringTests
|
|||
Assert.Equal(new[] { 0x1500u }, wieldConfirmed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_ConfirmationRequest_UsesRetailTwoIntegerHeader()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
var items = new ClientObjectTable();
|
||||
var combat = new CombatState();
|
||||
var spellbook = new Spellbook();
|
||||
var chat = new ChatLog();
|
||||
GameEvents.CharacterConfirmationRequest? received = null;
|
||||
GameEventWiring.WireAll(
|
||||
dispatcher,
|
||||
items,
|
||||
combat,
|
||||
spellbook,
|
||||
chat,
|
||||
onConfirmationRequest: request => received = request);
|
||||
|
||||
byte[] text = MakeString16L("Proceed?");
|
||||
byte[] payload = new byte[8 + text.Length];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 7u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 42u);
|
||||
text.CopyTo(payload.AsSpan(8));
|
||||
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(
|
||||
GameEventType.CharacterConfirmationRequest,
|
||||
payload));
|
||||
dispatcher.Dispatch(env!.Value);
|
||||
|
||||
Assert.Equal(new GameEvents.CharacterConfirmationRequest(7u, 42u, "Proceed?"), received);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_FriendsUpdate_ReplacesAuthoritativeSocialState()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
var friends = new FriendsState();
|
||||
GameEventWiring.WireAll(
|
||||
dispatcher,
|
||||
new ClientObjectTable(),
|
||||
new CombatState(),
|
||||
new Spellbook(),
|
||||
new ChatLog(),
|
||||
friends: friends);
|
||||
|
||||
byte[] name = MakeString16L("Alice");
|
||||
byte[] payload = new byte[4 + 12 + name.Length + 4 + 4 + 4];
|
||||
int p = 0;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 1u); p += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 0x50000001u); p += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 1u); p += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 0u); p += 4;
|
||||
name.CopyTo(payload.AsSpan(p)); p += name.Length;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 0u); p += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(p), 0u); p += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
payload.AsSpan(p), (uint)FriendsUpdateType.Full);
|
||||
|
||||
var env = GameEventEnvelope.TryParse(WrapEnvelope(
|
||||
GameEventType.FriendsListUpdate, payload));
|
||||
dispatcher.Dispatch(env!.Value);
|
||||
|
||||
FriendEntry friend = Assert.Single(friends.Snapshot());
|
||||
Assert.Equal("Alice", friend.Name);
|
||||
Assert.True(friend.Online);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_PopupString_RoutesToChatLog()
|
||||
{
|
||||
|
|
@ -155,6 +222,24 @@ public sealed class GameEventWiringTests
|
|||
Assert.Equal(ChatKind.Popup, chat.Snapshot()[0].Kind);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "2d 4h", "You have played for 2d 4h.")]
|
||||
[InlineData("Alice", "1y 2mo", "Alice has played for 1y 2mo.")]
|
||||
public void WireAll_QueryAgeResponse_UsesRetailWording(
|
||||
string name, string age, string expected)
|
||||
{
|
||||
var (d, _, _, _, chat) = MakeAll();
|
||||
byte[] nameBytes = MakeString16L(name);
|
||||
byte[] ageBytes = MakeString16L(age);
|
||||
byte[] payload = [.. nameBytes, .. ageBytes];
|
||||
|
||||
var env = GameEventEnvelope.TryParse(
|
||||
WrapEnvelope(GameEventType.QueryAgeResponse, payload));
|
||||
d.Dispatch(env!.Value);
|
||||
|
||||
Assert.Equal(expected, chat.Snapshot().Single().Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_PlayerDescription_PopulatesLocalPlayerStateVitals()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public sealed class ClientCommandRequestsTests
|
||||
{
|
||||
public static TheoryData<Func<uint, byte[]>, uint> ParameterlessActions => new()
|
||||
{
|
||||
{ ClientCommandRequests.BuildMarketplace, ClientCommandRequests.MarketplaceOpcode },
|
||||
{ ClientCommandRequests.BuildPkArena, ClientCommandRequests.PkArenaOpcode },
|
||||
{ ClientCommandRequests.BuildPkLiteArena, ClientCommandRequests.PkLiteArenaOpcode },
|
||||
{ ClientCommandRequests.BuildHouseRecall, ClientCommandRequests.HouseRecallOpcode },
|
||||
{ ClientCommandRequests.BuildMansionRecall, ClientCommandRequests.MansionRecallOpcode },
|
||||
{ ClientCommandRequests.BuildSuicide, ClientCommandRequests.SuicideOpcode },
|
||||
{ ClientCommandRequests.BuildClearFriends, ClientCommandRequests.ClearFriendsOpcode },
|
||||
{ ClientCommandRequests.BuildClearConsent, ClientCommandRequests.ClearConsentOpcode },
|
||||
{ ClientCommandRequests.BuildDisplayConsent, ClientCommandRequests.DisplayConsentOpcode },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ParameterlessActions))]
|
||||
public void ParameterlessAction_MatchesRetailEnvelope(
|
||||
Func<uint, byte[]> build, uint opcode)
|
||||
{
|
||||
byte[] body = build(0x1234u);
|
||||
|
||||
Assert.Equal(12, body.Length);
|
||||
Assert.Equal(InteractRequests.GameActionEnvelope, Read(body, 0));
|
||||
Assert.Equal(0x1234u, Read(body, 4));
|
||||
Assert.Equal(opcode, Read(body, 8));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, ClientCommandRequests.QueryAgeOpcode)]
|
||||
[InlineData(true, ClientCommandRequests.QueryBirthOpcode)]
|
||||
public void CharacterQuery_MatchesRetailPayload(bool birth, uint opcode)
|
||||
{
|
||||
byte[] body = birth
|
||||
? ClientCommandRequests.BuildQueryBirth(7u, 0x50000001u)
|
||||
: ClientCommandRequests.BuildQueryAge(7u, 0x50000001u);
|
||||
|
||||
Assert.Equal(16, body.Length);
|
||||
Assert.Equal(InteractRequests.GameActionEnvelope, Read(body, 0));
|
||||
Assert.Equal(7u, Read(body, 4));
|
||||
Assert.Equal(opcode, Read(body, 8));
|
||||
Assert.Equal(0x50000001u, Read(body, 12));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfirmationResponse_MatchesRetailPayload()
|
||||
{
|
||||
byte[] body = ClientCommandRequests.BuildConfirmationResponse(
|
||||
9u, confirmationType: 7u, contextId: 42u, accepted: true);
|
||||
|
||||
Assert.Equal(24, body.Length);
|
||||
Assert.Equal(InteractRequests.GameActionEnvelope, Read(body, 0));
|
||||
Assert.Equal(9u, Read(body, 4));
|
||||
Assert.Equal(ClientCommandRequests.ConfirmationResponseOpcode, Read(body, 8));
|
||||
Assert.Equal(7u, Read(body, 12));
|
||||
Assert.Equal(42u, Read(body, 16));
|
||||
Assert.Equal(1u, Read(body, 20));
|
||||
}
|
||||
|
||||
public static TheoryData<Func<uint, string, byte[]>, uint> StringActions => new()
|
||||
{
|
||||
{ ClientCommandRequests.BuildSetAfkMessage, ClientCommandRequests.SetAfkMessageOpcode },
|
||||
{ ClientCommandRequests.BuildEmote, ClientCommandRequests.EmoteOpcode },
|
||||
{ ClientCommandRequests.BuildAddFriend, ClientCommandRequests.AddFriendOpcode },
|
||||
{ ClientCommandRequests.BuildRemoveConsent, ClientCommandRequests.RemoveConsentOpcode },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(StringActions))]
|
||||
public void StringAction_PacksAlignedCp1252String16L(
|
||||
Func<uint, string, byte[]> build, uint opcode)
|
||||
{
|
||||
byte[] body = build(11u, "Bjørn");
|
||||
|
||||
Assert.Equal(20, body.Length);
|
||||
Assert.Equal(InteractRequests.GameActionEnvelope, Read(body, 0));
|
||||
Assert.Equal(11u, Read(body, 4));
|
||||
Assert.Equal(opcode, Read(body, 8));
|
||||
Assert.Equal(5, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(12)));
|
||||
Assert.Equal([0x42, 0x6A, 0xF8, 0x72, 0x6E], body[14..19]);
|
||||
Assert.Equal(0, body[19]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetAfkMode_MatchesRetailBooleanPayload()
|
||||
{
|
||||
byte[] body = ClientCommandRequests.BuildSetAfkMode(12u, away: true);
|
||||
|
||||
Assert.Equal(16, body.Length);
|
||||
Assert.Equal(ClientCommandRequests.SetAfkModeOpcode, Read(body, 8));
|
||||
Assert.Equal(1u, Read(body, 12));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FriendAndGlobalSquelchIntegerActions_MatchRetailPayloads()
|
||||
{
|
||||
byte[] remove = ClientCommandRequests.BuildRemoveFriend(2u, 0x50000001u);
|
||||
byte[] filter = ClientCommandRequests.BuildModifyGlobalSquelch(3u, add: false, 17u);
|
||||
|
||||
Assert.Equal(ClientCommandRequests.RemoveFriendOpcode, Read(remove, 8));
|
||||
Assert.Equal(0x50000001u, Read(remove, 12));
|
||||
Assert.Equal(ClientCommandRequests.ModifyGlobalSquelchOpcode, Read(filter, 8));
|
||||
Assert.Equal(0u, Read(filter, 12));
|
||||
Assert.Equal(17u, Read(filter, 16));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CharacterSquelch_PreservesRetailFieldOrder()
|
||||
{
|
||||
byte[] body = ClientCommandRequests.BuildModifyCharacterSquelch(
|
||||
4u, add: true, 0x50000001u, "Alice", 12u);
|
||||
|
||||
Assert.Equal(32, body.Length);
|
||||
Assert.Equal(ClientCommandRequests.ModifyCharacterSquelchOpcode, Read(body, 8));
|
||||
Assert.Equal(1u, Read(body, 12));
|
||||
Assert.Equal(0x50000001u, Read(body, 16));
|
||||
Assert.Equal(5, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20)));
|
||||
Assert.Equal(12u, Read(body, 28));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AccountSquelch_PreservesRetailFieldOrder()
|
||||
{
|
||||
byte[] body = ClientCommandRequests.BuildModifyAccountSquelch(
|
||||
5u, add: false, "Alice");
|
||||
|
||||
Assert.Equal(24, body.Length);
|
||||
Assert.Equal(ClientCommandRequests.ModifyAccountSquelchOpcode, Read(body, 8));
|
||||
Assert.Equal(0u, Read(body, 12));
|
||||
Assert.Equal(5, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(16)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DesiredComponentLevel_MatchesRetailTwoIntegerPayload()
|
||||
{
|
||||
byte[] body = ClientCommandRequests.BuildSetDesiredComponentLevel(
|
||||
6u, 0x12000042u, 25u);
|
||||
|
||||
Assert.Equal(20, body.Length);
|
||||
Assert.Equal(ClientCommandRequests.SetDesiredComponentLevelOpcode, Read(body, 8));
|
||||
Assert.Equal(0x12000042u, Read(body, 12));
|
||||
Assert.Equal(25u, Read(body, 16));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyFriendsCommand_IsAControlMessageWithoutGameActionEnvelope()
|
||||
{
|
||||
byte[] body = ClientCommandRequests.BuildLegacyFriendsCommand(0u, string.Empty);
|
||||
|
||||
Assert.Equal(12, body.Length);
|
||||
Assert.Equal(ClientCommandRequests.LegacyFriendsOpcode, Read(body, 0));
|
||||
Assert.Equal(0u, Read(body, 4));
|
||||
Assert.Equal(0, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(8)));
|
||||
}
|
||||
|
||||
private static uint Read(byte[] body, int offset) =>
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(offset));
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Social;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
public sealed class SocialStateMessagesTests
|
||||
{
|
||||
[Fact]
|
||||
public void FriendsUpdate_UnpacksRetailPListAndUpdateType()
|
||||
{
|
||||
var payload = new List<byte>();
|
||||
WriteU32(payload, 2u);
|
||||
WriteFriend(payload, 0x50000001u, online: true, appearOffline: false,
|
||||
"Alice", [0x50000002u], [0x50000003u, 0x50000004u]);
|
||||
WriteFriend(payload, 0x50000002u, online: false, appearOffline: true,
|
||||
"Bjørn", [], []);
|
||||
WriteU32(payload, (uint)FriendsUpdateType.Full);
|
||||
|
||||
FriendsUpdate? update = SocialStateMessages.ParseFriendsUpdate([.. payload]);
|
||||
|
||||
Assert.NotNull(update);
|
||||
Assert.Equal(FriendsUpdateType.Full, update!.Type);
|
||||
Assert.Collection(
|
||||
update.Entries,
|
||||
alice =>
|
||||
{
|
||||
Assert.Equal(0x50000001u, alice.Id);
|
||||
Assert.Equal("Alice", alice.Name);
|
||||
Assert.True(alice.Online);
|
||||
Assert.False(alice.AppearOffline);
|
||||
Assert.Equal([0x50000002u], alice.Friends);
|
||||
Assert.Equal([0x50000003u, 0x50000004u], alice.FriendOf);
|
||||
},
|
||||
bjorn =>
|
||||
{
|
||||
Assert.Equal("Bjørn", bjorn.Name);
|
||||
Assert.False(bjorn.Online);
|
||||
Assert.True(bjorn.AppearOffline);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FriendsUpdate_RejectsTruncatedFriendData()
|
||||
{
|
||||
byte[] payload = new byte[8];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, 1u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x50000001u);
|
||||
|
||||
Assert.Null(SocialStateMessages.ParseFriendsUpdate(payload));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SquelchDatabase_UnpacksRetailHashTablesAndVlongs()
|
||||
{
|
||||
var payload = new List<byte>();
|
||||
|
||||
WriteHashHeader(payload, count: 1, buckets: 128);
|
||||
WriteString16L(payload, "MutedAccount");
|
||||
WriteU32(payload, 0x11223344u);
|
||||
|
||||
WriteHashHeader(payload, count: 1, buckets: 128);
|
||||
WriteU32(payload, 0x50000001u);
|
||||
WriteSquelchInfo(payload, "Muted Character", accountWide: true, 2u, 17u);
|
||||
|
||||
WriteSquelchInfo(payload, string.Empty, accountWide: false, 7u, 19u);
|
||||
|
||||
SquelchDatabase? database =
|
||||
SocialStateMessages.ParseSquelchDatabase([.. payload]);
|
||||
|
||||
Assert.NotNull(database);
|
||||
Assert.Equal(0x11223344u, database!.Accounts["mutedaccount"]);
|
||||
SquelchInfo character = database.Characters[0x50000001u];
|
||||
Assert.Equal("Muted Character", character.Name);
|
||||
Assert.True(character.AccountWide);
|
||||
Assert.Equal([2u, 17u], character.MessageTypes.Order());
|
||||
Assert.False(database.Global.AccountWide);
|
||||
Assert.Equal([7u, 19u], database.Global.MessageTypes.Order());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SquelchDatabase_RejectsNonEmptyZeroBucketTable()
|
||||
{
|
||||
var payload = new List<byte>();
|
||||
WriteHashHeader(payload, count: 1, buckets: 0);
|
||||
|
||||
Assert.Null(SocialStateMessages.ParseSquelchDatabase([.. payload]));
|
||||
}
|
||||
|
||||
private static void WriteFriend(
|
||||
List<byte> destination,
|
||||
uint id,
|
||||
bool online,
|
||||
bool appearOffline,
|
||||
string name,
|
||||
IReadOnlyList<uint> friends,
|
||||
IReadOnlyList<uint> friendOf)
|
||||
{
|
||||
WriteU32(destination, id);
|
||||
WriteU32(destination, online ? 1u : 0u);
|
||||
WriteU32(destination, appearOffline ? 1u : 0u);
|
||||
WriteString16L(destination, name);
|
||||
WriteU32List(destination, friends);
|
||||
WriteU32List(destination, friendOf);
|
||||
}
|
||||
|
||||
private static void WriteSquelchInfo(
|
||||
List<byte> destination,
|
||||
string name,
|
||||
bool accountWide,
|
||||
params uint[] messageTypes)
|
||||
{
|
||||
uint highest = messageTypes.Length == 0 ? 0u : messageTypes.Max();
|
||||
int wordCount = messageTypes.Length == 0 ? 0 : checked((int)(highest / 32u + 1u));
|
||||
var words = new uint[wordCount];
|
||||
foreach (uint type in messageTypes)
|
||||
words[type / 32u] |= 1u << checked((int)(type % 32u));
|
||||
|
||||
WriteU32(destination, (uint)words.Length);
|
||||
foreach (uint word in words) WriteU32(destination, word);
|
||||
WriteString16L(destination, name);
|
||||
WriteU32(destination, accountWide ? 1u : 0u);
|
||||
}
|
||||
|
||||
private static void WriteU32List(List<byte> destination, IReadOnlyList<uint> values)
|
||||
{
|
||||
WriteU32(destination, (uint)values.Count);
|
||||
foreach (uint value in values) WriteU32(destination, value);
|
||||
}
|
||||
|
||||
private static void WriteHashHeader(List<byte> destination, ushort count, ushort buckets) =>
|
||||
WriteU32(destination, ((uint)buckets << 16) | count);
|
||||
|
||||
private static void WriteString16L(List<byte> destination, string value)
|
||||
{
|
||||
byte[] bytes = Encoding.GetEncoding(1252).GetBytes(value);
|
||||
int start = destination.Count;
|
||||
destination.Add((byte)bytes.Length);
|
||||
destination.Add((byte)(bytes.Length >> 8));
|
||||
destination.AddRange(bytes);
|
||||
while ((destination.Count - start & 3) != 0) destination.Add(0);
|
||||
}
|
||||
|
||||
private static void WriteU32(List<byte> destination, uint value)
|
||||
{
|
||||
destination.Add((byte)value);
|
||||
destination.Add((byte)(value >> 8));
|
||||
destination.Add((byte)(value >> 16));
|
||||
destination.Add((byte)(value >> 24));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue