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:
Erik 2026-07-13 14:50:15 +02:00
parent 5a45a7ac7f
commit 9ea579bdd0
47 changed files with 6659 additions and 99 deletions

View file

@ -1,4 +1,6 @@
using AcDream.App.UI;
using AcDream.Core.Physics;
using AcDream.Core.Social;
using AcDream.UI.Abstractions;
namespace AcDream.App.Tests.UI;
@ -6,23 +8,312 @@ namespace AcDream.App.Tests.UI;
public sealed class ClientCommandControllerTests
{
[Fact]
public void LifestoneRecall_ExecutesSuppliedSessionActionOnce()
public void RecallAndQueryCommands_ExecuteTheirExactBindings()
{
int recalls = 0;
var controller = new ClientCommandController(() => recalls++);
var calls = new List<string>();
var controller = NewController(calls: calls);
Execute(ClientCommandId.LifestoneRecall);
Execute(ClientCommandId.MarketplaceRecall);
Execute(ClientCommandId.PkArenaRecall);
Execute(ClientCommandId.PkLiteArenaRecall);
Execute(ClientCommandId.HouseRecall);
Execute(ClientCommandId.MansionRecall);
Execute(ClientCommandId.QueryAge);
Execute(ClientCommandId.QueryBirth);
Assert.Equal(
["ls", "mp", "pka", "pla", "house", "mansion", "age", "birth"],
calls);
void Execute(ClientCommandId id) => controller.Execute(
new ExecuteClientCommandCmd(id, Arguments: string.Empty));
}
[Fact]
public void PkArena_NonPk_ShowsRetailFailureWithoutSending()
{
var calls = new List<string>();
var errors = new List<uint>();
var controller = NewController(calls, errors, playerBitfield: 0x8u);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.LifestoneRecall, Arguments: string.Empty));
ClientCommandId.PkArenaRecall, string.Empty));
Assert.Equal(1, recalls);
Assert.Empty(calls);
Assert.Equal([0x055Fu], errors);
}
[Fact]
public void PkLiteArena_NonPkLite_ShowsRetailFailureWithoutSending()
{
var calls = new List<string>();
var errors = new List<uint>();
var controller = NewController(calls, errors, playerBitfield: 0x8u);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.PkLiteArenaRecall, string.Empty));
Assert.Empty(calls);
Assert.Equal([0x0560u], errors);
}
[Fact]
public void MissingPlayerDescription_DoesNotInventAClientRejection()
{
var calls = new List<string>();
var controller = NewController(calls, playerBitfield: null);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.PkArenaRecall, string.Empty));
Assert.Equal(["pka"], calls);
}
[Fact]
public void LocalPresentationCommands_UseApplicationServices()
{
var calls = new List<string>();
var messages = new List<string>();
var controller = NewController(calls, messages: messages);
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.ToggleFrameRate, ""));
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.ToggleUiLock, ""));
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.ShowVersion, ""));
Assert.Equal(["fps", "lock"], calls);
Assert.Equal(["Client version 1.2.3"], messages);
}
[Fact]
public void Die_RequiresRetailConfirmationBeforeSuicide()
{
var calls = new List<string>();
var controller = NewController(calls);
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Die, ""));
Assert.Equal(
[
"confirm:Do you really want to kill your character? You may drop items and accrue a vitae penalty.",
"suicide",
],
calls);
}
[Fact]
public void ChatAndLayoutCommands_UseNamedAndAutomaticPersistenceBindings()
{
var calls = new List<string>();
var messages = new List<string>();
var controller = NewController(calls, messages: messages);
Execute(ClientCommandId.ClearChat, "all");
Execute(ClientCommandId.SaveUi, "hunt");
Execute(ClientCommandId.LoadUi, "hunt");
Execute(ClientCommandId.SaveAutoUi, string.Empty);
Execute(ClientCommandId.LoadAutoUi, string.Empty);
Execute(ClientCommandId.SaveUi, "this-name-is-over-16-characters");
Assert.Equal(
["clear:True", "saveui:hunt", "loadui:hunt", "saveautoui", "loadautoui"],
calls);
Assert.Equal(["The file name must be 16 characters or less."], messages);
void Execute(ClientCommandId id, string arguments) =>
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
}
[Fact]
public void AwayCommands_RespectCurrentModeAndPackRetailMessageForm()
{
var calls = new List<string>();
var messages = new List<string>();
var controller = NewController(calls, messages: messages, isAway: false);
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Away, "on"));
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Away, "msg Stepped away"));
Assert.Equal(["afk:True", "afkmsg:Stepped away\n"], calls);
Assert.Equal(["New AFK message set: Stepped away\n"], messages);
calls.Clear();
controller = NewController(calls, isAway: true);
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Away, "off"));
Assert.Equal(["afk:False"], calls);
}
[Fact]
public void ConsentAndEmoteCommands_RouteTheirExactActions()
{
var calls = new List<string>();
var controller = NewController(calls, acceptsLootPermits: false);
Execute(ClientCommandId.Consent, "on");
Execute(ClientCommandId.Consent, "who");
Execute(ClientCommandId.Consent, "clear");
Execute(ClientCommandId.Consent, "remove Alice Example");
Execute(ClientCommandId.Emote, "waves happily");
Assert.Equal(
[
"consentmode:True",
"consentwho",
"consentclear",
"consentremove:Alice Example",
"emote:waves happily",
],
calls);
void Execute(ClientCommandId id, string arguments) =>
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
}
[Fact]
public void FriendsCommands_ReadAuthoritativeStateAndSendObjectId()
{
var friends = new FriendsState();
friends.Apply(new FriendsUpdate(
FriendsUpdateType.Full,
[
new FriendEntry(0x50000001u, "Alice", true, false, [], []),
new FriendEntry(0x50000002u, "Bjørn", false, false, [], []),
]));
var calls = new List<string>();
var messages = new List<string>();
var controller = NewController(calls, messages: messages, friends: friends);
Execute(ClientCommandId.Friends, "online");
Execute(ClientCommandId.FriendsRemove, "alice");
Execute(ClientCommandId.FriendsAdd, "Cara");
Execute(ClientCommandId.Friends, "old");
Assert.Contains("Alice (Online)", messages.Single());
Assert.DoesNotContain("Bjørn", messages.Single());
Assert.Equal(
["friendremove:1342177281", "friendadd:Cara", "friendsold"],
calls);
void Execute(ClientCommandId id, string arguments) =>
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
}
[Fact]
public void SquelchAndFilterCommands_ParseRetailOptions()
{
var calls = new List<string>();
var controller = NewController(calls, lastTeller: "Recent Teller");
Execute(ClientCommandId.Squelch, "-account Alice");
Execute(ClientCommandId.Unsquelch, "-reply");
Execute(ClientCommandId.Squelch, "-Magic Caster Name");
Execute(ClientCommandId.Filter, "-Combat_Self");
Execute(ClientCommandId.Unfilter, "-Tell");
Assert.Equal(
[
"accountsquelch:True:Alice",
"charsquelch:False:0:Recent Teller:1",
"charsquelch:True:0:Caster Name:7",
"globalsquelch:True:22",
"globalsquelch:False:3",
],
calls);
void Execute(ClientCommandId id, string arguments) =>
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
}
[Fact]
public void FillComponents_ClearWorksWithoutVendorAndBuyingRequiresOne()
{
var calls = new List<string>();
var messages = new List<string>();
var controller = NewController(calls, messages: messages, vendorOpen: false);
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.FillComponents, "clear"));
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.FillComponents, "scarabs 500"));
Assert.Equal(["clearcomps"], calls);
Assert.Equal(["Component list cleared.", "You need an open vendor."], messages);
}
[Fact]
public void UnknownCommandId_FailsAtApplicationBoundary()
{
var controller = new ClientCommandController(() => { });
var controller = NewController();
var command = new ExecuteClientCommandCmd((ClientCommandId)999, string.Empty);
Assert.Throws<ArgumentOutOfRangeException>(() => controller.Execute(command));
}
private static ClientCommandController NewController(
List<string>? calls = null,
List<uint>? errors = null,
uint? playerBitfield = 0x02000028u,
List<string>? messages = null,
bool isAway = false,
bool acceptsLootPermits = true,
FriendsState? friends = null,
SquelchState? squelch = null,
string? lastTeller = null,
bool vendorOpen = false)
{
calls ??= [];
errors ??= [];
messages ??= [];
return new ClientCommandController(new ClientCommandController.Bindings(
() => calls.Add("ls"),
() => calls.Add("mp"),
() => calls.Add("pka"),
() => calls.Add("pla"),
() => calls.Add("house"),
() => calls.Add("mansion"),
() => calls.Add("age"),
() => calls.Add("birth"),
() => calls.Add("fps"),
() => calls.Add("lock"),
messages.Add,
errors.Add,
() => playerBitfield,
() => "1.2.3",
() => new Position(
0xA9B40001u,
new CellFrame(new System.Numerics.Vector3(1f, 2f, 3f),
System.Numerics.Quaternion.Identity)),
() => null,
(message, completed) =>
{
calls.Add($"confirm:{message}");
completed(true);
},
() => calls.Add("suicide"),
all => calls.Add("clear:" + all),
name => calls.Add("saveui:" + name),
name => calls.Add("loadui:" + name),
() => calls.Add("saveautoui"),
() => calls.Add("loadautoui"),
() => isAway,
away => calls.Add("afk:" + away),
message => calls.Add("afkmsg:" + message),
() => acceptsLootPermits,
enabled => calls.Add("consentmode:" + enabled),
() => calls.Add("consentwho"),
() => calls.Add("consentclear"),
name => calls.Add("consentremove:" + name),
message => calls.Add("emote:" + message),
friends ?? new FriendsState(),
name => calls.Add("friendadd:" + name),
id => calls.Add("friendremove:" + id),
() => calls.Add("friendsclear"),
() => calls.Add("friendsold"),
squelch ?? new SquelchState(),
(add, id, name, type) => calls.Add($"charsquelch:{add}:{id}:{name}:{type}"),
(add, name) => calls.Add($"accountsquelch:{add}:{name}"),
(add, type) => calls.Add($"globalsquelch:{add}:{type}"),
() => lastTeller,
() => calls.Add("clearcomps"),
() => vendorOpen,
(category, price) => calls.Add($"fillcomps:{category}:{price}")));
}
}

View file

@ -101,6 +101,12 @@ public static class FixtureLoader
public static ElementInfo LoadPowerbarInfos()
=> LoadInfos("powerbar_21000072.json");
public static ImportedLayout LoadConfirmationDialog()
=> LayoutImporter.Build(LoadConfirmationDialogInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadConfirmationDialogInfos()
=> LoadInfos("dialogs_2100003C.json");
// ── Shared loader ────────────────────────────────────────────────────────
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)

View file

@ -0,0 +1,67 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RetailConfirmationDialogServiceTests
{
[Fact]
public void FixtureBuildsProductionConfirmationWidgets()
{
var layout = FixtureLoader.LoadConfirmationDialog();
Assert.IsType<UiDialogRoot>(layout.Root);
Assert.IsType<UiText>(layout.FindElement(RetailConfirmationDialogService.MessageElementId));
Assert.IsType<UiButton>(layout.FindElement(RetailConfirmationDialogService.AcceptButtonId));
Assert.IsType<UiButton>(layout.FindElement(RetailConfirmationDialogService.RejectButtonId));
}
[Fact]
public void ShowCentersModalAndAcceptCompletesIt()
{
var root = new UiRoot { Width = 1024f, Height = 768f };
var layout = FixtureLoader.LoadConfirmationDialog();
var service = new RetailConfirmationDialogService(root, layout);
bool? result = null;
service.Show("Do you really want to kill your character?", accepted => result = accepted);
Assert.Same(layout.Root, root.Modal);
var popup = Assert.IsAssignableFrom<UiElement>(
layout.FindElement(RetailConfirmationDialogService.PopupElementId));
Assert.Equal(MathF.Round((1024f - popup.Width) * 0.5f), popup.Left);
Assert.Equal(MathF.Round((768f - popup.Height) * 0.5f), popup.Top);
var accept = Assert.IsType<UiButton>(
layout.FindElement(RetailConfirmationDialogService.AcceptButtonId));
accept.OnClick!();
Assert.True(result);
Assert.Null(root.Modal);
Assert.False(service.IsOpen);
}
[Fact]
public void RequestsArePresentedInFifoOrder()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var layout = FixtureLoader.LoadConfirmationDialog();
var service = new RetailConfirmationDialogService(root, layout);
var results = new List<string>();
service.Show("first", accepted => results.Add($"first:{accepted}"));
service.Show("second", accepted => results.Add($"second:{accepted}"));
Assert.Equal(1, service.PendingCount);
Assert.IsType<UiButton>(layout.FindElement(
RetailConfirmationDialogService.RejectButtonId)).OnClick!();
Assert.Equal(["first:False"], results);
Assert.True(service.IsOpen);
Assert.Equal(0, service.PendingCount);
Assert.IsType<UiButton>(layout.FindElement(
RetailConfirmationDialogService.AcceptButtonId)).OnClick!();
Assert.Equal(["first:False", "second:True"], results);
Assert.False(service.IsOpen);
}
}

