diff --git a/src/AcDream.Plugins.MossTank/Navigation.cs b/src/AcDream.Plugins.MossTank/Navigation.cs index c3cec1418..5fde615c7 100644 --- a/src/AcDream.Plugins.MossTank/Navigation.cs +++ b/src/AcDream.Plugins.MossTank/Navigation.cs @@ -1009,6 +1009,31 @@ internal sealed class NavigationController RouteWaypoint waypoint, in PluginNavigationSnapshot navigation) { + // D-4 (round E architecture re-check): an .af "rcl" node whose + // spell name did not resolve against the character's known spells + // (MetafSerializer.ReadNavNode's "rcl" case, MetafSerializer.cs: + // 883-885) leaves RecallSpellId at 0 and Recall at its default + // ordinal (RouteRecallKind.PrimaryPortalRecall) — it never touches + // the enum. Every path that DOES intend a real cast + // (AddRouteRecallCore, the binary .nav loader, the D-1 legacy- + // JSON-route migration) always writes RecallSpellId directly, so + // no waypoint can legitimately reach this point with + // RecallSpellId == 0 unless it is Marketplace (which has no spell + // by design) or an unresolved import. Refuse the cast outright — + // SubmitRecall's old fallback (RouteWaypoint.SpellIdForRecall + // (waypoint.Recall)) would silently cast Primary Portal Recall + // (spell 48) for a waypoint that was never authored as one — and + // skip it the same way TickUse already refuses an unavailable + // object (waypoint.ObjectId == 0u, above). + if (waypoint.RecallSpellId == 0u && waypoint.Recall != RouteRecallKind.Marketplace) + { + _status = string.IsNullOrWhiteSpace(waypoint.RecallSpellName) + ? "Recall waypoint has no spell; skipping." + : $"Recall spell '{waypoint.RecallSpellName}' not found; skipping waypoint."; + CompleteAction(); + return true; + } + if (navigation.IsPortalSpace) _sawPortalSpace = true; if (_sawPortalSpace && !navigation.IsPortalSpace) @@ -1052,23 +1077,22 @@ internal sealed class NavigationController /// tab's own combo, and the metaf/binary-.nav loaders populate it from /// the file itself) so the common path is just "cast that spell," /// exactly like every other retail spell-cast action in this plugin. - /// The two fallbacks only matter for a waypoint saved before this round - /// with no recorded id: Marketplace still has none to record (VTank's - /// own combo issues it as a slash command too), and any other kind - /// resolves its id from the SAME table AddRouteRecallCore uses, rather - /// than the old runtime KnownSelfBuffs name lookup (which depended on - /// the character already knowing the spell under that exact name). + /// Round E item D-4 removed the Recall-enum fallback that used to run + /// here for a zero RecallSpellId (it silently cast Primary Portal + /// Recall for any waypoint whose recorded name failed to resolve, + /// since 's default ordinal IS + /// ) — TickRecall now + /// refuses and skips that case before this method is ever called, so + /// the only way to reach here with RecallSpellId == 0 is Marketplace, + /// which has no spell to cast (VTank's own combo issues it as a slash + /// command too). /// private bool SubmitRecall(RouteWaypoint waypoint) { if (waypoint.RecallSpellId != 0u) return _host.Automation.Magic.Cast(waypoint.RecallSpellId); - if (waypoint.Recall == RouteRecallKind.Marketplace) - return _host.Automation.Chat.Submit("/marketplace"); - - uint spellId = RouteWaypoint.SpellIdForRecall(waypoint.Recall); - return spellId != 0u && _host.Automation.Magic.Cast(spellId); + return _host.Automation.Chat.Submit("/marketplace"); } private bool TickJump( diff --git a/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs b/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs index 13873d457..1f359e8ee 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs @@ -945,6 +945,52 @@ public sealed class NavigationTests Assert.Contains("/marketplace", automation.SubmittedChat); } + /// + /// D-4 (round E architecture re-check): an .af "rcl" node whose spell + /// name did not resolve against the character's known spells leaves + /// RecallSpellId at 0 and Recall at its default ordinal + /// (RouteRecallKind.PrimaryPortalRecall, MetafSerializer.cs:883-885 + /// only ever assigns RecallSpellName/RecallSpellId, never the enum). + /// Before this fix SubmitRecall's Recall-based fallback silently cast + /// Primary Portal Recall (spell 48) for a waypoint that was never + /// authored as one. It must instead refuse the cast, name the + /// unresolved spell in the status notice, and skip the waypoint (the + /// same "refuse and continue" shape TickUse already uses for an + /// unavailable object). + /// + [Fact] + public void RecallWaypointWithUnresolvedSpellNameRefusesAndSkipsWithoutCasting() + { + var magic = new FakeMagic(); + var automation = new FakeAutomation + { + NavigationSnapshot = Snapshot(Position(0d, 0d)), + Magic = magic, + }; + var waypoint = new RouteWaypoint + { + Type = RouteWaypointType.Recall, + // Recall left at its default (PrimaryPortalRecall) — exactly + // what MetafSerializer.ReadNavNode's "rcl" case leaves it at + // when the imported name fails to resolve. + RecallSpellId = 0u, + RecallSpellName = "NotARealSpell", + Position = Position(0d, 0d), + }; + NavigationController controller = Controller(automation, RouteMode.Once, waypoint); + + Assert.True(controller.Tick(0.1d, canAct: true)); + + Assert.Empty(magic.CastSpellIds); + Assert.Empty(automation.SubmittedChat); + Assert.Contains("NotARealSpell", controller.Status); + + // The waypoint was skipped (not retried): the Once route's only + // waypoint is gone, so the very next tick reports completion. + Assert.False(controller.Tick(0.1d, canAct: true)); + Assert.Equal("Once route complete.", controller.Status); + } + private sealed class FakeMagic : IMagicCommands { public List CastSpellIds { get; } = [];