acdream/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs
Erik 5f9aa16f43 fix(chat): FA1 -- 8 fellowship WeenieError ids confirmed absent from retail, not missing
The FA1 contract asked to "add the 8 missing fellowship entries lane B
lists" (0x0417-0x041C, 0x04DB, 0x04DC). Re-ran this table's own binary-
sweep methodology specifically for these 8 ids rather than inventing
text for them: a full-text grep of the 1.4M-line
acclient_2013_pseudo_c.txt found ZERO comparisons/case-labels against any
of the 8 anywhere in the retail client, and a manual walk of
HandleFailureEvent's own case-label sequence confirmed the switch goes
straight from case 0x416/0x41d (skipping 0x417-0x41c) and from case
0x4da/0x4dd (skipping 0x4db/0x4dc).

Conclusion: retail's Sept-2013 client has no display text for any of
these 8 ids -- they are intentionally absent from this table, not
overlooked. This contradicts the FA1 contract's premise but not lane B's
own text, which only claimed the ids were "missing" from the table (true)
and that two of them (0x0417, 0x04DB) are on ACE's live send paths (also
true) -- it never claimed retail has text for them. Two of the ids are
therefore live-but-silent gaps against a real ACE server, and acdream's
current no-display behavior for them is ALREADY retail-faithful. Adding
invented English would be exactly the class of mistake SHOULD-FIX 4
(the no-default-case rule this table's Resolve() already implements)
exists to prevent.

Documents the finding at both table gaps and adds a conformance test
(Resolve_FellowshipIdsAbsentFromHandleFailureEvent_ReturnsNoText) proving
all 8 resolve to null text, matching the existing
Format_0x051D_ReturnsNull_NoRetailCaseExists precedent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 00:13:25 +02:00

394 lines
19 KiB
C#

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_0x051D_ReturnsNull_NoRetailCaseExists()
{
// Campaign CH slice CH2: the pre-CH2 "Turbine Chat is enabled."
// text for 0x051D was an ACE-derived guess, never decomp-confirmed.
// The full HandleFailureEvent port found NO case for 0x51D anywhere
// in the switch (only 0x51C has one — case 0x51c: at raw line
// 383115-383118 of acclient_2013_pseudo_c.txt) — retail's own
// client simply has no display text for this id.
//
// REJECT-review rework (SHOULD-FIX 4,
// docs/research/2026-08-09-ch2-review-findings.md): retail's switch
// has no default case — an unhandled id produces NO text, silently,
// toward the player. Format now returns null rather than inventing
// a "WeenieError 0xNNNN" hex fallback that has no retail
// counterpart.
Assert.Null(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));
}
[Fact]
public void Format_ThatIsNotAValidCommand()
{
// 0x0026 fires on /-prefixed text that ACE's command parser
// can't resolve. Filed after a 2026-04-25 trace where /help
// produced cryptic "WeenieError 0x0026" lines.
Assert.Equal(
"That is not a valid command.",
WeenieErrorMessages.Format(0x0026, null));
}
[Fact]
public void Format_YouAreNotInAllegiance()
{
Assert.Equal(
"You are not in an allegiance!",
WeenieErrorMessages.Format(0x0414, null));
}
[Fact]
public void Format_YouDoNotBelongToAFellowship()
{
Assert.Equal(
"You do not belong to a Fellowship.",
WeenieErrorMessages.Format(0x050F, null));
}
[Theory]
[InlineData(0x0036u, "Action cancelled!")]
[InlineData(0x003Du, "You charged too far!")]
[InlineData(0x004Au, "Ack! You killed yourself!")]
[InlineData(0x0550u, "Out of Range!")]
public void Format_CombatMovementErrors(uint code, string expected)
=> Assert.Equal(expected, WeenieErrorMessages.Format(code, null));
// ── PK status codes ───────────────────────────────────────────────
[Fact]
public void Format_YouAreNonPKAgain_ExactRetailText()
{
// 0x0504 = WeenieError.YouAreNonPKAgain. Filed after the user saw
// "WeenieError 0x0504" on login following a PK Lite status
// reversion. Retail: ClientCommunicationSystem::HandleFailureEvent
// @0x00571990 case 0x504 @0x005745cd; string recovered byte-exact
// from data_7d32c0 in the PDB-paired binary (the pseudo-C dump
// truncates at the declared 0x5f-wchar16 array bound, mid-sentence
// at "...protection of the Lig").
Assert.Equal(
"You are enveloped in a feeling of warmth as you are brought back into the protection of the Light. You are once again a Non-Player Killer.",
WeenieErrorMessages.Format(0x0504, null));
}
[Fact]
public void Format_YoureTooCloseToYourSanctuary()
{
// 0x0505, case 0x505 @0x00574c65, data_7d2640.
Assert.Equal(
"You're too close to your sanctuary!",
WeenieErrorMessages.Format(0x0505, null));
}
[Fact]
public void Format_CannotChangePKStatusWhileRecovering()
{
// 0x04EC, case 0x4ec @0x0057446f, data_7d3820.
Assert.Equal(
"You cannot modify your player killer status while you are recovering from a PK death.",
WeenieErrorMessages.Format(0x04EC, null));
}
[Fact]
public void Format_AdvocatesCannotChangePKStatus()
{
// 0x04ED, case 0x4ed @0x005744a1, data_7d37b0.
Assert.Equal(
"Advocates may not change their player killer status!",
WeenieErrorMessages.Format(0x04ED, null));
}
[Fact]
public void Format_LevelTooLowToChangePKStatus_NowResolvedByCH2()
{
// 0x04EE (LevelTooLowToChangePKStatusWithObject) sits right next to
// the PK-status codes above; the pre-CH2 test asserted the
// fallback because only a curated ~60-entry subset was ported then.
// Campaign CH slice CH2's full 338-row HandleFailureEvent port
// (Appendix A row 0x4EE, Type 0x00) resolves it for real.
Assert.Equal(
"Your level is too low to change your player killer status with this object.",
WeenieErrorMessages.Format(0x04EE, null));
}
// ── unknown codes — retail-faithful silence (SHOULD-FIX 4) ───────
//
// docs/research/2026-08-09-ch2-review-findings.md SHOULD-FIX 4: retail's
// HandleFailureEvent switch has no default case — an id it does not
// recognize produces NO text at all, toward the player. The prior
// "WeenieError 0xNNNN[: param]" hex fallback was acdream's own
// invention with no retail counterpart. These three tests are flipped
// (not deleted) to pin the new null-means-silence contract.
[Fact]
public void Format_UnknownCode_NoParam_ReturnsNull()
{
Assert.Null(WeenieErrorMessages.Format(0xABCD, null));
}
[Fact]
public void Format_UnknownCode_WithParam_ReturnsNull()
{
Assert.Null(WeenieErrorMessages.Format(0xDEAD, "Mana Stone"));
}
[Fact]
public void Format_UnknownCode_EmptyParam_ReturnsNull()
{
Assert.Null(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_0x004F_ResolvesToRetailText()
{
// REJECT-review rework (BLOCKER 2,
// docs/research/2026-08-09-ch2-review-findings.md): the prior
// "grep for a case label" transcription missed 0x04F because it
// dispatches via `else if (arg2 == 0x4f)`, not a switch case label.
// The binary sweep found its sprintf format string directly
// (VA 0x00571e23, in ClientCommunicationSystem::HandleFailureEvent's
// else-if chain). Retail preserves its own $s typo (only the first
// %s substitutes), same pattern as 0x4F4.
Assert.Equal(
"You fail to affect Drudge because $s cannot be harmed!",
WeenieErrorMessages.Format(0x004F, "Drudge"));
}
[Fact]
public void Format_HealingTargetAlreadyFull_SubstitutesParam()
{
Assert.Equal(
"+Acdream is already at full health!",
WeenieErrorMessages.Format(0x04FF, "+Acdream"));
}
// ── Campaign CH slice CH2: the full HandleFailureEvent table port ────
/// <summary>
/// Pins the table's size: 344 rows. REJECT-review rework (BLOCKER 2,
/// docs/research/2026-08-09-ch2-review-findings.md) added the 5 ids the
/// prior pass's case-label enumeration missed (dispatched via
/// <c>else if</c> chains, not switch cases) plus <c>0x4F8</c>, which now
/// resolves for real instead of being deliberately excluded: 338 + 5 + 1
/// = 344. A change to this number without a matching research/commit
/// citation is a red flag, not a routine edit.
/// </summary>
[Fact]
public void Resolve_FullTable_HasExactly344Rows()
{
int count = 0;
for (uint id = 0; id <= 0x600u; id++)
{
var (text, _) = WeenieErrorMessages.Resolve(id, null);
if (text is not null)
count++;
}
Assert.Equal(344, count);
}
[Fact]
public void Resolve_0x4F8_NowResolvesForReal()
{
// REJECT-review rework (BLOCKER 2): the prior pass excluded 0x4F8
// because its case body's 3-operator+ concatenation chain has
// confusing BN-generated self-referential operand names. The binary
// sweep dereferenced both literal data pointers directly
// (data_7d2ee8, data_7d2f80), sidestepping the naming confusion.
// Both %s placeholders substitute the SAME parameter (retail only
// has one arg3 to concatenate twice).
var (text, type) = WeenieErrorMessages.Resolve(0x4F8, "Someone");
Assert.Equal(
"Someone fails to affect you because you are not the same sort of player killer as Someone!",
text);
Assert.Equal(RetailLogTextType.Magic, type);
}
// ── REJECT-review rework (BLOCKER 2): every corrected/added row ──────
//
// docs/research/2026-08-09-ch2-review-findings.md — pins the exact text
// for every id the binary sweep + ACE cross-check corrected or added
// this pass, so a future regression to the wrong (previously-landed)
// text fails loudly instead of silently.
[Theory]
// 5 ids added — missed by the prior case-label enumeration because
// they dispatch via `else if (arg2 == N)`, not a switch case.
[InlineData(0x04Fu, "You fail to affect %s because $s cannot be harmed!", RetailLogTextType.Magic)]
[InlineData(0x3EEu, "The container is closed!", RetailLogTextType.ClientLocal)]
[InlineData(0x408u, "Your spell cannot be cast inside", RetailLogTextType.ClientLocal)]
[InlineData(0x48Au, "You must be a monarch to purchase this dwelling.", RetailLogTextType.Default)]
[InlineData(0x4E8u, "The %s cannot be used while on a hook and only the owner may open the hook.", RetailLogTextType.Default)]
// 16 ids corrected per the review's own flagged list.
[InlineData(0x051u, "You fail to affect %s because you are not a player killer!", RetailLogTextType.Magic)]
[InlineData(0x053u, "You fail to affect %s because you are not the same sort of player killer as %s!", RetailLogTextType.Magic)]
[InlineData(0x054u, "You fail to affect %s because you are acting across a house boundary!", RetailLogTextType.Magic)]
[InlineData(0x466u, "You must purchase Asheron's Call: Dark Majesty to interact with that portal.", RetailLogTextType.Magic)]
[InlineData(0x4A3u, "You must have linked with a portal in order to recall to it!", RetailLogTextType.Magic)]
[InlineData(0x4B5u, "You must specify a character to query.", RetailLogTextType.ClientLocal)]
[InlineData(0x4E0u, "You are currently wielding items which require a certain level of skill. Your attributes cannot be transferred while you are wielding these items. Please remove these items and try again.", RetailLogTextType.Default)]
[InlineData(0x4F7u, "%s fails to affect you because you are not a player killer!", RetailLogTextType.Magic)]
[InlineData(0x544u, "An unspecified error occurred while attempting to remove %s as an allegiance officer.", RetailLogTextType.Default)]
[InlineData(0x54Eu, "The hook does not contain a usable item. You cannot open the hook because you do not own the house to which it belongs.", RetailLogTextType.Default)]
[InlineData(0x552u, "You must purchase Asheron's Call -- Throne of Destiny to use this function.", RetailLogTextType.ClientLocal)]
[InlineData(0x553u, "You must purchase Asheron's Call -- Throne of Destiny to use this item.", RetailLogTextType.ClientLocal)]
[InlineData(0x554u, "You must purchase Asheron's Call -- Throne of Destiny to use this portal.", RetailLogTextType.ClientLocal)]
[InlineData(0x555u, "You must purchase Asheron's Call -- Throne of Destiny to access this quest.", RetailLogTextType.ClientLocal)]
[InlineData(0x57Fu, "Your allegiance chat privileges have been temporarily removed by %s. Until they are restored, you may not view or speak in the allegiance chat channel.", RetailLogTextType.Default)]
[InlineData(0x582u, "Your allegiance chat privileges have been restored by %s.", RetailLogTextType.Default)]
// 2 ids corrected that were NOT in the review's flagged list — found by
// an automated diff between the swept binary literals and the landed
// table (the review's own "sweep may find more" prediction).
[InlineData(0x4E9u, "The %s cannot be used while on a hook, use the '@house hooks on' command to make the hook openable.", RetailLogTextType.Default)]
[InlineData(0x518u, "This fellowship is locked; %s cannot be recruited into the fellowship.", RetailLogTextType.Default)]
public void Resolve_Blocker2CorrectedRows_MatchTheSweptBinaryLiteral(
uint id, string expectedTemplate, RetailLogTextType expectedType)
{
var (text, type) = WeenieErrorMessages.Resolve(id, param: null);
Assert.Equal(expectedTemplate, text);
Assert.Equal(expectedType, type);
}
// ── spot pins across all three retail routing destinations ──────────
[Theory]
// ClientLocal (0x1A) — the SpewBox destination.
[InlineData(0x017u, "You failed to go to non-combat mode.", RetailLogTextType.ClientLocal)]
[InlineData(0x02Au, "You are too encumbered to carry that!", RetailLogTextType.ClientLocal)]
[InlineData(0x04EBu, "You can't do that while in the air!", RetailLogTextType.ClientLocal)]
[InlineData(0x550u, "Out of Range!", RetailLogTextType.ClientLocal)]
// Magic (0x07) — the light-blue spell/portal-failure channel.
[InlineData(0x402u, "Your spell fizzled.", RetailLogTextType.Magic)]
[InlineData(0x49Bu, "You fail to link with the lifestone!", RetailLogTextType.Magic)]
[InlineData(0x593u, "Olthoi characters can only use Lifestone and PK Arena recalls!", RetailLogTextType.Magic)]
// Default (0x00) — the ordinary broadcast/green channel.
[InlineData(0x4A, "Ack! You killed yourself!", RetailLogTextType.Default)]
[InlineData(0x50Cu, "%s is now a closed fellowship.", RetailLogTextType.Default)]
[InlineData(0x55Fu, "Only Player Killer characters may use this command!", RetailLogTextType.Default)]
public void Resolve_SpotPins_TextAndTypeMatchAppendixA(uint id, string expectedTemplate, RetailLogTextType expectedType)
{
var (text, type) = WeenieErrorMessages.Resolve(id, param: null);
Assert.Equal(expectedTemplate, text);
Assert.Equal(expectedType, type);
}
[Fact]
public void Resolve_JumpFamily_SharesClientTextRefusalsConstantsVerbatim()
{
// HandleFailureEvent's 0x24/0x48/0x49 cases reuse the SAME string
// globals as the local jump-refusal sites — assert byte-identity,
// not just similar wording.
Assert.Equal(ClientTextRefusals.CantJumpInAir, WeenieErrorMessages.Resolve(0x024u, null).Text);
Assert.Equal(ClientTextRefusals.CantJumpPosition, WeenieErrorMessages.Resolve(0x048u, null).Text);
Assert.Equal(ClientTextRefusals.CantJumpLoad, WeenieErrorMessages.Resolve(0x049u, null).Text);
Assert.Equal(RetailLogTextType.ClientLocal, WeenieErrorMessages.Resolve(0x024u, null).Type);
Assert.Equal(RetailLogTextType.ClientLocal, WeenieErrorMessages.Resolve(0x048u, null).Type);
Assert.Equal(RetailLogTextType.ClientLocal, WeenieErrorMessages.Resolve(0x049u, null).Type);
}
[Fact]
public void Resolve_0x4F4_PreservesRetailDollarSTypo()
{
// Retail's own literal is "...because $s cannot affect anyone!" —
// a genuine retail typo (should have been %s). Only the FIRST %s
// substitutes; the literal "$s" must NOT be replaced.
var (text, type) = WeenieErrorMessages.Resolve(0x4F4u, "A drudge");
Assert.Equal("A drudge fails to affect you because $s cannot affect anyone!", text);
Assert.Equal(RetailLogTextType.Magic, type);
}
// ── Campaign FA slice FA1 (2026-08-11): the 8 "missing" fellowship ids ──
//
// Same class of proof as Format_0x051D_ReturnsNull_NoRetailCaseExists
// above: independently re-ran this file's own binary-sweep methodology
// (a full-text grep of acclient_2013_pseudo_c.txt for every
// literal/case-label comparison against these 8 ids, plus a manual
// walk of HandleFailureEvent's own case-label sequence around each)
// and found NO case for any of them anywhere in the Sept-2013 client.
// These are intentionally absent from the table, not overlooked —
// adding invented text for them would itself be the SHOULD-FIX-4 class
// of bug this table exists to avoid.
[Theory]
[InlineData(0x0417u)] // FellowshipIgnoringRequests — ACE sends this live; retail still shows nothing
[InlineData(0x0418u)] // FellowshipSquelched — ACE never sends it
[InlineData(0x0419u)] // FellowshipMaxDistanceExceeded — ACE never sends it
[InlineData(0x041Au)] // FellowshipMember — ACE never sends it
[InlineData(0x041Bu)] // FellowshipIllegalLevel — ACE never sends it
[InlineData(0x041Cu)] // FellowshipRecruitBusy — ACE never sends it (busy is a plain chat line instead)
[InlineData(0x04DBu)] // FellowshipDeclined — ACE sends this live; retail still shows nothing
[InlineData(0x04DCu)] // FellowshipTimeout — ACE never sends it
public void Resolve_FellowshipIdsAbsentFromHandleFailureEvent_ReturnsNoText(uint code)
{
var (text, _) = WeenieErrorMessages.Resolve(code, param: null);
Assert.Null(text);
}
}