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_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 ──── /// /// 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 /// else if chains, not switch cases) plus 0x4F8, 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. /// [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); } }