From a3b4a8e2e5082b249e480cf3d621d4dabf1f69ea Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 6 Sep 2026 23:20:20 +0200 Subject: [PATCH] fix(vt): H test-restoration + disabled-rule refusal + double-side gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item H (slice-1 fix round), six sub-parts: 1. Restored VtankMetaProfileSerializerTests.LoadsKnownTypedCondActRecord and SignedHighBitLandblockIdRoundTripsExactly (the latter adapted from the deleted RoundTripPreservesEveryVtankConditionActionAndEmbeddedNav's LandblockEquals/LandcellEquals coverage of unchecked((int)0x8B370000u)), both deleted as collateral damage of an unrelated file move in commit 0d10399e0. Neither calls the deleted VtankMetaProfileSerializer.Save writer (demoted to import-only in 3ff9461ef) — both are pure reader assertions. 2. MetafSerializer.SaveMeta now refuses (throws InvalidOperationException naming the count) to silently drop a disabled MetaRule: real VTank/ metaf has zero concept of "disabled" (confirmed: metaf_monolithic.py has no "enabled"/"disabled" occurrences anywhere), so MetaRule.Enabled is a MossTank-only extension with no metaf-compatible marker. A new SaveMeta(profile, dropDisabledRules: true) overload lets a caller accept the loss explicitly. MossTankMetaProfileStore.WriteLegacyExport (the .af convenience mirror beside MossTank's own fully-fidelity JSON storage) deliberately does NOT opt in — it leaves that mirror stale and logs a warning via its existing try/catch rather than losing the rule. Recorded as gap 6 in docs/research/vtank-kb/07-meta-and-expressions.md section 5. 3. New VtankProfilesDefault (src/AcDream.App/Plugins/): the graphical host's default VtankProfiles root (/vtank), extracted out of Program.cs's inline Path.Combine call into its own pure, injectable-root function specifically so the "Path.Combine only, never a hard-coded Windows path" guarantee is a real, failable Linux-path unit test (VtankProfilesDefaultTests.ResolveIsBuiltWithPathCombineOnly) rather than something only checkable by reading the source — the pattern item F's VtankProfileDirectory rewrite removed when Resolve/PortableDefault moved out of the plugin. 4. New BuffedDoubleRequirementDoesNotApplyBonusWhenBaseKeyIsAbsent: the KeyExistsDouble gate (ComputedItemInfo.cs:234) already existed in BuffedDouble, but only the int side (BuffedIntRequirementDoesNotApplyBonusWhenBaseKeyIsAbsent) had a pinning test. 5/6. DoubleSpellBonuses gained an explicit Change field (KB doc 05 section 2.2: "additive unless the static table's Change==1, in which case multiplicative", ComputedItemInfo.cs:244), replacing BuffedDouble's prior `(int)bonus.Bonus == 1` magnitude-based proxy — that proxy only worked because every multiplicative bonus in the current 19 rows happens to fall in [1.0, 2.0) and every additive one happens to be under 1.0; it would have silently mis-branched on a future row like an additive 1.5 or a multiplicative 2.0+. Every existing row's Change value was derived mechanically from its old proxy result (no behavior change for the current table), and BuffedDoubleRequirementAppliesAdditiveBonusWhenBaseKeyExists/ AppliesMultiplicativeBonusWhenChangeIsSet pin both branches through the real named field. Full MossTank suite: 574 -> 581. App.Tests (Plugin|LaunchOptions|RuntimeOptions filter): 135 -> 137. Core.Tests (Plugin filter): 50/50 (no change, no Core-side edits this item). Co-Authored-By: Claude Fable 5.1 --- .../vtank-kb/07-meta-and-expressions.md | 1 + .../Plugins/VtankProfilesDefault.cs | 16 +++ src/AcDream.App/Program.cs | 2 +- .../MetafSerializer.cs | 33 +++++- .../MossTankMetaProfileStore.cs | 7 ++ .../VtankLootRequirementEvaluator.cs | 41 ++++--- .../Plugins/VtankProfilesDefaultTests.cs | 37 ++++++ .../MetafSerializerTests.cs | 57 +++++++++ .../VtankLootRequirementEvaluatorTests.cs | 111 ++++++++++++++++++ .../VtankMetaProfileSerializerTests.cs | 69 +++++++++++ 10 files changed, 358 insertions(+), 16 deletions(-) create mode 100644 src/AcDream.App/Plugins/VtankProfilesDefault.cs create mode 100644 tests/AcDream.App.Tests/Plugins/VtankProfilesDefaultTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/VtankMetaProfileSerializerTests.cs diff --git a/docs/research/vtank-kb/07-meta-and-expressions.md b/docs/research/vtank-kb/07-meta-and-expressions.md index 3c487fcf..e737f247 100644 --- a/docs/research/vtank-kb/07-meta-and-expressions.md +++ b/docs/research/vtank-kb/07-meta-and-expressions.md @@ -723,6 +723,7 @@ line counts). This is an unusually precise reverse-engineering result — the | 3 | **`MonsterNameCountWithinDistance`'s name pattern is case-insensitive in MossTank, case-sensitive in retail** | `d7.c()` compiles with `RegexOptions.Compiled` only (`d7.cs:26`) — case-sensitive | `Meta.cs`'s `MonsterCount` helper (`Meta.cs:536-552`) compiles with `RegexOptions.IgnoreCase \| RegexOptions.CultureInvariant` | **Medium-high.** A pattern authored against retail's case-sensitive matching (e.g. deliberately excluding a differently-cased variant name) will over-match in MossTank. Note `ChatMessage`/`ChatMessageCapture` do **not** have this divergence — MossTank's `ChatMatch` (`Meta.cs:463-513`) is correctly case-sensitive (`RegexOptions.CultureInvariant` only), matching `hl.cs`/`c5.cs`. | | 4 | **`;` sequence-operator value convention is inverted vs. retail (but matches UtilityBelt, and was chosen deliberately)** | Retail VTank: `a;b` evaluates to **`a`** (the left/first operand), discarding `b`'s value, while still executing `b` for side effects (`ExpressionEvaluator.cs:787-790`); `;` is an ordinary operator that can nest anywhere via normal precedence | `ExpressionProgram.Evaluate` (`ExpressionEngine.cs:20-27`) treats top-level `;`-separated statements as a `Node[]` program and returns the value of the **last** statement — this matches UtilityBelt's own audited grammar ("multiple `;`-separated statements, returning the final result", `2026-08-26-mosstank-vtank-utilitybelt-research.md:237`), which the campaign explicitly chose as MossTank's baseline dialect (§3.1 there) | **Medium, and by design, not an oversight.** Retail VTank and UtilityBelt already disagree with each other on `;`'s return value; MossTank correctly implements UtilityBelt's convention. The compat risk is narrower than a plain bug: only a `.met` authored *against retail's own* semantics (chaining `sideeffect[]; realcheck[]` and relying on the *first* value) evaluates to the opposite result once imported. | | 5 | **Watchdog ring pre-fill differs (far sentinel vs. arm-time position)** | All 10 position-history slots start as a sentinel far from any real coordinate (`(1000,1000,1000)`, `h7.cs:45-49`), guaranteeing expiry cannot even be *reachable* until one full `timeSpan` window has elapsed and every slot has been overwritten with a real sample | `SetWatchdog` (`Meta.cs:585-596`) pre-fills all 10 slots with the **current** position at arm time | **Lower-medium.** Both converge on "earliest possible expiry is ~one `TimeSpanSeconds` after arming" in the common case, but they diverge for a watchdog that is armed, then the player leaves and returns to very near the arm point before the window closes — retail's sentinel-seeded ring cannot spuriously read the arm-time position as one of its 10 samples, MossTank's can. | +| 6 | **`MetaRule.Enabled` (a per-rule disable-without-delete toggle) has no metaf-compatible representation at all** | Real VTank/metaf has no such concept — `metaf_monolithic.py` has zero occurrences of "enabled"/"disabled" anywhere; a rule in a `.met`/`.af` is simply present or absent | `MetafSerializer.SaveMeta` (Campaign VT slice-1 item H) now **refuses to save** a profile containing any disabled rule by default (throws `InvalidOperationException` naming the count) rather than silently dropping it — the caller must opt in via `SaveMeta(profile, dropDisabledRules: true)` to accept the loss explicitly. `MossTankMetaProfileStore.WriteLegacyExport` (the `.af` convenience mirror written alongside MossTank's own fully-fidelity JSON storage) deliberately does NOT opt in: it leaves that mirror stale rather than losing the disabled rule, and logs a warning via the existing try/catch. | **Low** (by design, not a bug): this is a MossTank-only UI extension with no VTank equivalent to diverge from; the representational loss is confined to the legacy-export mirror, never the authoritative profile data, and is now impossible to hit silently. | ### 5.3 Confirmed non-gaps (the format/engine is otherwise unusually faithful) diff --git a/src/AcDream.App/Plugins/VtankProfilesDefault.cs b/src/AcDream.App/Plugins/VtankProfilesDefault.cs new file mode 100644 index 00000000..63db4bd1 --- /dev/null +++ b/src/AcDream.App/Plugins/VtankProfilesDefault.cs @@ -0,0 +1,16 @@ +namespace AcDream.App.Plugins; + +/// +/// The graphical host's default root for IPluginHost.VtankProfiles +/// when RuntimeOptions.VtankProfileDirectoryOverride +/// (ACDREAM_VTANK_PROFILE_DIR) is unset — extracted out of +/// Program.cs's inline composition into its own pure, injectable-root +/// function so the "built with +/// only, never a hard-coded Windows path" guarantee is a real, failable unit +/// test rather than something only checkable by reading the source. +/// +internal static class VtankProfilesDefault +{ + internal static string Resolve(string dataDirectory) => + Path.Combine(dataDirectory, "vtank"); +} diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index ac54163c..1edd786a 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -188,7 +188,7 @@ var host = new AppPluginHost( lootClassifiers, new FilePluginStorage( runtimeOptions.VtankProfileDirectoryOverride - ?? Path.Combine(applicationPaths.DataDirectory, "vtank"))); + ?? VtankProfilesDefault.Resolve(applicationPaths.DataDirectory))); GraphicalPluginSession pluginSession = GraphicalPluginSession.Create( applicationPaths, runtimeOptions.Plugins, diff --git a/src/AcDream.Plugins.MossTank/MetafSerializer.cs b/src/AcDream.Plugins.MossTank/MetafSerializer.cs index f29e0194..04ae8ecd 100644 --- a/src/AcDream.Plugins.MossTank/MetafSerializer.cs +++ b/src/AcDream.Plugins.MossTank/MetafSerializer.cs @@ -292,9 +292,40 @@ internal static class MetafSerializer } } - public static string SaveMeta(MetaProfile profile) + /// + /// Saves as .af. Real VTank/metaf has + /// no concept of a "disabled" rule at all — Rule/State + /// carry no such flag (confirmed: metaf_monolithic.py has zero + /// occurrences of "enabled"/"disabled" anywhere), so + /// is a MossTank-only extension with no + /// metaf-compatible marker to preserve it through. Rather than silently + /// dropping a disabled rule (irrecoverable once re-imported — see + /// docs/research/vtank-kb/07-meta-and-expressions.md section 5), + /// this overload throws when the profile has any: callers that + /// understand and accept the loss (e.g. a legacy-export convenience + /// copy sitting alongside MossTank's own fully-fidelity JSON storage) + /// must say so explicitly via + /// . + /// + public static string SaveMeta(MetaProfile profile) => + SaveMeta(profile, dropDisabledRules: false); + + /// See 's class doc for why exists. + public static string SaveMeta(MetaProfile profile, bool dropDisabledRules) { ArgumentNullException.ThrowIfNull(profile); + if (!dropDisabledRules) + { + int disabledCount = profile.Rules.Count(static rule => !rule.Enabled); + if (disabledCount > 0) + { + throw new InvalidOperationException( + $"SaveMeta: {disabledCount} disabled rule(s) have no metaf-compatible " + + "representation and would be silently dropped. Call " + + "SaveMeta(profile, dropDisabledRules: true) to accept that loss " + + "explicitly."); + } + } var lines = new List { MetaHeader }; // EmbedNav's referenced Nav is a separate NAV: block elsewhere in // the file (metaf's own layout — see the class doc comment); the diff --git a/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs index 8ba153b5..c29c31b4 100644 --- a/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs +++ b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs @@ -208,6 +208,13 @@ internal sealed class MossTankMetaProfileStore return; try { + // MetafSerializer.SaveMeta(profile) (item H, slice-1 fix round) + // throws when the profile has any disabled rule — real VTank/ + // metaf has no marker for MossTank's own "disabled" extension, so + // this .af mirror is deliberately left stale (rather than + // silently dropping the rule) until the user re-enables or + // deletes it. The authoritative JSON save above always has full + // fidelity regardless. _host.Storage.WriteText( $"exports/meta/{LegacyFileName(name)}.af", MetafSerializer.SaveMeta(profile)); diff --git a/src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs b/src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs index 693afd34..80c006e2 100644 --- a/src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs +++ b/src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs @@ -23,20 +23,33 @@ internal static class VtankLootRequirementEvaluator [6095] = (28, 80), }; - private static readonly IReadOnlyDictionary - DoubleSpellBonuses = new Dictionary + /// + /// VTClassic's static double-spell-bonus table (ComputedItemInfo.cs:88-139). + /// Change is the real per-row selector KB doc 05 section 2.2 names + /// ("additive unless the static table's Change==1, in which case + /// multiplicative", ComputedItemInfo.cs:244) — carried explicitly + /// here rather than inferred from whether Bonus's truncated + /// integer part happens to equal 1 (a prior port's proxy, which this + /// item replaced: it only worked because every multiplicative bonus in + /// this table happens to be in [1.0, 2.0) and every additive one happens + /// to be under 1.0 — a coincidence of the current 19 rows, not a rule, + /// and it would have silently mis-branched on a future row like an + /// additive 1.5 or a multiplicative 2.0+). + /// + private static readonly IReadOnlyDictionary + DoubleSpellBonuses = new Dictionary { - [3251] = (152, .01), [3250] = (152, .03), - [4670] = (152, .05), [6098] = (152, .07), - [2603] = (VtankDoubleBase + 12, .03), - [2591] = (VtankDoubleBase + 12, .05), - [4666] = (VtankDoubleBase + 12, .07), - [6094] = (VtankDoubleBase + 12, .09), - [2600] = (29, .03), [3985] = (29, .04), - [2588] = (29, .05), [4663] = (29, .07), [6091] = (29, .09), - [3201] = (144, 1.05), [3199] = (144, 1.10), - [3202] = (144, 1.15), [3200] = (144, 1.20), - [6086] = (144, 1.25), [6087] = (144, 1.30), + [3251] = (152, .01, false), [3250] = (152, .03, false), + [4670] = (152, .05, false), [6098] = (152, .07, false), + [2603] = (VtankDoubleBase + 12, .03, false), + [2591] = (VtankDoubleBase + 12, .05, false), + [4666] = (VtankDoubleBase + 12, .07, false), + [6094] = (VtankDoubleBase + 12, .09, false), + [2600] = (29, .03, false), [3985] = (29, .04, false), + [2588] = (29, .05, false), [4663] = (29, .07, false), [6091] = (29, .09, false), + [3201] = (144, 1.05, true), [3199] = (144, 1.10, true), + [3202] = (144, 1.15, true), [3200] = (144, 1.20, true), + [6086] = (144, 1.25, true), [6087] = (144, 1.30, true), }; private static readonly IReadOnlyDictionary ArmorColorSlots = @@ -453,7 +466,7 @@ internal static class VtankLootRequirementEvaluator { continue; } - value = (int)bonus.Bonus == 1 ? value * bonus.Bonus : value + bonus.Bonus; + value = bonus.Change ? value * bonus.Bonus : value + bonus.Bonus; } return value; } diff --git a/tests/AcDream.App.Tests/Plugins/VtankProfilesDefaultTests.cs b/tests/AcDream.App.Tests/Plugins/VtankProfilesDefaultTests.cs new file mode 100644 index 00000000..8bf05d5f --- /dev/null +++ b/tests/AcDream.App.Tests/Plugins/VtankProfilesDefaultTests.cs @@ -0,0 +1,37 @@ +using AcDream.App.Plugins; + +namespace AcDream.App.Tests.Plugins; + +/// +/// Item H (Campaign VT slice-1 fix round): a genuinely failable Linux-path +/// guarantee for — re-derives the +/// expected value with from an +/// injected root and compares, so a future hard-coded +/// dataDirectory + "\vtank" (or any other literal-backslash +/// regression) fails this test on any OS, not just by inspection. +/// +public sealed class VtankProfilesDefaultTests +{ + [Fact] + public void ResolveIsBuiltWithPathCombineOnly() + { + const string injectedRoot = "C:/some/injected/data-dir"; + string expected = Path.Combine(injectedRoot, "vtank"); + + string actual = VtankProfilesDefault.Resolve(injectedRoot); + + Assert.Equal(expected, actual); + Assert.DoesNotContain( + injectedRoot + "\\vtank", + actual.Replace(Path.DirectorySeparatorChar, '/'), + StringComparison.Ordinal); + } + + [Fact] + public void ResolveAppendsVtankBeneathWhicheverRootIsInjected() + { + Assert.Equal( + Path.Combine("/home/user/.local/share/acdream", "vtank"), + VtankProfilesDefault.Resolve("/home/user/.local/share/acdream")); + } +} diff --git a/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs index 9f21c599..377ec927 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs @@ -333,6 +333,63 @@ public sealed class MetafSerializerTests return null; } + // Item H (slice-1 fix round): real VTank/metaf has no marker for a + // disabled rule at all, so SaveMeta refuses by default rather than + // silently dropping it — see docs/research/vtank-kb/ + // 07-meta-and-expressions.md section 5, gap 6. + [Fact] + public void SaveMetaRefusesToDropADisabledRuleByDefault() + { + var profile = new MetaProfile + { + Rules = + [ + new MetaRule + { + Enabled = false, + Condition = MetaCondition.Always(), + Action = new MetaAction + { + Kind = MetaActionKind.ChatCommand, + Text = "/say must not silently vanish", + }, + }, + ], + }; + + InvalidOperationException thrown = Assert.Throws( + () => MetafSerializer.SaveMeta(profile)); + Assert.Contains("1 disabled rule", thrown.Message, StringComparison.Ordinal); + } + + [Fact] + public void SaveMetaDropsDisabledRulesOnlyWhenExplicitlyToldTo() + { + var profile = new MetaProfile + { + Rules = + [ + new MetaRule + { + Enabled = false, + Condition = MetaCondition.Always(), + Action = new MetaAction + { + Kind = MetaActionKind.ChatCommand, + Text = "/say must not run", + }, + }, + ], + }; + + string source = MetafSerializer.SaveMeta(profile, dropDisabledRules: true); + + Assert.DoesNotContain("must not run", source, StringComparison.Ordinal); + Assert.True(MetafSerializer.TryLoadMeta( + source, NoOpSpellCatalog.Instance, out MetaProfile loaded, out string error), error); + Assert.Empty(loaded.Rules); + } + [Fact] public void NestedAllAnyNotParsesCorrectly() { diff --git a/tests/AcDream.Plugins.MossTank.Tests/VtankLootRequirementEvaluatorTests.cs b/tests/AcDream.Plugins.MossTank.Tests/VtankLootRequirementEvaluatorTests.cs index 4b92ed03..76a18d13 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/VtankLootRequirementEvaluatorTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/VtankLootRequirementEvaluatorTests.cs @@ -75,6 +75,117 @@ public sealed class VtankLootRequirementEvaluatorTests Assert.Null(error); } + // Item H (slice-1 fix round): the KeyExistsDouble gate + // (ComputedItemInfo.cs:234) must reject the bonus exactly like + // KeyExistsInt above — the int-side test existed already, the + // double-side one did not. + // Spell 2600 grants +.03 (additive, Change=false) to DoubleValueKey 29 + // (VtankLootRequirementEvaluator's DoubleSpellBonuses table). + private const uint BonusDoubleKey = 29u; + private const uint BonusDoubleAdditiveSpellId = 2600u; + + [Fact] + public void BuffedDoubleRequirementDoesNotApplyBonusWhenBaseKeyIsAbsent() + { + PluginInventoryItem item = Item() with + { + AppraisedSpellIds = [BonusDoubleAdditiveSpellId], + }; + var properties = new PluginItemProperties( + Ints: new Dictionary(), + Int64s: new Dictionary(), + Bools: new Dictionary(), + Floats: new Dictionary(), // key 29 absent entirely + Strings: new Dictionary(), + DataIds: new Dictionary(), + InstanceIds: new Dictionary()); + + // Type 2005 = BuffedDoubleValKeyGE: values[0]=threshold, values[1]=key. + // Pre-fix (no double-side gate) this would read base=0, add the + // +.03 bonus unconditionally (0.03 >= 0.02 => true); the gate must + // keep it at the un-buffed base value (0 >= 0.02 => false) since the + // key never existed at all. + var requirement = new VtankLootRequirement + { + Type = 2005, + Payload = $"0.02\r\n{BonusDoubleKey}\r\n", + }; + bool matched = VtankLootRequirementEvaluator.IsMatch( + [requirement], item, properties, host: null, out string? error); + Assert.Null(error); + Assert.False(matched); + } + + [Fact] + public void BuffedDoubleRequirementAppliesAdditiveBonusWhenBaseKeyExists() + { + PluginInventoryItem item = Item() with + { + AppraisedSpellIds = [BonusDoubleAdditiveSpellId], + }; + var properties = new PluginItemProperties( + Ints: new Dictionary(), + Int64s: new Dictionary(), + Bools: new Dictionary(), + Floats: new Dictionary { [BonusDoubleKey] = 0d }, // key exists, base 0 + Strings: new Dictionary(), + DataIds: new Dictionary(), + InstanceIds: new Dictionary()); + + var requirement = new VtankLootRequirement + { + Type = 2005, + Payload = $"0.02\r\n{BonusDoubleKey}\r\n", + }; + // Base key exists (value 0) so the +.03 additive bonus applies: + // 0.03 >= 0.02. + Assert.True(VtankLootRequirementEvaluator.IsMatch( + [requirement], item, properties, host: null, out string? error)); + Assert.Null(error); + } + + // Spell 3201 grants a x1.05 MULTIPLICATIVE (Change=true) bonus to + // DoubleValueKey 144 — the branch this item's fix makes an explicit, + // named selector instead of inferring it from whether the bonus + // value's truncated int happens to equal 1. + [Fact] + public void BuffedDoubleRequirementAppliesMultiplicativeBonusWhenChangeIsSet() + { + const uint MultiplicativeKey = 144u; + const uint MultiplicativeSpellId = 3201u; + PluginInventoryItem item = Item() with + { + AppraisedSpellIds = [MultiplicativeSpellId], + }; + var properties = new PluginItemProperties( + Ints: new Dictionary(), + Int64s: new Dictionary(), + Bools: new Dictionary(), + Floats: new Dictionary { [MultiplicativeKey] = 10d }, // base 10 + Strings: new Dictionary(), + DataIds: new Dictionary(), + InstanceIds: new Dictionary()); + + // 10 * 1.05 = 10.5 >= 10.4 (true); if this were wrongly treated as + // additive it would be 10 + 1.05 = 11.05 (also >= 10.4 — so instead + // assert the exact computed value via a tight threshold that only + // the multiplicative result clears): 10.5 >= 10.45 is true, + // 10 + 1.05 = 11.05 is also true, so pin the boundary the other way: + // a threshold only the ADDITIVE result would clear, proving this is + // NOT additive. + var additiveWouldPass = new VtankLootRequirement + { + Type = 2005, + Payload = $"10.6\r\n{MultiplicativeKey}\r\n", + }; + // Additive would give 11.05 (>= 10.6 => true); multiplicative gives + // 10.5 (>= 10.6 => false). Observing "false" here proves the branch + // took the multiplicative path. + Assert.False(VtankLootRequirementEvaluator.IsMatch( + [additiveWouldPass], item, properties, host: null, out string? error)); + Assert.Null(error); + } + private static PluginInventoryItem Item() => new( 0u, 0u, "Test Item", 0u, 0u, 0u, 0u, 0u, 0u, 0u, 0u, 1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0); diff --git a/tests/AcDream.Plugins.MossTank.Tests/VtankMetaProfileSerializerTests.cs b/tests/AcDream.Plugins.MossTank.Tests/VtankMetaProfileSerializerTests.cs new file mode 100644 index 00000000..edafbf14 --- /dev/null +++ b/tests/AcDream.Plugins.MossTank.Tests/VtankMetaProfileSerializerTests.cs @@ -0,0 +1,69 @@ +using System.Globalization; + +namespace AcDream.Plugins.MossTank.Tests; + +/// +/// Item H (Campaign VT slice-1 fix round): restores two reader-only tests a +/// prior commit (0d10399e0, "A2 VTank profile directory resolution") deleted +/// as collateral damage of an unrelated file move — LoadsKnownTypedCondActRecord +/// and the signed-high-bit round trip embedded in the deleted +/// RoundTripPreservesEveryVtankConditionActionAndEmbeddedNav. Both are +/// adapted to the reader-only contract VtankMetaProfileSerializer has +/// had since it was demoted to a one-shot .met import (commit +/// 3ff9461ef, "MossTank does not implement .met"): neither calls the deleted +/// Save writer. +/// +public sealed class VtankMetaProfileSerializerTests +{ + [Fact] + public void LoadsKnownTypedCondActRecord() + { + const string source = "1\r\nCondAct\r\n5\r\nCType\r\nAType\r\n" + + "CData\r\nAData\r\nState\r\nn\r\nn\r\nn\r\nn\r\nn\r\n1\r\n" + + "i\r\n1\r\ni\r\n2\r\ni\r\n0\r\ns\r\n/say ready\r\n" + + "s\r\nDefault\r\n"; + + Assert.True(VtankMetaProfileSerializer.TryLoad( + source, out MetaProfile profile, out string error), error); + + MetaRule rule = Assert.Single(profile.Rules); + Assert.Equal(MetaConditionKind.Always, rule.Condition.Kind); + Assert.Equal(MetaActionKind.ChatCommand, rule.Action.Kind); + Assert.Equal("/say ready", rule.Action.Text); + Assert.Equal("Default", rule.State); + } + + /// + /// Condition type 17 (LandblockEquals) reads a raw signed decimal int + /// line (ReadCondition case 17: reader.Expect("i"); + /// value.Number = reader.ReadInt();) into a + /// field. A real landblock id with the high bit set — 0x8B370000, a + /// legitimate 32-bit landblock/object id — becomes negative once cast to + /// a signed (VTank's own on-disk encoding, exactly + /// what the deleted round-trip test exercised via + /// unchecked((int)0x8B370000u)); this pins that the reader + /// recovers the exact same bit pattern rather than silently clamping or + /// mis-widening it (a double exactly represents every 32-bit int, so + /// this is a real assertion, not a tautology once the string form is + /// the true value under test). + /// + [Fact] + public void SignedHighBitLandblockIdRoundTripsExactly() + { + int highBitValue = unchecked((int)0x8B370000u); + Assert.True(highBitValue < 0, "0x8B370000 must decode as negative once cast to Int32."); + string source = "1\r\nCondAct\r\n5\r\nCType\r\nAType\r\n" + + "CData\r\nAData\r\nState\r\nn\r\nn\r\nn\r\nn\r\nn\r\n1\r\n" + + "i\r\n17\r\ni\r\n0\r\n" + + "i\r\n" + highBitValue.ToString(CultureInfo.InvariantCulture) + "\r\n" + + "i\r\n0\r\n" + + "s\r\nDefault\r\n"; + + Assert.True(VtankMetaProfileSerializer.TryLoad( + source, out MetaProfile profile, out string error), error); + + MetaRule rule = Assert.Single(profile.Rules); + Assert.Equal(MetaConditionKind.LandblockEquals, rule.Condition.Kind); + Assert.Equal((double)highBitValue, rule.Condition.Number); + } +}