feat(runtime): share chat commands and run login sequence
This commit is contained in:
parent
5535d0adac
commit
41b15efd4d
57 changed files with 1739 additions and 345 deletions
51
src/AcDream.Runtime/Chat/ChannelResolver.cs
Normal file
51
src/AcDream.Runtime/Chat/ChannelResolver.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="ChatChannelKind"/> to the legacy <c>ChatChannel</c>
|
||||
/// bitflag id used by the 0x0147 ChatChannel GameAction. Ported from
|
||||
/// holtburger's <c>resolve_legacy_channel</c>
|
||||
/// (<c>references/holtburger/crates/holtburger-core/src/client/commands.rs</c>
|
||||
/// lines 50-62) cross-referenced against the <c>ChatChannel</c> enum
|
||||
/// (<c>references/holtburger/crates/holtburger-protocol/src/messages/chat/types.rs</c>
|
||||
/// lines 8-24) for the actual numeric ids.
|
||||
///
|
||||
/// <para>
|
||||
/// Returns <c>null</c> for non-legacy kinds (Say, Tell, Unknown, and the
|
||||
/// Turbine-routed General/Trade/etc.) — those callers handle dispatch
|
||||
/// themselves. <see cref="ChatChannelKind.Say"/> rides Talk (0x0015) and
|
||||
/// <see cref="ChatChannelKind.Tell"/> rides Tell (0x005D); the Turbine
|
||||
/// channels need TurbineChat wiring not yet in place (Phase I.6).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ChannelResolver
|
||||
{
|
||||
/// <summary>Result of a successful legacy-channel resolution.</summary>
|
||||
public readonly record struct Resolved(uint ChannelId, string DisplayName);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve <paramref name="kind"/> to a legacy ChatChannel id + a
|
||||
/// human-readable display name for chat-log echo. Returns <c>null</c>
|
||||
/// for non-legacy kinds; the caller routes those separately.
|
||||
/// </summary>
|
||||
public static Resolved? Resolve(ChatChannelKind kind) => kind switch
|
||||
{
|
||||
// ChatChannel values from holtburger-protocol/src/messages/chat/types.rs:
|
||||
// Fellow = 0x00000800
|
||||
// Vassals = 0x00001000
|
||||
// Patron = 0x00002000
|
||||
// Monarch = 0x00004000
|
||||
// CoVassals = 0x01000000
|
||||
// AllegianceBroadcast = 0x02000000
|
||||
ChatChannelKind.Fellowship => new Resolved(0x00000800u, "Fellowship"),
|
||||
// CH3 (2026-08-09): Allegiance itself is ALWAYS Turbine now — see
|
||||
// ChatChannelKind.AllegianceBroadcast's doc comment. This resolver
|
||||
// handles only the legacy pipeline, so the id 0x02000000 moved to
|
||||
// that dedicated kind (retail's @ab verb).
|
||||
ChatChannelKind.AllegianceBroadcast => new Resolved(0x02000000u, "Allegiance"),
|
||||
ChatChannelKind.Vassals => new Resolved(0x00001000u, "Vassals"),
|
||||
ChatChannelKind.Patron => new Resolved(0x00002000u, "Patron"),
|
||||
ChatChannelKind.Monarch => new Resolved(0x00004000u, "Monarch"),
|
||||
ChatChannelKind.CoVassals => new Resolved(0x01000000u, "CoVassals"),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
59
src/AcDream.Runtime/Chat/ChatChannelKind.cs
Normal file
59
src/AcDream.Runtime/Chat/ChatChannelKind.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Outbound chat channel selector. Mirrors holtburger's <c>ChatChannelKind</c>
|
||||
/// (<c>references/holtburger/crates/holtburger-core/src/client/types.rs</c>
|
||||
/// lines 35-49) plus a synthetic <see cref="Say"/> + <see cref="Tell"/> for
|
||||
/// the two non-channel cases — local speech and whispers — so a single
|
||||
/// <see cref="SendChatCmd"/> can carry every outbound flavour the chat
|
||||
/// panel emits.
|
||||
///
|
||||
/// <para>
|
||||
/// Channels split into:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Legacy</b> (Fellowship, Vassals, Patron, Monarch, CoVassals,
|
||||
/// AllegianceBroadcast): map to a fixed <c>ChatChannel</c> bitflag id
|
||||
/// via <see cref="ChannelResolver"/> and ride 0x0147 ChatChannel.</item>
|
||||
/// <item><b>Turbine</b> (Allegiance, General, Trade, Lfg, Roleplay, Society,
|
||||
/// Olthoi): resolve to a server-assigned Turbine room id at runtime via
|
||||
/// <c>TurbineChatMembershipGate</c> and ride 0xF7DE TurbineChat.
|
||||
/// Campaign CH slice CH3 (2026-08-09) moved <see cref="Allegiance"/>
|
||||
/// here unconditionally — retail's <c>@a</c> binds to
|
||||
/// <c>DoTurbineChat_Allegiance</c>, never the legacy bitflag; see
|
||||
/// <see cref="AllegianceBroadcast"/> for the legacy sibling
|
||||
/// (retail's <c>@ab</c>).</item>
|
||||
/// <item><b>Say</b> / <b>Tell</b>: route to the dedicated 0x0015 / 0x005D
|
||||
/// opcodes — no channel id needed.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public enum ChatChannelKind
|
||||
{
|
||||
Say,
|
||||
Tell,
|
||||
Fellowship,
|
||||
Allegiance,
|
||||
Vassals,
|
||||
Patron,
|
||||
Monarch,
|
||||
CoVassals,
|
||||
General,
|
||||
Trade,
|
||||
Lfg,
|
||||
Roleplay,
|
||||
Society,
|
||||
Olthoi,
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CH slice CH3 (2026-08-09): the legacy
|
||||
/// <c>ChatChannel.AllegianceBroadcast (0x02000000)</c> bound to retail's
|
||||
/// <c>@ab</c> verb — a monarch/speaker-permission broadcast over the
|
||||
/// legacy 0x0147 pipe. Distinct from <see cref="Allegiance"/>, which is
|
||||
/// ALWAYS the Turbine room now: retail's <c>@a</c> binds unconditionally
|
||||
/// to <c>DoTurbineChat_Allegiance</c> once Turbine chat starts (research
|
||||
/// doc §4.3), so it never falls back to this bitflag.
|
||||
/// </summary>
|
||||
AllegianceBroadcast,
|
||||
|
||||
Unknown,
|
||||
}
|
||||
388
src/AcDream.Runtime/Chat/ChatCommandRouter.cs
Normal file
388
src/AcDream.Runtime/Chat/ChatCommandRouter.cs
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Chat;
|
||||
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>What a submit did, so the caller can clear its input + give feedback.
|
||||
/// <c>UnknownCommand</c> is produced only for command-shaped but verbless
|
||||
/// input ("/", "//x", "@ x"); real unknown verbs route to the server.</summary>
|
||||
public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped }
|
||||
|
||||
/// <summary>
|
||||
/// Shared chat-submit pipeline (retail <c>ChatInterface::ProcessCommand @
|
||||
/// 0x004F5100</c> analogue). Every graphical and headless chat entrance routes
|
||||
/// through here.
|
||||
///
|
||||
/// <para>
|
||||
/// Flow: emote-prefix rewrite, retail client-command catalog, local
|
||||
/// presentation command, degenerate-prefix guard, the retail
|
||||
/// <c>ChannelSystem::GetChannelID</c> fallback (unregistered GM/faction
|
||||
/// channel tags), explicit server command, then chat parse. Unknown
|
||||
/// slash/at verbs publish <see cref="SendServerCommandCmd"/> in canonical
|
||||
/// <c>@</c> form; the active host sends those through Talk, the only wire
|
||||
/// path ACE parses commands on. Prefix text with no letter verb is refused
|
||||
/// locally so command-shaped input can never leak into speech.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ChatCommandRouter
|
||||
{
|
||||
public static SubmitOutcome Submit(
|
||||
string? raw,
|
||||
IChatCommandFeedback feedback,
|
||||
ICommandBus bus,
|
||||
ChatChannelKind defaultChannel)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(feedback);
|
||||
ArgumentNullException.ThrowIfNull(bus);
|
||||
string trimmed = (raw ?? string.Empty).Trim();
|
||||
if (trimmed.Length == 0)
|
||||
return SubmitOutcome.Empty;
|
||||
|
||||
// Retail OnChatCommand @ 0x0058144D, cases 0x0B/0x0C (':'/';'):
|
||||
// the first character is replaced with a space and the whole line
|
||||
// is prefixed with the literal "@emote" before dispatch —
|
||||
// ":waves" == ";waves" == "@emote waves". Campaign CH slice CH4
|
||||
// (2026-08-09). Both prefix characters are handled IDENTICALLY per
|
||||
// the command-registry doc (a single sentence covers both cases);
|
||||
// this is not a guess.
|
||||
if (trimmed[0] is ':' or ';')
|
||||
{
|
||||
trimmed = "@emote " + trimmed[1..];
|
||||
}
|
||||
|
||||
if (RetailClientCommandCatalog.TryMatch(trimmed, out var clientCommand))
|
||||
{
|
||||
if (!clientCommand.HasValidArguments)
|
||||
{
|
||||
// #363 / register row AP-183: retail's bad-args refusal is
|
||||
// ALWAYS 0x1A (ClientLocal / SpewBox-only) — verified
|
||||
// against seven decompiled handlers (DoDie, DoChannelList/
|
||||
// On/Off, DoAllegiance, DoHouseAvailableList, and — added at
|
||||
// the consolidated-review round 2026-08-10, retiring
|
||||
// register row AP-113 — DoLifestone/DoMarketplace). A
|
||||
// Definition with its own InvalidArgumentsText is the
|
||||
// handler's own bespoke refusal string (byte-recovered from
|
||||
// the PDB-paired acclient.exe; Binary Ninja mis-attributes
|
||||
// both DoLifestone's and DoMarketplace's own literal to an
|
||||
// unrelated vtable-slot symbol, the same class of artifact
|
||||
// this file already documents for the Help* family),
|
||||
// printed before the handler returns "handled" (1) so
|
||||
// retail's generic fallback never fires for it. The 0x26
|
||||
// fallback below therefore covers ONLY verbs with no
|
||||
// bespoke retail refusal of their own — not every bad-args
|
||||
// case, and not a placeholder pending more extraction:
|
||||
// ClientCommunicationSystem::DoCommand @0x0057E46D calls
|
||||
// HandleFailureEvent(0x26) when a registered handler
|
||||
// returns 0 (bad args) with no bespoke string, resolving to
|
||||
// "That is not a valid command." (WeenieErrorMessages
|
||||
// [0x026]) — never the acdream-invented "Usage: {Usage}"
|
||||
// line this branch used to synthesize. Consolidated-review
|
||||
// NIT (d): dispatches on the resolved entry's own Type
|
||||
// rather than assuming ShowInterfaceText's hardcoded
|
||||
// ClientLocal is correct for it.
|
||||
if (clientCommand.InvalidArgumentsText is { } bespokeRefusal)
|
||||
{
|
||||
feedback.ShowInterfaceText(bespokeRefusal);
|
||||
}
|
||||
else
|
||||
{
|
||||
(string? text, RetailLogTextType type) fallback =
|
||||
WeenieErrorMessages.Resolve(0x026u, null);
|
||||
string fallbackText = fallback.text ?? "That is not a valid command.";
|
||||
if (fallback.type == RetailLogTextType.ClientLocal)
|
||||
feedback.ShowInterfaceText(fallbackText);
|
||||
else
|
||||
feedback.ShowSystemMessage(fallbackText);
|
||||
}
|
||||
return SubmitOutcome.ClientHandled;
|
||||
}
|
||||
|
||||
bus.Publish(new ExecuteClientCommandCmd(
|
||||
clientCommand.Command, clientCommand.Arguments));
|
||||
return SubmitOutcome.ClientHandled;
|
||||
}
|
||||
|
||||
if (TryHandleLocalPresentationCommand(trimmed, feedback))
|
||||
return SubmitOutcome.ClientHandled;
|
||||
|
||||
// Command-shaped but no letter verb ("/", "//shrug", "@ x"):
|
||||
// refuse locally rather than putting junk on the wire or in speech.
|
||||
// #363/#367: this is one of retail's DoHelp-family "Unknown
|
||||
// command" fallbacks (0x1A ClientLocal, SpewBox-only) — routed
|
||||
// through the interface-text seam now that one exists, instead of
|
||||
// the chat scroll.
|
||||
if (trimmed[0] is '/' or '@'
|
||||
&& (trimmed.Length == 1 || !char.IsLetter(trimmed[1])))
|
||||
{
|
||||
feedback.ShowInterfaceText(
|
||||
$"Unknown command: {ChatInputParser.GetVerbToken(trimmed)}. Type /help for the list of supported commands.");
|
||||
return SubmitOutcome.UnknownCommand;
|
||||
}
|
||||
|
||||
// Campaign CH slice CH4 (2026-08-09), doc §1/§2.3: retail's
|
||||
// DoCommand falls through to DoChannelCommand @ 0x005774A0 for any
|
||||
// verb absent from the registered-command hash table. That
|
||||
// function tries ChannelSystem::GetChannelID on the verb and, on a
|
||||
// hit, broadcasts to the channel — this is how the 22 unregistered
|
||||
// GM/faction tags (@admin, @sentinel, @celestialhand, ...) work in
|
||||
// retail despite never being "registered". Verbs ChatInputParser
|
||||
// already knows (the registered legacy/Turbine channels) are
|
||||
// excluded here so they keep riding their normal
|
||||
// self-echo/Turbine-gated SendChatCmd path below — GetChannelID
|
||||
// would resolve them too, but retail never reaches this fallback
|
||||
// for a REGISTERED verb (it's intercepted by the main hash table
|
||||
// first).
|
||||
SubmitOutcome? fallbackOutcome = TryDispatchChannelFallback(trimmed, bus);
|
||||
if (fallbackOutcome is { } outcome)
|
||||
return outcome;
|
||||
|
||||
if (TryBuildServerCommand(trimmed, out string serverCommand))
|
||||
{
|
||||
bus.Publish(new SendServerCommandCmd(serverCommand));
|
||||
return SubmitOutcome.Sent;
|
||||
}
|
||||
|
||||
// #363 / register row AP-183: retail's registered legacy-channel
|
||||
// verbs (DoStupidChannelHack @0x0057B144) and /reply
|
||||
// (DoReply @0x00577910) refuse locally at 0x1A instead of silently
|
||||
// dropping the line the way ChatInputParser.Parse's pure "return
|
||||
// null" shape does for these cases.
|
||||
if (ChatInputParser.IsBareRegisteredChannelVerb(trimmed))
|
||||
{
|
||||
feedback.ShowInterfaceText("You must specify the text you wish to say!");
|
||||
return SubmitOutcome.ClientHandled;
|
||||
}
|
||||
|
||||
if (ChatInputParser.IsReplyMissingLastTeller(
|
||||
trimmed,
|
||||
feedback.LastIncomingTellSender))
|
||||
{
|
||||
feedback.ShowInterfaceText("Someone must @tell you first!");
|
||||
return SubmitOutcome.ClientHandled;
|
||||
}
|
||||
|
||||
var parsed = ChatInputParser.Parse(
|
||||
trimmed,
|
||||
defaultChannel,
|
||||
feedback.LastIncomingTellSender,
|
||||
feedback.LastOutgoingTellTarget);
|
||||
if (parsed is { } chat)
|
||||
{
|
||||
bus.Publish(new SendChatCmd(chat.Channel, chat.TargetName, chat.Text));
|
||||
return SubmitOutcome.Sent;
|
||||
}
|
||||
|
||||
return SubmitOutcome.Dropped;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns null when the verb is not one of the 22 unregistered
|
||||
/// fallback channel tags (caller continues its own dispatch chain);
|
||||
/// otherwise returns the outcome to return immediately.
|
||||
/// </summary>
|
||||
private static SubmitOutcome? TryDispatchChannelFallback(
|
||||
string trimmed,
|
||||
ICommandBus bus)
|
||||
{
|
||||
if (trimmed[0] is not ('/' or '@'))
|
||||
return null;
|
||||
|
||||
string verb = ChatInputParser.GetVerbToken(trimmed);
|
||||
string tag = verb[1..].TrimEnd(',');
|
||||
|
||||
// CH4 REJECT-review Blocker 1 (2026-08-09): apply the catalog
|
||||
// ownership rule BEFORE any channel-tag resolution. Retail's
|
||||
// registered-command hash table is checked FIRST (doc §1) and
|
||||
// unconditionally wins over DoChannelCommand's fallback — a
|
||||
// catalog-owned verb must never resolve as a channel broadcast
|
||||
// here, even if its own TryMatch declines ownership for some
|
||||
// OTHER reason than "not owned" (RetailClientCommandCatalog's
|
||||
// "allegiance"/"all" already claim ownership unconditionally via
|
||||
// TryMatchAllegiance, so this line is currently redundant for
|
||||
// that specific verb — it's the blanket guard for the rest of the
|
||||
// catalog, e.g. "house", "lifestone", …). Skipping this check is
|
||||
// exactly how an unmatched "@allegiance boot Bob" used to reach
|
||||
// this method and broadcast "boot Bob" to the Allegiance channel.
|
||||
if (RetailClientCommandCatalog.KnownVerbs.Contains(tag, StringComparer.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
string normalizedChatVerb = "/" + tag;
|
||||
if (ChatInputParser.IsKnownVerb(normalizedChatVerb))
|
||||
return null; // registered verb — handled by the normal channel path.
|
||||
|
||||
if (!RetailChannelTagTable.TryResolve(tag, out uint channelId))
|
||||
return null;
|
||||
|
||||
int separator = trimmed.IndexOfAny([' ', '\t']);
|
||||
string text = separator < 0 ? string.Empty : trimmed[(separator + 1)..].Trim();
|
||||
if (text.Length == 0)
|
||||
{
|
||||
// CH4 REJECT-review SHOULD-FIX 3 (2026-08-09): retail's
|
||||
// DoChannelCommand @0x005774A7 returns 0 SILENTLY when argc<=0
|
||||
// for an UNREGISTERED tag; DoCommand's own final fallback then
|
||||
// sends the raw @-line to the server via Event_Talk — this is
|
||||
// passthrough, not a local refusal. "You must specify the text
|
||||
// you wish to say!" belongs to DoStupidChannelHack
|
||||
// @0x0057B144, which only runs for the REGISTERED channel
|
||||
// verbs (fellowship, vassals, patron, monarch, covassals,
|
||||
// allegiance-broadcast), never these 22 GM/faction tags. Let
|
||||
// TryBuildServerCommand's passthrough handle it instead.
|
||||
return null;
|
||||
}
|
||||
|
||||
bus.Publish(new SendRawChannelCmd(channelId, text));
|
||||
return SubmitOutcome.Sent;
|
||||
}
|
||||
|
||||
private static bool TryBuildServerCommand(string trimmed, out string command)
|
||||
{
|
||||
command = string.Empty;
|
||||
if (trimmed[0] is not ('/' or '@'))
|
||||
return false;
|
||||
|
||||
string verb = ChatInputParser.GetVerbToken(trimmed);
|
||||
string normalizedChatVerb = "/" + verb[1..];
|
||||
if (ChatInputParser.IsKnownVerb(normalizedChatVerb))
|
||||
return false;
|
||||
|
||||
command = "@" + trimmed[1..];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryHandleLocalPresentationCommand(
|
||||
string trimmed,
|
||||
IChatCommandFeedback feedback)
|
||||
{
|
||||
if (EqAny(trimmed, "/help", "/?", "@help", "@?"))
|
||||
{
|
||||
EmitBareHelp(feedback);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Campaign CH slice CH4 (2026-08-09), Tier 2 item 15: "/help <verb>"
|
||||
// / "@help <verb>" — retail's DoHelp @ 0x0057F9E0 looks the verb up
|
||||
// and calls its registered help callback. Non-retail "/h" alias
|
||||
// deleted per the command-registry doc §4's removal list.
|
||||
if (StartsWithAny(trimmed, "/help ", "@help ", "/? ", "@? "))
|
||||
{
|
||||
string verb = trimmed[(trimmed.IndexOf(' ') + 1)..].Trim();
|
||||
EmitVerbHelp(verb, feedback);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bare <c>/help</c> — retail's <c>DoHelp</c> arg2<=0 branch. TWO
|
||||
/// separate scroll entries, never one concatenated blob (Campaign CH
|
||||
/// user-gate round 3, finding (b) — see <see cref="RetailCommandHelpTable"/>'s
|
||||
/// class remarks for the full print-sequence trace).
|
||||
/// </summary>
|
||||
private static void EmitBareHelp(IChatCommandFeedback feedback)
|
||||
{
|
||||
feedback.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
|
||||
feedback.ShowSystemMessage(RetailCommandHelpTable.AvailableHelpListing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>/help <verb></c> — retail's <c>DoHelp</c> arg2>0 branch.
|
||||
/// Campaign CH user-gate round 3, finding (c): a resolved verb gets the
|
||||
/// SAME two-entry shape as the bare listing (Note, then
|
||||
/// <see cref="RetailCommandHelpTable.ForMoreInformationPrefix"/>
|
||||
/// immediately concatenated — NOT a third entry — with the verb's own
|
||||
/// Detail text); an unresolved verb gets retail's real
|
||||
/// <see cref="RetailCommandHelpTable.UnknownCommand"/> text instead of
|
||||
/// an acdream-invented "No help available" message.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Consolidated-review round (2026-08-10), SHOULD-FIX 1: lookup order
|
||||
/// is now (1) <see cref="RetailCommandHelpTable.CatalogVerbsWithNoRetailHelp"/>
|
||||
/// — a catalog leaf verb retail itself registers with a NULL help
|
||||
/// pointer (index/clist/on/off) goes straight to
|
||||
/// <see cref="RetailCommandHelpTable.UnknownCommand"/>, matching
|
||||
/// retail's own <c>DoHelp</c> exactly, and must never reach either
|
||||
/// text source below; (2)
|
||||
/// <see cref="RetailCommandHelpTable.TryGetCatalogVerbDetailText"/> —
|
||||
/// retail's OWN Detail_HelpType text, byte-swept from the retail
|
||||
/// binary, for the 42 catalog leaf verbs it covers; (3)
|
||||
/// <see cref="RetailClientCommandCatalog.TryGetHelpText"/> — the
|
||||
/// catalog's acdream-authored summary, now purely the fallback for
|
||||
/// catalog verbs not yet extracted (currently only messagetypes and
|
||||
/// its 3 aliases); (4) <see cref="RetailCommandHelpTable.TryGetHelpText"/>
|
||||
/// — chat-alias/channel verbs and the group-topic nodes, a disjoint key
|
||||
/// space from the catalog so this reordering changes nothing for them.
|
||||
/// </remarks>
|
||||
private static void EmitVerbHelp(
|
||||
string verb,
|
||||
IChatCommandFeedback feedback)
|
||||
{
|
||||
if (verb.Length == 0)
|
||||
{
|
||||
EmitBareHelp(feedback);
|
||||
return;
|
||||
}
|
||||
|
||||
string normalized = verb.TrimStart('/', '@');
|
||||
|
||||
if (RetailCommandHelpTable.CatalogVerbsWithNoRetailHelp.Contains(
|
||||
normalized.TrimEnd(',')))
|
||||
{
|
||||
// Retail types this 0x1A (ClientLocal) -> SpewBox-only, the
|
||||
// SAME fallback an unregistered verb gets — DoHelp's help-
|
||||
// pointer-null guard skips its callback branch entirely. See
|
||||
// RetailCommandHelpTable.CatalogVerbsWithNoRetailHelp's remarks.
|
||||
feedback.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand);
|
||||
return;
|
||||
}
|
||||
|
||||
if (RetailCommandHelpTable.TryGetCatalogVerbDetailText(normalized, out string retailDetailText))
|
||||
{
|
||||
feedback.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
|
||||
feedback.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + retailDetailText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (RetailClientCommandCatalog.TryGetHelpText(normalized, out string catalogText))
|
||||
{
|
||||
feedback.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
|
||||
feedback.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + catalogText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (RetailCommandHelpTable.TryGetHelpText(normalized, out string tableText))
|
||||
{
|
||||
feedback.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
|
||||
feedback.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + tableText);
|
||||
return;
|
||||
}
|
||||
|
||||
// Retail types this 0x1A (ClientLocal) -> SpewBox-only. #363/#367:
|
||||
// now routed through IChatCommandFeedback.ShowInterfaceText instead
|
||||
// of the chat scroll — see RetailCommandHelpTable.UnknownCommand.
|
||||
feedback.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand);
|
||||
}
|
||||
|
||||
private static bool EqAny(string value, params string[] options)
|
||||
{
|
||||
for (int i = 0; i < options.Length; i++)
|
||||
{
|
||||
if (value.Equals(options[i], StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool StartsWithAny(string value, params string[] options)
|
||||
{
|
||||
for (int i = 0; i < options.Length; i++)
|
||||
{
|
||||
if (value.StartsWith(options[i], StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
471
src/AcDream.Runtime/Chat/ChatInputParser.cs
Normal file
471
src/AcDream.Runtime/Chat/ChatInputParser.cs
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Phase I.4: pure-function parse of a chat-input line into the
|
||||
/// <c>(channel, target?, text)</c> triple a <see cref="SendChatCmd"/>
|
||||
/// needs. Ported from holtburger
|
||||
/// <c>references/holtburger/apps/holtburger-cli/src/pages/game/input/commands.rs</c>
|
||||
/// — the alias table around line ~457 and the <c>parse_targeted_chat_command</c>
|
||||
/// / <c>parse_message_only_command</c> helpers near the top of the file.
|
||||
///
|
||||
/// <para>
|
||||
/// Edge cases handled (each pinned by a test in
|
||||
/// <c>ChatInputParserTests</c>):
|
||||
/// <list type="bullet">
|
||||
/// <item>empty / whitespace-only → <c>null</c></item>
|
||||
/// <item><c>/say</c> with no message → <c>null</c></item>
|
||||
/// <item><c>/t</c> with no target / no message → <c>null</c></item>
|
||||
/// <item><c>/r</c> with no <c>lastTellSender</c> → <c>null</c></item>
|
||||
/// <item>unknown <c>/xyz</c> verb → rewritten to <c>@xyz</c> when this pure
|
||||
/// parser is called directly. The production <see
|
||||
/// cref="ChatCommandRouter"/> intercepts it first and publishes a typed
|
||||
/// <see cref="SendServerCommandCmd"/>.</item>
|
||||
/// <item>multi-word <c>/t</c> target: first whitespace token is target,
|
||||
/// rest is message (matches Rust <c>split_once</c> semantics)</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ChatInputParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Outcome of a successful parse. Pass straight into
|
||||
/// <see cref="SendChatCmd"/>'s constructor.
|
||||
/// </summary>
|
||||
public readonly record struct ParsedInput(
|
||||
ChatChannelKind Channel,
|
||||
string? TargetName,
|
||||
string Text);
|
||||
|
||||
// Alias tables. Order matters only for error messages — verb
|
||||
// matching is exact-token, not prefix.
|
||||
private static readonly string[] SayAliases = { "/say", "/s" };
|
||||
// Campaign CH slice CH4 (2026-08-09): retail's DoTell @0x00577E40
|
||||
// registers "tell" under FOUR verb strings — tell/t/send/whisper/w —
|
||||
// per the command-registry doc §2.2.
|
||||
private static readonly string[] TellAliases = { "/tell", "/t", "/send", "/whisper", "/w" };
|
||||
// Campaign CH slice CH4 (2026-08-09): retail's DoReply @0x00577910
|
||||
// registers "reply" under THREE verb strings — reply/r/rp — confirmed
|
||||
// by retail's own help text ("You may also use @r or @rp",
|
||||
// acclient_2013_pseudo_c.txt:1030742). "/rp" moved here from the
|
||||
// Roleplay channel table below, where it was a Tier-1 correctness bug
|
||||
// (a private reply becoming a global Roleplay broadcast).
|
||||
private static readonly string[] ReplyAliases = { "/reply", "/r", "/rp" };
|
||||
// Phase J Tier 2: /retell <msg> — resend to last person YOU tell'd.
|
||||
// Mirrors retail's @retell. Distinct from /reply which targets the
|
||||
// last person who tell'd US. Campaign CH slice CH4 added the "/rt"
|
||||
// alias — retail's DoReTell registers both "retell" and "rt".
|
||||
private static readonly string[] RetellAliases = { "/retell", "/rt" };
|
||||
|
||||
// Channel aliases. Each maps a single verb token to a channel kind.
|
||||
// The same list drives both the verb test and the prefix-strip.
|
||||
// Long-form aliases mirror retail muscle memory (e.g. "/patron" for
|
||||
// "/p"). Phase J added the long forms after a 2026-04-25 live session
|
||||
// showed "/patron hello" falling through as plain Say with the literal
|
||||
// "/patron " prefix.
|
||||
//
|
||||
// Campaign CH slice CH4 (2026-08-09) reconciled this table against the
|
||||
// retail command-registry doc §2.3/§2.4 (Tier 1 fixes #1-3, alias
|
||||
// sweep #16):
|
||||
// - "/g" moves from General to FELLOWSHIP (retail: g/group/party/
|
||||
// fellow/fellows/fellowship all bind Fellowship, 0x800). Sending
|
||||
// fellowship chatter to General was a live correctness bug.
|
||||
// - "/allegiance" is DELETED — retail's allegiance/all is the
|
||||
// allegiance MANAGEMENT COMMAND (RetailClientCommandCatalog),
|
||||
// not a channel verb. The channel-send verbs are a/ab/guild/gu.
|
||||
// - "/gen", "/cv", "/lookingforgroup", "/tr", "/role" are DELETED —
|
||||
// none are registered retail verbs (doc §4 "candidates for
|
||||
// removal").
|
||||
// - Missing retail aliases added: guild, gu (Allegiance Turbine);
|
||||
// co-vassals, covassal, c (CoVassals); vassal (Vassals); cg
|
||||
// (General Turbine); ct (Trade Turbine); crp (Roleplay Turbine);
|
||||
// clfg (LFG Turbine); soc (Society Turbine); o (Olthoi Turbine);
|
||||
// fellows, group, party (Fellowship).
|
||||
private static readonly (string Verb, ChatChannelKind Channel)[] ChannelVerbs =
|
||||
{
|
||||
("/general", ChatChannelKind.General),
|
||||
("/cg", ChatChannelKind.General),
|
||||
("/f", ChatChannelKind.Fellowship),
|
||||
("/fellow", ChatChannelKind.Fellowship),
|
||||
("/fellows", ChatChannelKind.Fellowship),
|
||||
("/fellowship", ChatChannelKind.Fellowship),
|
||||
("/g", ChatChannelKind.Fellowship),
|
||||
("/group", ChatChannelKind.Fellowship),
|
||||
("/party", ChatChannelKind.Fellowship),
|
||||
("/a", ChatChannelKind.Allegiance),
|
||||
("/guild", ChatChannelKind.Allegiance),
|
||||
("/gu", ChatChannelKind.Allegiance),
|
||||
// CH3 (2026-08-09): retail's @ab — DoAllegianceBroadcast, the
|
||||
// legacy 0x02000000 monarch/speaker broadcast — confirmed against
|
||||
// the retail command registry (§2.3/§2.5). "/allegiancebroadcast"
|
||||
// was deliberately NOT added: it is not a registered retail verb
|
||||
// (only "ab" is), and CH3's scope is "cross-check the registry;
|
||||
// do not add verbs it doesn't have."
|
||||
("/ab", ChatChannelKind.AllegianceBroadcast),
|
||||
("/m", ChatChannelKind.Monarch),
|
||||
("/monarch", ChatChannelKind.Monarch),
|
||||
("/p", ChatChannelKind.Patron),
|
||||
("/patron", ChatChannelKind.Patron),
|
||||
("/v", ChatChannelKind.Vassals),
|
||||
("/vassal", ChatChannelKind.Vassals),
|
||||
("/vassals", ChatChannelKind.Vassals),
|
||||
("/c", ChatChannelKind.CoVassals),
|
||||
("/covassal", ChatChannelKind.CoVassals),
|
||||
("/covassals", ChatChannelKind.CoVassals),
|
||||
("/co-vassals", ChatChannelKind.CoVassals),
|
||||
("/lfg", ChatChannelKind.Lfg),
|
||||
("/clfg", ChatChannelKind.Lfg),
|
||||
("/trade", ChatChannelKind.Trade),
|
||||
("/ct", ChatChannelKind.Trade),
|
||||
("/crp", ChatChannelKind.Roleplay),
|
||||
("/roleplay", ChatChannelKind.Roleplay),
|
||||
("/society", ChatChannelKind.Society),
|
||||
("/soc", ChatChannelKind.Society),
|
||||
("/olthoi", ChatChannelKind.Olthoi),
|
||||
("/o", ChatChannelKind.Olthoi),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The six retail channel groups whose registered-verb handler is
|
||||
/// <c>ClientCommunicationSystem::DoStupidChannelHack @0x0057B144</c>
|
||||
/// (command-registry doc §2.3) — the legacy 0x0147 channels. The
|
||||
/// Turbine-only channels (General/Trade/Lfg/Roleplay/Society/Olthoi)
|
||||
/// are never this handler's clients. <see cref="ChatChannelKind.Allegiance"/>
|
||||
/// (retail's <c>@a</c>) still belongs here even though acdream always
|
||||
/// sends it over Turbine once connected (<see cref="ChatChannelKind"/>'s
|
||||
/// own doc comment) — DoStupidChannelHack dispatches on the REGISTERED
|
||||
/// VERB, before Turbine send-time routing decides where the text goes;
|
||||
/// retail's <c>"a"</c>/<c>"ab"</c> are one registered verb group.
|
||||
/// </summary>
|
||||
private static readonly HashSet<ChatChannelKind> LegacyChannelHackKinds =
|
||||
[
|
||||
ChatChannelKind.Fellowship,
|
||||
ChatChannelKind.Allegiance,
|
||||
ChatChannelKind.AllegianceBroadcast,
|
||||
ChatChannelKind.Vassals,
|
||||
ChatChannelKind.Patron,
|
||||
ChatChannelKind.Monarch,
|
||||
ChatChannelKind.CoVassals,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="trimmed"/> is one of the
|
||||
/// <see cref="LegacyChannelHackKinds"/> verbs with NO message — retail's
|
||||
/// <c>DoStupidChannelHack</c> "You must specify the text you wish to
|
||||
/// say!" refusal (<c>0x1A</c> ClientLocal,
|
||||
/// acclient_2013_pseudo_c.txt:1030730, data_7da9b0). This parser stays
|
||||
/// pure (no side effects — <see cref="Parse"/> just returns
|
||||
/// <see langword="null"/> for this shape); <see cref="ChatCommandRouter"/>
|
||||
/// owns the actual refusal text and routes it to the SpewBox.
|
||||
/// </summary>
|
||||
public static bool IsBareRegisteredChannelVerb(string trimmed)
|
||||
{
|
||||
foreach (var (verb, channel) in ChannelVerbs)
|
||||
{
|
||||
if (LegacyChannelHackKinds.Contains(channel) && IsBareVerb(trimmed, [verb]))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="trimmed"/> is <c>/reply</c>/<c>/r</c>/
|
||||
/// <c>/rp</c> WITH a message but no prior incoming Tell — retail's
|
||||
/// <c>ClientCommunicationSystem::DoReply @0x00577910</c> "Someone must
|
||||
/// @tell you first!" refusal (<c>0x1A</c> ClientLocal,
|
||||
/// acclient_2013_pseudo_c.txt:387538, data_7da974), the
|
||||
/// <c>gmCCommunicationSystem::GetLastTeller() == 0</c> branch. Bare
|
||||
/// <c>/r</c> with no message at all is a DIFFERENT retail branch (its
|
||||
/// own copy of the "you must specify text" string) and is deliberately
|
||||
/// out of scope here — not named by register row AP-183 / issue #363.
|
||||
/// </summary>
|
||||
public static bool IsReplyMissingLastTeller(string trimmed, string? lastTellSender) =>
|
||||
string.IsNullOrEmpty(lastTellSender) && TryParseMessageOnly(trimmed, ReplyAliases, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Parse <paramref name="raw"/> into a <see cref="ParsedInput"/>
|
||||
/// triple, or <c>null</c> if the input is empty / whitespace /
|
||||
/// missing required arguments (e.g. <c>/t</c> with no target).
|
||||
/// </summary>
|
||||
/// <param name="raw">User input. The caller should
|
||||
/// <see cref="string.Trim()"/> before passing.</param>
|
||||
/// <param name="defaultChannel">Channel to use for unprefixed text
|
||||
/// or unrecognised slash commands.</param>
|
||||
/// <param name="lastTellSender">Sender of the most recent incoming
|
||||
/// Tell, used to resolve <c>/r</c> reply target. Null if no Tell
|
||||
/// has arrived this session.</param>
|
||||
/// <param name="lastOutgoingTellTarget">Target of the most recent
|
||||
/// outgoing Tell, used to resolve <c>/retell</c>. Null if the
|
||||
/// player hasn't sent a Tell yet this session.</param>
|
||||
public static ParsedInput? Parse(
|
||||
string raw,
|
||||
ChatChannelKind defaultChannel,
|
||||
string? lastTellSender,
|
||||
string? lastOutgoingTellTarget = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
var trimmed = raw.Trim();
|
||||
|
||||
// Phase J Tier 1: ACE accepts both / and @ as equivalent verb
|
||||
// prefixes (per ACE help: "Note: You may substitute a forward
|
||||
// slash (/) for the at symbol (@)."). For verbs WE recognize,
|
||||
// normalize @ to / and re-enter parsing. For unknown @-verbs
|
||||
// (e.g. @acehelp, @tele, @die), pass the literal @-prefixed
|
||||
// text through to the default channel so ACE's CommandManager
|
||||
// server-side handler intercepts it.
|
||||
if (trimmed.StartsWith("@"))
|
||||
{
|
||||
string substituted = "/" + trimmed.Substring(1);
|
||||
string verb = ExtractVerb(substituted);
|
||||
if (IsKnownVerb(verb))
|
||||
{
|
||||
return Parse(substituted, defaultChannel, lastTellSender, lastOutgoingTellTarget);
|
||||
}
|
||||
// Unknown @-verb — keep the original @ so ACE recognizes
|
||||
// it server-side. Always emit as Say: ACE's GameActionTalk
|
||||
// (the 0x0015 Talk action) is the ONLY wire path that parses
|
||||
// @commands — on a chat channel the text would broadcast as
|
||||
// ordinary channel speech. Retail likewise resolves commands
|
||||
// before channel routing.
|
||||
return new ParsedInput(ChatChannelKind.Say, null, trimmed);
|
||||
}
|
||||
|
||||
// /say <msg>
|
||||
if (TryParseMessageOnly(trimmed, SayAliases, out var sayMsg))
|
||||
return new ParsedInput(ChatChannelKind.Say, null, sayMsg);
|
||||
if (IsBareVerb(trimmed, SayAliases))
|
||||
return null;
|
||||
|
||||
// /tell <target> <msg> or /t <target> <msg>
|
||||
if (TryParseTargeted(trimmed, TellAliases, out var tellTarget, out var tellMsg))
|
||||
return new ParsedInput(ChatChannelKind.Tell, tellTarget, tellMsg);
|
||||
if (IsBareVerb(trimmed, TellAliases) || IsVerbWithSingleToken(trimmed, TellAliases))
|
||||
return null;
|
||||
|
||||
// /r <msg> / /reply <msg> — needs prior incoming tell.
|
||||
if (TryParseMessageOnly(trimmed, ReplyAliases, out var replyMsg))
|
||||
{
|
||||
if (string.IsNullOrEmpty(lastTellSender)) return null;
|
||||
return new ParsedInput(ChatChannelKind.Tell, lastTellSender, replyMsg);
|
||||
}
|
||||
if (IsBareVerb(trimmed, ReplyAliases))
|
||||
return null;
|
||||
|
||||
// /retell <msg> — needs prior outgoing tell.
|
||||
if (TryParseMessageOnly(trimmed, RetellAliases, out var retellMsg))
|
||||
{
|
||||
if (string.IsNullOrEmpty(lastOutgoingTellTarget)) return null;
|
||||
return new ParsedInput(ChatChannelKind.Tell, lastOutgoingTellTarget, retellMsg);
|
||||
}
|
||||
if (IsBareVerb(trimmed, RetellAliases))
|
||||
return null;
|
||||
|
||||
// Channel verbs (/g, /f, /a, ...).
|
||||
foreach (var (verb, channel) in ChannelVerbs)
|
||||
{
|
||||
if (TryParseMessageOnly(trimmed, new[] { verb }, out var msg))
|
||||
return new ParsedInput(channel, null, msg);
|
||||
if (IsBareVerb(trimmed, new[] { verb }))
|
||||
return null;
|
||||
}
|
||||
|
||||
// Unknown slash verb: retail treats / and @ as equivalent command
|
||||
// prefixes, but ACE's GameActionTalk only intercepts the @ form on
|
||||
// the wire (a /-prefixed Talk is plain speech server-side). Rewrite
|
||||
// to @ and emit as Say — same reasoning as the unknown-@ branch at
|
||||
// the top: only the Talk action parses commands. (Deliberate
|
||||
// divergence from holtburger's literal fall-through, which would
|
||||
// SAY "/ci 629" out loud.)
|
||||
if (trimmed.Length > 1 && trimmed[0] == '/' && char.IsLetter(trimmed[1]))
|
||||
return new ParsedInput(ChatChannelKind.Say, null, "@" + trimmed.Substring(1));
|
||||
|
||||
// Plain speech (no recognized verb): emit on the default channel
|
||||
// so the user's text round-trips instead of being silently dropped.
|
||||
return new ParsedInput(defaultChannel, null, trimmed);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Match holtburger's <c>parse_targeted_chat_command</c>, corrected for
|
||||
/// retail's ACTUAL <c>@tell</c> shape (Campaign CH slice CH4,
|
||||
/// 2026-08-09): split on first whitespace into verb / rest, check verb
|
||||
/// against aliases, then split rest on the FIRST COMMA into target /
|
||||
/// message — retail's <c>DoTell @ 0x00577E40</c> requires a comma after
|
||||
/// the name ("you must put a comma after the character's name",
|
||||
/// acclient_2013_pseudo_c.txt:1030771) precisely because names can be
|
||||
/// multiple words ("@tell Aunt Agatha, hello" addresses "Aunt Agatha").
|
||||
/// Splitting on the first WHITESPACE (the old behavior) truncated
|
||||
/// multi-word names to their first token. Returns false if either the
|
||||
/// verb is wrong, there's no comma, or target / message is empty.
|
||||
/// </summary>
|
||||
private static bool TryParseTargeted(string command, string[] aliases, out string target, out string message)
|
||||
{
|
||||
target = string.Empty;
|
||||
message = string.Empty;
|
||||
|
||||
int firstWs = IndexOfWhitespace(command);
|
||||
if (firstWs < 0) return false;
|
||||
|
||||
var verb = TrimVerbComma(command.Substring(0, firstWs));
|
||||
if (!ContainsExact(aliases, verb)) return false;
|
||||
|
||||
var rest = command.Substring(firstWs + 1).TrimStart();
|
||||
if (rest.Length == 0) return false;
|
||||
|
||||
int commaIndex = rest.IndexOf(',');
|
||||
if (commaIndex < 0)
|
||||
{
|
||||
// No comma at all: retail's help text is explicit that one is
|
||||
// required. Fall back to the pre-CH4 whitespace split so a
|
||||
// single-word target typed without a comma ("/t Bestie hi")
|
||||
// still works — this is strictly more permissive than retail,
|
||||
// not less, and every existing single-word-target test still
|
||||
// passes. Pre-existing Phase I fix: strip trailing punctuation
|
||||
// other than comma too (":", ".", "!", "?", ";") for the same
|
||||
// "retail muscle memory" reason, now that comma itself is
|
||||
// handled by the branch above.
|
||||
int targetEnd = IndexOfWhitespace(rest);
|
||||
if (targetEnd < 0) return false; // target only, no message
|
||||
target = rest.Substring(0, targetEnd).TrimEnd(',', ';', ':', '.', '!', '?');
|
||||
message = rest.Substring(targetEnd + 1).TrimStart();
|
||||
}
|
||||
else
|
||||
{
|
||||
target = rest.Substring(0, commaIndex).TrimEnd();
|
||||
message = rest.Substring(commaIndex + 1).TrimStart();
|
||||
}
|
||||
|
||||
if (target.Length == 0 || message.Length == 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Match holtburger's <c>parse_message_only_command</c>: split on
|
||||
/// first whitespace, alias-match the verb, return the trimmed rest
|
||||
/// as the message. Empty-message → false.
|
||||
/// </summary>
|
||||
private static bool TryParseMessageOnly(string command, string[] aliases, out string message)
|
||||
{
|
||||
message = string.Empty;
|
||||
int firstWs = IndexOfWhitespace(command);
|
||||
if (firstWs < 0) return false;
|
||||
|
||||
var verb = TrimVerbComma(command.Substring(0, firstWs));
|
||||
if (!ContainsExact(aliases, verb)) return false;
|
||||
|
||||
message = command.Substring(firstWs + 1).TrimStart();
|
||||
return message.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="command"/> is exactly one of the
|
||||
/// alias verbs (no message, no extra whitespace beyond trimming).
|
||||
/// </summary>
|
||||
private static bool IsBareVerb(string command, string[] aliases)
|
||||
{
|
||||
string trimmedVerb = TrimVerbComma(command);
|
||||
foreach (var alias in aliases)
|
||||
if (trimmedVerb == alias) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="command"/> is "verb arg" with no
|
||||
/// further whitespace — e.g. <c>/t Bestie</c> (target but no
|
||||
/// message). Used to short-circuit the "fall through to default
|
||||
/// channel" path so a half-typed tell doesn't get sent as Say.
|
||||
/// </summary>
|
||||
private static bool IsVerbWithSingleToken(string command, string[] aliases)
|
||||
{
|
||||
int firstWs = IndexOfWhitespace(command);
|
||||
if (firstWs < 0) return false;
|
||||
var verb = TrimVerbComma(command.Substring(0, firstWs));
|
||||
if (!ContainsExact(aliases, verb)) return false;
|
||||
|
||||
var rest = command.Substring(firstWs + 1).TrimStart();
|
||||
if (rest.Length == 0) return true;
|
||||
return IndexOfWhitespace(rest) < 0;
|
||||
}
|
||||
|
||||
private static int IndexOfWhitespace(string s)
|
||||
{
|
||||
for (int i = 0; i < s.Length; i++)
|
||||
if (char.IsWhiteSpace(s[i])) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static bool ContainsExact(string[] aliases, string verb)
|
||||
{
|
||||
for (int i = 0; i < aliases.Length; i++)
|
||||
if (aliases[i] == verb) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pull the first whitespace-separated token from <paramref name="command"/>.
|
||||
/// Used by the @-prefix normalization to know whether the verb is
|
||||
/// one we route locally (Tell / Channel / etc.) or an unknown
|
||||
/// command that should pass through to the server intact.
|
||||
/// </summary>
|
||||
private static string ExtractVerb(string command)
|
||||
{
|
||||
int ws = IndexOfWhitespace(command);
|
||||
return ws < 0 ? command : command.Substring(0, ws);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Union of every alias verb (with leading <c>/</c>) the parser
|
||||
/// recognises. Used by the <c>@</c>-prefix passthrough decision:
|
||||
/// if the substituted verb is in this set, we normalize to the
|
||||
/// <c>/</c> path; otherwise the original <c>@</c>-prefixed text
|
||||
/// is preserved so ACE's command handler sees it.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> AllKnownVerbs = BuildKnownVerbs();
|
||||
|
||||
private static HashSet<string> BuildKnownVerbs()
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var v in SayAliases) set.Add(v);
|
||||
foreach (var v in TellAliases) set.Add(v);
|
||||
foreach (var v in ReplyAliases) set.Add(v);
|
||||
foreach (var v in RetellAliases) set.Add(v);
|
||||
foreach (var (v, _) in ChannelVerbs) set.Add(v);
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if <paramref name="verb"/> (with leading
|
||||
/// <c>/</c>) is one this parser routes — used by callers that
|
||||
/// need to distinguish "unknown slash command" from "known
|
||||
/// verb with bad arguments" without reproducing the alias
|
||||
/// tables. <c>@</c>-prefixed verbs need to be normalized to
|
||||
/// <c>/</c> before passing. Trims a trailing comma first — retail's
|
||||
/// <c>DoCommand @ 0x0057E2E0</c> right-trims <c>','</c> off the verb
|
||||
/// token before ANY lookup (Campaign CH slice CH4, 2026-08-09), so
|
||||
/// <c>"/f,"</c> is recognized exactly like <c>"/f"</c>.
|
||||
/// </summary>
|
||||
public static bool IsKnownVerb(string verb) => AllKnownVerbs.Contains(TrimVerbComma(verb));
|
||||
|
||||
/// <summary>
|
||||
/// Every chat-alias / channel verb this parser recognizes (with
|
||||
/// leading <c>/</c>). Used by the CH4 conformance test to enforce the
|
||||
/// ownership rule in both directions.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> KnownVerbs => AllKnownVerbs;
|
||||
|
||||
/// <summary>
|
||||
/// Right-trim a trailing <c>','</c> from a verb token — retail's
|
||||
/// <c>DoCommand</c> trim-char set (<c>0x0079452C</c>, right-trim only)
|
||||
/// applied before every verb-hash-table lookup. <c>"@f, hi"</c> ≡
|
||||
/// <c>"@f hi"</c>.
|
||||
/// </summary>
|
||||
private static string TrimVerbComma(string verb) => verb.TrimEnd(',');
|
||||
|
||||
/// <summary>
|
||||
/// Pull the first whitespace-separated token (the command verb)
|
||||
/// from <paramref name="command"/>. Returns the entire string if
|
||||
/// there is no whitespace.
|
||||
/// </summary>
|
||||
public static string GetVerbToken(string command) => ExtractVerb(command);
|
||||
}
|
||||
95
src/AcDream.Runtime/Chat/ClientCommandId.cs
Normal file
95
src/AcDream.Runtime/Chat/ClientCommandId.cs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Backend-neutral identity for a command that retail executes in the client
|
||||
/// instead of sending as chat text.
|
||||
/// </summary>
|
||||
public enum ClientCommandId
|
||||
{
|
||||
/// <summary>Recall to the character's bound lifestone.</summary>
|
||||
LifestoneRecall,
|
||||
|
||||
MarketplaceRecall,
|
||||
PkArenaRecall,
|
||||
PkLiteArenaRecall,
|
||||
EnterPkLite,
|
||||
HouseRecall,
|
||||
MansionRecall,
|
||||
QueryAge,
|
||||
QueryBirth,
|
||||
ToggleFrameRate,
|
||||
ToggleUiLock,
|
||||
ShowVersion,
|
||||
ShowLocation,
|
||||
ShowLastCorpseLocation,
|
||||
Die,
|
||||
ClearChat,
|
||||
SaveUi,
|
||||
LoadUi,
|
||||
SaveAutoUi,
|
||||
LoadAutoUi,
|
||||
Away,
|
||||
Consent,
|
||||
Emote,
|
||||
ListEmotes,
|
||||
Friends,
|
||||
FriendsAdd,
|
||||
FriendsRemove,
|
||||
Squelch,
|
||||
Unsquelch,
|
||||
Filter,
|
||||
Unfilter,
|
||||
ListMessageTypes,
|
||||
FillComponents,
|
||||
|
||||
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
|
||||
/// <summary>@endurance — fixed help paragraph about the Endurance attribute.</summary>
|
||||
Endurance,
|
||||
/// <summary>@speaker — fixed deprecation notice ("see @allegiance officer").</summary>
|
||||
Speaker,
|
||||
/// <summary>
|
||||
/// @title <text> — retail sets the popup chat window's title;
|
||||
/// acdream's binding is a pure no-op (the value is neither stored nor
|
||||
/// consumed — no title-bar chrome exists yet, AP-182, corrected
|
||||
/// 2026-08-09 at the CH4 REJECT-review nit 11, which found this
|
||||
/// summary's earlier "local state only" wording implied storage that
|
||||
/// does not happen).
|
||||
/// </summary>
|
||||
SetChatTitle,
|
||||
/// <summary>@chat on|off — global Speech squelch toggle (message type 2).</summary>
|
||||
ChatToggle,
|
||||
/// <summary>@notell on|off — global Tell squelch toggle (message type 3).</summary>
|
||||
NoTellToggle,
|
||||
/// <summary>@join <channel tag> — sets the matching PlayerModule::Hear*Chat option on.</summary>
|
||||
JoinChannel,
|
||||
/// <summary>@leave <channel tag> — clears the matching PlayerModule::Hear*Chat option.</summary>
|
||||
LeaveChannel,
|
||||
/// <summary>@permit add|remove <name> — corpse-looting permission management.</summary>
|
||||
Permit,
|
||||
/// <summary>@hslist <type> / "@house available" — list houses available for purchase.</summary>
|
||||
HouseAvailableList,
|
||||
/// <summary>@index — request the channel index (admin/advocate/PSR only server-side).</summary>
|
||||
IndexChannels,
|
||||
/// <summary>@clist <channel> — request the member list of a channel.</summary>
|
||||
ListChannel,
|
||||
/// <summary>@on <channel> — join a GM/faction channel.</summary>
|
||||
OnChannel,
|
||||
/// <summary>@off <channel> — leave a GM/faction channel.</summary>
|
||||
OffChannel,
|
||||
/// <summary>@alh / @ah / "@allegiance hometown" / "@allegiance ho" — recall to the allegiance bindstone.</summary>
|
||||
AllegianceHometown,
|
||||
/// <summary>"@allegiance info [name]" — request allegiance member info.</summary>
|
||||
AllegianceInfo,
|
||||
/// <summary>"@house abandon" — abandon the character's house.</summary>
|
||||
HouseAbandon,
|
||||
/// <summary>
|
||||
/// CH4 REJECT-review Blocker 1 (2026-08-09): "@allegiance"/"@all" with
|
||||
/// any subcommand beyond the 2 ported ones (info, hometown/ho). Never
|
||||
/// dispatched — <see cref="RetailClientCommandCatalog.Match.HasValidArguments"/>
|
||||
/// is always
|
||||
/// false for this id, so <c>ChatCommandRouter</c> shows retail's own
|
||||
/// "Please see @help Allegiance..." refusal and never publishes an
|
||||
/// <c>ExecuteClientCommandCmd</c>.
|
||||
/// </summary>
|
||||
AllegianceUnrecognizedSubcommand,
|
||||
}
|
||||
8
src/AcDream.Runtime/Chat/ExecuteClientCommandCmd.cs
Normal file
8
src/AcDream.Runtime/Chat/ExecuteClientCommandCmd.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// User intent to execute a retail client command. The application host owns
|
||||
/// the corresponding game-state or network action; UI backends only publish
|
||||
/// this record.
|
||||
/// </summary>
|
||||
public sealed record ExecuteClientCommandCmd(ClientCommandId Command, string Arguments);
|
||||
17
src/AcDream.Runtime/Chat/IChatCommandFeedback.cs
Normal file
17
src/AcDream.Runtime/Chat/IChatCommandFeedback.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// The exact feedback surface used by the retail chat command parser/router.
|
||||
/// Presentation hosts may implement it, while headless execution binds these
|
||||
/// members directly to the canonical Runtime communication state.
|
||||
/// </summary>
|
||||
public interface IChatCommandFeedback
|
||||
{
|
||||
void ShowInterfaceText(string text);
|
||||
|
||||
void ShowSystemMessage(string text);
|
||||
|
||||
string? LastIncomingTellSender { get; }
|
||||
|
||||
string? LastOutgoingTellTarget { get; }
|
||||
}
|
||||
24
src/AcDream.Runtime/Chat/ICommandBus.cs
Normal file
24
src/AcDream.Runtime/Chat/ICommandBus.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes user-intent commands from panels to the systems that handle
|
||||
/// them (WorldSession, ChatService, Inventory, ...). Panels never touch
|
||||
/// those systems directly — they <see cref="Publish{T}(T)"/> a record
|
||||
/// and the bus dispatches.
|
||||
///
|
||||
/// <para>
|
||||
/// D.2a scaffolding: <see cref="NullCommandBus"/> is the default wire-up
|
||||
/// — commands are accepted but dropped. Real routing lands alongside
|
||||
/// chat and inventory (Sprint 2 of the UI plan) when we actually need
|
||||
/// commands flowing server-ward.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface ICommandBus
|
||||
{
|
||||
/// <summary>
|
||||
/// Publish a command record. The bus routes by runtime type via
|
||||
/// registered handlers. Never blocks; handlers run on the publish
|
||||
/// thread today (render thread for panel-triggered commands).
|
||||
/// </summary>
|
||||
void Publish<T>(T command) where T : notnull;
|
||||
}
|
||||
345
src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs
Normal file
345
src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Exact live-session dependencies for the four chat-core command records.
|
||||
/// Both graphical and no-window hosts bind this record to the active
|
||||
/// <c>WorldSession</c>'s send methods and the same canonical Runtime state.
|
||||
/// </summary>
|
||||
public sealed record LiveChatCommandBindings(
|
||||
Action<ExecuteClientCommandCmd> ExecuteClientCommand,
|
||||
RuntimeCommunicationState Communication,
|
||||
ChatLog Chat,
|
||||
TurbineChatState TurbineChat,
|
||||
RuntimeCharacterState CharacterState,
|
||||
Func<uint> PlayerGuid,
|
||||
Action<string> SendTalk,
|
||||
Action<string, string> SendTell,
|
||||
Action<uint, string> SendChannel,
|
||||
Action<uint, uint, uint, uint, string, uint> SendTurbineChat,
|
||||
Action<string>? Log = null);
|
||||
|
||||
/// <summary>
|
||||
/// One generation's active binding for the four chat-core records. The route
|
||||
/// becomes inert before the transport is disposed and clears every delegate
|
||||
/// during teardown.
|
||||
/// </summary>
|
||||
public sealed class LiveChatCommandRoute
|
||||
: ILiveSessionCommandRouting,
|
||||
ICommandBus
|
||||
{
|
||||
private static readonly Dictionary<ChatChannelKind, ChatChannelKindLite>
|
||||
TurbineChannelKinds = new()
|
||||
{
|
||||
[ChatChannelKind.Allegiance] = ChatChannelKindLite.Allegiance,
|
||||
[ChatChannelKind.General] = ChatChannelKindLite.General,
|
||||
[ChatChannelKind.Trade] = ChatChannelKindLite.Trade,
|
||||
[ChatChannelKind.Lfg] = ChatChannelKindLite.Lfg,
|
||||
[ChatChannelKind.Roleplay] = ChatChannelKindLite.Roleplay,
|
||||
[ChatChannelKind.Society] = ChatChannelKindLite.Society,
|
||||
[ChatChannelKind.Olthoi] = ChatChannelKindLite.Olthoi,
|
||||
};
|
||||
|
||||
private readonly object _gate = new();
|
||||
private LiveCommandBus? _commands;
|
||||
private int _state; // 0 = constructed, 1 = active, 2 = disposed
|
||||
|
||||
public LiveChatCommandRoute(LiveChatCommandBindings bindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
ArgumentNullException.ThrowIfNull(bindings.ExecuteClientCommand);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Communication);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Chat);
|
||||
ArgumentNullException.ThrowIfNull(bindings.TurbineChat);
|
||||
ArgumentNullException.ThrowIfNull(bindings.CharacterState);
|
||||
ArgumentNullException.ThrowIfNull(bindings.PlayerGuid);
|
||||
ArgumentNullException.ThrowIfNull(bindings.SendTalk);
|
||||
ArgumentNullException.ThrowIfNull(bindings.SendTell);
|
||||
ArgumentNullException.ThrowIfNull(bindings.SendChannel);
|
||||
ArgumentNullException.ThrowIfNull(bindings.SendTurbineChat);
|
||||
|
||||
var commands = new LiveCommandBus();
|
||||
commands.Register<ExecuteClientCommandCmd>(command =>
|
||||
SendIfActive(() => bindings.ExecuteClientCommand(command)));
|
||||
commands.Register<SendServerCommandCmd>(command =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(command.Text))
|
||||
SendIfActive(() => bindings.SendTalk(command.Text));
|
||||
});
|
||||
commands.Register<SendChatCmd>(command => RouteChat(bindings, command));
|
||||
commands.Register<SendRawChannelCmd>(command =>
|
||||
SendIfActive(() =>
|
||||
bindings.SendChannel(command.ChannelId, command.Text)));
|
||||
_commands = commands;
|
||||
}
|
||||
|
||||
public bool IsActive
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
return _state == 1;
|
||||
}
|
||||
}
|
||||
|
||||
public void Activate()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state == 2)
|
||||
throw new ObjectDisposedException(nameof(LiveChatCommandRoute));
|
||||
_state = 1;
|
||||
}
|
||||
}
|
||||
|
||||
public void Publish<T>(T command) where T : notnull
|
||||
{
|
||||
if (!TryPublish(command))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[LiveChatCommandRoute] unsupported command type "
|
||||
+ $"{typeof(T).FullName}; dropping.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes one of the four extracted command records and returns
|
||||
/// <see langword="true"/>. Other records are left to a host's sibling
|
||||
/// command router and return <see langword="false"/> without logging.
|
||||
/// </summary>
|
||||
public bool TryPublish<T>(T command) where T : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
Type type = typeof(T);
|
||||
if (type != typeof(ExecuteClientCommandCmd)
|
||||
&& type != typeof(SendServerCommandCmd)
|
||||
&& type != typeof(SendChatCmd)
|
||||
&& type != typeof(SendRawChannelCmd))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state == 1)
|
||||
_commands?.Publish(command);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
LiveCommandBus? commands;
|
||||
lock (_gate)
|
||||
{
|
||||
_state = 2;
|
||||
commands = _commands;
|
||||
_commands = null;
|
||||
}
|
||||
commands?.Clear();
|
||||
}
|
||||
|
||||
private void RouteChat(
|
||||
LiveChatCommandBindings bindings,
|
||||
SendChatCmd command)
|
||||
{
|
||||
if (string.IsNullOrEmpty(command.Text))
|
||||
return;
|
||||
|
||||
switch (command.Channel)
|
||||
{
|
||||
case ChatChannelKind.Say:
|
||||
SendIfActive(() => bindings.SendTalk(command.Text));
|
||||
return;
|
||||
|
||||
case ChatChannelKind.Tell:
|
||||
if (string.IsNullOrEmpty(command.TargetName))
|
||||
return;
|
||||
if (!SendIfActive(() =>
|
||||
bindings.SendTell(command.TargetName, command.Text)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
bindings.Chat.OnSelfSent(
|
||||
ChatKind.Tell,
|
||||
command.Text,
|
||||
logTextType: (uint)RetailLogTextType.SpeechDirectSend,
|
||||
targetOrChannel: command.TargetName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.Channel == ChatChannelKind.Allegiance
|
||||
&& !bindings.TurbineChat.Enabled)
|
||||
{
|
||||
RouteLegacyChannel(
|
||||
bindings,
|
||||
ChatChannelKind.AllegianceBroadcast,
|
||||
command.Text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (TurbineChannelKinds.TryGetValue(
|
||||
command.Channel,
|
||||
out ChatChannelKindLite liteKind))
|
||||
{
|
||||
RouteTurbineChat(bindings, liteKind, command.Text);
|
||||
return;
|
||||
}
|
||||
|
||||
RouteLegacyChannel(bindings, command.Channel, command.Text);
|
||||
}
|
||||
|
||||
private void RouteTurbineChat(
|
||||
LiveChatCommandBindings bindings,
|
||||
ChatChannelKindLite kind,
|
||||
string text)
|
||||
{
|
||||
TurbineChatState turbineChat = bindings.TurbineChat;
|
||||
TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate(
|
||||
kind,
|
||||
turbineChat,
|
||||
bindings.CharacterState.Options,
|
||||
bindings.CharacterState.IsOlthoiPlayer);
|
||||
|
||||
if (gate.Status != TurbineChatGateStatus.Allowed)
|
||||
{
|
||||
if (TurbineChatMembershipGate.ResolveRefusalText(gate) is
|
||||
(string refusalText, RetailLogTextType refusalType))
|
||||
{
|
||||
bindings.Communication.AddText(refusalText, refusalType);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
uint cookie = turbineChat.NextContextId();
|
||||
uint senderGuid = bindings.PlayerGuid();
|
||||
bindings.Log?.Invoke(
|
||||
$"chat: outbound TurbineChat {gate.DisplayName} "
|
||||
+ $"room=0x{gate.RoomId:X8} chatType={gate.ChatType} "
|
||||
+ $"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={text.Length}");
|
||||
SendIfActive(() => bindings.SendTurbineChat(
|
||||
gate.RoomId,
|
||||
gate.ChatType,
|
||||
(uint)TurbineChat.DispatchType.SendToRoomById,
|
||||
senderGuid,
|
||||
text,
|
||||
cookie));
|
||||
}
|
||||
|
||||
private void RouteLegacyChannel(
|
||||
LiveChatCommandBindings bindings,
|
||||
ChatChannelKind channel,
|
||||
string text)
|
||||
{
|
||||
ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(channel);
|
||||
if (legacy is null)
|
||||
{
|
||||
bindings.Log?.Invoke(
|
||||
$"chat: SendChatCmd kind={channel} dropped (no legacy id)");
|
||||
return;
|
||||
}
|
||||
|
||||
bindings.Log?.Invoke(
|
||||
$"chat: outbound legacy ChatChannel {legacy.Value.DisplayName} "
|
||||
+ $"id=0x{legacy.Value.ChannelId:X8} len={text.Length}");
|
||||
if (!SendIfActive(() =>
|
||||
bindings.SendChannel(legacy.Value.ChannelId, text)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool serverEchoes = new ChatChannelInfo.Legacy(
|
||||
legacy.Value.ChannelId,
|
||||
legacy.Value.DisplayName).IsSelfEchoChannel();
|
||||
if (serverEchoes)
|
||||
return;
|
||||
|
||||
bindings.Chat.OnSelfSent(
|
||||
ChatKind.Channel,
|
||||
text,
|
||||
targetOrChannel: legacy.Value.DisplayName,
|
||||
logTextType: LegacyChannelChatType.Resolve(
|
||||
legacy.Value.ChannelId,
|
||||
ownSend: true));
|
||||
}
|
||||
|
||||
private bool SendIfActive(Action send)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state != 1)
|
||||
return false;
|
||||
send();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stable host-owned bus over a replaceable generation route. A retained
|
||||
/// login-command runner never captures an obsolete transport.
|
||||
/// </summary>
|
||||
public sealed class LiveChatCommandSurface : ICommandBus
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private LiveChatCommandRoute? _active;
|
||||
|
||||
public ILiveSessionCommandRouting Attach(LiveChatCommandRoute route)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(route);
|
||||
lock (_gate)
|
||||
{
|
||||
if (_active is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A live chat command route is already attached.");
|
||||
}
|
||||
_active = route;
|
||||
return new RouteLease(this, route);
|
||||
}
|
||||
}
|
||||
|
||||
public void Publish<T>(T command) where T : notnull
|
||||
{
|
||||
LiveChatCommandRoute? route;
|
||||
lock (_gate)
|
||||
route = _active;
|
||||
route?.Publish(command);
|
||||
}
|
||||
|
||||
private void Release(LiveChatCommandRoute expected)
|
||||
{
|
||||
expected.Dispose();
|
||||
lock (_gate)
|
||||
{
|
||||
if (ReferenceEquals(_active, expected))
|
||||
_active = null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RouteLease(
|
||||
LiveChatCommandSurface owner,
|
||||
LiveChatCommandRoute route)
|
||||
: ILiveSessionCommandRouting
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private LiveChatCommandSurface? _owner = owner;
|
||||
|
||||
public void Activate() => route.Activate();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_owner is null)
|
||||
return;
|
||||
_owner.Release(route);
|
||||
_owner = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/AcDream.Runtime/Chat/LiveCommandBus.cs
Normal file
71
src/AcDream.Runtime/Chat/LiveCommandBus.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Real <see cref="ICommandBus"/> implementation — single-handler-per-type
|
||||
/// dispatch keyed by <c>typeof(T)</c>. Replaces <see cref="NullCommandBus"/>
|
||||
/// in live sessions; <see cref="NullCommandBus"/> persists for tests and
|
||||
/// non-live UI scenarios where no command flow is wanted.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Threading.</b> Both <see cref="Register{T}"/> and <see cref="Publish{T}"/>
|
||||
/// run on the render thread today (panels render on the render thread, and
|
||||
/// host wiring happens at startup). The internal handler dictionary is
|
||||
/// not synchronized — register all handlers during host setup before the
|
||||
/// panel host starts rendering.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Phase I.3 of the chat/UI consolidation plan
|
||||
/// (<c>~/.claude/plans/ticklish-conjuring-cake.md</c>): primary client of
|
||||
/// the bus is the <see cref="SendChatCmd"/> handler wired by GameWindow
|
||||
/// against <c>WorldSession.SendTalk/SendTell/SendChannel</c> + the local
|
||||
/// <c>ChatLog</c> echo.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class LiveCommandBus : ICommandBus
|
||||
{
|
||||
private readonly Dictionary<Type, Delegate> _handlers = new();
|
||||
|
||||
/// <summary>
|
||||
/// Register a single handler for commands of type <typeparamref name="T"/>.
|
||||
/// Throws <see cref="InvalidOperationException"/> if a handler is already
|
||||
/// registered for this type — single-handler-per-type is intentional so
|
||||
/// command routing is unambiguous.
|
||||
/// </summary>
|
||||
public void Register<T>(Action<T> handler) where T : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
if (_handlers.ContainsKey(typeof(T)))
|
||||
throw new InvalidOperationException(
|
||||
$"A handler for command type {typeof(T).FullName} is already registered.");
|
||||
_handlers[typeof(T)] = handler;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Publish<T>(T command) where T : notnull
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
if (_handlers.TryGetValue(typeof(T), out var handler))
|
||||
{
|
||||
((Action<T>)handler).Invoke(command);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Soft-warn: command published with no registered handler.
|
||||
// Don't throw — the host may publish optional commands a non-
|
||||
// live build doesn't wire (e.g. inventory pre-Phase I.7).
|
||||
Console.WriteLine(
|
||||
$"[LiveCommandBus] no handler registered for {typeof(T).FullName}; dropping.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Release every registered handler. Session-scoped owners call this
|
||||
/// during teardown so a retained bus cannot keep an obsolete transport or
|
||||
/// host object graph alive.
|
||||
/// </summary>
|
||||
public void Clear() => _handlers.Clear();
|
||||
}
|
||||
170
src/AcDream.Runtime/Chat/LoginCommandSequence.cs
Normal file
170
src/AcDream.Runtime/Chat/LoginCommandSequence.cs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
public readonly record struct LoginCommandFailure(
|
||||
int CommandIndex,
|
||||
string Command,
|
||||
string Error);
|
||||
|
||||
/// <summary>
|
||||
/// Executes configured login lines through the same parser and command bus as
|
||||
/// typed chat. A sequence is armed only by an entered-world edge, belongs to
|
||||
/// one exact Runtime generation, and never lets one command or status-report
|
||||
/// failure abort the remaining lines or the session.
|
||||
/// </summary>
|
||||
public sealed class LoginCommandSequence
|
||||
{
|
||||
private readonly string[] _commands;
|
||||
private readonly TimeSpan _delay;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IChatCommandFeedback _feedback;
|
||||
private readonly ICommandBus _bus;
|
||||
private readonly Action<LoginCommandFailure> _onFailure;
|
||||
private RuntimeGenerationToken _generation;
|
||||
private RuntimeGenerationToken? _lastStartedGeneration;
|
||||
private long _nextDeadline;
|
||||
private int _nextIndex;
|
||||
private bool _active;
|
||||
|
||||
public LoginCommandSequence(
|
||||
IEnumerable<string?>? commands,
|
||||
TimeSpan delay,
|
||||
IChatCommandFeedback feedback,
|
||||
ICommandBus bus,
|
||||
Action<LoginCommandFailure>? onFailure = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
if (delay < TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(delay));
|
||||
ArgumentNullException.ThrowIfNull(feedback);
|
||||
ArgumentNullException.ThrowIfNull(bus);
|
||||
|
||||
_commands = commands?.Select(static command => command ?? string.Empty)
|
||||
.ToArray() ?? [];
|
||||
_delay = delay;
|
||||
_feedback = feedback;
|
||||
_bus = bus;
|
||||
_onFailure = onFailure ?? (static _ => { });
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public int CommandCount => _commands.Length;
|
||||
public bool IsActive => _active;
|
||||
public int NextCommandIndex => _nextIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Arms this generation once and executes its first due command
|
||||
/// immediately. Repeated entered-world callbacks in the same generation
|
||||
/// are ignored; a reconnect generation starts the list again from zero.
|
||||
/// </summary>
|
||||
public void EnteredWorld(RuntimeGenerationToken generation)
|
||||
{
|
||||
if (_lastStartedGeneration == generation)
|
||||
return;
|
||||
|
||||
_lastStartedGeneration = generation;
|
||||
_generation = generation;
|
||||
_nextIndex = 0;
|
||||
_active = _commands.Length > 0;
|
||||
_nextDeadline = _timeProvider.GetTimestamp();
|
||||
DrainDue(generation, isInWorld: true);
|
||||
}
|
||||
|
||||
public void Tick(
|
||||
RuntimeGenerationToken generation,
|
||||
bool isInWorld)
|
||||
{
|
||||
DrainDue(generation, isInWorld);
|
||||
}
|
||||
|
||||
public void Cancel(RuntimeGenerationToken generation)
|
||||
{
|
||||
if (_active && _generation == generation)
|
||||
_active = false;
|
||||
}
|
||||
|
||||
private void DrainDue(
|
||||
RuntimeGenerationToken generation,
|
||||
bool isInWorld)
|
||||
{
|
||||
if (!_active || !isInWorld || generation != _generation)
|
||||
return;
|
||||
|
||||
long now = _timeProvider.GetTimestamp();
|
||||
while (_active
|
||||
&& generation == _generation
|
||||
&& _nextIndex < _commands.Length
|
||||
&& now >= _nextDeadline)
|
||||
{
|
||||
int commandIndex = _nextIndex;
|
||||
string command = _commands[commandIndex];
|
||||
try
|
||||
{
|
||||
SubmitOutcome outcome = ChatCommandRouter.Submit(
|
||||
command,
|
||||
_feedback,
|
||||
_bus,
|
||||
ChatChannelKind.Say);
|
||||
if (outcome is SubmitOutcome.UnknownCommand
|
||||
or SubmitOutcome.Dropped)
|
||||
{
|
||||
ReportFailure(new LoginCommandFailure(
|
||||
commandIndex,
|
||||
command,
|
||||
$"Chat command routing returned {outcome}."));
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
ReportFailure(new LoginCommandFailure(
|
||||
commandIndex,
|
||||
command,
|
||||
error.GetBaseException().Message));
|
||||
}
|
||||
|
||||
// The command handler may have synchronously stopped or replaced
|
||||
// the session. Cancel() then owns the state; never advance an old
|
||||
// generation after returning from user-controlled code.
|
||||
if (!_active || generation != _generation)
|
||||
return;
|
||||
|
||||
_nextIndex++;
|
||||
if (_nextIndex >= _commands.Length)
|
||||
{
|
||||
_active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Inter-command delay starts after the prior handler returns.
|
||||
// A slow synchronous wire/client handler must not consume the
|
||||
// configured delay merely by taking time itself.
|
||||
now = _timeProvider.GetTimestamp();
|
||||
_nextDeadline = Add(_timeProvider, now, _delay);
|
||||
}
|
||||
}
|
||||
|
||||
private void ReportFailure(LoginCommandFailure failure)
|
||||
{
|
||||
try
|
||||
{
|
||||
_onFailure(failure);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Status/diagnostic reporting observes the sequence. It can never
|
||||
// poison login command execution or the session transaction.
|
||||
}
|
||||
}
|
||||
|
||||
private static long Add(
|
||||
TimeProvider provider,
|
||||
long timestamp,
|
||||
TimeSpan duration)
|
||||
{
|
||||
double delta = duration.TotalSeconds * provider.TimestampFrequency;
|
||||
if (delta >= long.MaxValue - timestamp)
|
||||
return long.MaxValue;
|
||||
return checked(timestamp + (long)Math.Ceiling(delta));
|
||||
}
|
||||
}
|
||||
21
src/AcDream.Runtime/Chat/NullCommandBus.cs
Normal file
21
src/AcDream.Runtime/Chat/NullCommandBus.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// No-op <see cref="ICommandBus"/>. Accepts any published command and
|
||||
/// discards it. Used as the default in D.2a until chat / inventory panels
|
||||
/// need real command routing.
|
||||
/// </summary>
|
||||
public sealed class NullCommandBus : ICommandBus
|
||||
{
|
||||
/// <summary>Shared singleton — the bus is stateless.</summary>
|
||||
public static readonly NullCommandBus Instance = new();
|
||||
|
||||
private NullCommandBus() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Publish<T>(T command) where T : notnull
|
||||
{
|
||||
// Intentionally empty. Panel-emitted commands in D.2a are
|
||||
// read-only diagnostics; nothing routes server-ward yet.
|
||||
}
|
||||
}
|
||||
162
src/AcDream.Runtime/Chat/RetailChannelTagTable.cs
Normal file
162
src/AcDream.Runtime/Chat/RetailChannelTagTable.cs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
using System.Collections.Frozen;
|
||||
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>ChannelSystem::GetChannelID @ 0x005CF1F0</c> — every legacy
|
||||
/// <c>ChatChannel</c> bitflag tag, both the ones with a registered send verb
|
||||
/// (fellowship/vassals/patron/monarch/covassals/allegiance-broadcast — those
|
||||
/// ride <see cref="ChatInputParser"/>'s normal channel-verb path) and the 22
|
||||
/// GM/faction tags that have NO registered verb and are only reachable
|
||||
/// through <c>DoChannelCommand @ 0x005774A0</c>'s fallback.
|
||||
///
|
||||
/// <para>
|
||||
/// Two production consumers:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ChatCommandRouter"/>'s A.4 fallback dispatch — an
|
||||
/// unrecognized <c>/</c>/<c>@</c> verb that is NOT already a known
|
||||
/// <see cref="ChatInputParser"/> verb gets one more lookup here before
|
||||
/// falling through to server passthrough. Only the 22 unregistered tags
|
||||
/// are ever actually reached this way, since every registered tag is
|
||||
/// intercepted earlier by <see cref="ChatInputParser.IsKnownVerb"/>.</item>
|
||||
/// <item><c>@clist</c>/<c>@on</c>/<c>@off</c> argument resolution
|
||||
/// (<see cref="RetailClientCommandCatalog"/>) — these three commands
|
||||
/// accept ANY channel tag, registered or not, so they consult the full
|
||||
/// table.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Bit values cross-checked against
|
||||
/// <c>references/ACE/Source/ACE.Entity/Enum/Channel.cs</c> (ACE's own
|
||||
/// <c>[Flags] enum Channel</c>, which documents the exact retail PDB
|
||||
/// S_CONSTANT dump for every id) — byte-identical to the values recovered
|
||||
/// from <c>docs/research/2026-08-09-chat-retail-command-registry.md</c> §2.3.
|
||||
/// <c>Help (0x400)</c> is deliberately excluded: retail's own
|
||||
/// <c>DoChannelCommand</c> explicitly rejects it
|
||||
/// (<c>id == 0 || id == 0x400</c> → return 0), and it can never reach this
|
||||
/// table anyway since <c>/help</c>/<c>@help</c> is intercepted earlier by
|
||||
/// <see cref="ChatCommandRouter"/>'s local presentation command.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RetailChannelTagTable
|
||||
{
|
||||
private static readonly FrozenDictionary<string, uint> ByTag =
|
||||
new Dictionary<string, uint>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// The 22 tags with NO registered send verb (retail-registry doc
|
||||
// §2.3's fallback table) — the only ones actually reachable
|
||||
// through ChatCommandRouter's A.4 dispatch.
|
||||
["abuse"] = 0x00000001u,
|
||||
["ad"] = 0x00000002u,
|
||||
["admin"] = 0x00000002u,
|
||||
["au"] = 0x00000004u,
|
||||
["audit"] = 0x00000004u,
|
||||
["av"] = 0x00000008u,
|
||||
["av1"] = 0x00000008u,
|
||||
["advocate"] = 0x00000008u,
|
||||
["advocate1"] = 0x00000008u,
|
||||
["av2"] = 0x00000010u,
|
||||
["advocate2"] = 0x00000010u,
|
||||
["av3"] = 0x00000020u,
|
||||
["advocate3"] = 0x00000020u,
|
||||
["sent"] = 0x00000200u,
|
||||
["sentinel"] = 0x00000200u,
|
||||
["celestialhand"] = 0x08000000u,
|
||||
["celhan"] = 0x08000000u,
|
||||
["eldrytchweb"] = 0x10000000u,
|
||||
["eldweb"] = 0x10000000u,
|
||||
["radiantblood"] = 0x20000000u,
|
||||
["radblo"] = 0x20000000u,
|
||||
["ol"] = 0x40000000u,
|
||||
["olthoi"] = 0x40000000u,
|
||||
|
||||
// Registered-verb tags (already reachable via ChatInputParser's
|
||||
// normal channel-verb path — OR, for "allegiance"/"all", via
|
||||
// RetailClientCommandCatalog's unconditional ownership of that
|
||||
// verb, see TryMatchAllegiance). Present here ONLY so
|
||||
// @clist/@on/@off accept the same tag spellings retail's
|
||||
// GetChannelID resolves — ChatCommandRouter's A.4 fallback never
|
||||
// reaches these entries: "a"/"ab"/"fellowship"/"vassals"/
|
||||
// "patron"/"monarch"/"co-vassals"/etc. are intercepted by
|
||||
// ChatInputParser.IsKnownVerb; "allegiance" is intercepted
|
||||
// EARLIER still, by RetailClientCommandCatalog.TryMatch claiming
|
||||
// the verb before A.4 dispatch is ever attempted (CH4
|
||||
// REJECT-review Blocker 1, 2026-08-09) — the previous wording
|
||||
// here ("IsKnownVerb intercepts them first", unqualified) was
|
||||
// false for "allegiance" specifically, and that gap is exactly
|
||||
// how the broadcast-to-channel bug happened: an unmatched
|
||||
// subcommand fell through past both catalog and IsKnownVerb,
|
||||
// all the way to this table's own "allegiance" entry.
|
||||
["fellowship"] = 0x00000800u,
|
||||
["fellow"] = 0x00000800u,
|
||||
["fellows"] = 0x00000800u,
|
||||
["f"] = 0x00000800u,
|
||||
["group"] = 0x00000800u,
|
||||
["g"] = 0x00000800u,
|
||||
["party"] = 0x00000800u,
|
||||
["vassals"] = 0x00001000u,
|
||||
["vassal"] = 0x00001000u,
|
||||
["v"] = 0x00001000u,
|
||||
["patron"] = 0x00002000u,
|
||||
["p"] = 0x00002000u,
|
||||
["monarch"] = 0x00004000u,
|
||||
["m"] = 0x00004000u,
|
||||
["covassals"] = 0x01000000u,
|
||||
["covassal"] = 0x01000000u,
|
||||
["co-vassals"] = 0x01000000u,
|
||||
["c"] = 0x01000000u,
|
||||
["a"] = 0x02000000u,
|
||||
["ab"] = 0x02000000u,
|
||||
["allegiance"] = 0x02000000u,
|
||||
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Resolve a channel tag (no leading <c>/</c> or <c>@</c>) to its legacy bitflag id.</summary>
|
||||
public static bool TryResolve(string tag, out uint channelId) =>
|
||||
ByTag.TryGetValue(tag, out channelId);
|
||||
|
||||
/// <summary>
|
||||
/// Tag strings that have a genuine send verb elsewhere — either
|
||||
/// <see cref="ChatInputParser"/>'s channel-verb table, or, for
|
||||
/// "allegiance"/"all", <see cref="RetailClientCommandCatalog"/>'s
|
||||
/// unconditional ownership of that verb — even though they also
|
||||
/// resolve in <see cref="ByTag"/> above. "olthoi" is the deliberate
|
||||
/// odd one out: retail's <c>GetChannelID</c> fallback resolves it
|
||||
/// PRE-Turbine (registry doc §2.3, "ol (and olthoi pre-Turbine)"), but
|
||||
/// once <c>StartupTurbineChatSystem</c> runs — acdream's assumed
|
||||
/// steady state, see <c>TurbineChatState</c> — "olthoi"/"o" become
|
||||
/// registered Turbine verbs (<c>DoTurbineChat_Olthoi</c>, §2.4) and
|
||||
/// <see cref="ChatInputParser"/> owns them instead. "ol" itself is
|
||||
/// never added by <c>StartupTurbineChatSystem</c>, so it remains a
|
||||
/// genuine fallback-only tag in every state.
|
||||
/// </summary>
|
||||
private static readonly FrozenSet<string> RegisteredVerbTags =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"fellowship", "fellow", "fellows", "f", "group", "g", "party",
|
||||
"vassals", "vassal", "v",
|
||||
"patron", "p",
|
||||
"monarch", "m",
|
||||
"covassals", "covassal", "co-vassals", "c",
|
||||
"a", "ab", "allegiance",
|
||||
"olthoi",
|
||||
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// True only for tags that have NO registered send verb anywhere — the
|
||||
/// actual reachable set of <see cref="ChatCommandRouter"/>'s A.4
|
||||
/// fallback dispatch. Used by the conformance test to enumerate exactly
|
||||
/// the registry doc's §2.3 fallback list.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// CH4 REJECT-review nit 12 (2026-08-09): previously excluded by
|
||||
/// channel-ID membership, which wrongly reported "olthoi" as
|
||||
/// unregistered — it shares id <c>0x40000000</c> with the genuinely-
|
||||
/// unregistered "ol", but "olthoi" (unlike "ol") has its own
|
||||
/// registered Turbine verb. Excluding by TAG STRING instead
|
||||
/// (<see cref="RegisteredVerbTags"/>) fixes "olthoi" without changing
|
||||
/// "ol"'s (correct, unregistered) answer.
|
||||
/// </remarks>
|
||||
public static bool IsUnregisteredFallbackTag(string tag) =>
|
||||
ByTag.ContainsKey(tag) && !RegisteredVerbTags.Contains(tag);
|
||||
}
|
||||
770
src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs
Normal file
770
src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs
Normal file
|
|
@ -0,0 +1,770 @@
|
|||
using System.Collections.Frozen;
|
||||
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable catalog of commands the named retail client executes locally.
|
||||
/// This is intentionally separate from chat aliases and ACE server commands.
|
||||
///
|
||||
/// <para>
|
||||
/// 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
|
||||
{
|
||||
private sealed record Definition(
|
||||
ClientCommandId Command,
|
||||
string Usage,
|
||||
string HelpText,
|
||||
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,
|
||||
string? InvalidArgumentsText);
|
||||
|
||||
// ClientCommunicationSystem::DoLifestone @ 0x0056FC70. Consolidated
|
||||
// review round (2026-08-10), SHOULD-FIX 3: retires register row
|
||||
// AP-113. DoLifestone's own bad-args branch prints ITS OWN 0x1A string
|
||||
// and returns 1 — retail never reaches the generic HandleFailureEvent
|
||||
// (0x26) fallback for it. Binary Ninja mis-attributes the literal to
|
||||
// an unrelated vtable-slot symbol
|
||||
// (ClientCommunicationSystem::`vftable'.RecvNotice_AddItemToTrade,
|
||||
// the classic pooled-string artifact); recovered byte-exact by reading
|
||||
// the raw `push imm32` operand at 0x0056fc84 and decoding the UTF-16LE
|
||||
// string at data VA 0x007d0578 directly against the PDB-paired
|
||||
// C:\Users\erikn\Downloads\acclient.exe (verified MATCH).
|
||||
private static readonly Definition Lifestone = new(
|
||||
ClientCommandId.LifestoneRecall,
|
||||
Usage: "/lifestone",
|
||||
HelpText: "/lifestone (/lif, /ls) - Returns you to the last lifestone you used without killing you.",
|
||||
ValidateArguments: static arguments => arguments.Length == 0,
|
||||
InvalidArgumentsText: "Please see @help lifestone for more information on how to use this command.");
|
||||
|
||||
// ClientCommunicationSystem::DoMarketplace @ 0x0056FCE0. Same
|
||||
// methodology and same vtable-mislabeling artifact as Lifestone above
|
||||
// (mis-attributed to RecvNotice_UpdateToolbarSelectionDisplay);
|
||||
// operand at 0x0056fcf4, data VA 0x007d0610. Not a previously-filed
|
||||
// register row — Marketplace was never flagged as diverging — this is
|
||||
// a plain accuracy improvement alongside Lifestone's AP-113 retirement.
|
||||
private static readonly Definition Marketplace = new(
|
||||
ClientCommandId.MarketplaceRecall,
|
||||
Usage: "/marketplace",
|
||||
HelpText: "/marketplace (/mar, /mp) - Teleports you to the Marketplace of Dereth.",
|
||||
ValidateArguments: static arguments => arguments.Length == 0,
|
||||
InvalidArgumentsText: "Please see @help marketplace for more information on how to use this command.");
|
||||
|
||||
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.");
|
||||
|
||||
// ClientCommunicationSystem::DoPKLite/HelpPKLite @ 0x0057A490/0x0057A540.
|
||||
// Retail registers exactly one verb string ("pklite" @ 0x007E16B0) —
|
||||
// Campaign CH slice CH4 (2026-08-09) added the "pkl" alias per the
|
||||
// command-registry doc §2.6.
|
||||
private static readonly Definition PkLite = NoArguments(
|
||||
ClientCommandId.EnterPkLite,
|
||||
"/pklite",
|
||||
"@pklite (@pkl) - Sets your status to Player Killer Lite. Type @help pklite for more details.");
|
||||
|
||||
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.");
|
||||
|
||||
// GameActionHouseAbandon.Handle / retail help data_7dd3d0:
|
||||
// "@house abandon - Abandons your house.\n"
|
||||
private static readonly Definition HouseAbandon = NoArguments(
|
||||
ClientCommandId.HouseAbandon,
|
||||
"/house abandon",
|
||||
"@house abandon - Abandons your house.");
|
||||
|
||||
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.");
|
||||
|
||||
// Campaign CH slice CH4 (2026-08-09): retail registers exactly one
|
||||
// handler (DoMessageTypes @ 0x0057A010) under four verb strings —
|
||||
// "messagetypes", "message_types", "msgtypes", "msg_types" — all
|
||||
// aliases of the SAME definition, not separate commands.
|
||||
private static readonly Definition MessageTypes = NoArguments(
|
||||
ClientCommandId.ListMessageTypes,
|
||||
"/messagetypes",
|
||||
"/messagetypes (/message_types, /msgtypes, /msg_types) - 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.");
|
||||
|
||||
// ── Campaign CH slice CH4 (2026-08-09) additions ────────────────────
|
||||
|
||||
// ClientCommunicationSystem::DoEndurance @ 0x0057C5F0. Exact retail
|
||||
// text extracted from acclient_2013_pseudo_c.txt:1031097
|
||||
// (data_7de2f8) — the first paragraph only; the full multi-paragraph
|
||||
// block is reproduced verbatim by DoEndurance itself and is long
|
||||
// enough that only the opening line is duplicated here as a teaser —
|
||||
// this is the SAME text callers already see through
|
||||
// ClientCommandController.
|
||||
private static readonly Definition Endurance = NoArguments(
|
||||
ClientCommandId.Endurance,
|
||||
"/endurance",
|
||||
"The endurance attribute has a number of abilities tied to it. Type @help endurance for the full description.");
|
||||
|
||||
// ClientCommunicationSystem::DoSpeaker @ 0x0057DAB0. Exact retail text:
|
||||
// acclient_2013_pseudo_c.txt:393309 / 1031426 (data_7e0cd8).
|
||||
private static readonly Definition Speaker = NoArguments(
|
||||
ClientCommandId.Speaker,
|
||||
"/speaker",
|
||||
"This command is no longer in use, please see @allegiance officer.");
|
||||
|
||||
// ClientCommunicationSystem::DoTitle @ 0x0057A640. Exact retail help:
|
||||
// acclient_2013_pseudo_c.txt:1031162 (data_7df2c4) — "@title <new
|
||||
// title> - Sets the title of the popup chat window.\n". No confirmation
|
||||
// text was found at the success site; acdream's binding is a pure
|
||||
// no-op (the value is neither stored nor consumed — no title-bar
|
||||
// chrome exists to render it yet, AP-182; corrected 2026-08-09 at the
|
||||
// CH4 REJECT-review nit 11, which found the earlier "acdream stores
|
||||
// the title" wording false).
|
||||
private static readonly Definition SetTitle = AnyArguments(
|
||||
ClientCommandId.SetChatTitle,
|
||||
"/title <new title>",
|
||||
"@title <new title> - Sets the title of the popup chat window.");
|
||||
|
||||
// ClientCommunicationSystem::DoChatToggle @ 0x0056FAD0 — Event_
|
||||
// ModifyGlobalSquelch(remove, 2) for "on", (add, 2) for "off". Exact
|
||||
// retail help: acclient_2013_pseudo_c.txt:1030716/1030720.
|
||||
private static readonly Definition ChatToggle = new(
|
||||
ClientCommandId.ChatToggle,
|
||||
Usage: "/chat <on|off>",
|
||||
HelpText: "@chat <on/off> - Sets whether or not you receive normal chat. When set to \"off\", you will no longer receive any spoken speech (normal chat). However, you will still receive tells.",
|
||||
ValidateArguments: static arguments =>
|
||||
arguments.Equals("on", StringComparison.OrdinalIgnoreCase)
|
||||
|| arguments.Equals("off", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// ClientCommunicationSystem::DoNoTell @ 0x0056FBD0 — same mechanism,
|
||||
// message type 3 (Tell). Exact retail help:
|
||||
// acclient_2013_pseudo_c.txt:1030724/1030728.
|
||||
private static readonly Definition NoTellToggle = new(
|
||||
ClientCommandId.NoTellToggle,
|
||||
Usage: "/notell <on|off>",
|
||||
HelpText: "@notell <on/off> - Sets whether or not you receive @tells. When set to \"on\", you will not receive any tells.",
|
||||
ValidateArguments: static arguments =>
|
||||
arguments.Equals("on", StringComparison.OrdinalIgnoreCase)
|
||||
|| arguments.Equals("off", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Tags accepted by <c>@join</c>/<c>@leave</c> mapped to the linear
|
||||
/// <c>SetSingleCharacterOption (0x0005)</c> option id — retail
|
||||
/// PlayerModule::SetHear*Chat flags, per
|
||||
/// <c>AcDream.Core.Net.Messages.CharacterOptionId</c>.
|
||||
/// </summary>
|
||||
public static bool TryResolveJoinLeaveOption(string tag, out uint optionId) =>
|
||||
JoinLeaveTags.TryGetValue(tag.Trim(), out optionId);
|
||||
|
||||
private static readonly FrozenDictionary<string, uint> JoinLeaveTags =
|
||||
new Dictionary<string, uint>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["allegiance"] = 0x1Bu, // CharacterOptionId.ListenToAllegianceChat
|
||||
["general"] = 0x23u, // CharacterOptionId.ListenToGeneralChat
|
||||
["trade"] = 0x24u, // CharacterOptionId.ListenToTradeChat
|
||||
["lfg"] = 0x25u, // CharacterOptionId.ListenToLFGChat
|
||||
["roleplay"] = 0x26u, // CharacterOptionId.ListenToRoleplayChat
|
||||
["society"] = 0x2Eu, // CharacterOptionId.ListenToSocietyChat
|
||||
["soc"] = 0x2Eu,
|
||||
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// ClientCommunicationSystem::DoJoinChat/DoLeaveChat @ 0x0056F510/
|
||||
// 0x0056F7F0. Tags per the registry doc §2.2: Allegiance, General,
|
||||
// Trade, LFG, Roleplay, Society, Soc. Exact retail help:
|
||||
// acclient_2013_pseudo_c.txt:1030689/1030693.
|
||||
private static readonly Definition JoinChannel = new(
|
||||
ClientCommandId.JoinChannel,
|
||||
Usage: "/join <channel tag>",
|
||||
HelpText: "@join <channel tag> - Allows you to hear and speak on the given channel.",
|
||||
ValidateArguments: static arguments => JoinLeaveTags.ContainsKey(arguments.Trim()));
|
||||
|
||||
private static readonly Definition LeaveChannel = new(
|
||||
ClientCommandId.LeaveChannel,
|
||||
Usage: "/leave <channel tag>",
|
||||
HelpText: "@leave <channel tag> - Prevents you from hearing or speaking on the given channel.",
|
||||
ValidateArguments: static arguments => JoinLeaveTags.ContainsKey(arguments.Trim()));
|
||||
|
||||
// ClientCommunicationSystem::DoPermit @ 0x005785A0. Exact retail help:
|
||||
// acclient_2013_pseudo_c.txt:1030850-1030852 (data_7dbac8). CH4
|
||||
// REJECT-review SHOULD-FIX 5 (2026-08-09): DoPermit joins every token
|
||||
// after "add"/"remove" into the name (JoinArgsAsName) so a multi-word
|
||||
// character name works — "@permit add Aunt Agatha" grants Aunt Agatha,
|
||||
// not just "Aunt". The old exactly-2-tokens gate rejected that input
|
||||
// outright; the shape check now only requires a mode word plus AT
|
||||
// LEAST one more token, and ExecutePermit
|
||||
// (ClientCommandController.cs) joins the remainder.
|
||||
private static readonly Definition Permit = new(
|
||||
ClientCommandId.Permit,
|
||||
Usage: "/permit <add|remove> <name>",
|
||||
HelpText: "@permit add <name> - Allows another player to loot your corpse. @permit remove <name> - Removes permission to access your corpse from the named character.",
|
||||
ValidateArguments: static arguments =>
|
||||
{
|
||||
string[] parts = arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length >= 2
|
||||
&& (parts[0].Equals("add", StringComparison.OrdinalIgnoreCase)
|
||||
|| parts[0].Equals("remove", StringComparison.OrdinalIgnoreCase));
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Retail house-type spellings mapped to ACE's <c>HouseType</c> enum
|
||||
/// value (the <c>@hslist</c>/ListAvailableHouses payload).
|
||||
/// </summary>
|
||||
public static bool TryResolveHouseType(string type, out uint houseType) =>
|
||||
HouseTypes.TryGetValue(type.Trim(), out houseType);
|
||||
|
||||
private static readonly FrozenDictionary<string, uint> HouseTypes =
|
||||
new Dictionary<string, uint>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["cottage"] = 1u,
|
||||
["villa"] = 2u,
|
||||
["mansion"] = 3u,
|
||||
["apartment"] = 4u,
|
||||
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// ClientCommunicationSystem::DoHouseAvailableList @ 0x00570510. Exact
|
||||
// retail help: acclient_2013_pseudo_c.txt:1031049 (data_7dd9d0).
|
||||
// #363 / register row AP-183: the bad-args refusal is retail's own
|
||||
// specific string too, verified at acclient_2013_pseudo_c.txt:381481
|
||||
// (AddTextToScroll(..., 0x1a, ...)), full text at
|
||||
// acclient_2013_pseudo_c.txt:1029383 (data_7d0a98) — NOT the
|
||||
// acdream-synthesized "Usage: /hslist <house type>" line this
|
||||
// Definition used to fall back to.
|
||||
private static readonly Definition HouseAvailableList = new(
|
||||
ClientCommandId.HouseAvailableList,
|
||||
Usage: "/hslist <house type>",
|
||||
HelpText: "@hslist <house type> - Lists the number and, if appropriate, positions of houses currently available for purchase. Types include: Apartment, Cottage, Villa, Mansion",
|
||||
ValidateArguments: static arguments => HouseTypes.ContainsKey(arguments.Trim()),
|
||||
InvalidArgumentsText: "Please see @help hslist for more information on how to use this command");
|
||||
|
||||
// ClientCommunicationSystem::DoChannelIndex @ 0x0056E640. No help
|
||||
// string was extracted for the bare form; the verb is admin/advocate/
|
||||
// PSR gated server-side (GameActionChannelIndex.Handle). CH4
|
||||
// REJECT-review nit 14 (2026-08-09): DoChannelIndex ignores its argc —
|
||||
// "@index foo" sends the SAME Event_ChannelIndex() as bare "@index" —
|
||||
// so acdream must accept (and discard) any arguments too, not just none.
|
||||
private static readonly Definition IndexChannels = AnyArguments(
|
||||
ClientCommandId.IndexChannels,
|
||||
"/index",
|
||||
"@index - Requests the channel index (restricted).");
|
||||
|
||||
// ClientCommunicationSystem::DoChannelList @ 0x0057A9B0. Exact retail
|
||||
// no-arg text: acclient_2013_pseudo_c.txt:1031202 (data_7dfaf8)
|
||||
// "Please specify the channel name." CH4 REJECT-review SHOULD-FIX 6
|
||||
// (2026-08-09): retail's own argc check is "!= 1" — a resolved-but-
|
||||
// UNKNOWN tag still reaches the handler and raises
|
||||
// HandleFailureEvent(0x422) ("That channel doesn't exist.",
|
||||
// WeenieErrorMessages[0x422]); only a MISSING or MULTI-WORD argument
|
||||
// prints this usage line locally. Using tag resolution itself as the
|
||||
// argument-shape gate (the old behavior) silently swallowed an unknown
|
||||
// tag instead of raising 0x422 — see ClientCommandController's
|
||||
// dispatch (ListChannel/OnChannel/OffChannel cases) for the
|
||||
// ShowWeenieError(0x422) call this shape-only gate now allows through.
|
||||
private static readonly Definition ListChannel = new(
|
||||
ClientCommandId.ListChannel,
|
||||
Usage: "/clist <channel>",
|
||||
HelpText: "@clist <channel> - Requests the member list of a channel (restricted).",
|
||||
ValidateArguments: static arguments => IsSingleToken(arguments),
|
||||
InvalidArgumentsText: "Please specify the channel name.");
|
||||
|
||||
private static readonly Definition OnChannel = new(
|
||||
ClientCommandId.OnChannel,
|
||||
Usage: "/on <channel>",
|
||||
HelpText: "@on <channel> - Joins a channel (restricted).",
|
||||
ValidateArguments: static arguments => IsSingleToken(arguments),
|
||||
InvalidArgumentsText: "Please specify the channel name.");
|
||||
|
||||
private static readonly Definition OffChannel = new(
|
||||
ClientCommandId.OffChannel,
|
||||
Usage: "/off <channel>",
|
||||
HelpText: "@off <channel> - Leaves a channel (restricted).",
|
||||
ValidateArguments: static arguments => IsSingleToken(arguments),
|
||||
InvalidArgumentsText: "Please specify the channel name.");
|
||||
|
||||
private static bool IsSingleToken(string arguments) =>
|
||||
arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length == 1;
|
||||
|
||||
// GameActionRecallAllegianceHometown.Handle. Exact retail help:
|
||||
// acclient_2013_pseudo_c.txt:1031230 — "@allegiance hometown -
|
||||
// Recalls you to your allegiance bindstone, if your allegiance has
|
||||
// tied to one.\n"
|
||||
private static readonly Definition AllegianceHometown = NoArguments(
|
||||
ClientCommandId.AllegianceHometown,
|
||||
"/alh",
|
||||
"@allegiance hometown (@alh, @ah) - Recalls you to your allegiance bindstone, if your allegiance has tied to one.");
|
||||
|
||||
// GameActionAllegianceInfoRequest.Handle — String16L name, empty = self.
|
||||
// Exact retail help: acclient_2013_pseudo_c.txt:1031214 —
|
||||
// "@allegiance info <name> - Requests information on a member of your
|
||||
// allegiance.\n"
|
||||
private static readonly Definition AllegianceInfo = AnyArguments(
|
||||
ClientCommandId.AllegianceInfo,
|
||||
"/allegiance info [name]",
|
||||
"@allegiance info <name> - Requests information on a member of your allegiance.");
|
||||
|
||||
// ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0. Exact retail
|
||||
// text: acclient_2013_pseudo_c.txt:1031375 (data_7e0bd0) — "Please see
|
||||
// @help Allegiance for more information on how to use this command.".
|
||||
// Printed at label_57da4b (0x0057DA4B) whenever NO subcommand string
|
||||
// matches ANY of the 12 retail dispatches (boot/info/chat/broadcast/
|
||||
// ban/officer/title/hometown/ho/motd/name/lock/house) — retail keeps
|
||||
// this ENTIRELY client-side; DoAllegiance never falls through to
|
||||
// DoChannelCommand or the server for an unrecognized subcommand. CH4
|
||||
// REJECT-review Blocker 1 (2026-08-09): acdream previously let an
|
||||
// unmatched subcommand escape TryMatchAllegiance (return false, "not
|
||||
// owned"), which fell all the way through to the unregistered-tag
|
||||
// channel-fallback and broadcast the raw subcommand text ("boot Bob")
|
||||
// to the legacy Allegiance channel (0x02000000) — a real chat-visible
|
||||
// bug. TryMatchAllegiance below now claims ownership of "allegiance"/
|
||||
// "all" UNCONDITIONALLY, exactly like retail's registered-command hash
|
||||
// table does, and shows this refusal for every subcommand beyond the
|
||||
// 2 ported ones (info/hometown/ho — TS-68 tracks the other 10).
|
||||
private static readonly Definition AllegianceUnrecognizedSubcommand = new(
|
||||
ClientCommandId.AllegianceUnrecognizedSubcommand,
|
||||
Usage: "/allegiance <sub>",
|
||||
HelpText: "Please see @help Allegiance for more information on how to use this command.",
|
||||
ValidateArguments: static _ => false,
|
||||
InvalidArgumentsText: "Please see @help Allegiance for more information on how to use this command.");
|
||||
|
||||
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,
|
||||
["pklite"] = PkLite,
|
||||
["pkl"] = PkLite,
|
||||
["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,
|
||||
["message_types"] = MessageTypes,
|
||||
["msgtypes"] = MessageTypes,
|
||||
["msg_types"] = MessageTypes,
|
||||
["fillcomps"] = FillComponents,
|
||||
["endurance"] = Endurance,
|
||||
["speaker"] = Speaker,
|
||||
["title"] = SetTitle,
|
||||
["chat"] = ChatToggle,
|
||||
["notell"] = NoTellToggle,
|
||||
["join"] = JoinChannel,
|
||||
["leave"] = LeaveChannel,
|
||||
["permit"] = Permit,
|
||||
["hslist"] = HouseAvailableList,
|
||||
["index"] = IndexChannels,
|
||||
["clist"] = ListChannel,
|
||||
["on"] = OnChannel,
|
||||
["off"] = OffChannel,
|
||||
["alh"] = AllegianceHometown,
|
||||
["ah"] = AllegianceHometown,
|
||||
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a complete chat-bar line. Returns false when its verb is not
|
||||
/// client-owned; callers can then try chat aliases or the server-command
|
||||
/// path. Both retail command prefixes are accepted.
|
||||
/// </summary>
|
||||
public static bool TryMatch(string input, out Match match)
|
||||
{
|
||||
match = default;
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return false;
|
||||
|
||||
string trimmed = input.Trim();
|
||||
if (trimmed.Length < 2 || trimmed[0] is not ('/' or '@'))
|
||||
return false;
|
||||
|
||||
int separator = IndexOfWhitespace(trimmed);
|
||||
string verb = separator < 0
|
||||
? trimmed[1..]
|
||||
: trimmed.Substring(1, separator - 1);
|
||||
// Retail DoCommand @ 0x0057E2E0 right-trims ',' off the verb token
|
||||
// before lookup (trim char set 0x0079452C) — "@f, hi" resolves the
|
||||
// SAME as "@f hi". Campaign CH slice CH4 (2026-08-09).
|
||||
verb = verb.TrimEnd(',');
|
||||
string arguments = separator < 0
|
||||
? string.Empty
|
||||
: trimmed[(separator + 1)..].Trim();
|
||||
|
||||
Definition? definition;
|
||||
if (verb.Equals("house", StringComparison.OrdinalIgnoreCase)
|
||||
|| verb.Equals("hou", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TryMatchHouse(arguments, out match);
|
||||
}
|
||||
|
||||
if (verb.Equals("allegiance", StringComparison.OrdinalIgnoreCase)
|
||||
|| verb.Equals("all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TryMatchAllegiance(arguments, out match);
|
||||
}
|
||||
|
||||
if (!ByVerb.TryGetValue(verb, out definition))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
match = new Match(
|
||||
definition.Command,
|
||||
arguments,
|
||||
definition.Usage,
|
||||
definition.ValidateArguments(arguments),
|
||||
definition.InvalidArgumentsText);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>@house <sub></c> / <c>@hou <sub></c> dispatcher.
|
||||
/// Retail's real <c>DoHouse @ 0x00580860</c> handles 15 subcommands
|
||||
/// (see the registry doc §2.5b) locally; acdream Campaign CH slice CH4
|
||||
/// (2026-08-09) ports 4 of them (recall/re, mansion_recall/alleg_recall/
|
||||
/// ma, abandon). Every OTHER subcommand — open, close, storage, remove,
|
||||
/// boot, boot_all, remove_all, guest, available, hooks, on, off, and
|
||||
/// any misspelling of the 4 ported ones — returns <c>false</c>
|
||||
/// uniformly (there is no separate local-swallow branch; CH4
|
||||
/// REJECT-review nit 10, 2026-08-09, corrected this comment, which
|
||||
/// previously described a swallow path that does not exist in the code
|
||||
/// below), letting <see cref="ChatCommandRouter"/> fall through to
|
||||
/// server passthrough (ACE replies "Unknown command") rather than
|
||||
/// being swallowed locally with a wrong usage message — the Tier-1 #4
|
||||
/// fix from the command-registry doc. The 12 unported subcommands are
|
||||
/// tracked by TS-68.
|
||||
/// </summary>
|
||||
private static bool TryMatchHouse(string arguments, out Match match)
|
||||
{
|
||||
match = default;
|
||||
string subcommand = arguments.ToLowerInvariant();
|
||||
Definition? definition = subcommand switch
|
||||
{
|
||||
"recall" or "re" => HouseRecall,
|
||||
"mansion_recall" or "alleg_recall" or "ma" => MansionRecall,
|
||||
"abandon" => HouseAbandon,
|
||||
_ => null,
|
||||
};
|
||||
if (definition is null)
|
||||
return false;
|
||||
|
||||
match = new Match(
|
||||
definition.Command,
|
||||
Arguments: string.Empty,
|
||||
definition.Usage,
|
||||
HasValidArguments: true,
|
||||
InvalidArgumentsText: null);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>@allegiance <sub></c> / <c>@all <sub></c> dispatcher.
|
||||
/// Retail's real <c>DoAllegiance @ 0x0057D5A0</c> handles 12
|
||||
/// subcommands (see the registry doc §2.5) locally; acdream Campaign CH
|
||||
/// slice CH4 (2026-08-09) ports 2 of them (info, hometown/ho).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>CH4 REJECT-review Blocker 1 correction (2026-08-09):</b> every
|
||||
/// OTHER subcommand — boot, ban, officer, title, name, lock, house,
|
||||
/// motd, chat, broadcast, or garbage — is NOT yet ported (TS-68), but
|
||||
/// unlike <see cref="TryMatchHouse"/> this method NEVER returns
|
||||
/// <c>false</c> for the "allegiance"/"all" verb: retail's own
|
||||
/// <c>DoAllegiance</c> claims the ENTIRE verb unconditionally and
|
||||
/// prints its own client-local refusal
|
||||
/// (<see cref="AllegianceUnrecognizedSubcommand"/>) for an unrecognized
|
||||
/// subcommand — it never falls through to <c>DoChannelCommand</c> or
|
||||
/// the server. The original CH4 implementation returned <c>false</c>
|
||||
/// here (matching <see cref="TryMatchHouse"/>'s reasoning), which let
|
||||
/// an unmatched subcommand escape all the way to the unregistered-tag
|
||||
/// channel-fallback and broadcast the raw text to the Allegiance
|
||||
/// channel — a real bug, not merely an incomplete port.
|
||||
/// </remarks>
|
||||
private static bool TryMatchAllegiance(string arguments, out Match match)
|
||||
{
|
||||
int separator = IndexOfWhitespace(arguments);
|
||||
string subcommand = separator < 0 ? arguments : arguments[..separator];
|
||||
string rest = separator < 0 ? string.Empty : arguments[(separator + 1)..].Trim();
|
||||
|
||||
if (subcommand.Equals("hometown", StringComparison.OrdinalIgnoreCase)
|
||||
|| subcommand.Equals("ho", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
match = new Match(
|
||||
AllegianceHometown.Command,
|
||||
Arguments: string.Empty,
|
||||
AllegianceHometown.Usage,
|
||||
HasValidArguments: true,
|
||||
InvalidArgumentsText: null);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (subcommand.Equals("info", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
match = new Match(
|
||||
AllegianceInfo.Command,
|
||||
rest,
|
||||
AllegianceInfo.Usage,
|
||||
HasValidArguments: true,
|
||||
InvalidArgumentsText: null);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Every other subcommand (or none at all) — claim ownership
|
||||
// anyway and show retail's own refusal text. See the remarks
|
||||
// above; this is what stops "allegiance"/"all" from ever reaching
|
||||
// ChatCommandRouter's channel-fallback or server-passthrough path.
|
||||
match = new Match(
|
||||
AllegianceUnrecognizedSubcommand.Command,
|
||||
arguments,
|
||||
AllegianceUnrecognizedSubcommand.Usage,
|
||||
HasValidArguments: false,
|
||||
AllegianceUnrecognizedSubcommand.InvalidArgumentsText);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every verb string this catalog dispatches, INCLUDING the
|
||||
/// specially-parsed "house"/"hou"/"allegiance"/"all" verbs (which are
|
||||
/// not literal keys of the backing dictionary because their dispatch
|
||||
/// depends on the subcommand). Used by the CH4 conformance test to
|
||||
/// enforce the ownership rule in both directions: every retail-registry
|
||||
/// verb this catalog claims must actually be in the registry, and vice
|
||||
/// versa.
|
||||
/// </summary>
|
||||
// CH4 re-review nit 6 (2026-08-09): FrozenSet with an explicit
|
||||
// OrdinalIgnoreCase comparer, matching ByVerb/JoinLeaveTags/HouseTypes
|
||||
// above — callers (ChatCommandRouter.TryDispatchChannelFallback, the
|
||||
// CH4 conformance test) already treat this collection as
|
||||
// case-insensitive; the array was doing that per-call via LINQ's
|
||||
// Contains(item, comparer) overload instead of baking it into the set.
|
||||
public static IReadOnlyCollection<string> KnownVerbs { get; } =
|
||||
ByVerb.Keys.Concat(["house", "hou", "allegiance", "all"])
|
||||
.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// <c>/help <verb></c> lookup for a catalog-dispatched command —
|
||||
/// retail's <c>DoHelp @ 0x0057F9E0</c> looking up the verb's registered
|
||||
/// help callback. Does not cover chat-alias or channel verbs (see
|
||||
/// <see cref="RetailCommandHelpTable"/> for those) nor the deferred
|
||||
/// allegiance/house subcommand overviews (also
|
||||
/// <see cref="RetailCommandHelpTable"/>).
|
||||
/// </summary>
|
||||
public static bool TryGetHelpText(string verb, out string helpText)
|
||||
{
|
||||
string trimmedVerb = verb.TrimEnd(',');
|
||||
if (trimmedVerb.Equals("house", StringComparison.OrdinalIgnoreCase)
|
||||
|| trimmedVerb.Equals("hou", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
helpText = RetailCommandHelpTable.HouseOverview;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (trimmedVerb.Equals("allegiance", StringComparison.OrdinalIgnoreCase)
|
||||
|| trimmedVerb.Equals("all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
helpText = RetailCommandHelpTable.AllegianceOverview;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ByVerb.TryGetValue(trimmedVerb, out Definition? definition))
|
||||
{
|
||||
helpText = definition.HelpText;
|
||||
return true;
|
||||
}
|
||||
|
||||
helpText = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
for (int i = 1; i < value.Length; i++)
|
||||
{
|
||||
if (char.IsWhiteSpace(value[i]))
|
||||
return i;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
1133
src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs
Normal file
1133
src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs
Normal file
File diff suppressed because it is too large
Load diff
32
src/AcDream.Runtime/Chat/RuntimeChatCommandFeedback.cs
Normal file
32
src/AcDream.Runtime/Chat/RuntimeChatCommandFeedback.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
||||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Presentation-free feedback for chat commands executed by a host rather
|
||||
/// than a panel. It borrows the canonical communication owner; it creates no
|
||||
/// transcript or reply-target mirror.
|
||||
/// </summary>
|
||||
public sealed class RuntimeChatCommandFeedback : IChatCommandFeedback
|
||||
{
|
||||
private readonly RuntimeCommunicationState _communication;
|
||||
|
||||
public RuntimeChatCommandFeedback(RuntimeCommunicationState communication)
|
||||
{
|
||||
_communication = communication
|
||||
?? throw new ArgumentNullException(nameof(communication));
|
||||
}
|
||||
|
||||
public string? LastIncomingTellSender =>
|
||||
_communication.CommandTargets.LastIncomingTellSender;
|
||||
|
||||
public string? LastOutgoingTellTarget =>
|
||||
_communication.CommandTargets.LastOutgoingTellTarget;
|
||||
|
||||
public void ShowInterfaceText(string text) =>
|
||||
_communication.AddText(text, RetailLogTextType.ClientLocal);
|
||||
|
||||
public void ShowSystemMessage(string text) =>
|
||||
_communication.Chat.OnSystemMessage(text, chatType: 0x00u);
|
||||
}
|
||||
14
src/AcDream.Runtime/Chat/SendChatCmd.cs
Normal file
14
src/AcDream.Runtime/Chat/SendChatCmd.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Command published by chat panels to send a message. The host resolves
|
||||
/// <see cref="Channel"/> + <see cref="TargetName"/> + <see cref="Text"/>
|
||||
/// into the right wire opcode (Talk, Tell, or ChatChannel) and echoes
|
||||
/// locally via <c>ChatLog.OnSelfSent</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="TargetName"/> is meaningful only for
|
||||
/// <see cref="ChatChannelKind.Tell"/>; ignored otherwise.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record SendChatCmd(ChatChannelKind Channel, string? TargetName, string Text);
|
||||
17
src/AcDream.Runtime/Chat/SendRawChannelCmd.cs
Normal file
17
src/AcDream.Runtime/Chat/SendRawChannelCmd.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CH slice CH4 (2026-08-09): broadcast to a legacy <c>ChatChannel
|
||||
/// (0x0147)</c> bitflag id resolved directly from a channel TAG, bypassing
|
||||
/// <see cref="ChatChannelKind"/> and <see cref="ChannelResolver"/> entirely.
|
||||
///
|
||||
/// <para>
|
||||
/// This is retail's <c>DoChannelCommand @ 0x005774A0</c> fallback path — the
|
||||
/// 22 <c>ChannelSystem::GetChannelID</c> tags that have no registered verb
|
||||
/// (GM/faction channels like <c>@admin</c>, <c>@sentinel</c>,
|
||||
/// <c>@celestialhand</c>) plus the argument channel-tag resolution
|
||||
/// <c>@clist</c>/<c>@on</c>/<c>@off</c> use. See
|
||||
/// <see cref="RetailChannelTagTable"/> for the tag→id table.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record SendRawChannelCmd(uint ChannelId, string Text);
|
||||
8
src/AcDream.Runtime/Chat/SendServerCommandCmd.cs
Normal file
8
src/AcDream.Runtime/Chat/SendServerCommandCmd.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace AcDream.Runtime.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Command text owned by the connected server rather than the retail client.
|
||||
/// <see cref="Text"/> is canonicalized to an <c>@</c> prefix because ACE
|
||||
/// consumes administrator commands from the Talk game action.
|
||||
/// </summary>
|
||||
public sealed record SendServerCommandCmd(string Text);
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Runtime.ExceptionServices;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Runtime.Chat;
|
||||
|
||||
namespace AcDream.Runtime.Session;
|
||||
|
||||
|
|
@ -39,7 +40,8 @@ public sealed record LiveSessionHostBindings(
|
|||
/// <see cref="EnteredWorld"/>'s narrow <c>SetActiveCharacter(string)</c>
|
||||
/// fan-out, this exists so a status writer can emit the
|
||||
/// <c>enteredWorld</c> event's <c>characterId</c> field.</summary>
|
||||
Action<LiveSessionCharacterSelection> CharacterEntered);
|
||||
Action<LiveSessionCharacterSelection> CharacterEntered,
|
||||
LoginCommandSequence? LoginCommands = null);
|
||||
|
||||
/// <summary>
|
||||
/// Runtime host for the one canonical <see cref="LiveSessionController"/>.
|
||||
|
|
@ -47,7 +49,9 @@ public sealed record LiveSessionHostBindings(
|
|||
/// composition, but never mirrors session, generation, identity, routing, or
|
||||
/// command state.
|
||||
/// </summary>
|
||||
public sealed class LiveSessionHost : IRuntimeSessionCommands
|
||||
public sealed class LiveSessionHost
|
||||
: IRuntimeSessionCommands,
|
||||
IRuntimeLiveSessionFramePhase
|
||||
{
|
||||
private sealed class PendingRouteRollback(
|
||||
ILiveSessionCommandRouting? commands,
|
||||
|
|
@ -98,6 +102,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
private readonly Action<LiveSessionCharacterSelection> _characterEntered;
|
||||
private readonly Action<RuntimeGenerationToken> _reset;
|
||||
private readonly LiveSessionLifecycleHost _lifecycle;
|
||||
private readonly LoginCommandSequence? _loginCommands;
|
||||
private PendingRouteRollback? _pendingRouteRollback;
|
||||
|
||||
public LiveSessionHost(
|
||||
|
|
@ -114,6 +119,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
?? throw new ArgumentNullException(nameof(bindings.EnteredWorld));
|
||||
_characterEntered = bindings.CharacterEntered
|
||||
?? throw new ArgumentNullException(nameof(bindings.CharacterEntered));
|
||||
_loginCommands = bindings.LoginCommands;
|
||||
ArgumentNullException.ThrowIfNull(_routing.CreateEvents);
|
||||
ArgumentNullException.ThrowIfNull(_routing.CreateCommands);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Reset);
|
||||
|
|
@ -176,6 +182,17 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
RuntimeGenerationToken expectedGeneration) =>
|
||||
_controller.Stop(expectedGeneration);
|
||||
|
||||
/// <summary>
|
||||
/// Pumps the canonical network session first, then any due login command.
|
||||
/// Both graphical and headless frame loops use this host boundary so the
|
||||
/// sequencing contract cannot drift between them.
|
||||
/// </summary>
|
||||
public void Tick()
|
||||
{
|
||||
_controller.Tick();
|
||||
_loginCommands?.Tick(_controller.Generation, _controller.IsInWorld);
|
||||
}
|
||||
|
||||
private LiveSessionBinding BindSession(WorldSession session)
|
||||
{
|
||||
DrainPendingRouteRollback();
|
||||
|
|
@ -211,6 +228,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
// state below. Treat physical route convergence as the same hard
|
||||
// barrier used by normal LiveSessionBinding teardown.
|
||||
DrainPendingRouteRollback();
|
||||
_loginCommands?.Cancel(retiringGeneration);
|
||||
_reset(retiringGeneration);
|
||||
}
|
||||
|
||||
|
|
@ -234,6 +252,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
_enteredWorld.LoadCharacterSettings(name);
|
||||
_enteredWorld.ArmPlayerModeAutoEntry();
|
||||
_characterEntered(selection);
|
||||
_loginCommands?.EnteredWorld(_controller.Generation);
|
||||
}
|
||||
|
||||
private void RethrowWithRetryableRollback(
|
||||
|
|
|
|||
|
|
@ -228,6 +228,22 @@ public sealed class SessionStatusWriter
|
|||
error,
|
||||
});
|
||||
|
||||
public void LoginCommandFailed(
|
||||
string sessionId,
|
||||
int commandIndex,
|
||||
string command,
|
||||
string error) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "loginCommandFailed",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
commandIndex,
|
||||
command,
|
||||
error,
|
||||
});
|
||||
|
||||
public void Disconnected(string sessionId, string reason)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue