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:
parent
2b79ca3257
commit
3178d9202b
2 changed files with 81 additions and 11 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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; } = [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue