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,20 +1,69 @@
using AcDream.Core.Physics;
using AcDream.Core.Ui;
using AcDream.Core.Social;
using AcDream.UI.Abstractions;
namespace AcDream.App.UI;
/// <summary>
/// Application-layer executor for typed retail client commands. Chat panels
/// remain backend- and network-agnostic; this controller translates their
/// intent to the live-session action supplied by the composition root.
/// remain backend- and network-agnostic; this controller owns the boundary
/// between verified command behavior and live session/UI services.
/// </summary>
public sealed class ClientCommandController
{
private readonly Action _teleportToLifestone;
public sealed record Bindings(
Action TeleportToLifestone,
Action TeleportToMarketplace,
Action TeleportToPkArena,
Action TeleportToPkLiteArena,
Action TeleportToHouse,
Action TeleportToMansion,
Action QueryAge,
Action QueryBirth,
Action ToggleFrameRate,
Action ToggleUiLock,
Action<string> ShowSystemMessage,
Action<uint> ShowWeenieError,
Func<uint?> PlayerPublicWeenieBitfield,
Func<string> ClientVersion,
Func<Position?> CurrentPosition,
Func<Position?> LastOutsideCorpsePosition,
Action<string, Action<bool>> ShowConfirmation,
Action Suicide,
Action<bool> ClearChat,
Action<string> SaveUi,
Action<string> LoadUi,
Action SaveAutoUi,
Action LoadAutoUi,
Func<bool> IsAway,
Action<bool> SetAway,
Action<string> SetAwayMessage,
Func<bool> AcceptLootPermits,
Action<bool> SetAcceptLootPermits,
Action DisplayConsent,
Action ClearConsent,
Action<string> RemoveConsent,
Action<string> SendEmote,
FriendsState Friends,
Action<string> AddFriend,
Action<uint> RemoveFriend,
Action ClearFriends,
Action RequestLegacyFriends,
SquelchState Squelch,
Action<bool, uint, string, uint> ModifyCharacterSquelch,
Action<bool, string> ModifyAccountSquelch,
Action<bool, uint> ModifyGlobalSquelch,
Func<string?> LastTeller,
Action ClearDesiredComponents,
Func<bool> HasOpenVendor,
Action<uint?, uint> FillComponentBuyList);
public ClientCommandController(Action teleportToLifestone)
private readonly Bindings _bindings;
public ClientCommandController(Bindings bindings)
{
_teleportToLifestone = teleportToLifestone
?? throw new ArgumentNullException(nameof(teleportToLifestone));
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
}
public void Execute(ExecuteClientCommandCmd command)
@ -23,14 +72,595 @@ public sealed class ClientCommandController
switch (command.Command)
{
// Retail: ClientCommunicationSystem::DoLifestone @ 0x0056FC70
// -> CM_Character::Event_TeleToLifestone @ 0x006A1B90.
// ClientCommunicationSystem::DoLifestone @ 0x0056FC70.
case ClientCommandId.LifestoneRecall:
_teleportToLifestone();
_bindings.TeleportToLifestone();
break;
// ClientCommunicationSystem::DoMarketplace @ 0x0056FCE0.
case ClientCommandId.MarketplaceRecall:
_bindings.TeleportToMarketplace();
break;
// DoPKArena @ 0x005788D0 gates on ACCWeenieObject::IsPK.
case ClientCommandId.PkArenaRecall:
if (HasPlayerFlag(EntityCollisionFlags.IsPK) == false)
_bindings.ShowWeenieError(0x055Fu);
else
_bindings.TeleportToPkArena();
break;
// DoPKLArena @ 0x005789D0 gates on ACCWeenieObject::IsPKLite.
case ClientCommandId.PkLiteArenaRecall:
if (HasPlayerFlag(EntityCollisionFlags.IsPKLite) == false)
_bindings.ShowWeenieError(0x0560u);
else
_bindings.TeleportToPkLiteArena();
break;
case ClientCommandId.HouseRecall:
_bindings.TeleportToHouse();
break;
case ClientCommandId.MansionRecall:
_bindings.TeleportToMansion();
break;
case ClientCommandId.QueryAge:
_bindings.QueryAge();
break;
case ClientCommandId.QueryBirth:
_bindings.QueryBirth();
break;
// DoFrameRate @ 0x005707D0 toggles the display flag; it does
// not print a one-shot FPS sample into chat.
case ClientCommandId.ToggleFrameRate:
_bindings.ToggleFrameRate();
break;
// DoLockUI @ 0x005703B0 toggles PlayerModule::LockUI and
// broadcasts the new state to every UI element.
case ClientCommandId.ToggleUiLock:
_bindings.ToggleUiLock();
break;
case ClientCommandId.ShowVersion:
_bindings.ShowSystemMessage($"Client version {_bindings.ClientVersion()}");
break;
case ClientCommandId.ShowLocation:
Position? position = _bindings.CurrentPosition();
_bindings.ShowSystemMessage(position is { ObjCellId: not 0u }
? $"Your location is: {RetailPositionFormatter.Format(position.Value)}"
: "Not in valid cell!");
break;
case ClientCommandId.ShowLastCorpseLocation:
Position? corpse = _bindings.LastOutsideCorpsePosition();
string? coordinates = corpse is null
? null
: RetailPositionFormatter.FormatOutdoorCell(corpse.Value.ObjCellId);
_bindings.ShowSystemMessage(coordinates is null
? "We're sorry, but we have no record of your last outside corpse location."
: $"The last time you died outside, your corpse was located at ({coordinates}).");
break;
// ClientCommunicationSystem::DoDie @ 0x00580050 and
// DieDialogCallback @ 0x0057BA70.
case ClientCommandId.Die:
_bindings.ShowConfirmation(
"Do you really want to kill your character? You may drop items and accrue a vitae penalty.",
accepted =>
{
if (accepted)
_bindings.Suicide();
});
break;
case ClientCommandId.ClearChat:
_bindings.ClearChat(FirstArgument(command.Arguments)
.Equals("all", StringComparison.OrdinalIgnoreCase));
break;
case ClientCommandId.SaveUi:
ExecuteUiProfile(command.Arguments, save: true);
break;
case ClientCommandId.LoadUi:
ExecuteUiProfile(command.Arguments, save: false);
break;
case ClientCommandId.SaveAutoUi:
if (RequireNoArguments(command.Arguments, "/saveautoui"))
_bindings.SaveAutoUi();
break;
case ClientCommandId.LoadAutoUi:
if (RequireNoArguments(command.Arguments, "/loadautoui"))
_bindings.LoadAutoUi();
break;
case ClientCommandId.Away:
ExecuteAway(command.Arguments);
break;
case ClientCommandId.Consent:
ExecuteConsent(command.Arguments);
break;
case ClientCommandId.Emote:
if (!string.IsNullOrWhiteSpace(command.Arguments))
_bindings.SendEmote(command.Arguments.Trim());
break;
case ClientCommandId.ListEmotes:
_bindings.ShowSystemMessage(StandardEmotes);
break;
case ClientCommandId.Friends:
ExecuteFriends(command.Arguments);
break;
case ClientCommandId.FriendsAdd:
AddFriend(command.Arguments);
break;
case ClientCommandId.FriendsRemove:
RemoveFriend(command.Arguments);
break;
case ClientCommandId.Squelch:
ExecuteSquelch(command.Arguments, add: true);
break;
case ClientCommandId.Unsquelch:
ExecuteSquelch(command.Arguments, add: false);
break;
case ClientCommandId.Filter:
ExecuteGlobalFilter(command.Arguments, add: true);
break;
case ClientCommandId.Unfilter:
ExecuteGlobalFilter(command.Arguments, add: false);
break;
case ClientCommandId.ListMessageTypes:
_bindings.ShowSystemMessage(
"Squelch channels are as follows:\n "
+ string.Join(", ", MessageTypes.Values));
break;
case ClientCommandId.FillComponents:
ExecuteFillComponents(command.Arguments);
break;
default:
throw new ArgumentOutOfRangeException(
nameof(command), command.Command, "Unknown retail client command.");
}
}
private void ExecuteUiProfile(string arguments, bool save)
{
string[] parts = SplitArguments(arguments);
string command = save ? "saveui" : "loadui";
if (parts.Length > 1)
{
_bindings.ShowSystemMessage($"Please use @help {command} for proper usage.");
return;
}
string name = parts.Length == 0 ? string.Empty : parts[0];
if (name.Length > 16)
{
_bindings.ShowSystemMessage("The file name must be 16 characters or less.");
return;
}
if (save) _bindings.SaveUi(name);
else _bindings.LoadUi(name);
}
private bool RequireNoArguments(string arguments, string usage)
{
if (string.IsNullOrWhiteSpace(arguments)) return true;
_bindings.ShowSystemMessage($"Usage: {usage}");
return false;
}
private void ExecuteAway(string arguments)
{
string first = FirstArgument(arguments);
if (first.Length == 0 || first.Equals("on", StringComparison.OrdinalIgnoreCase))
{
if (!_bindings.IsAway()) _bindings.SetAway(true);
return;
}
if (first.Equals("off", StringComparison.OrdinalIgnoreCase))
{
if (_bindings.IsAway()) _bindings.SetAway(false);
return;
}
if (first.Equals("msg", StringComparison.OrdinalIgnoreCase))
{
string message = RemainderAfterFirstArgument(arguments).Trim(' ');
if (message.Length > 191) message = message[..191];
if (message.Length > 0 && !message.Contains('\n')) message += "\n";
_bindings.SetAwayMessage(message);
_bindings.ShowSystemMessage(message.Length == 0
? "New AFK message set: I am currently away from the keyboard."
: $"New AFK message set: {message}");
return;
}
_bindings.ShowSystemMessage(AwayHelp);
}
private void ExecuteConsent(string arguments)
{
string first = FirstArgument(arguments);
if (first.Equals("on", StringComparison.OrdinalIgnoreCase))
{
if (!_bindings.AcceptLootPermits()) _bindings.SetAcceptLootPermits(true);
_bindings.ShowSystemMessage(
"You can now accept corpse looting permissions from other players.");
}
else if (first.Equals("off", StringComparison.OrdinalIgnoreCase))
{
if (_bindings.AcceptLootPermits()) _bindings.SetAcceptLootPermits(false);
_bindings.ShowSystemMessage(
"You are no longer accepting corpse looting permissions from other players.");
}
else if (first.Equals("who", StringComparison.OrdinalIgnoreCase))
{
_bindings.DisplayConsent();
}
else if (first.Equals("clear", StringComparison.OrdinalIgnoreCase))
{
_bindings.ClearConsent();
}
else if (first.Equals("remove", StringComparison.OrdinalIgnoreCase))
{
string name = RemainderAfterFirstArgument(arguments).Trim();
if (name.Length == 0)
_bindings.ShowSystemMessage(
"Please specify a person to remove from your consent list.");
else
_bindings.RemoveConsent(name);
}
else
{
_bindings.ShowSystemMessage("Please specify a valid consent command.");
}
}
private void ExecuteFriends(string arguments)
{
string operation = FirstArgument(arguments);
if (operation.Length == 0)
{
DisplayFriends(onlineOnly: false);
return;
}
if (operation.Equals("online", StringComparison.OrdinalIgnoreCase))
{
DisplayFriends(onlineOnly: true);
return;
}
string remainder = RemainderAfterFirstArgument(arguments);
if (operation.Equals("add", StringComparison.OrdinalIgnoreCase))
AddFriend(remainder);
else if (operation.Equals("remove", StringComparison.OrdinalIgnoreCase))
RemoveFriend(remainder);
else if (operation.Equals("old", StringComparison.OrdinalIgnoreCase)
&& string.IsNullOrWhiteSpace(remainder))
_bindings.RequestLegacyFriends();
else
_bindings.ShowSystemMessage("Invalid friends command specified.");
}
private void AddFriend(string arguments)
{
string name = arguments.Trim();
if (name.Length == 0)
{
_bindings.ShowSystemMessage(
"You must specify the name of the friend you wish to add.");
return;
}
if (_bindings.Friends.Snapshot().Count >= 50)
{
_bindings.ShowWeenieError(0x0561u);
return;
}
_bindings.AddFriend(name);
}
private void RemoveFriend(string arguments)
{
string name = arguments.Trim();
if (name.Length == 0)
{
_bindings.ShowSystemMessage(
"You must specify the name of the friend you wish to remove.");
return;
}
if (name.Equals("-all", StringComparison.OrdinalIgnoreCase))
{
_bindings.ClearFriends();
_bindings.Friends.Clear();
_bindings.ShowSystemMessage("Your friends list has been cleared.\n");
return;
}
FriendEntry? friend = _bindings.Friends.Snapshot().FirstOrDefault(
entry => entry.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (friend is null)
_bindings.ShowWeenieError(0x0563u);
else
_bindings.RemoveFriend(friend.Id);
}
private void DisplayFriends(bool onlineOnly)
{
IReadOnlyList<FriendEntry> entries = _bindings.Friends.Snapshot();
if (entries.Count == 0)
{
_bindings.ShowSystemMessage("Your friends list is empty!\n");
return;
}
var lines = entries
.Where(entry => !onlineOnly || entry.Online)
.Select(entry => $" {entry.Name}{(entry.Online ? " (Online)" : string.Empty)}")
.ToArray();
_bindings.ShowSystemMessage(lines.Length == 0
? "Your friends:\n You have no friends that are online.\n"
: "Your friends:\n" + string.Join("\n", lines) + "\n");
}
private void ExecuteSquelch(string arguments, bool add)
{
if (string.IsNullOrWhiteSpace(arguments))
{
DisplayCharacterSquelches();
return;
}
if (!TryParseSquelch(arguments, out SquelchArguments parsed, out string error))
{
_bindings.ShowSystemMessage(error);
return;
}
if (parsed.AccountWide)
_bindings.ModifyAccountSquelch(add, parsed.Name);
else
_bindings.ModifyCharacterSquelch(add, 0u, parsed.Name, parsed.MessageType);
}
private void ExecuteGlobalFilter(string arguments, bool add)
{
if (string.IsNullOrWhiteSpace(arguments))
{
DisplayGlobalFilters();
return;
}
string[] parts = SplitArguments(arguments);
if (parts.Length != 1 || !parts[0].StartsWith('-'))
{
_bindings.ShowSystemMessage("Incorrect usage, use @help for proper usage.");
return;
}
if (!TryGetMessageType(parts[0][1..], out uint type) || type == 1u)
{
_bindings.ShowSystemMessage("You must specify a valid message type.");
return;
}
_bindings.ModifyGlobalSquelch(add, type);
}
private bool TryParseSquelch(
string arguments,
out SquelchArguments parsed,
out string error)
{
parsed = default;
error = string.Empty;
string[] parts = SplitArguments(arguments);
bool account = false;
uint messageType = 1u;
string? replyName = null;
int index = 0;
for (; index < parts.Length && parts[index].StartsWith('-'); index++)
{
string option = parts[index][1..];
if (option.Equals("account", StringComparison.OrdinalIgnoreCase))
account = true;
else if (option.Equals("reply", StringComparison.OrdinalIgnoreCase))
{
replyName = _bindings.LastTeller();
if (string.IsNullOrWhiteSpace(replyName))
{
error = "A player must @tell you before you can use the -reply option.";
return false;
}
}
else if (!TryGetMessageType(option, out messageType))
{
error = $"\"{option}\" is not a valid squelch category.";
return false;
}
}
string name = replyName ?? string.Join(' ', parts.Skip(index));
if (name.Length == 0)
{
error = "You have not specified a squelch target.";
return false;
}
parsed = new SquelchArguments(account, messageType, name);
return true;
}
private void DisplayCharacterSquelches()
{
SquelchDatabase database = _bindings.Squelch.Snapshot();
var lines = database.Characters.Values
.Where(info => info.MessageTypes.Count > 0)
.Select(FormatSquelchInfo)
.ToArray();
_bindings.ShowSystemMessage(
"(account) denotes a character whose account has also been squelched.\n"
+ "Format: Name : List of squelched message types.\n--------\n"
+ (lines.Length == 0 ? "none\n" : string.Join("\n", lines) + "\n"));
}
private void DisplayGlobalFilters()
{
SquelchInfo global = _bindings.Squelch.Snapshot().Global;
string list = global.MessageTypes.Count == 0
? "none"
: FormatMessageTypes(global.MessageTypes);
_bindings.ShowSystemMessage(
"The following types of messages are currently being filtered globally:\n"
+ list + "\n(For a list of filter options, type @help filter)\n");
}
private static string FormatSquelchInfo(SquelchInfo info) =>
$"Name: {info.Name}{(info.AccountWide ? " (account) " : " ")}"
+ FormatMessageTypes(info.MessageTypes);
private static string FormatMessageTypes(IReadOnlySet<uint> types)
{
if (types.Contains(1u)) return "All message types";
return string.Join(", ", MessageTypes
.Where(pair => pair.Key != 1u && types.Contains(pair.Key))
.Select(pair => pair.Value));
}
private void ExecuteFillComponents(string arguments)
{
string[] parts = SplitArguments(arguments);
if (parts.Length > 2)
{
_bindings.ShowSystemMessage("Please use @help fillcomps for proper usage.");
return;
}
if (parts.Length > 0 && parts[0].Equals("clear", StringComparison.OrdinalIgnoreCase))
{
_bindings.ClearDesiredComponents();
_bindings.ShowSystemMessage("Component list cleared.");
return;
}
uint? category = null;
uint maximumPrice = 0u;
foreach (string part in parts)
{
if (uint.TryParse(part, out uint price))
{
if (price == 0)
{
_bindings.ShowSystemMessage("Please specify a value greater than zero.");
return;
}
maximumPrice = price;
}
else if (TryGetComponentCategory(part, out uint parsedCategory))
{
category = parsedCategory;
}
else
{
_bindings.ShowSystemMessage("Invalid component type specified.");
return;
}
}
if (!_bindings.HasOpenVendor())
{
_bindings.ShowSystemMessage("You need an open vendor.");
return;
}
_bindings.FillComponentBuyList(category, maximumPrice);
}
private static bool TryGetComponentCategory(string value, out uint category)
{
category = value.ToLowerInvariant() switch
{
"scarab" or "scarabs" => 0u,
"herb" or "herbs" => 1u,
"powderedgem" or "powderedgems" or "powder" or "powders" => 2u,
"alchemicalsubstance" or "alchemicalsubstances" or "potion" or "potions" => 3u,
"talisman" or "talismans" => 4u,
"taper" or "tapers" => 5u,
"pea" or "peas" => 6u,
_ => uint.MaxValue,
};
return category != uint.MaxValue;
}
private static bool TryGetMessageType(string value, out uint type)
{
foreach ((uint key, string name) in MessageTypes)
{
if (name.Equals(value, StringComparison.OrdinalIgnoreCase))
{
type = key;
return true;
}
}
type = 0u;
return false;
}
private static string FirstArgument(string arguments)
{
string trimmed = arguments.Trim();
int separator = trimmed.IndexOfAny([' ', '\t', '\r', '\n']);
return separator < 0 ? trimmed : trimmed[..separator];
}
private static string RemainderAfterFirstArgument(string arguments)
{
string trimmed = arguments.Trim();
int separator = trimmed.IndexOfAny([' ', '\t', '\r', '\n']);
return separator < 0 ? string.Empty : trimmed[(separator + 1)..].TrimStart();
}
private static string[] SplitArguments(string arguments) =>
arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
private readonly record struct SquelchArguments(
bool AccountWide, uint MessageType, string Name);
private static readonly IReadOnlyDictionary<uint, string> MessageTypes =
new Dictionary<uint, string>
{
[1] = "All",
[2] = "Speech",
[3] = "Tell",
[6] = "Combat",
[7] = "Magic",
[12] = "Emote",
[16] = "Appraisal",
[17] = "Spellcasting",
[18] = "Allegiance",
[19] = "Fellowship",
[21] = "Combat_Enemy",
[22] = "Combat_Self",
[23] = "Recall",
[24] = "Craft",
[25] = "Salvaging",
};
private const string StandardEmotes =
"Standard Emotes:\n"
+ "Note: These commands should be bound on either side by asterisks. (Example: *wave*)\n"
+ "ShakeFist; Beckon; BeSeeingYou; BlowKiss; BowDeep; ClapHands; Cry; Laugh; Nod; Point; Shrug; Wave; Akimbo; HeartyLaugh; Salute; TapFoot; WaveHigh; WaveLow; Yawn; Stretch; Cringe; Kneel; Plead; Shiver; Shoo; Slouch; Spit; Surrender; Woah; Winded; YMCA; Eat; Drink; Teapot; Pray; Mock; Cheer; Helper; Warm Hands; Scratch Head; Shake Head\n\n";
private const string AwayHelp =
"@afk - Turns on AFK (away-from-keyboard) mode. When set to AFK, other players that send you directed chatyou will receive a customizable message that your are not currently at the keyboard.\n"
+ "@afk on - Turns on AFK mode. When set to AFK, other players that send you directed chatyou will receive a customizable message that your are not currently at the keyboard.\n"
+ "@afk off - Turn off AFK mode.\n"
+ "@afk msg <message> - Set the message that will be sent to players that send you directed chat while you are in AFK mode. Issuing \"@afk msg\" with no message will set your AFK message back to the default. Your custom AFK message is limited to 192 characters.\n";
/// <summary>
/// Null means the local PublicWeenieDesc has not arrived yet. Retail only
/// rejects when it has a player object and the required bit is absent, so
/// unknown follows the same send-and-let-the-server-decide path.
/// </summary>
private bool? HasPlayerFlag(EntityCollisionFlags flag)
{
uint? bitfield = _bindings.PlayerPublicWeenieBitfield();
return bitfield is null
? null
: (EntityCollisionFlagsExt.FromPwdBitfield(bitfield.Value) & flag) != 0;
}
}