View file

@ -3,6 +3,7 @@ using System.Text.Json;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
using EnumIDMap = DatReaderWriter.DBObjs.EnumIDMap;
namespace AcDream.App.Tests.UI.Layout;
@ -44,6 +45,25 @@ public sealed class RetailLayoutFixtureGenerator
"Asheron's Call");
using var dats = new DatCollection(datDir, DatAccessType.Read);
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
Assert.True(dats.Portal.TryGet<EnumIDMap>(masterDid, out var master));
Assert.True(master!.ClientEnumToID.TryGetValue(5u, out uint uiMapDid));
Assert.True(dats.Portal.TryGet<EnumIDMap>(uiMapDid, out var uiMap));
Assert.True(uiMap!.ClientEnumToID.TryGetValue(2u, out uint dialogsLayoutDid));
var dialogs = LayoutImporter.ImportInfos(
dats,
dialogsLayoutDid,
RetailConfirmationDialogService.RootElementId);
Assert.NotNull(dialogs);
var dialogsJson = JsonSerializer.Serialize(dialogs, new JsonSerializerOptions
{
IncludeFields = true,
WriteIndented = true,
});
File.WriteAllText(
Path.Combine(FixtureDirectory(), $"dialogs_{dialogsLayoutDid:X8}.json"),
dialogsJson);
foreach (var (layoutId, fileName) in Layouts)
{
var info = LayoutImporter.ImportInfos(dats, layoutId);
@ -67,6 +87,7 @@ public sealed class RetailLayoutFixtureGenerator
});
File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), json);
}
}
private static string FixtureDirectory([CallerFilePath] string thisFile = "")

