fix(vt): H test-restoration + disabled-rule refusal + double-side gate
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 commit0d10399e0. Neither calls the deleted VtankMetaProfileSerializer.Save writer (demoted to import-only in3ff9461ef) — 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 (<DataDirectory>/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 <noreply@anthropic.com>
This commit is contained in:
parent
e53507396f
commit
a3b4a8e2e5
10 changed files with 358 additions and 16 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
16
src/AcDream.App/Plugins/VtankProfilesDefault.cs
Normal file
16
src/AcDream.App/Plugins/VtankProfilesDefault.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
namespace AcDream.App.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// The graphical host's default root for <c>IPluginHost.VtankProfiles</c>
|
||||
/// when <c>RuntimeOptions.VtankProfileDirectoryOverride</c>
|
||||
/// (<c>ACDREAM_VTANK_PROFILE_DIR</c>) is unset — extracted out of
|
||||
/// <c>Program.cs</c>'s inline composition into its own pure, injectable-root
|
||||
/// function so the "built with <see cref="Path.Combine(string, string)"/>
|
||||
/// only, never a hard-coded Windows path" guarantee is a real, failable unit
|
||||
/// test rather than something only checkable by reading the source.
|
||||
/// </summary>
|
||||
internal static class VtankProfilesDefault
|
||||
{
|
||||
internal static string Resolve(string dataDirectory) =>
|
||||
Path.Combine(dataDirectory, "vtank");
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -292,9 +292,40 @@ internal static class MetafSerializer
|
|||
}
|
||||
}
|
||||
|
||||
public static string SaveMeta(MetaProfile profile)
|
||||
/// <summary>
|
||||
/// Saves <paramref name="profile"/> as <c>.af</c>. Real VTank/metaf has
|
||||
/// no concept of a "disabled" rule at all — <c>Rule</c>/<c>State</c>
|
||||
/// carry no such flag (confirmed: <c>metaf_monolithic.py</c> has zero
|
||||
/// occurrences of "enabled"/"disabled" anywhere), so
|
||||
/// <see cref="MetaRule.Enabled"/> 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
|
||||
/// <c>docs/research/vtank-kb/07-meta-and-expressions.md</c> 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
|
||||
/// <see cref="SaveMeta(MetaProfile, bool)"/>.
|
||||
/// </summary>
|
||||
public static string SaveMeta(MetaProfile profile) =>
|
||||
SaveMeta(profile, dropDisabledRules: false);
|
||||
|
||||
/// <summary>See <see cref="SaveMeta(MetaProfile)"/>'s class doc for why <paramref name="dropDisabledRules"/> exists.</summary>
|
||||
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<string> { MetaHeader };
|
||||
// EmbedNav's referenced Nav is a separate NAV: block elsewhere in
|
||||
// the file (metaf's own layout — see the class doc comment); the
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -23,20 +23,33 @@ internal static class VtankLootRequirementEvaluator
|
|||
[6095] = (28, 80),
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<uint, (uint Key, double Bonus)>
|
||||
DoubleSpellBonuses = new Dictionary<uint, (uint, double)>
|
||||
/// <summary>
|
||||
/// VTClassic's static double-spell-bonus table (<c>ComputedItemInfo.cs:88-139</c>).
|
||||
/// <c>Change</c> is the real per-row selector KB doc 05 section 2.2 names
|
||||
/// ("additive unless the static table's <c>Change==1</c>, in which case
|
||||
/// multiplicative", <c>ComputedItemInfo.cs:244</c>) — carried explicitly
|
||||
/// here rather than inferred from whether <c>Bonus</c>'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+).
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyDictionary<uint, (uint Key, double Bonus, bool Change)>
|
||||
DoubleSpellBonuses = new Dictionary<uint, (uint, double, bool)>
|
||||
{
|
||||
[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<string, int[]> 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;
|
||||
}
|
||||
|
|
|
|||
37
tests/AcDream.App.Tests/Plugins/VtankProfilesDefaultTests.cs
Normal file
37
tests/AcDream.App.Tests/Plugins/VtankProfilesDefaultTests.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
using AcDream.App.Plugins;
|
||||
|
||||
namespace AcDream.App.Tests.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Item H (Campaign VT slice-1 fix round): a genuinely failable Linux-path
|
||||
/// guarantee for <see cref="VtankProfilesDefault.Resolve"/> — re-derives the
|
||||
/// expected value with <see cref="Path.Combine(string, string)"/> from an
|
||||
/// injected root and compares, so a future hard-coded
|
||||
/// <c>dataDirectory + "\vtank"</c> (or any other literal-backslash
|
||||
/// regression) fails this test on any OS, not just by inspection.
|
||||
/// </summary>
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<InvalidOperationException>(
|
||||
() => 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<uint, int>(),
|
||||
Int64s: new Dictionary<uint, long>(),
|
||||
Bools: new Dictionary<uint, bool>(),
|
||||
Floats: new Dictionary<uint, double>(), // key 29 absent entirely
|
||||
Strings: new Dictionary<uint, string>(),
|
||||
DataIds: new Dictionary<uint, uint>(),
|
||||
InstanceIds: new Dictionary<uint, uint>());
|
||||
|
||||
// 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<uint, int>(),
|
||||
Int64s: new Dictionary<uint, long>(),
|
||||
Bools: new Dictionary<uint, bool>(),
|
||||
Floats: new Dictionary<uint, double> { [BonusDoubleKey] = 0d }, // key exists, base 0
|
||||
Strings: new Dictionary<uint, string>(),
|
||||
DataIds: new Dictionary<uint, uint>(),
|
||||
InstanceIds: new Dictionary<uint, uint>());
|
||||
|
||||
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<uint, int>(),
|
||||
Int64s: new Dictionary<uint, long>(),
|
||||
Bools: new Dictionary<uint, bool>(),
|
||||
Floats: new Dictionary<uint, double> { [MultiplicativeKey] = 10d }, // base 10
|
||||
Strings: new Dictionary<uint, string>(),
|
||||
DataIds: new Dictionary<uint, uint>(),
|
||||
InstanceIds: new Dictionary<uint, uint>());
|
||||
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
using System.Globalization;
|
||||
|
||||
namespace AcDream.Plugins.MossTank.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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 — <c>LoadsKnownTypedCondActRecord</c>
|
||||
/// and the signed-high-bit round trip embedded in the deleted
|
||||
/// <c>RoundTripPreservesEveryVtankConditionActionAndEmbeddedNav</c>. Both are
|
||||
/// adapted to the reader-only contract <c>VtankMetaProfileSerializer</c> has
|
||||
/// had since it was demoted to a one-shot <c>.met</c> import (commit
|
||||
/// 3ff9461ef, "MossTank does not implement .met"): neither calls the deleted
|
||||
/// <c>Save</c> writer.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Condition type 17 (LandblockEquals) reads a raw signed decimal int
|
||||
/// line (<c>ReadCondition</c> case 17: <c>reader.Expect("i");
|
||||
/// value.Number = reader.ReadInt();</c>) into a <see cref="double"/>
|
||||
/// 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 <see cref="int"/> (VTank's own on-disk encoding, exactly
|
||||
/// what the deleted round-trip test exercised via
|
||||
/// <c>unchecked((int)0x8B370000u)</c>); 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).
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue