Owner live report 2026-09-07: "Under Route, Add recall is missing a lot of recalls that VTank has and they do not work in routes yet." RouteRecallKind's old 4-entry model (Lifestone/Marketplace/ PrimaryPortal/SecondaryPortal) is replaced with VTank's real cmbRecallType table: 26 castable recall spells from metaf's own NRecall table (metaf_monolithic.py:10981-11008) plus Marketplace Recall, VTank's one entry with no spell (issued as /marketplace). Deviation (no refs/vtank/ checkout in this worktree to confirm VTank's real combo layout directly): the old Lifestone member, which issued "/lifestone" as a slash command, is DROPPED rather than kept alongside the new spell-based LifestoneRecall (1635) — VTank's real combo has ONE "Lifestone Recall" entry and it is a real castable spell, so keeping both would show two menu rows reading "Lifestone Recall" with different behavior. Marketplace is the one member kept as a slash command per the explicit "keep ours" instruction. PrimaryPortal/ SecondaryPortal are renamed to PrimaryPortalRecall/SecondaryPortalRecall and now use their real spell ids (48, 2647) instead of the old runtime KnownSelfBuffs name lookup. This reshuffles the enum's underlying ordinals; the one place that mattered (MossTankRouteProfileStore's legacy pre-cutover JSON migration DTO, which stores Recall as a raw int) already guards with Enum.IsDefined and is documented as a migration-only path for not-yet-migrated files — its fallback default moved from the deleted Lifestone to PrimaryPortalRecall. Changes: - RouteRecallKind: 26 named members in VTank's own combo order (values are plain sequential indices, not spell ids, so Enum.GetNames/ GetValues — which sort by underlying VALUE — reproduce that order) plus Marketplace appended last. - RouteWaypoint.RecallDisplayName: VTank's exact label text per kind. - RouteWaypoint.SpellIdForRecall (new): the real spell id per kind (Marketplace = 0, the existing "no spell" sentinel). - NavigationController.SubmitRecall: unchanged fast path (RecallSpellId != 0 -> cast) now covers every recall added through the UI; the fallback for a waypoint with no recorded id resolves through SpellIdForRecall instead of the old runtime spell-name lookup, and Marketplace still falls through to "/marketplace". - MossTankPanel.AddRouteRecallCore: populates RecallSpellId AND RecallSpellName on the new waypoint (matching what a metaf import or the binary .nav loader already produces), so a waypoint added from the Route tab's own combo executes identically to one round-tripped through a real route file. - mosstank.xml: the recall <menu> comment updated; rows 4->7 now that scrolling through 27 entries is real, not grammar-only. Tests added (NavigationTests.cs, MetafSerializerTests.cs): - RouteRecallKindListsVTanksTwentySixRecallsInOrderPlusMarketplaceLast (the 26-entry order via Enum.GetNames). - RecallNameAndSpellIdTablesAgree (27-case Theory: name<->id both ways; RouteRecallKind is internal so the Theory parameter is the public int ordinal, cast back inside the method — a public method cannot expose an internal-typed parameter, CS0051). - RecallWaypointWithNonZeroSpellIdCastsThatSpell / RecallWaypointForMarketplaceSubmitsTheSlashCommandNotACast (execution, via a new FakeMagic tracking fake — FakeAutomation.Magic is now settable instead of always NoOpAutomationSurface). - RecallNodeRoundTripsByNameAndResolvesTheRealSpellIdFromTheCatalog (three recalls through SaveNav/TryLoadNav with a new FakeSpellCatalog that actually knows the spells, proving both the name AND the resolved id survive the .af round trip). Mutations shown to fail, then reverted: swapping the first two enum members failed the order test; reducing SubmitRecall to `return false` failed both execution tests (no cast recorded, "/marketplace" not submitted). Verified: dotnet build AcDream.slnx -c Release green; MossTank suite 713/713 (682 -> 713, 31 new tests); App markup/plugin filter 242/242 (unchanged). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
835 lines
36 KiB
C#
835 lines
36 KiB
C#
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Plugins.MossTank.Tests;
|
|
|
|
public sealed class MetafSerializerTests
|
|
{
|
|
private static readonly string FixturesRoot = Path.Combine(
|
|
AppContext.BaseDirectory, "Fixtures", "vtank");
|
|
|
|
private static string[] AllAfFixtures() => Directory.GetFiles(
|
|
Path.Combine(FixturesRoot, "af"), "*.af");
|
|
|
|
private static string[] NavOnlyAfFixtures() => Directory.GetFiles(
|
|
Path.Combine(FixturesRoot, "af"), "nav_*.af");
|
|
|
|
private static string[] MetaAfFixtures() => Directory.GetFiles(
|
|
Path.Combine(FixturesRoot, "af"), "*.af")
|
|
.Where(static path => !Path.GetFileName(path).StartsWith(
|
|
"nav_", StringComparison.Ordinal))
|
|
.ToArray();
|
|
|
|
public static TheoryData<string> AllAfFixtureData()
|
|
{
|
|
var data = new TheoryData<string>();
|
|
foreach (string path in AllAfFixtures())
|
|
data.Add(path);
|
|
return data;
|
|
}
|
|
|
|
public static TheoryData<string> MetaAfFixtureData()
|
|
{
|
|
var data = new TheoryData<string>();
|
|
foreach (string path in MetaAfFixtures())
|
|
data.Add(path);
|
|
return data;
|
|
}
|
|
|
|
public static TheoryData<string> NavOnlyAfFixtureData()
|
|
{
|
|
var data = new TheoryData<string>();
|
|
foreach (string path in NavOnlyAfFixtures())
|
|
data.Add(path);
|
|
return data;
|
|
}
|
|
|
|
// Proof (1): every .af under the fixture set parses without error.
|
|
[Theory]
|
|
[MemberData(nameof(MetaAfFixtureData))]
|
|
public void EveryMetaAfFixtureParses(string path)
|
|
{
|
|
string text = File.ReadAllText(path);
|
|
Assert.True(
|
|
MetafSerializer.TryLoadMeta(text, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error),
|
|
$"{Path.GetFileName(path)}: {error}");
|
|
Assert.NotEmpty(profile.Rules);
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(NavOnlyAfFixtureData))]
|
|
public void EveryNavOnlyAfFixtureParses(string path)
|
|
{
|
|
string text = File.ReadAllText(path);
|
|
var target = new NavigationSettings();
|
|
Assert.True(
|
|
MetafSerializer.TryLoadNav(text, target, NoOpSpellCatalog.Instance, out string error),
|
|
$"{Path.GetFileName(path)}: {error}");
|
|
}
|
|
|
|
// Campaign VT slice 1c step 3 (content sanity): a real nav-only .af
|
|
// fixture — zero STATE: blocks, one or more NAV: blocks — must be
|
|
// REFUSED by TryLoadMeta rather than silently "succeeding" with an
|
|
// empty MetaProfile (a NAV:-only pass through the STATE:/NAV: loop
|
|
// never adds a Rule). Proves the metas/ folder's content-sanity check
|
|
// against every real nav-only fixture, not just a hand-built one.
|
|
[Theory]
|
|
[MemberData(nameof(NavOnlyAfFixtureData))]
|
|
public void EveryNavOnlyAfFixtureIsRefusedByTryLoadMeta(string path)
|
|
{
|
|
string text = File.ReadAllText(path);
|
|
bool loaded = MetafSerializer.TryLoadMeta(
|
|
text, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error);
|
|
|
|
Assert.False(loaded, $"{Path.GetFileName(path)} should not parse as a Meta profile.");
|
|
Assert.Empty(profile.Rules);
|
|
Assert.Contains("navs/", error, StringComparison.Ordinal);
|
|
}
|
|
|
|
// The other direction: a STATE:-only .af with no embedded NAV: block at
|
|
// all (bella.af — confirmed zero "NAV:" lines) must be REFUSED by
|
|
// TryLoadNav (already the case: "no NAV: block found") with a notice
|
|
// naming the metas/ folder it belongs in.
|
|
[Fact]
|
|
public void MetaOnlyContentIsRefusedByTryLoadNavWithMetasFolderNotice()
|
|
{
|
|
string text = File.ReadAllText(Path.Combine(FixturesRoot, "af", "bella.af"));
|
|
var target = new NavigationSettings();
|
|
|
|
bool loaded = MetafSerializer.TryLoadNav(
|
|
text, target, NoOpSpellCatalog.Instance, out string error);
|
|
|
|
Assert.False(loaded);
|
|
Assert.Contains("metas/", error, StringComparison.Ordinal);
|
|
}
|
|
|
|
// Proof (2): parse -> write -> parse is identical (model equality; the
|
|
// writer's own byte-for-byte shape is proof (4), below).
|
|
[Theory]
|
|
[MemberData(nameof(MetaAfFixtureData))]
|
|
public void MetaParseWriteParseIsIdentical(string path)
|
|
{
|
|
string text = File.ReadAllText(path);
|
|
Assert.True(MetafSerializer.TryLoadMeta(
|
|
text, NoOpSpellCatalog.Instance, out MetaProfile first, out string error1), error1);
|
|
string rewritten = MetafSerializer.SaveMeta(first);
|
|
Assert.True(MetafSerializer.TryLoadMeta(
|
|
rewritten, NoOpSpellCatalog.Instance, out MetaProfile second, out string error2), error2);
|
|
AssertProfilesEqual(first, second);
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(NavOnlyAfFixtureData))]
|
|
public void NavParseWriteParseIsIdentical(string path)
|
|
{
|
|
string text = File.ReadAllText(path);
|
|
var first = new NavigationSettings();
|
|
Assert.True(MetafSerializer.TryLoadNav(
|
|
text, first, NoOpSpellCatalog.Instance, out string error1), error1);
|
|
string rewritten = MetafSerializer.SaveNav(first);
|
|
var second = new NavigationSettings();
|
|
Assert.True(MetafSerializer.TryLoadNav(
|
|
rewritten, second, NoOpSpellCatalog.Instance, out string error2), error2);
|
|
AssertNavigationEqual(first, second);
|
|
}
|
|
|
|
// Proof (3): for the met/ and nav/ samples that have a matching af/
|
|
// conversion, our model loaded from the binary import path equals our
|
|
// model loaded from metaf's own .af conversion.
|
|
public static TheoryData<string, string> MetMatchingAfPairs()
|
|
{
|
|
var data = new TheoryData<string, string>();
|
|
string metDir = Path.Combine(FixturesRoot, "met");
|
|
string afDir = Path.Combine(FixturesRoot, "af");
|
|
foreach (string metPath in Directory.GetFiles(metDir, "*.met"))
|
|
{
|
|
string name = Path.GetFileNameWithoutExtension(metPath);
|
|
string afPath = Path.Combine(afDir, name + ".af");
|
|
if (File.Exists(afPath))
|
|
data.Add(metPath, afPath);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(MetMatchingAfPairs))]
|
|
public void BinaryImportMatchesMetafAfConversion(string metPath, string afPath)
|
|
{
|
|
string metText = File.ReadAllText(metPath);
|
|
Assert.True(
|
|
VtankMetaProfileSerializer.TryLoad(metText, out MetaProfile fromMet, out string metError),
|
|
$"{Path.GetFileName(metPath)}: {metError}");
|
|
|
|
string afText = File.ReadAllText(afPath);
|
|
Assert.True(
|
|
MetafSerializer.TryLoadMeta(afText, NoOpSpellCatalog.Instance, out MetaProfile fromAf, out string afError),
|
|
$"{Path.GetFileName(afPath)}: {afError}");
|
|
|
|
AssertProfilesEqual(fromMet, fromAf);
|
|
}
|
|
|
|
public static TheoryData<string, string> NavMatchingAfPairs()
|
|
{
|
|
var data = new TheoryData<string, string>();
|
|
string navDir = Path.Combine(FixturesRoot, "nav");
|
|
string afDir = Path.Combine(FixturesRoot, "af");
|
|
foreach (string navPath in Directory.GetFiles(navDir, "*.nav"))
|
|
{
|
|
string name = Path.GetFileNameWithoutExtension(navPath);
|
|
string afPath = Path.Combine(afDir, name + ".af");
|
|
if (File.Exists(afPath))
|
|
data.Add(navPath, afPath);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(NavMatchingAfPairs))]
|
|
public void BinaryNavImportMatchesMetafAfConversion(string navPath, string afPath)
|
|
{
|
|
string navText = File.ReadAllText(navPath);
|
|
var fromNav = new NavigationSettings();
|
|
Assert.True(
|
|
VtankNavRouteSerializer.TryLoad(navText, fromNav, NoOpSpellCatalog.Instance, out string navError),
|
|
$"{Path.GetFileName(navPath)}: {navError}");
|
|
|
|
string afText = File.ReadAllText(afPath);
|
|
var fromAf = new NavigationSettings();
|
|
Assert.True(
|
|
MetafSerializer.TryLoadNav(afText, fromAf, NoOpSpellCatalog.Instance, out string afError),
|
|
$"{Path.GetFileName(afPath)}: {afError}");
|
|
|
|
AssertNavigationEqual(fromNav, fromAf);
|
|
}
|
|
|
|
// Proof (4): the writer's output is REAL byte-identical to metaf's own
|
|
// canonical emission (item E, slice-1 fix round: this used to strip
|
|
// "~~" comments before comparing, which hid two facts — metaf's own
|
|
// ExportToMetAF DOES mechanically emit the auto-completion banner
|
|
// (OutputText.metaHeader, metaf_monolithic.py:365-406) plus a "~~ {"/
|
|
// "~~ }" editor-fold pair around every STATE:/NAV: block
|
|
// (State.ExportToMetAF py:12463-12467, Nav.ExportToMetAF py:12050-12054),
|
|
// which this writer now reproduces; and several committed fixtures do
|
|
// NOT carry metaf's fresh canonical header at all, for reasons that
|
|
// have nothing to do with this writer's correctness — see below).
|
|
public static TheoryData<string> ByteIdenticalFixtureData()
|
|
{
|
|
var data = new TheoryData<string>();
|
|
// Excluded, all for reasons independent of this writer (confirmed
|
|
// by inspecting each file's own first bytes, not asserted):
|
|
//
|
|
// bore_enhanced.af / bore_quest.af: hand-edited after generation —
|
|
// some IF:/DO: lines use a space instead of metaf's own tab
|
|
// separator (bore_quest.af line 9: "\tIF: Death", not
|
|
// "\tIF:\tDeath"; metaf's Rule.ExportToMetAF always joins with a
|
|
// tab, py:12371/12373).
|
|
//
|
|
// aphus.af, neftet.af, hunting.af, follower.af: the header's
|
|
// internal line breaks are metaf's own bare "\n" for a freshly
|
|
// generated file (confirmed against augments.af/bella.af/
|
|
// gauntlet_leader.af/empyrean_facility.af/example_sort_meta.af,
|
|
// whose first 8 bytes are "~~ {\n~~ "), but these four instead open
|
|
// with "~~ {\r\n~~ " (aphus/neftet/follower) or, for hunting.af, a
|
|
// wholly custom banner ("~~ Hunting Meta - Solo lifestone
|
|
// grinding..." instead of "~~ FOR AUTO-COMPLETION ASSISTANCE...").
|
|
// Both are evidence the file was re-saved by something other than
|
|
// a fresh metaf conversion (a text editor normalizing every line
|
|
// ending to CRLF loses metaf's internal bare-LF header formatting;
|
|
// a custom banner is an intentional hand edit) — not something
|
|
// this writer could or should reproduce.
|
|
//
|
|
// lockandkey.af: opens with a fully custom multi-line description
|
|
// ("~~\n~~\tlockandkey.af - Portal Bot with Stipend..."), never
|
|
// metaf's own banner at all.
|
|
//
|
|
// All five still exercise every other proof (parse,
|
|
// parse-write-parse, and — for aphus/augments/lockandkey/neftet —
|
|
// the ptl/tlk two-triple fidelity direct assertions in
|
|
// PtlNodeKeepsBothCoordinateTriplesDistinct) normally.
|
|
foreach (string name in new[]
|
|
{
|
|
"bella", "gauntlet_leader", "empyrean_facility",
|
|
"augments", "example_sort_meta",
|
|
})
|
|
{
|
|
data.Add(Path.Combine(FixturesRoot, "af", name + ".af"));
|
|
}
|
|
return data;
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(ByteIdenticalFixtureData))]
|
|
public void WriterOutputMatchesMetafCanonicalEmission(string path)
|
|
{
|
|
string original = File.ReadAllText(path);
|
|
Assert.True(
|
|
MetafSerializer.TryLoadMeta(original, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error),
|
|
error);
|
|
string rewritten = MetafSerializer.SaveMeta(profile);
|
|
Assert.Equal(original, rewritten);
|
|
}
|
|
|
|
// Proof (4), nav-only case: SaveNav's own header (navHeader) and
|
|
// single-Nav fold-marker wrap are byte-identical to a real nav-only
|
|
// .af fixture. Every listed fixture carries metaf's own fresh banner
|
|
// (first bytes "~~ {\n~~ "); round 3 item 8 extended this from a
|
|
// single fixture (nav_ab.af) to the three checkpoint/recall/portal2
|
|
// fixtures copied from the metas repo (nav_briennecarlus.af carries
|
|
// chk+jmp, nav_empyrean.af carries rcl+ptl, nav_lockandkeyjaw.af
|
|
// carries rcl+chk) so the theory actually exercises every node kind
|
|
// metaf's own grammar supports, not just plain point waypoints.
|
|
public static TheoryData<string> NavByteIdenticalFixtureData()
|
|
{
|
|
var data = new TheoryData<string>();
|
|
foreach (string name in new[]
|
|
{
|
|
"nav_ab", "nav_briennecarlus", "nav_empyrean", "nav_lockandkeyjaw",
|
|
})
|
|
{
|
|
data.Add(Path.Combine(FixturesRoot, "af", name + ".af"));
|
|
}
|
|
return data;
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(NavByteIdenticalFixtureData))]
|
|
public void WriterOutputMatchesMetafCanonicalEmissionNavOnly(string path)
|
|
{
|
|
string original = File.ReadAllText(path);
|
|
var settings = new NavigationSettings();
|
|
Assert.True(
|
|
MetafSerializer.TryLoadNav(original, settings, NoOpSpellCatalog.Instance, out string error),
|
|
error);
|
|
string rewritten = MetafSerializer.SaveNav(settings);
|
|
Assert.Equal(original, rewritten);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExampleSortMetaBinaryImportMatchesAf()
|
|
{
|
|
string metText = File.ReadAllText(Path.Combine(FixturesRoot, "met", "example_sort_meta.met"));
|
|
Assert.True(VtankMetaProfileSerializer.TryLoad(metText, out MetaProfile fromMet, out string metError), metError);
|
|
|
|
string afText = File.ReadAllText(Path.Combine(FixturesRoot, "af", "example_sort_meta.af"));
|
|
Assert.True(
|
|
MetafSerializer.TryLoadMeta(afText, NoOpSpellCatalog.Instance, out MetaProfile fromAf, out string afError),
|
|
afError);
|
|
|
|
AssertProfilesEqual(fromMet, fromAf);
|
|
}
|
|
|
|
// aphus.af's embedded "nav0__stipend_nav" route carries a real "ptl"
|
|
// node whose two coordinate triples genuinely differ (line 1026:
|
|
// "-101.597905190786 -96.6216093699137 2.08333134651184E-05" myxyz,
|
|
// "59.3936458587647 -28.7256083488464 0.0508250035345554" tgtxyz). A
|
|
// prior port collapsed both onto RouteWaypoint.Position, which this
|
|
// test would have caught immediately: before the fix, Position held
|
|
// 59.39/-28.72/0.0508 (the second triple) and there was nowhere to
|
|
// read -101.59/-96.62/2.08e-05 back from at all.
|
|
[Fact]
|
|
public void PtlNodeKeepsBothCoordinateTriplesDistinct()
|
|
{
|
|
string original = File.ReadAllText(Path.Combine(FixturesRoot, "af", "aphus.af"));
|
|
Assert.True(
|
|
MetafSerializer.TryLoadMeta(original, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error),
|
|
error);
|
|
|
|
RouteWaypoint waypoint = FindNavWaypoint(profile, "Portal to Town Network");
|
|
|
|
Assert.Equal(-101.597905190786d, waypoint.Position.EastWest, 6);
|
|
Assert.Equal(-96.6216093699137d, waypoint.Position.NorthSouth, 6);
|
|
Assert.Equal(2.08333134651184E-05d, waypoint.Position.Elevation, 10);
|
|
Assert.Equal(59.3936458587647d, waypoint.ReferencePosition.EastWest, 6);
|
|
Assert.Equal(-28.7256083488464d, waypoint.ReferencePosition.NorthSouth, 6);
|
|
Assert.Equal(0.0508250035345554d, waypoint.ReferencePosition.Elevation, 6);
|
|
|
|
string rewritten = MetafSerializer.SaveMeta(profile);
|
|
Assert.Contains(
|
|
"ptl -101.597905190786 -96.6216093699137 2.08333134651184E-05 "
|
|
+ "59.3936458587647 -28.7256083488464 0.0508250035345554 14 "
|
|
+ "{Portal to Town Network}",
|
|
rewritten,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Walks every <see cref="MetaActionKind.LoadEmbeddedNavigationRoute"/>
|
|
/// action's typed <see cref="MetaAction.EmbeddedRoute"/> looking for a
|
|
/// waypoint by object name.
|
|
/// </summary>
|
|
private static RouteWaypoint FindNavWaypoint(MetaProfile profile, string objectName)
|
|
{
|
|
foreach (MetaRule rule in profile.Rules)
|
|
{
|
|
RouteWaypoint? found = FindNavWaypoint(rule.Action, objectName);
|
|
if (found is not null)
|
|
return found;
|
|
}
|
|
throw new InvalidOperationException($"no nav waypoint named '{objectName}' found.");
|
|
}
|
|
|
|
private static RouteWaypoint? FindNavWaypoint(MetaAction action, string objectName)
|
|
{
|
|
if (action.Kind == MetaActionKind.LoadEmbeddedNavigationRoute
|
|
&& action.EmbeddedRoute is { } nav)
|
|
{
|
|
foreach (RouteWaypoint waypoint in nav.Waypoints)
|
|
{
|
|
if (waypoint.ObjectName == objectName)
|
|
return waypoint;
|
|
}
|
|
}
|
|
foreach (MetaAction child in action.Children)
|
|
{
|
|
RouteWaypoint? found = FindNavWaypoint(child, objectName);
|
|
if (found is not null)
|
|
return found;
|
|
}
|
|
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()
|
|
{
|
|
string af = string.Join("\r\n",
|
|
[
|
|
"STATE: {Default}",
|
|
"\tIF:\tAll",
|
|
"\t\t\tAlways",
|
|
"\t\t\tNot Death",
|
|
"\t\t\tAny",
|
|
"\t\t\t\tVendorOpen",
|
|
"\t\t\t\tVendorClosed",
|
|
"\t\tDO:\tDoAll",
|
|
"\t\t\t\tChat {hello}",
|
|
"\t\t\t\tSetState {Next}",
|
|
]) + "\r\n";
|
|
Assert.True(
|
|
MetafSerializer.TryLoadMeta(af, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error),
|
|
error);
|
|
MetaRule rule = Assert.Single(profile.Rules);
|
|
Assert.Equal(MetaConditionKind.All, rule.Condition.Kind);
|
|
Assert.Equal(3, rule.Condition.Children.Count);
|
|
Assert.Equal(MetaConditionKind.Always, rule.Condition.Children[0].Kind);
|
|
Assert.Equal(MetaConditionKind.Not, rule.Condition.Children[1].Kind);
|
|
Assert.Equal(MetaConditionKind.CharacterDeath, rule.Condition.Children[1].Children[0].Kind);
|
|
Assert.Equal(MetaConditionKind.Any, rule.Condition.Children[2].Kind);
|
|
Assert.Equal(2, rule.Condition.Children[2].Children.Count);
|
|
Assert.Equal(MetaActionKind.All, rule.Action.Kind);
|
|
Assert.Equal(2, rule.Action.Children.Count);
|
|
Assert.Equal("hello", rule.Action.Children[0].Text);
|
|
Assert.Equal("Next", rule.Action.Children[1].Text);
|
|
}
|
|
|
|
// Item J (slice-1 fix round): MobsInDist_Priority is never exercised by
|
|
// any committed real fixture (only its name appears, in the
|
|
// auto-completion header banner text) — this synthesized round trip
|
|
// pins the argument order (count, distance, priority — see the
|
|
// regex-table comment in MetafSerializer.cs) with three DISTINCT
|
|
// numeric values so an accidental swap of any two fields fails loudly
|
|
// instead of silently agreeing with itself.
|
|
[Fact]
|
|
public void MobsInDistPriorityRoundTripsAllThreeNumbersDistinctly()
|
|
{
|
|
string af = string.Join("\r\n",
|
|
[
|
|
"STATE: {Default}",
|
|
"\tIF:\tMobsInDist_Priority 7 12.5 3",
|
|
"\t\tDO:\tChat {seen}",
|
|
]) + "\r\n";
|
|
|
|
Assert.True(
|
|
MetafSerializer.TryLoadMeta(af, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error),
|
|
error);
|
|
MetaRule rule = Assert.Single(profile.Rules);
|
|
Assert.Equal(MetaConditionKind.MonsterPriorityCountWithinDistance, rule.Condition.Kind);
|
|
Assert.Equal(7, rule.Condition.Number);
|
|
Assert.Equal(12.5, rule.Condition.SecondaryNumber);
|
|
Assert.Equal(3, rule.Condition.TertiaryNumber);
|
|
|
|
string rewritten = MetafSerializer.SaveMeta(profile);
|
|
Assert.True(
|
|
MetafSerializer.TryLoadMeta(rewritten, NoOpSpellCatalog.Instance, out MetaProfile reloaded, out string error2),
|
|
error2);
|
|
MetaRule reloadedRule = Assert.Single(reloaded.Rules);
|
|
Assert.Equal(7, reloadedRule.Condition.Number);
|
|
Assert.Equal(12.5, reloadedRule.Condition.SecondaryNumber);
|
|
Assert.Equal(3, reloadedRule.Condition.TertiaryNumber);
|
|
}
|
|
|
|
[Fact]
|
|
public void SynthesizedGetOptFollowAndJumpFixtureRoundTrips()
|
|
{
|
|
// GetOpt, the "flw" nav node, and "jmp" never appear in any real
|
|
// fixture in metas/af (grep confirmed at slice-1 authoring time), so
|
|
// this small hand-authored fixture exercises them — validated as
|
|
// real metaf grammar via `py metaf_monolithic.py` during authoring
|
|
// (not re-run automatically here, since python is not guaranteed on
|
|
// the Ubuntu portable CI lane).
|
|
string af = string.Join("\r\n",
|
|
[
|
|
"STATE: {Default}",
|
|
"\tIF:\tAlways",
|
|
"\t\tDO:\tDoAll",
|
|
"\t\t\t\tGetOpt {AttackDistance} {myvar}",
|
|
"\t\t\t\tSetOpt {AttackDistance} {myvar}",
|
|
"NAV: myfollow follow",
|
|
"\tflw 00001234 {Some Monster}",
|
|
]) + "\r\n";
|
|
Assert.True(
|
|
MetafSerializer.TryLoadMeta(af, NoOpSpellCatalog.Instance, out MetaProfile profile, out string error),
|
|
error);
|
|
MetaRule rule = Assert.Single(profile.Rules);
|
|
MetaAction getOpt = rule.Action.Children[0];
|
|
MetaAction setOpt = rule.Action.Children[1];
|
|
Assert.Equal(MetaActionKind.GetVtankOption, getOpt.Kind);
|
|
Assert.Equal("AttackDistance", getOpt.Text);
|
|
Assert.Equal("myvar", getOpt.SecondaryText);
|
|
Assert.Equal(MetaActionKind.SetVtankOption, setOpt.Kind);
|
|
|
|
// Slice 1c review D1 / slice 7 item 7: a document with a STATE:
|
|
// rule section is a Meta profile — even one with an embedded NAV:
|
|
// block of its own — and TryLoadNav must now REFUSE it outright
|
|
// rather than silently skip the STATE: rules and load whichever
|
|
// NAV: block happened to come first (the prior behavior this same
|
|
// fixture used to exercise; see FollowNavNodeParsesAsANavOnlyDocument
|
|
// below for the "flw" node's own parsing coverage, now split into a
|
|
// standalone NAV:-only fixture).
|
|
var nav = new NavigationSettings();
|
|
bool navLoaded = MetafSerializer.TryLoadNav(
|
|
af, nav, NoOpSpellCatalog.Instance, out string navError);
|
|
Assert.False(navLoaded);
|
|
Assert.Contains("STATE:", navError, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The "flw" nav node's own parsing coverage, split out of
|
|
/// <see cref="SynthesizedGetOptFollowAndJumpFixtureRoundTrips"/> once
|
|
/// that fixture became a refusal pin (slice 7 item 7) — a standalone
|
|
/// NAV:-only document (no STATE: preamble) is exactly what TryLoadNav
|
|
/// is meant to accept.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FollowNavNodeParsesAsANavOnlyDocument()
|
|
{
|
|
string af = string.Join("\r\n",
|
|
[
|
|
"NAV: myfollow follow",
|
|
"\tflw 00001234 {Some Monster}",
|
|
]) + "\r\n";
|
|
var nav = new NavigationSettings();
|
|
Assert.True(
|
|
MetafSerializer.TryLoadNav(af, nav, NoOpSpellCatalog.Instance, out string error),
|
|
error);
|
|
Assert.Equal(RouteMode.Target, nav.Mode);
|
|
Assert.Equal(0x00001234u, nav.FollowTargetObjectId);
|
|
Assert.Equal("Some Monster", nav.FollowTargetName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 3 item 5: the retail 2000 ms jump-charge ceiling
|
|
/// (refs/vtank/decompiled/bi.cs:502-505) is EXECUTION-time behavior
|
|
/// (NavigationController.TickJump), not a storage-format limit — metaf
|
|
/// and VTank both round-trip the authored value verbatim
|
|
/// (metaf_monolithic.py:11708-11820, NJump — no clamp at all). An
|
|
/// authored 5000 ms waypoint must therefore survive an .af load
|
|
/// unchanged; renamed from JumpNodeClampsChargeMillisecondsTo2000,
|
|
/// which pinned the pre-fix (wrong) load-time clamp.
|
|
/// </summary>
|
|
[Fact]
|
|
public void JumpNodeLoadPreservesAuthoredChargeMillisecondsAboveRetailCeiling()
|
|
{
|
|
const string af = """
|
|
NAV: j once
|
|
jmp 1 2 3 90 {True} 5000
|
|
""";
|
|
var target = new NavigationSettings();
|
|
Assert.True(MetafSerializer.TryLoadNav(af, target, NoOpSpellCatalog.Instance, out string error), error);
|
|
RouteWaypoint waypoint = Assert.Single(target.Waypoints);
|
|
Assert.Equal(RouteWaypointType.Jump, waypoint.Type);
|
|
Assert.Equal(5000, waypoint.JumpChargeMilliseconds);
|
|
Assert.True(waypoint.JumpRun);
|
|
Assert.Equal(90f, waypoint.JumpHeadingDegrees);
|
|
}
|
|
|
|
[Fact]
|
|
public void JumpNodeBelowClampPassesThroughUnchanged()
|
|
{
|
|
const string af = """
|
|
NAV: j once
|
|
jmp 1 2 3 90 {False} 500
|
|
""";
|
|
var target = new NavigationSettings();
|
|
Assert.True(MetafSerializer.TryLoadNav(af, target, NoOpSpellCatalog.Instance, out string error), error);
|
|
RouteWaypoint waypoint = Assert.Single(target.Waypoints);
|
|
Assert.Equal(500, waypoint.JumpChargeMilliseconds);
|
|
Assert.False(waypoint.JumpRun);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 3 item 5 companion: SaveNav must round-trip the same
|
|
/// above-ceiling value back out unchanged (the save side never had a
|
|
/// clamp; this pins that save+load together preserve it).
|
|
/// </summary>
|
|
[Fact]
|
|
public void JumpNodeSaveThenLoadRoundTripsChargeMillisecondsAboveRetailCeiling()
|
|
{
|
|
var source = new NavigationSettings { Mode = RouteMode.Once };
|
|
source.Waypoints.Add(new RouteWaypoint
|
|
{
|
|
Type = RouteWaypointType.Jump,
|
|
JumpHeadingDegrees = 90f,
|
|
JumpRun = true,
|
|
JumpChargeMilliseconds = 5000,
|
|
});
|
|
|
|
string af = MetafSerializer.SaveNav(source);
|
|
|
|
var target = new NavigationSettings();
|
|
Assert.True(MetafSerializer.TryLoadNav(af, target, NoOpSpellCatalog.Instance, out string error), error);
|
|
RouteWaypoint waypoint = Assert.Single(target.Waypoints);
|
|
Assert.Equal(5000, waypoint.JumpChargeMilliseconds);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round D item 3: ".af rcl round-trips by NAME (metaf writes the
|
|
/// name)" — SaveNav's "rcl" node carries ONLY RecallSpellName
|
|
/// (MetafSerializer's own "rcl {waypoint.RecallSpellName}" format),
|
|
/// and TryLoadNav resolves RecallSpellId back from that name against
|
|
/// the supplied ISpellCatalog. This proves three of VTank's 26 real
|
|
/// recalls (one from the start, middle, and end of the new table)
|
|
/// round-trip both the name AND — given a catalog that actually knows
|
|
/// the spell, unlike NoOpSpellCatalog — the correct real spell id.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RecallNodeRoundTripsByNameAndResolvesTheRealSpellIdFromTheCatalog()
|
|
{
|
|
var source = new NavigationSettings { Mode = RouteMode.Once };
|
|
RouteRecallKind[] kinds =
|
|
[
|
|
RouteRecallKind.PrimaryPortalRecall,
|
|
RouteRecallKind.MountLetheRecall,
|
|
RouteRecallKind.EldrytchWebStrongholdRecall,
|
|
];
|
|
foreach (RouteRecallKind kind in kinds)
|
|
{
|
|
source.Waypoints.Add(new RouteWaypoint
|
|
{
|
|
Type = RouteWaypointType.Recall,
|
|
Recall = kind,
|
|
RecallSpellName = RouteWaypoint.RecallDisplayName(kind),
|
|
RecallSpellId = RouteWaypoint.SpellIdForRecall(kind),
|
|
});
|
|
}
|
|
|
|
string af = MetafSerializer.SaveNav(source);
|
|
|
|
var catalog = new FakeSpellCatalog(kinds.Select(
|
|
kind => new PluginSpellInfo(
|
|
RouteWaypoint.SpellIdForRecall(kind),
|
|
RouteWaypoint.RecallDisplayName(kind),
|
|
Family: 0, Tier: 1, Difficulty: 1, ManaCost: 0,
|
|
DurationSeconds: 0f, School: 0, Description: string.Empty,
|
|
IsSelfTargeted: true, IsBeneficial: true)));
|
|
var target = new NavigationSettings();
|
|
Assert.True(MetafSerializer.TryLoadNav(af, target, catalog, out string error), error);
|
|
|
|
Assert.Equal(kinds.Length, target.Waypoints.Count);
|
|
for (int i = 0; i < kinds.Length; i++)
|
|
{
|
|
RouteWaypoint waypoint = target.Waypoints[i];
|
|
Assert.Equal(RouteWaypoint.RecallDisplayName(kinds[i]), waypoint.RecallSpellName);
|
|
Assert.Equal(RouteWaypoint.SpellIdForRecall(kinds[i]), waypoint.RecallSpellId);
|
|
}
|
|
}
|
|
|
|
private static void AssertProfilesEqual(MetaProfile expected, MetaProfile actual)
|
|
{
|
|
Assert.Equal(expected.Rules.Count, actual.Rules.Count);
|
|
for (int i = 0; i < expected.Rules.Count; i++)
|
|
{
|
|
MetaRule a = expected.Rules[i];
|
|
MetaRule b = actual.Rules[i];
|
|
Assert.Equal(a.State, b.State);
|
|
AssertConditionsEqual(a.Condition, b.Condition);
|
|
AssertActionsEqual(a.Action, b.Action);
|
|
}
|
|
}
|
|
|
|
private static void AssertConditionsEqual(MetaCondition expected, MetaCondition actual)
|
|
{
|
|
Assert.Equal(expected.Kind, actual.Kind);
|
|
Assert.Equal(expected.Text, actual.Text);
|
|
Assert.Equal(expected.SecondaryText, actual.SecondaryText);
|
|
Assert.Equal(expected.Number, actual.Number, 6);
|
|
Assert.Equal(expected.SecondaryNumber, actual.SecondaryNumber, 6);
|
|
Assert.Equal(expected.TertiaryNumber, actual.TertiaryNumber, 6);
|
|
Assert.Equal(expected.Children.Count, actual.Children.Count);
|
|
for (int i = 0; i < expected.Children.Count; i++)
|
|
AssertConditionsEqual(expected.Children[i], actual.Children[i]);
|
|
}
|
|
|
|
private static void AssertActionsEqual(MetaAction expected, MetaAction actual)
|
|
{
|
|
Assert.Equal(expected.Kind, actual.Kind);
|
|
if (expected.Kind == MetaActionKind.LoadEmbeddedNavigationRoute)
|
|
{
|
|
// Item B: MetaAction carries a typed EmbeddedRoute, not a
|
|
// re-parse-me blob in Text (which is now always empty for this
|
|
// action kind) — compare the routes waypoint-by-waypoint
|
|
// instead of skipping the comparison entirely.
|
|
Assert.Equal(expected.Text, actual.Text);
|
|
Assert.NotNull(expected.EmbeddedRoute);
|
|
Assert.NotNull(actual.EmbeddedRoute);
|
|
AssertNavigationEqual(expected.EmbeddedRoute!, actual.EmbeddedRoute!);
|
|
}
|
|
else
|
|
{
|
|
Assert.Equal(expected.Text, actual.Text);
|
|
}
|
|
Assert.Equal(expected.SecondaryText, actual.SecondaryText);
|
|
Assert.Equal(expected.Number, actual.Number, 6);
|
|
Assert.Equal(expected.SecondaryNumber, actual.SecondaryNumber, 6);
|
|
Assert.Equal(expected.Children.Count, actual.Children.Count);
|
|
for (int i = 0; i < expected.Children.Count; i++)
|
|
AssertActionsEqual(expected.Children[i], actual.Children[i]);
|
|
}
|
|
|
|
private static void AssertNavigationEqual(NavigationSettings expected, NavigationSettings actual)
|
|
{
|
|
Assert.Equal(expected.Mode, actual.Mode);
|
|
if (expected.Mode == RouteMode.Target)
|
|
{
|
|
Assert.Equal(expected.FollowTargetObjectId, actual.FollowTargetObjectId);
|
|
Assert.Equal(expected.FollowTargetName, actual.FollowTargetName);
|
|
return;
|
|
}
|
|
Assert.Equal(expected.Waypoints.Count, actual.Waypoints.Count);
|
|
for (int i = 0; i < expected.Waypoints.Count; i++)
|
|
{
|
|
RouteWaypoint a = expected.Waypoints[i];
|
|
RouteWaypoint b = actual.Waypoints[i];
|
|
Assert.Equal(a.Type, b.Type);
|
|
Assert.Equal(a.Position.EastWest, b.Position.EastWest, 3);
|
|
Assert.Equal(a.Position.NorthSouth, b.Position.NorthSouth, 3);
|
|
Assert.Equal(a.Position.Elevation, b.Position.Elevation, 3);
|
|
Assert.Equal(a.ReferencePosition.EastWest, b.ReferencePosition.EastWest, 3);
|
|
Assert.Equal(a.ReferencePosition.NorthSouth, b.ReferencePosition.NorthSouth, 3);
|
|
Assert.Equal(a.ReferencePosition.Elevation, b.ReferencePosition.Elevation, 3);
|
|
Assert.Equal(a.ObjectId, b.ObjectId);
|
|
Assert.Equal(a.ObjectName, b.ObjectName);
|
|
Assert.Equal(a.Text, b.Text);
|
|
Assert.Equal(a.DurationMilliseconds, b.DurationMilliseconds);
|
|
// Recall waypoints are the one field-set that is genuinely
|
|
// asymmetric between the two formats with a no-op spell
|
|
// catalog: the binary .nav stores a spell ID and derives the
|
|
// name only via a successful catalog lookup (which NoOpSpellCatalog
|
|
// never provides), while .af stores the name literally and
|
|
// derives the ID the same (catalog-dependent) way in reverse.
|
|
// Comparing RecallSpellName/Id here would fail for reasons that
|
|
// have nothing to do with the .af port's correctness.
|
|
Assert.Equal(a.JumpHeadingDegrees, b.JumpHeadingDegrees, 3);
|
|
Assert.Equal(a.JumpRun, b.JumpRun);
|
|
Assert.Equal(a.JumpChargeMilliseconds, b.JumpChargeMilliseconds);
|
|
// JumpDirection is likewise NOT compared here: metaf's .af
|
|
// format has no field for it at all (docs/research/vtank-kb/
|
|
// 06-navigation-and-nav.md section 6, gap 9), so a binary .nav
|
|
// fixture using StrafeLeft/StrafeRight always comes back
|
|
// Forward from the matching .af conversion — a real, expected
|
|
// asymmetry, not a bug in either reader.
|
|
}
|
|
}
|
|
|
|
private sealed class NoOpSpellCatalog : ISpellCatalog
|
|
{
|
|
public static NoOpSpellCatalog Instance { get; } = new();
|
|
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs => [];
|
|
public bool TryGet(uint spellId, out PluginSpellInfo info)
|
|
{
|
|
info = default;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round D item 3: unlike <see cref="NoOpSpellCatalog"/>, this one
|
|
/// actually knows the spells handed to it — needed to prove
|
|
/// TryLoadNav's name->id resolution (MetafSerializer's own
|
|
/// ResolveSpellIdByName, which only checks KnownSelfBuffs and
|
|
/// KnownCombatSpells) works for a real recall spell, not just that it
|
|
/// harmlessly returns 0 for an unknown one.
|
|
/// </summary>
|
|
private sealed class FakeSpellCatalog(IEnumerable<PluginSpellInfo> selfBuffs) : ISpellCatalog
|
|
{
|
|
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs { get; } = selfBuffs.ToArray();
|
|
public bool TryGet(uint spellId, out PluginSpellInfo info)
|
|
{
|
|
foreach (PluginSpellInfo spell in KnownSelfBuffs)
|
|
{
|
|
if (spell.SpellId == spellId)
|
|
{
|
|
info = spell;
|
|
return true;
|
|
}
|
|
}
|
|
info = default;
|
|
return false;
|
|
}
|
|
}
|
|
}
|