File diff suppressed because it is too large Load diff

View file

@ -99,6 +99,31 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
Assert.False(saved.Visible);
}
[Fact]
public void NamedProfile_SaveAndRestore_AppliesEveryMountedWindow()
{
var store = new SettingsStore(PathName);
var root = new UiRoot { Width = 800, Height = 600 };
RetailWindowHandle chat = Mount(root, "chat");
RetailWindowHandle radar = Mount(root, "radar");
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (800, 600));
chat.MoveTo(44f, 55f);
radar.MoveTo(300f, 120f);
radar.Hide();
persistence.SaveNamed("hunting");
chat.MoveTo(1f, 2f);
radar.MoveTo(3f, 4f);
radar.Show();
persistence.RestoreNamed("hunting");
Assert.Equal((44f, 55f), (chat.Left, chat.Top));
Assert.Equal((300f, 120f), (radar.Left, radar.Top));
Assert.False(radar.IsVisible);
}
private static RetailWindowHandle Mount(
UiRoot root,
string name,

View file

@ -8,6 +8,17 @@ namespace AcDream.App.Tests.UI;
public class UiTextTests
{
[Fact]
public void WrapWords_UsesMeasuredWidthAndPreservesParagraphs()
{
IReadOnlyList<string> lines = UiText.WrapWords(
"one two three\nfour",
text => text.Length * 10f,
maximumWidth: 75f);
Assert.Equal(["one two", "three", "four"], lines);
}
[Fact]
public void ClampScroll_PinsToZero_WhenContentFitsView()
{
@ -142,6 +153,28 @@ public class UiTextTests
Assert.Equal(new UiText.Pos(1, 8), e2);
}
[Fact]
public void WrapWords_PreservesNewlinesAndWrapsAtSpaces()
{
IReadOnlyList<string> lines = UiText.WrapWords(
"one two three\nfour",
static text => text.Length,
maximumWidth: 7f);
Assert.Equal(["one two", "three", "four"], lines);
}
[Fact]
public void WrapWords_SplitsAnOverWidthWordAtCharacterBoundaries()
{
IReadOnlyList<string> lines = UiText.WrapWords(
"go abcdef",
static text => text.Length,
maximumWidth: 5f);
Assert.Equal(["go ab", "cdef"], lines);
}
// ── VOffset: vertical positioning for single-line mode ───────────────────
/// <summary>

View file

@ -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()
{

View file

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

View file

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

View file

@ -0,0 +1,57 @@
using AcDream.Core.Social;
namespace AcDream.Core.Tests.Social;
public sealed class SocialStateTests
{
[Fact]
public void FriendsState_AppliesRetailIncrementalUpdateKindsByObjectId()
{
var state = new FriendsState();
state.Apply(Update(FriendsUpdateType.Full,
Friend(1u, "Alice", online: false),
Friend(2u, "Bjørn", online: true)));
state.Apply(Update(FriendsUpdateType.OnlineStatus,
Friend(1u, "Alice", online: true)));
state.Apply(Update(FriendsUpdateType.Add,
Friend(3u, "Cara", online: true)));
state.Apply(Update(FriendsUpdateType.RemoveSilent,
Friend(2u, "Bjørn", online: false)));
Assert.Collection(
state.Snapshot(),
alice =>
{
Assert.Equal(1u, alice.Id);
Assert.True(alice.Online);
},
cara => Assert.Equal(3u, cara.Id));
}
[Fact]
public void SquelchState_ReplacesDatabaseAtomically()
{
var state = new SquelchState();
var expected = new SquelchDatabase(
new Dictionary<string, uint>(StringComparer.OrdinalIgnoreCase)
{
["Account"] = 7u,
},
new Dictionary<uint, SquelchInfo>
{
[42u] = new("Character", false, new HashSet<uint> { 2u }),
},
new SquelchInfo(string.Empty, false, new HashSet<uint> { 7u }));
state.Replace(expected);
Assert.Same(expected, state.Snapshot());
}
private static FriendEntry Friend(uint id, string name, bool online) =>
new(id, name, online, AppearOffline: false, [], []);
private static FriendsUpdate Update(
FriendsUpdateType type, params FriendEntry[] entries) =>
new(type, entries);
}

View file

@ -0,0 +1,31 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Ui;
namespace AcDream.Core.Tests.Ui;
public sealed class RetailPositionFormatterTests
{
[Fact]
public void Format_MatchesPositionToStringFieldOrderAndPrecision()
{
var position = new Position(
0xA9B40001u,
new CellFrame(
new Vector3(1.25f, -2.5f, 3f),
new Quaternion(0.1f, 0.2f, 0.3f, 0.9f)));
Assert.Equal(
"0xA9B40001 [1.250000 -2.500000 3.000000] 0.900000 0.100000 0.200000 0.300000",
RetailPositionFormatter.Format(position));
}
[Fact]
public void FormatOutdoorCell_UsesRetailRadarCoordinateOrder()
{
Assert.True(RadarCoordinates.TryFromCell(0xA9B40001u, out var expected));
Assert.Equal(expected.CombinedText,
RetailPositionFormatter.FormatOutdoorCell(0xA9B40001u));
Assert.Null(RetailPositionFormatter.FormatOutdoorCell(0xA9B40164u));
}
}

View file

@ -45,7 +45,7 @@ public class ChatCommandRouterTests
}
[Fact]
public void ClearCommand_DrainsLog_DoesNotPublish()
public void ClearCommand_PublishesTypedRetailCommand()
{
var (vm, log, bus) = Fixture();
log.OnSystemMessage("x", chatType: 0);
@ -53,8 +53,10 @@ public class ChatCommandRouterTests
var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
Assert.Empty(bus.Published);
Assert.Empty(log.Snapshot());
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
Assert.Equal(ClientCommandId.ClearChat, command.Command);
Assert.Equal("", command.Arguments);
Assert.Single(log.Snapshot());
}
[Theory]

View file

@ -70,7 +70,7 @@ public sealed class ChatPanelInputTests
}
[Fact]
public void Submit_FramerateCommand_PrintsFpsAndDoesNotPublish()
public void Submit_FramerateCommand_PublishesTypedClientCommand()
{
var log = new ChatLog();
var vm = new ChatVM(log) { FpsProvider = () => 60f };
@ -84,13 +84,13 @@ public sealed class ChatPanelInputTests
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Empty(bus.Published);
var entry = Assert.Single(log.Snapshot());
Assert.Contains("60.0 FPS", entry.Text);
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
Assert.Equal(ClientCommandId.ToggleFrameRate, command.Command);
Assert.Empty(log.Snapshot());
}
[Fact]
public void Submit_LocCommand_PrintsPositionAndDoesNotPublish()
public void Submit_LocCommand_PublishesTypedClientCommand()
{
var log = new ChatLog();
var vm = new ChatVM(log)
@ -107,14 +107,13 @@ public sealed class ChatPanelInputTests
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Empty(bus.Published);
var entry = Assert.Single(log.Snapshot());
Assert.Contains("(10.0, 20.0, 30.0)", entry.Text);
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
Assert.Equal(ClientCommandId.ShowLocation, command.Command);
Assert.Empty(log.Snapshot());
}
[Theory]
[InlineData("/foo", "@foo")]
[InlineData("/mp /tools/script.py", "@mp /tools/script.py")]
[InlineData("/genio public", "@genio public")]
public void Submit_UnknownSlashCommand_RoutesToExplicitServerCommand(string raw, string expectedText)
{
@ -213,7 +212,7 @@ public sealed class ChatPanelInputTests
}
[Fact]
public void Submit_ClearCommand_DrainsLog_AndDoesNotPublish()
public void Submit_ClearCommand_PublishesTypedClientCommand()
{
var log = new ChatLog();
log.OnSystemMessage("seed line", chatType: 0);
@ -228,8 +227,9 @@ public sealed class ChatPanelInputTests
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Empty(bus.Published);
Assert.Empty(log.Snapshot());
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
Assert.Equal(ClientCommandId.ClearChat, command.Command);
Assert.Single(log.Snapshot());
}
[Fact]

View file

@ -20,6 +20,81 @@ public sealed class RetailClientCommandCatalogTests
Assert.Empty(match.Arguments);
}
[Theory]
[InlineData("/marketplace", ClientCommandId.MarketplaceRecall)]
[InlineData("@MAR", ClientCommandId.MarketplaceRecall)]
[InlineData("/mp", ClientCommandId.MarketplaceRecall)]
[InlineData("/pkarena", ClientCommandId.PkArenaRecall)]
[InlineData("/pka", ClientCommandId.PkArenaRecall)]
[InlineData("/pklarena", ClientCommandId.PkLiteArenaRecall)]
[InlineData("/pla", ClientCommandId.PkLiteArenaRecall)]
[InlineData("/hor", ClientCommandId.HouseRecall)]
[InlineData("/hr", ClientCommandId.HouseRecall)]
[InlineData("/hom", ClientCommandId.MansionRecall)]
[InlineData("/hoa", ClientCommandId.MansionRecall)]
[InlineData("/age", ClientCommandId.QueryAge)]
[InlineData("/birth", ClientCommandId.QueryBirth)]
[InlineData("/framerate", ClientCommandId.ToggleFrameRate)]
[InlineData("/lockui", ClientCommandId.ToggleUiLock)]
[InlineData("/version", ClientCommandId.ShowVersion)]
[InlineData("/loc", ClientCommandId.ShowLocation)]
[InlineData("/corpse", ClientCommandId.ShowLastCorpseLocation)]
[InlineData("/cor", ClientCommandId.ShowLastCorpseLocation)]
public void AdditionalRetailAliases_Resolve(string input, ClientCommandId expected)
{
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
Assert.Equal(expected, match.Command);
Assert.True(match.HasValidArguments);
}
[Theory]
[InlineData("/clear all", ClientCommandId.ClearChat)]
[InlineData("/saveui hunt", ClientCommandId.SaveUi)]
[InlineData("/loadui hunt", ClientCommandId.LoadUi)]
[InlineData("/saveautoui", ClientCommandId.SaveAutoUi)]
[InlineData("/loadautoui", ClientCommandId.LoadAutoUi)]
[InlineData("/afk msg lunch", ClientCommandId.Away)]
[InlineData("/consent who", ClientCommandId.Consent)]
[InlineData("/e waves", ClientCommandId.Emote)]
[InlineData("/em waves", ClientCommandId.Emote)]
[InlineData("/emote waves", ClientCommandId.Emote)]
[InlineData("/me waves", ClientCommandId.Emote)]
[InlineData("/emotes", ClientCommandId.ListEmotes)]
[InlineData("/friends online", ClientCommandId.Friends)]
[InlineData("/friends_add Alice", ClientCommandId.FriendsAdd)]
[InlineData("/friends_remove Alice", ClientCommandId.FriendsRemove)]
[InlineData("/squelch -tell Alice", ClientCommandId.Squelch)]
[InlineData("/unsquelch Alice", ClientCommandId.Unsquelch)]
[InlineData("/filter -combat", ClientCommandId.Filter)]
[InlineData("/unfilter -combat", ClientCommandId.Unfilter)]
[InlineData("/messagetypes", ClientCommandId.ListMessageTypes)]
[InlineData("/fillcomps scarabs 500", ClientCommandId.FillComponents)]
public void CommandFamilies_ResolveToTypedClientCommands(
string input, ClientCommandId expected)
{
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
Assert.Equal(expected, match.Command);
Assert.True(match.HasValidArguments);
}
[Theory]
[InlineData("/house recall", ClientCommandId.HouseRecall)]
[InlineData("@house mansion_recall", ClientCommandId.MansionRecall)]
[InlineData("/house alleg_recall", ClientCommandId.MansionRecall)]
public void HouseRecallSubcommands_Resolve(string input, ClientCommandId expected)
{
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
Assert.Equal(expected, match.Command);
Assert.True(match.HasValidArguments);
}
[Fact]
public void UnsupportedHouseSubcommand_RemainsClientOwnedAndShowsUsage()
{
Assert.True(RetailClientCommandCatalog.TryMatch("/house nope", out var match));
Assert.False(match.HasValidArguments);
}
[Fact]
public void LifestoneArgument_IsRecognizedButInvalid()
{

View file

@ -202,6 +202,7 @@ public sealed class SettingsStoreTests : System.IDisposable
ShowHelm = false,
LockUI = true,
UseMouseTurning = true,
AcceptLootPermits = false,
};
store.SaveGameplay(original);
@ -434,4 +435,19 @@ public sealed class SettingsStoreTests : System.IDisposable
Assert.Equal(hd, store.LoadWindowLayout("Alice", "1600x900", "chat", default));
Assert.Equal(uhd, store.LoadWindowLayout("Alice", "3200x1800", "chat", default));
}
[Fact]
public void NamedWindowLayouts_RoundTripIndependentlyOfCharacterAndResolution()
{
var store = new SettingsStore(_tempPath);
var hunting = new UiWindowLayout(10f, 20f, 500f, 250f, true, false, true);
var crafting = new UiWindowLayout(30f, 40f, 300f, 400f, false, true, false);
store.SaveNamedWindowLayout("hunting", "chat", hunting);
store.SaveNamedWindowLayout("crafting", "chat", crafting);
Assert.Equal(hunting, store.LoadNamedWindowLayout("hunting", "chat", default));
Assert.Equal(crafting, store.LoadNamedWindowLayout("crafting", "chat", default));
Assert.Null(store.LoadNamedWindowLayout("missing", "chat", default));
}
}