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
|
|
@ -36,7 +36,8 @@ public static class ChatCommandRouter
|
|||
{
|
||||
if (!clientCommand.HasValidArguments)
|
||||
{
|
||||
vm.ShowSystemMessage($"Usage: {clientCommand.Usage}");
|
||||
vm.ShowSystemMessage(clientCommand.InvalidArgumentsText
|
||||
?? $"Usage: {clientCommand.Usage}");
|
||||
return SubmitOutcome.ClientHandled;
|
||||
}
|
||||
|
||||
|
|
@ -98,24 +99,6 @@ public static class ChatCommandRouter
|
|||
return true;
|
||||
}
|
||||
|
||||
if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls"))
|
||||
{
|
||||
vm.Clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (EqAny(trimmed, "/framerate", "@framerate"))
|
||||
{
|
||||
vm.ShowFps();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (EqAny(trimmed, "/loc", "@loc"))
|
||||
{
|
||||
vm.ShowLocation();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
|
|||
/// This is intentionally separate from chat aliases and ACE server commands.
|
||||
///
|
||||
/// <para>
|
||||
/// First vertical slice: retail registers <c>lifestone</c>, <c>lif</c>, and
|
||||
/// <c>ls</c> against <c>ClientCommunicationSystem::DoLifestone @ 0x0056FC70</c>.
|
||||
/// See <c>docs/research/2026-07-13-retail-client-command-routing-pseudocode.md</c>.
|
||||
/// Aliases and ownership come from the named-retail command table. Packet
|
||||
/// and local-state behavior is recorded in the command-family pseudocode
|
||||
/// notes under <c>docs/research/</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RetailClientCommandCatalog
|
||||
|
|
@ -18,14 +18,16 @@ public static class RetailClientCommandCatalog
|
|||
ClientCommandId Command,
|
||||
string Usage,
|
||||
string HelpText,
|
||||
Func<string, bool> ValidateArguments);
|
||||
Func<string, bool> ValidateArguments,
|
||||
string? InvalidArgumentsText = null);
|
||||
|
||||
/// <summary>A resolved retail command plus its raw, trimmed argument text.</summary>
|
||||
public readonly record struct Match(
|
||||
ClientCommandId Command,
|
||||
string Arguments,
|
||||
string Usage,
|
||||
bool HasValidArguments);
|
||||
bool HasValidArguments,
|
||||
string? InvalidArgumentsText);
|
||||
|
||||
private static readonly Definition Lifestone = new(
|
||||
ClientCommandId.LifestoneRecall,
|
||||
|
|
@ -33,12 +35,212 @@ public static class RetailClientCommandCatalog
|
|||
HelpText: "/lifestone (/lif, /ls) - Returns you to the last lifestone you used without killing you.",
|
||||
ValidateArguments: static arguments => arguments.Length == 0);
|
||||
|
||||
private static readonly Definition Marketplace = NoArguments(
|
||||
ClientCommandId.MarketplaceRecall,
|
||||
"/marketplace",
|
||||
"/marketplace (/mar, /mp) - Teleports you to the Marketplace of Dereth.");
|
||||
|
||||
private static readonly Definition PkArena = NoArguments(
|
||||
ClientCommandId.PkArenaRecall,
|
||||
"/pkarena",
|
||||
"/pkarena (/pka) - Teleports a Player Killer to the PK Arena.");
|
||||
|
||||
private static readonly Definition PkLiteArena = NoArguments(
|
||||
ClientCommandId.PkLiteArenaRecall,
|
||||
"/pklarena",
|
||||
"/pklarena (/pla) - Teleports a PKLite player to the PKLite Arena.");
|
||||
|
||||
private static readonly Definition HouseRecall = NoArguments(
|
||||
ClientCommandId.HouseRecall,
|
||||
"/house recall",
|
||||
"/house recall (/hor, /hr) - Teleports you to your house.");
|
||||
|
||||
private static readonly Definition MansionRecall = NoArguments(
|
||||
ClientCommandId.MansionRecall,
|
||||
"/house mansion_recall",
|
||||
"/house mansion_recall (/hom, /hoa) - Teleports you to your allegiance mansion.");
|
||||
|
||||
private static readonly Definition QueryAge = NoArguments(
|
||||
ClientCommandId.QueryAge,
|
||||
"/age",
|
||||
"/age - Displays how long your character has been played.");
|
||||
|
||||
private static readonly Definition QueryBirth = NoArguments(
|
||||
ClientCommandId.QueryBirth,
|
||||
"/birth",
|
||||
"/birth - Displays when your character was created.");
|
||||
|
||||
private static readonly Definition FrameRate = NoArguments(
|
||||
ClientCommandId.ToggleFrameRate,
|
||||
"/framerate",
|
||||
"/framerate - Toggles the framerate display.");
|
||||
|
||||
private static readonly Definition LockUi = NoArguments(
|
||||
ClientCommandId.ToggleUiLock,
|
||||
"/lockui",
|
||||
"/lockui - Toggles whether the interface can be moved or resized.");
|
||||
|
||||
private static readonly Definition Version = NoArguments(
|
||||
ClientCommandId.ShowVersion,
|
||||
"/version",
|
||||
"/version - Displays the client version.");
|
||||
|
||||
private static readonly Definition Location = NoArguments(
|
||||
ClientCommandId.ShowLocation,
|
||||
"/loc",
|
||||
"/loc - Displays your current position.");
|
||||
|
||||
private static readonly Definition Corpse = new(
|
||||
ClientCommandId.ShowLastCorpseLocation,
|
||||
Usage: "/corpse",
|
||||
HelpText: "/corpse (/cor) - Displays the location of your last outdoor death.",
|
||||
// DoCorpse @ 0x00578220 ignores its argument count.
|
||||
ValidateArguments: static _ => true);
|
||||
|
||||
private static readonly Definition Die = new(
|
||||
ClientCommandId.Die,
|
||||
Usage: "/die",
|
||||
HelpText: "/die - Kills your character after confirmation.",
|
||||
ValidateArguments: static arguments => arguments.Length == 0,
|
||||
InvalidArgumentsText: "Please see @help die for more information on how to use this command.");
|
||||
|
||||
private static readonly Definition Clear = AnyArguments(
|
||||
ClientCommandId.ClearChat,
|
||||
"/clear [all]",
|
||||
"/clear [all] - Clears the current chat window, or every chat window.");
|
||||
|
||||
private static readonly Definition SaveUi = AnyArguments(
|
||||
ClientCommandId.SaveUi,
|
||||
"/saveui [filename]",
|
||||
"/saveui [filename] - Saves the current interface layout.");
|
||||
|
||||
private static readonly Definition LoadUi = AnyArguments(
|
||||
ClientCommandId.LoadUi,
|
||||
"/loadui [filename]",
|
||||
"/loadui [filename] - Loads a saved interface layout.");
|
||||
|
||||
private static readonly Definition SaveAutoUi = AnyArguments(
|
||||
ClientCommandId.SaveAutoUi,
|
||||
"/saveautoui",
|
||||
"/saveautoui - Saves the automatic character-and-resolution interface layout.");
|
||||
|
||||
private static readonly Definition LoadAutoUi = AnyArguments(
|
||||
ClientCommandId.LoadAutoUi,
|
||||
"/loadautoui",
|
||||
"/loadautoui - Loads the automatic character-and-resolution interface layout.");
|
||||
|
||||
private static readonly Definition Away = AnyArguments(
|
||||
ClientCommandId.Away,
|
||||
"/afk [on|off|msg <message>]",
|
||||
"/afk [on|off|msg <message>] - Sets your away-from-keyboard status.");
|
||||
|
||||
private static readonly Definition Consent = AnyArguments(
|
||||
ClientCommandId.Consent,
|
||||
"/consent <on|off|who|clear|remove <name>>",
|
||||
"/consent - Manages corpse-looting consent.");
|
||||
|
||||
private static readonly Definition Emote = AnyArguments(
|
||||
ClientCommandId.Emote,
|
||||
"/emote <text>",
|
||||
"/emote (/e, /em, /me) - Performs a text emote.");
|
||||
|
||||
private static readonly Definition Emotes = NoArguments(
|
||||
ClientCommandId.ListEmotes,
|
||||
"/emotes",
|
||||
"/emotes - Lists all standard emotes.");
|
||||
|
||||
private static readonly Definition Friends = AnyArguments(
|
||||
ClientCommandId.Friends,
|
||||
"/friends [add|remove|online|old]",
|
||||
"/friends - Helps you manage your friends list.");
|
||||
|
||||
private static readonly Definition FriendsAdd = AnyArguments(
|
||||
ClientCommandId.FriendsAdd,
|
||||
"/friends_add <name>",
|
||||
"/friends_add <name> - Adds a character to your friends list.");
|
||||
|
||||
private static readonly Definition FriendsRemove = AnyArguments(
|
||||
ClientCommandId.FriendsRemove,
|
||||
"/friends_remove <name|-all>",
|
||||
"/friends_remove <name|-all> - Removes friends from your list.");
|
||||
|
||||
private static readonly Definition Squelch = AnyArguments(
|
||||
ClientCommandId.Squelch,
|
||||
"/squelch [options] <name>",
|
||||
"/squelch - Ignores messages from a player or account.");
|
||||
|
||||
private static readonly Definition Unsquelch = AnyArguments(
|
||||
ClientCommandId.Unsquelch,
|
||||
"/unsquelch [options] <name>",
|
||||
"/unsquelch - Stops ignoring messages from a player or account.");
|
||||
|
||||
private static readonly Definition Filter = AnyArguments(
|
||||
ClientCommandId.Filter,
|
||||
"/filter -<message type>",
|
||||
"/filter -<message type> - Globally hides a message category.");
|
||||
|
||||
private static readonly Definition Unfilter = AnyArguments(
|
||||
ClientCommandId.Unfilter,
|
||||
"/unfilter -<message type>",
|
||||
"/unfilter -<message type> - Shows a globally hidden message category.");
|
||||
|
||||
private static readonly Definition MessageTypes = NoArguments(
|
||||
ClientCommandId.ListMessageTypes,
|
||||
"/messagetypes",
|
||||
"/messagetypes - Lists valid filter and squelch message types.");
|
||||
|
||||
private static readonly Definition FillComponents = AnyArguments(
|
||||
ClientCommandId.FillComponents,
|
||||
"/fillcomps [component type] [pyreal value]",
|
||||
"/fillcomps - Helps you buy components in bulk.");
|
||||
|
||||
private static readonly FrozenDictionary<string, Definition> ByVerb =
|
||||
new Dictionary<string, Definition>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["lifestone"] = Lifestone,
|
||||
["lif"] = Lifestone,
|
||||
["ls"] = Lifestone,
|
||||
["marketplace"] = Marketplace,
|
||||
["mar"] = Marketplace,
|
||||
["mp"] = Marketplace,
|
||||
["pkarena"] = PkArena,
|
||||
["pka"] = PkArena,
|
||||
["pklarena"] = PkLiteArena,
|
||||
["pla"] = PkLiteArena,
|
||||
["hor"] = HouseRecall,
|
||||
["hr"] = HouseRecall,
|
||||
["hom"] = MansionRecall,
|
||||
["hoa"] = MansionRecall,
|
||||
["age"] = QueryAge,
|
||||
["birth"] = QueryBirth,
|
||||
["framerate"] = FrameRate,
|
||||
["lockui"] = LockUi,
|
||||
["version"] = Version,
|
||||
["loc"] = Location,
|
||||
["corpse"] = Corpse,
|
||||
["cor"] = Corpse,
|
||||
["die"] = Die,
|
||||
["clear"] = Clear,
|
||||
["saveui"] = SaveUi,
|
||||
["loadui"] = LoadUi,
|
||||
["saveautoui"] = SaveAutoUi,
|
||||
["loadautoui"] = LoadAutoUi,
|
||||
["afk"] = Away,
|
||||
["consent"] = Consent,
|
||||
["e"] = Emote,
|
||||
["em"] = Emote,
|
||||
["emote"] = Emote,
|
||||
["me"] = Emote,
|
||||
["emotes"] = Emotes,
|
||||
["friends"] = Friends,
|
||||
["friends_add"] = FriendsAdd,
|
||||
["friends_remove"] = FriendsRemove,
|
||||
["squelch"] = Squelch,
|
||||
["unsquelch"] = Unsquelch,
|
||||
["filter"] = Filter,
|
||||
["unfilter"] = Unfilter,
|
||||
["messagetypes"] = MessageTypes,
|
||||
["fillcomps"] = FillComponents,
|
||||
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -60,22 +262,88 @@ public static class RetailClientCommandCatalog
|
|||
string verb = separator < 0
|
||||
? trimmed[1..]
|
||||
: trimmed.Substring(1, separator - 1);
|
||||
if (!ByVerb.TryGetValue(verb, out Definition? definition))
|
||||
return false;
|
||||
|
||||
string arguments = separator < 0
|
||||
? string.Empty
|
||||
: trimmed[(separator + 1)..].Trim();
|
||||
|
||||
Definition? definition;
|
||||
if (verb.Equals("house", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
definition = arguments.ToLowerInvariant() switch
|
||||
{
|
||||
"recall" => HouseRecall,
|
||||
"mansion_recall" or "alleg_recall" => MansionRecall,
|
||||
_ => null,
|
||||
};
|
||||
if (definition is null)
|
||||
{
|
||||
match = new Match(
|
||||
ClientCommandId.HouseRecall,
|
||||
arguments,
|
||||
"/house recall | /house mansion_recall",
|
||||
HasValidArguments: false,
|
||||
InvalidArgumentsText: null);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The subcommand selected the operation; its own handler has no
|
||||
// additional arguments.
|
||||
arguments = string.Empty;
|
||||
}
|
||||
else if (!ByVerb.TryGetValue(verb, out definition))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
match = new Match(
|
||||
definition.Command,
|
||||
arguments,
|
||||
definition.Usage,
|
||||
definition.ValidateArguments(arguments));
|
||||
definition.ValidateArguments(arguments),
|
||||
definition.InvalidArgumentsText);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Help line generated from the same definition routing uses.</summary>
|
||||
public static string BuildHelpText() => Lifestone.HelpText;
|
||||
public static string BuildHelpText() => string.Join("\n ",
|
||||
Lifestone.HelpText,
|
||||
Marketplace.HelpText,
|
||||
PkArena.HelpText,
|
||||
PkLiteArena.HelpText,
|
||||
HouseRecall.HelpText,
|
||||
MansionRecall.HelpText,
|
||||
QueryAge.HelpText,
|
||||
QueryBirth.HelpText,
|
||||
FrameRate.HelpText,
|
||||
LockUi.HelpText,
|
||||
Version.HelpText,
|
||||
Location.HelpText,
|
||||
Corpse.HelpText,
|
||||
Die.HelpText,
|
||||
Clear.HelpText,
|
||||
SaveUi.HelpText,
|
||||
LoadUi.HelpText,
|
||||
SaveAutoUi.HelpText,
|
||||
LoadAutoUi.HelpText,
|
||||
Away.HelpText,
|
||||
Consent.HelpText,
|
||||
Emote.HelpText,
|
||||
Emotes.HelpText,
|
||||
Friends.HelpText,
|
||||
Squelch.HelpText,
|
||||
Unsquelch.HelpText,
|
||||
Filter.HelpText,
|
||||
Unfilter.HelpText,
|
||||
MessageTypes.HelpText,
|
||||
FillComponents.HelpText);
|
||||
|
||||
private static Definition NoArguments(
|
||||
ClientCommandId command, string usage, string helpText) =>
|
||||
new(command, usage, helpText, static arguments => arguments.Length == 0);
|
||||
|
||||
private static Definition AnyArguments(
|
||||
ClientCommandId command, string usage, string helpText) =>
|
||||
new(command, usage, helpText, static _ => true);
|
||||
|
||||
private static int IndexOfWhitespace(string value)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ public sealed record GameplaySettings(
|
|||
bool ShowHelm, // 0x100000 — render helm overlay on character
|
||||
bool ShowCloak, // 0x800000 — render cloak on character
|
||||
bool LockUI, // 0x1000000 — disable panel drag/resize
|
||||
bool UseMouseTurning) // 0x400000 — turn character when right-mouse drags
|
||||
bool UseMouseTurning, // 0x400000 — turn character when right-mouse drags
|
||||
bool AcceptLootPermits = false) // 0x80000 — accept corpse-looting permissions
|
||||
{
|
||||
/// <summary>Sensible starting values for first launch. NOT bit-exact
|
||||
/// to retail's <c>Default_CharacterOption = 0x50C4A54A</c> +
|
||||
|
|
@ -61,5 +62,7 @@ public sealed record GameplaySettings(
|
|||
ShowHelm: true,
|
||||
ShowCloak: true,
|
||||
LockUI: false,
|
||||
UseMouseTurning: false);
|
||||
UseMouseTurning: false,
|
||||
// Default_CharacterOption 0x50C4A54A leaves bit 0x80000 clear.
|
||||
AcceptLootPermits: false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,7 +162,8 @@ public sealed class SettingsStore
|
|||
ShowHelm: ReadBool(gp, "showHelm", d.ShowHelm),
|
||||
ShowCloak: ReadBool(gp, "showCloak", d.ShowCloak),
|
||||
LockUI: ReadBool(gp, "lockUI", d.LockUI),
|
||||
UseMouseTurning: ReadBool(gp, "useMouseTurning", d.UseMouseTurning));
|
||||
UseMouseTurning: ReadBool(gp, "useMouseTurning", d.UseMouseTurning),
|
||||
AcceptLootPermits: ReadBool(gp, "acceptLootPermits", d.AcceptLootPermits));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -419,6 +420,70 @@ public sealed class SettingsStore
|
|||
WriteMutableRoot(root);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load one window from a user-named retail <c>@saveui</c> profile.
|
||||
/// Named profiles are intentionally independent of character and screen
|
||||
/// resolution, matching retail's portable UI files.
|
||||
/// </summary>
|
||||
public UiWindowLayout? LoadNamedWindowLayout(
|
||||
string profileName,
|
||||
string windowName,
|
||||
UiWindowLayout fallback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profileName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
|
||||
if (!File.Exists(_path)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject;
|
||||
JsonObject? node = root?["namedWindowLayouts"]?[profileName]?[windowName] as JsonObject;
|
||||
return node is null
|
||||
? null
|
||||
: new UiWindowLayout(
|
||||
ReadNodeFloat(node, "x", fallback.X),
|
||||
ReadNodeFloat(node, "y", fallback.Y),
|
||||
ReadNodeFloat(node, "width", fallback.Width),
|
||||
ReadNodeFloat(node, "height", fallback.Height),
|
||||
ReadNodeBool(node, "visible", fallback.Visible),
|
||||
ReadNodeBool(node, "collapsed", fallback.Collapsed),
|
||||
ReadNodeBool(node, "maximized", fallback.Maximized));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: failed to load named window layout from {_path}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Save one window to a user-named retail <c>@saveui</c> profile.</summary>
|
||||
public void SaveNamedWindowLayout(
|
||||
string profileName,
|
||||
string windowName,
|
||||
UiWindowLayout layout)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(profileName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
|
||||
|
||||
JsonObject root = LoadMutableRoot();
|
||||
JsonObject profiles = GetOrCreateObject(root, "namedWindowLayouts");
|
||||
JsonObject profile = GetOrCreateObject(profiles, profileName);
|
||||
profile[windowName] = SerializeWindowLayout(layout);
|
||||
root["version"] = CurrentSchemaVersion;
|
||||
WriteMutableRoot(root);
|
||||
}
|
||||
|
||||
private static JsonObject SerializeWindowLayout(UiWindowLayout layout) => new()
|
||||
{
|
||||
["x"] = layout.X,
|
||||
["y"] = layout.Y,
|
||||
["width"] = layout.Width,
|
||||
["height"] = layout.Height,
|
||||
["visible"] = layout.Visible,
|
||||
["collapsed"] = layout.Collapsed,
|
||||
["maximized"] = layout.Maximized,
|
||||
};
|
||||
|
||||
private JsonObject LoadMutableRoot()
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_path);
|
||||
|
|
@ -481,6 +546,7 @@ public sealed class SettingsStore
|
|||
=> new(StringComparer.Ordinal)
|
||||
{
|
||||
["advancedCombatUI"] = g.AdvancedCombatUI,
|
||||
["acceptLootPermits"] = g.AcceptLootPermits,
|
||||
["allowGive"] = g.AllowGive,
|
||||
["autoRepeatAttack"] = g.AutoRepeatAttack,
|
||||
["autoTarget"] = g.AutoTarget,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue