fix(chat): translate WeenieError templates + strip Tell target punctuation + Turbine routing diagnostics
Three post-launch fixes from the 2026-04-25 live verify session.
1. WeenieError display bug. Many ACE WeenieError / WeenieErrorWithString
codes are *informational*, not error-level — the user saw cryptic
"WeenieError 0x051B: General" / "WeenieError 0x051D" at login, but
those decode as "You have entered the General channel." and
"Turbine Chat is enabled." per ACE WeenieError(WithString).cs
templates. New static helper Core/Chat/WeenieErrorMessages.cs maps
~30 high-frequency codes to retail-faithful templates with `_`
placeholder substitution. ChatLog.OnWeenieError now routes through
Format(); unknown codes still fall back to "WeenieError 0xNNNN[: param]"
so nothing is silently lost. New codes can be added in 30 seconds
when the user reports one.
2. Tell target eats trailing punctuation. Retail muscle memory is
"/t Name, message" — comma is the separator. Our split-on-whitespace
pulled "Name," (with comma) as the target, server returned 0x052B
"That person is not available now." because no such character.
ChatInputParser.TryParseTargeted now strips a trailing ,;:.!? from
the target token so "/t Caith, hi" and "/t Caith hi" both work.
Added 7 Theory cases covering each separator + the long-form alias.
3. TurbineChat routing diagnostics. The user's ACE login showed the
"TurbineChatIsEnabled" + "YouHaveEnteredThe_Channel" notifications
for General/Trade/LFG, confirming TurbineChat IS active server-side.
But outbound /g /trade /lfg might still fall back to legacy
ChatChannel (which the server then rejects). Added diagnostic
Console.WriteLines so the next launch shows:
- "chat: SetTurbineChatChannels parsed enabled=true general=0x... ..."
(when ACE sends the 0x0295 channel-id table)
- "chat: outbound TurbineChat General room=0x... cookie=0x... len=N"
(when SendChatCmd routes a Turbine kind through 0xF7DE)
- "chat: outbound legacy ChatChannel Fellowship id=0x... len=N"
(when SendChatCmd uses the legacy 0x0147 path)
- "chat: SendChatCmd kind=General dropped (turbine.Enabled=false no legacy id)"
(when neither path can dispatch — usually means ACE didn't send
0x0295 yet and the kind is Turbine-only)
Sets up Bug 3 (proper outbound TurbineChat for /g /trade /lfg) for
a follow-up commit once the next live trace shows the actual flow.
18 new tests:
- WeenieErrorMessagesTests: 11 covering known templates + fallback.
- ChatInputParserTests: +7 Theory cases for trailing-punctuation strip.
Solution total: 1007 green (114 UI + 650 Core + 243 Core.Net), 0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
762df152d1
commit
e17caa2942
7 changed files with 338 additions and 4 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -111,9 +111,15 @@ public sealed class ChatLog
|
|||
/// </remarks>
|
||||
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: "",
|
||||
|
|
|
|||
153
src/AcDream.Core/Chat/WeenieErrorMessages.cs
Normal file
153
src/AcDream.Core/Chat/WeenieErrorMessages.cs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Translates ACE <c>WeenieError</c> + <c>WeenieErrorWithString</c> codes
|
||||
/// into the human-readable templates the retail client showed. The
|
||||
/// retail client baked these strings into <c>string_table.bin</c>; for
|
||||
/// our purposes we mirror ACE's enum-doc comments
|
||||
/// (<c>references/ACE/Source/ACE.Entity/Enum/WeenieError.cs</c> +
|
||||
/// <c>WeenieErrorWithString.cs</c>) since they preserve the original
|
||||
/// templates verbatim, including the literal <c>_</c> placeholder where
|
||||
/// the parameter goes.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why we need this:</b> Many of the codes ACE sends — especially
|
||||
/// the high-frequency ones the user actually sees in normal play —
|
||||
/// are <i>informational</i>, not error-level (e.g. <c>0x051B</c> =
|
||||
/// "You have entered the X channel.", <c>0x051D</c> = "Turbine Chat
|
||||
/// is enabled."). Displaying them as <c>WeenieError 0xNNNN</c> is
|
||||
/// noisy and misleading. With the proper template they read as the
|
||||
/// retail player would have seen them.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// We don't translate every code — only the ~30 most likely to appear
|
||||
/// in a normal session. Unknown codes fall back to the
|
||||
/// <c>WeenieError 0xNNNN[: param]</c> form so nothing is silently
|
||||
/// lost. New codes can be added in 30 seconds when the user reports
|
||||
/// one.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class WeenieErrorMessages
|
||||
{
|
||||
/// <summary>
|
||||
/// Format a WeenieError / WeenieErrorWithString into a human-readable
|
||||
/// system-message string.
|
||||
/// </summary>
|
||||
/// <param name="errorCode">The wire error code.</param>
|
||||
/// <param name="param">The interpolated substring (null for plain
|
||||
/// <c>WeenieError</c>, set for <c>WeenieErrorWithString</c>).</param>
|
||||
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}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Codes from <c>WeenieError</c> (no param). Templates copied
|
||||
/// verbatim from ACE enum-doc comments.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<uint, string> 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
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Codes from <c>WeenieErrorWithString</c>. Templates copied
|
||||
/// verbatim from ACE enum-doc comments. The <c>_</c> placeholder
|
||||
/// is substituted with the param at format time.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<uint, string> 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.",
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
112
tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs
Normal file
112
tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
using AcDream.Core.Chat;
|
||||
|
||||
namespace AcDream.Core.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="WeenieErrorMessages"/>. 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.
|
||||
/// </summary>
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue