using AcDream.Core.Physics;
using AcDream.Core.Ui;
using AcDream.Core.Social;
using AcDream.UI.Abstractions;
namespace AcDream.App.UI;
///
/// Application-layer executor for typed retail client commands. Chat panels
/// remain backend- and network-agnostic; this controller owns the boundary
/// between verified command behavior and live session/UI services.
///
public sealed class ClientCommandController
{
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 ShowSystemMessage,
Action ShowWeenieError,
Func PlayerPublicWeenieBitfield,
Func ClientVersion,
Func CurrentPosition,
Func LastOutsideCorpsePosition,
Action> ShowConfirmation,
Action Suicide,
Action ClearChat,
Action SaveUi,
Action LoadUi,
Action SaveAutoUi,
Action LoadAutoUi,
Func IsAway,
Action SetAway,
Action SetAwayMessage,
Func AcceptLootPermits,
Action SetAcceptLootPermits,
Action DisplayConsent,
Action ClearConsent,
Action RemoveConsent,
Action SendEmote,
FriendsState Friends,
Action AddFriend,
Action RemoveFriend,
Action ClearFriends,
Action RequestLegacyFriends,
SquelchState Squelch,
Action ModifyCharacterSquelch,
Action ModifyAccountSquelch,
Action ModifyGlobalSquelch,
Func LastTeller,
Action ClearDesiredComponents,
Func HasOpenVendor,
Action FillComponentBuyList);
private readonly Bindings _bindings;
public ClientCommandController(Bindings bindings)
{
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
}
public void Execute(ExecuteClientCommandCmd command)
{
ArgumentNullException.ThrowIfNull(command);
switch (command.Command)
{
// ClientCommunicationSystem::DoLifestone @ 0x0056FC70.
case ClientCommandId.LifestoneRecall:
_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 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 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 MessageTypes =
new Dictionary
{
[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 - 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";
///
/// 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.
///
private bool? HasPlayerFlag(EntityCollisionFlags flag)
{
uint? bitfield = _bindings.PlayerPublicWeenieBitfield();
return bitfield is null
? null
: (EntityCollisionFlagsExt.FromPwdBitfield(bitfield.Value) & flag) != 0;
}
}