diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d5428eb..b404dd6 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1312,6 +1312,10 @@ public sealed class GameWindow : IDisposable uint senderGuid = _playerServerGuid != 0u ? _playerServerGuid : playerGuid; + Console.WriteLine( + $"chat: outbound TurbineChat {turbine.Value.DisplayName} " + + $"room=0x{turbine.Value.RoomId:X8} chatType={turbine.Value.ChatType} " + + $"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={cmd.Text.Length}"); liveSession.SendTurbineChatTo( roomId: turbine.Value.RoomId, chatType: turbine.Value.ChatType, @@ -1326,7 +1330,22 @@ public sealed class GameWindow : IDisposable } var resolved = AcDream.UI.Abstractions.ChannelResolver.Resolve(cmd.Channel); - if (resolved is null) return; + if (resolved is null) + { + // Diagnostic: the user picked a channel kind that + // (a) isn't a Turbine channel TurbineChatState + // knows about and (b) has no legacy ChatChannel + // mapping. Most common cause: TurbineChat hasn't + // been enabled yet (server didn't send 0x0295) + // and the kind is General/Trade/LFG/etc. + Console.WriteLine( + $"chat: SendChatCmd kind={cmd.Channel} dropped " + + $"(turbine.Enabled={turbineChat.Enabled} no legacy id)"); + return; + } + Console.WriteLine( + $"chat: outbound legacy ChatChannel {resolved.Value.DisplayName} " + + $"id=0x{resolved.Value.ChannelId:X8} len={cmd.Text.Length}"); liveSession.SendChannel(resolved.Value.ChannelId, cmd.Text); chat.OnSelfSent( AcDream.Core.Chat.ChatKind.Channel, cmd.Text, diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index db1c3d7..f6c5b2b 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -92,6 +92,18 @@ public static class GameEventWiring societyCelestialHandRoom: p.Value.SocietyCelestialHandRoom, societyEldrytchWebRoom: p.Value.SocietyEldrytchWebRoom, societyRadiantBloodRoom: p.Value.SocietyRadiantBloodRoom); + + // Diagnostic: confirm the channel ids landed. Without + // this print there's no easy way to tell from the live + // log whether ACE actually sent 0x0295 or whether + // outbound /g /trade /lfg are silently falling back to + // the (broken) legacy ChatChannel path. + Console.WriteLine( + $"chat: SetTurbineChatChannels parsed enabled={turbineChat.Enabled} " + + $"general=0x{p.Value.GeneralRoom:X8} trade=0x{p.Value.TradeRoom:X8} " + + $"lfg=0x{p.Value.LfgRoom:X8} roleplay=0x{p.Value.RoleplayRoom:X8} " + + $"society=0x{p.Value.SocietyRoom:X8} olthoi=0x{p.Value.OlthoiRoom:X8} " + + $"allegiance=0x{p.Value.AllegianceRoom:X8}"); }); } diff --git a/src/AcDream.Core/Chat/ChatLog.cs b/src/AcDream.Core/Chat/ChatLog.cs index 06e31ce..c2e9aaa 100644 --- a/src/AcDream.Core/Chat/ChatLog.cs +++ b/src/AcDream.Core/Chat/ChatLog.cs @@ -111,9 +111,15 @@ public sealed class ChatLog /// public void OnWeenieError(uint errorId, string? param) { - string text = string.IsNullOrEmpty(param) - ? $"WeenieError 0x{errorId:X4}" - : $"WeenieError 0x{errorId:X4}: {param}"; + // Phase I (post-launch fix): translate the wire code into the + // retail-faithful template via WeenieErrorMessages. Many codes + // are *informational* (e.g. 0x051B "You have entered the X + // channel.", 0x051D "Turbine Chat is enabled.") not errors; + // the old "WeenieError 0xNNNN" framing was misleading. Unknown + // codes still fall back to the raw "WeenieError 0xNNNN[: param]" + // form so nothing is silently lost. See + // WeenieErrorMessages.Format for the templates + lookup table. + string text = WeenieErrorMessages.Format(errorId, param); Append(new ChatEntry( Kind: ChatKind.System, Sender: "", diff --git a/src/AcDream.Core/Chat/WeenieErrorMessages.cs b/src/AcDream.Core/Chat/WeenieErrorMessages.cs new file mode 100644 index 0000000..b5af01e --- /dev/null +++ b/src/AcDream.Core/Chat/WeenieErrorMessages.cs @@ -0,0 +1,153 @@ +using System.Collections.Generic; + +namespace AcDream.Core.Chat; + +/// +/// Translates ACE WeenieError + WeenieErrorWithString codes +/// into the human-readable templates the retail client showed. The +/// retail client baked these strings into string_table.bin; for +/// our purposes we mirror ACE's enum-doc comments +/// (references/ACE/Source/ACE.Entity/Enum/WeenieError.cs + +/// WeenieErrorWithString.cs) since they preserve the original +/// templates verbatim, including the literal _ placeholder where +/// the parameter goes. +/// +/// +/// Why we need this: Many of the codes ACE sends — especially +/// the high-frequency ones the user actually sees in normal play — +/// are informational, not error-level (e.g. 0x051B = +/// "You have entered the X channel.", 0x051D = "Turbine Chat +/// is enabled."). Displaying them as WeenieError 0xNNNN is +/// noisy and misleading. With the proper template they read as the +/// retail player would have seen them. +/// +/// +/// +/// We don't translate every code — only the ~30 most likely to appear +/// in a normal session. Unknown codes fall back to the +/// WeenieError 0xNNNN[: param] form so nothing is silently +/// lost. New codes can be added in 30 seconds when the user reports +/// one. +/// +/// +public static class WeenieErrorMessages +{ + /// + /// Format a WeenieError / WeenieErrorWithString into a human-readable + /// system-message string. + /// + /// The wire error code. + /// The interpolated substring (null for plain + /// WeenieError, set for WeenieErrorWithString). + public static string Format(uint errorCode, string? param) + { + // WeenieErrorWithString templates use the literal underscore + // character `_` as the placeholder for the param. We + // substitute it for `param` if present, otherwise drop it. + if (param is not null && WithStringTemplates.TryGetValue(errorCode, out var withTemplate)) + { + return withTemplate.Replace("_", param); + } + + if (NoParamTemplates.TryGetValue(errorCode, out var template)) + { + // Some "no-param" codes do still arrive with a meaningless + // param string; ignore it. + return template; + } + + // Unknown code — fall back to the raw form. + return string.IsNullOrEmpty(param) + ? $"WeenieError 0x{errorCode:X4}" + : $"WeenieError 0x{errorCode:X4}: {param}"; + } + + /// + /// Codes from WeenieError (no param). Templates copied + /// verbatim from ACE enum-doc comments. + /// + private static readonly Dictionary NoParamTemplates = new() + { + // Tell-related + [0x052B] = "That person is not available now.", // CharacterNotAvailable + + // Chat-channel related + [0x051D] = "Turbine Chat is enabled.", // TurbineChatIsEnabled + + // Trade + [0x0529] = "Trade Complete!", // TradeComplete + + // Allegiance + [0x0496] = "Your Allegiance has been dissolved!", // YourAllegianceHasBeenDissolved + [0x0497] = "Your patron's Allegiance to you has been broken!", + // YourPatronsAllegianceHasBeenBroken + [0x0535] = "You do not have the authority within your allegiance to do that.", + + // Movement / teleport / housing + [0x0498] = "You have moved too far!", // YouHaveMovedTooFar + [0x0499] = "That is not a valid destination!", // TeleToInvalidPosition + [0x0532] = "You must wait 30 days after purchasing a house before you may purchase another with any character on the same account.", + + // Fellowship + [0x0528] = "The fellowship is locked; you cannot open locked fellowships.", + + // Player flavour + [0x0526] = "You chicken out.", // YouChickenOut + }; + + /// + /// Codes from WeenieErrorWithString. Templates copied + /// verbatim from ACE enum-doc comments. The _ placeholder + /// is substituted with the param at format time. + /// + private static readonly Dictionary WithStringTemplates = new() + { + // Channel join / leave (high frequency at login) + [0x051B] = "You have entered the _ channel.", // YouHaveEnteredThe_Channel + [0x051C] = "You have left the _ channel.", // YouHaveLeftThe_Channel + + // Chat-server failures + [0x051E] = "_ will not receive your message, please use urgent assistance to speak with an in-game representative.", + [0x051F] = "Message Blocked: _", // MessageBlocked_ + + // Hear / loud-list + [0x0521] = "_ has been added to the list of people you can hear.", + [0x0522] = "_ has been removed from the list of people you can hear.", + [0x0525] = "You fail to remove _ from your loud list.", + + // Snooping (admin) + [0x052C] = "You are now snooping on _.", + [0x052D] = "You are no longer snooping on _.", + [0x052E] = "You fail to snoop on _.", + [0x052F] = "_ attempted to snoop on you.", + [0x0551] = "You are not listening to the _ channel.", + + // Allegiance + [0x046A] = "_ doesn't know what to do with that.", + [0x0413] = "_ is already one of your followers.", + [0x0416] = "_ cannot have any more Vassals.", + [0x03EF] = "_ is not accepting gifts right now.", + + // Combat / spell failures + [0x004E] = "You fail to affect _ because you cannot affect anyone!", + [0x004F] = "You fail to affect _ because they cannot be harmed!", + [0x0050] = "You fail to affect _ because beneficial spells do not affect them!", + [0x0051] = "You fail to affect _ because you are not a player killer!", + [0x0052] = "You fail to affect _ because they are not a player killer!", + [0x0053] = "You fail to affect _ because you are not the same sort of player killer as them!", + [0x0054] = "You fail to affect _ because you are acting across a house boundary!", + + // Inventory / etiquette + [0x001E] = "_ is too busy to accept gifts right now.", + [0x002B] = "_ cannot carry anymore.", + + // Fellowship + [0x0517] = "_ is not close enough to your level.", + [0x0518] = "This fellowship is locked; _ cannot be recruited into the fellowship.", + + // Hooks + [0x0510] = "Maximum number of _ hooked.", + [0x0514] = "Maximum number of _ hooked until one is removed.", + [0x0515] = "You no longer have the maximum number of _ hooked. You may hook additional.", + }; +} diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs index 1df2bab..eea4925 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs @@ -143,6 +143,14 @@ public static class ChatInputParser target = rest.Substring(0, targetEnd); message = rest.Substring(targetEnd + 1).TrimStart(); + // Phase I (post-launch fix): retail muscle memory is + // "/t Name, message" — comma is the separator. Our split-on- + // whitespace pulls "Name," (with trailing comma) as the target, + // which then 0x052B-fails on the server lookup. Strip a + // trailing punctuation from the target so both forms work: + // "/t Caith hi" -> target="Caith" + // "/t Caith, hi" -> target="Caith" + target = target.TrimEnd(',', ';', ':', '.', '!', '?'); if (target.Length == 0 || message.Length == 0) return false; return true; } diff --git a/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs b/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs new file mode 100644 index 0000000..edf47fc --- /dev/null +++ b/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs @@ -0,0 +1,112 @@ +using AcDream.Core.Chat; + +namespace AcDream.Core.Tests.Chat; + +/// +/// Tests for . The retail client showed +/// these as plain-language strings; we mirror that via templated lookup. +/// Filed after the 2026-04-25 live launch where the user saw cryptic +/// "WeenieError 0x051B" in chat for what was actually a friendly login +/// notification. +/// +public sealed class WeenieErrorMessagesTests +{ + // ── known codes — informational, parameterised ─────────────────── + + [Fact] + public void Format_YouHaveEnteredChannel_SubstitutesParam() + { + // 0x051B = WeenieErrorWithString.YouHaveEnteredThe_Channel. + // Template "You have entered the _ channel." with `_` placeholder. + Assert.Equal( + "You have entered the General channel.", + WeenieErrorMessages.Format(0x051B, "General")); + } + + [Fact] + public void Format_YouHaveEnteredChannel_WorksForEachChannelName() + { + Assert.Equal("You have entered the Trade channel.", WeenieErrorMessages.Format(0x051B, "Trade")); + Assert.Equal("You have entered the LFG channel.", WeenieErrorMessages.Format(0x051B, "LFG")); + Assert.Equal("You have entered the Roleplay channel.",WeenieErrorMessages.Format(0x051B, "Roleplay")); + } + + [Fact] + public void Format_YouHaveLeftChannel_SubstitutesParam() + { + Assert.Equal( + "You have left the General channel.", + WeenieErrorMessages.Format(0x051C, "General")); + } + + // ── known codes — informational, no parameter ──────────────────── + + [Fact] + public void Format_TurbineChatIsEnabled_NoParamForm() + { + // 0x051D came in WeenieError (no param) form at login. + Assert.Equal( + "Turbine Chat is enabled.", + WeenieErrorMessages.Format(0x051D, param: null)); + } + + // ── known codes — error-level ──────────────────────────────────── + + [Fact] + public void Format_CharacterNotAvailable_NoParam() + { + // 0x052B fired by the server when a Tell target lookup fails + // (e.g. the user typed "/t je, hello" → server got "je," → no + // character). Should read like the retail message. + Assert.Equal( + "That person is not available now.", + WeenieErrorMessages.Format(0x052B, param: null)); + } + + [Fact] + public void Format_TradeComplete() + { + Assert.Equal("Trade Complete!", WeenieErrorMessages.Format(0x0529, null)); + } + + // ── unknown codes — graceful fallback preserves debug info ─────── + + [Fact] + public void Format_UnknownCode_NoParam_FallsBackToHexForm() + { + Assert.Equal("WeenieError 0xABCD", WeenieErrorMessages.Format(0xABCD, null)); + } + + [Fact] + public void Format_UnknownCode_WithParam_FallsBackToColonForm() + { + Assert.Equal( + "WeenieError 0xDEAD: Mana Stone", + WeenieErrorMessages.Format(0xDEAD, "Mana Stone")); + } + + [Fact] + public void Format_UnknownCode_EmptyParam_StaysAsHexOnly() + { + // Empty string param shouldn't add a stray colon. + Assert.Equal("WeenieError 0xCAFE", WeenieErrorMessages.Format(0xCAFE, "")); + } + + // ── parameterised templates with non-trivial params ────────────── + + [Fact] + public void Format_HearListAdded_SubstitutesParam() + { + Assert.Equal( + "Caith has been added to the list of people you can hear.", + WeenieErrorMessages.Format(0x0521, "Caith")); + } + + [Fact] + public void Format_FailToAffectCannotBeHarmed_SubstitutesParam() + { + Assert.Equal( + "You fail to affect Drudge because they cannot be harmed!", + WeenieErrorMessages.Format(0x004F, "Drudge")); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs index 0ca465e..9bfb368 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs @@ -71,6 +71,30 @@ public sealed class ChatInputParserTests Assert.Equal("Lancelot hello", parsed.Value.Text); } + [Theory] + [InlineData("/t Caith, hi", "Caith")] + [InlineData("/t Caith: hi", "Caith")] + [InlineData("/t Caith; hi", "Caith")] + [InlineData("/t Caith. hi", "Caith")] + [InlineData("/t Caith! hi", "Caith")] + [InlineData("/t Caith? hi", "Caith")] + [InlineData("/tell Caith, hi", "Caith")] + public void TellPrefix_StripsTrailingPunctuationFromTarget(string raw, string expectedTarget) + { + // Retail muscle memory: "/t Name, message" — comma is the + // separator. Filed after a 2026-04-25 live-launch session + // where typing "/t je, hello" produced target="je," and the + // server responded with WeenieError 0x052B (CharacterNotAvailable) + // because no character "je," exists. Strip a trailing + // ,;:.!? from the target so both forms work. + var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null); + + Assert.NotNull(parsed); + Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel); + Assert.Equal(expectedTarget, parsed.Value.TargetName); + Assert.Equal("hi", parsed.Value.Text); + } + // -- Reply aliases --------------------------------------------------- [Fact]