fix(vtank): slice 7 round E item D-4 — refuse recall with unresolved spell name

An .af "rcl" node whose spell name failed to resolve against the
character's known spells (MetafSerializer.ReadNavNode's "rcl" case)
leaves RecallSpellId at 0 and Recall at its default ordinal
(RouteRecallKind.PrimaryPortalRecall — the enum field is never touched
on that path). SubmitRecall's old Recall-based fallback then silently
cast Primary Portal Recall (spell 48) for a waypoint that was never
authored as one. TickRecall now refuses the cast, names the unresolved
spell in the status notice, and skips the waypoint (CompleteAction) —
the same "refuse and continue" shape TickUse already uses for an
unavailable object. SubmitRecall's dead fallback branch (every genuine
path — AddRouteRecallCore, the binary .nav loader, the D-1 legacy-JSON
migration — writes RecallSpellId directly) is removed.

Mutation shown to fail first:
RecallWaypointWithUnresolvedSpellNameRefusesAndSkipsWithoutCasting
against the pre-fix code cast spell 48 (magic.CastSpellIds == [48]).
MossTank suite 715 -> 716; App markup/plugin filter holds 243/243.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 17:46:46 +02:00
parent 2b79ca3257
commit 3178d9202b
2 changed files with 81 additions and 11 deletions

View file

@ -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 <see cref="RouteRecallKind"/>'s default ordinal IS
/// <see cref="RouteRecallKind.PrimaryPortalRecall"/>) — 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).
/// </summary>
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(

View file

@ -945,6 +945,52 @@ public sealed class NavigationTests
Assert.Contains("/marketplace", automation.SubmittedChat);
}
/// <summary>
/// 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).
/// </summary>
[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<uint> CastSpellIds { get; } = [];