fix(vtank): slice 7 round D item 3 — VTank's full 26-recall table

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>
This commit is contained in:
Erik 2026-09-07 16:54:37 +02:00
parent 435ced86f7
commit 5318adbb30
6 changed files with 401 additions and 37 deletions

View file

@ -287,7 +287,7 @@ internal sealed partial class MossTankPanel
private string _routeNotice = "Add the current position or a selected object.";
private string _routeChatDraft = "/ls";
private int _routePauseSeconds = 5;
private RouteRecallKind _routeRecallKind = RouteRecallKind.PrimaryPortal;
private RouteRecallKind _routeRecallKind = RouteRecallKind.PrimaryPortalRecall;
private RouteInsertMode _routeInsertMode = RouteInsertMode.AddToEnd;
private IReadOnlyList<string> _metaRows = Array.Empty<string>();
// Fix round B item 12: the Meta grid's 6 columns, materialized once per
@ -2376,13 +2376,22 @@ internal sealed partial class MossTankPanel
private void AddRouteRecallCore()
{
// Round D item 3: "Add Recall" writes the spell id AND name onto
// the waypoint (RecallLabel and metaf's "rcl" export both prefer
// RecallSpellName over the bare enum), the same shape a metaf
// import or the binary .nav loader already produces — so a
// waypoint added right here from the combo executes identically
// to one round-tripped through a real route file.
string name = RouteWaypoint.RecallDisplayName(_routeRecallKind);
AddRouteWaypoint(new RouteWaypoint
{
Type = RouteWaypointType.Recall,
Recall = _routeRecallKind,
RecallSpellId = RouteWaypoint.SpellIdForRecall(_routeRecallKind),
RecallSpellName = name,
Position = _host.Automation.Navigation.Snapshot.Position,
});
_routeNotice = $"Added {RouteWaypoint.RecallDisplayName(_routeRecallKind)}.";
_routeNotice = $"Added {name}.";
}
private void AddRoutePauseCore()

View file

@ -600,7 +600,13 @@ internal sealed class MossTankRouteProfileStore
LegacyReferenceValid = LegacyReferenceValid,
Text = Text ?? string.Empty,
DurationMilliseconds = Math.Clamp(DurationMilliseconds, 0, 3_600_000),
Recall = Enum.IsDefined(Recall) ? Recall : RouteRecallKind.Lifestone,
// Round D item 3 removed RouteRecallKind.Lifestone (the old
// /lifestone-slash-command member, superseded by the real
// LifestoneRecall spell) — a pre-cutover un-migrated JSON
// profile carrying that ordinal now falls back to
// PrimaryPortalRecall instead, the same "still a valid,
// harmless recall kind" sentinel role Lifestone played here.
Recall = Enum.IsDefined(Recall) ? Recall : RouteRecallKind.PrimaryPortalRecall,
RecallSpellId = RecallSpellId,
RecallSpellName = RecallSpellName ?? string.Empty,
JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees) ? JumpHeadingDegrees : 0f,

View file

@ -25,12 +25,65 @@ internal enum RouteWaypointType
Jump = 9,
}
/// <summary>
/// VTank's own <c>cmbRecallType</c> combo (Route tab, docs/research/
/// vtank-kb/08-ui-views.md §1) — 26 real castable recall spells (metaf's
/// own <c>NRecall</c> table, <c>metaf_monolithic.py:10981-11008</c> in
/// `C:\Users\erikn\source\repos\metas`) plus VTank's own
/// <c>Marketplace Recall</c>, which has no spell and is issued as the
/// <c>/marketplace</c> slash command instead — appended last since it is
/// not one of the 26 named spells. Declared in VTank's own combo order so
/// <c>Enum.GetNames</c>/<c>GetValues</c> (which sort by underlying VALUE,
/// not declaration order) reproduce that order directly: every member's
/// value is a plain sequential index (0..26), never a spell id — spell
/// ids live in the separate <see cref="RouteWaypoint.SpellIdForRecall(RouteRecallKind)"/>
/// lookup so this enum's own values stay index-shaped and sortable.
///
/// Round D item 3 (owner: "Under Route, Add recall is missing a lot of
/// recalls that VTank has and they do not work in routes yet.") replaces
/// the old 4-member enum (Lifestone, Marketplace, PrimaryPortal,
/// SecondaryPortal). Deviation: the old <c>Lifestone</c> member (which
/// issued the <c>/lifestone</c> slash command) is DROPPED rather than kept
/// alongside the new spell-based <see cref="LifestoneRecall"/> — VTank's
/// real combo has ONE "Lifestone Recall" entry and it is a real castable
/// spell (id 1635, confirmed by metaf's own NRecall table), so keeping
/// both would show two menu rows reading "Lifestone Recall" with
/// different behavior. <see cref="Marketplace"/> is the one member kept
/// as a slash command, per the explicit "keep ours" instruction (VTank's
/// own Marketplace Recall has no spell to cast either). PrimaryPortal and
/// SecondaryPortal are renamed to <see cref="PrimaryPortalRecall"/> and
/// <see cref="SecondaryPortalRecall"/> and now use their real spell ids
/// (48, 2647) instead of the old runtime KnownSelfBuffs name lookup.
/// </summary>
internal enum RouteRecallKind
{
Lifestone,
PrimaryPortalRecall,
SecondaryPortalRecall,
LifestoneRecall,
LifestoneSending,
PortalRecall,
RecallAphusLassel,
RecallTheSanctuary,
RecallToTheSingularityCaul,
GlendenWoodRecall,
AerlintheRecall,
MountLetheRecall,
UlgrimsRecall,
BurRecall,
ParadoxTouchedOlthoiInfestedAreaRecall,
CallOfTheMhoireForge,
ColosseumRecall,
FacilityHubRecall,
GearKnightInvasionAreaCampRecall,
LostCityOfNeftetRecall,
ReturnToTheKeep,
RynthidRecall,
ViridianRiseRecall,
ViridianRiseGreatTreeRecall,
CelestialHandStrongholdRecall,
RadiantBloodStrongholdRecall,
EldrytchWebStrongholdRecall,
Marketplace,
PrimaryPortal,
SecondaryPortal,
}
internal enum RouteJumpDirection
@ -152,15 +205,80 @@ internal sealed class RouteWaypoint
+ ")";
}
/// <summary>VTank's own <c>cmbRecallType</c> label text (docs/research/vtank-kb/08-ui-views.md §1) for each <see cref="RouteRecallKind"/>.</summary>
internal static string RecallDisplayName(RouteRecallKind value) => value switch
{
RouteRecallKind.Lifestone => "Lifestone Recall",
RouteRecallKind.PrimaryPortalRecall => "Primary Portal Recall",
RouteRecallKind.SecondaryPortalRecall => "Secondary Portal Recall",
RouteRecallKind.LifestoneRecall => "Lifestone Recall",
RouteRecallKind.LifestoneSending => "Lifestone Sending",
RouteRecallKind.PortalRecall => "Portal Recall",
RouteRecallKind.RecallAphusLassel => "Recall Aphus Lassel",
RouteRecallKind.RecallTheSanctuary => "Recall the Sanctuary",
RouteRecallKind.RecallToTheSingularityCaul => "Recall to the Singularity Caul",
RouteRecallKind.GlendenWoodRecall => "Glenden Wood Recall",
RouteRecallKind.AerlintheRecall => "Aerlinthe Recall",
RouteRecallKind.MountLetheRecall => "Mount Lethe Recall",
RouteRecallKind.UlgrimsRecall => "Ulgrim's Recall",
RouteRecallKind.BurRecall => "Bur Recall",
RouteRecallKind.ParadoxTouchedOlthoiInfestedAreaRecall =>
"Paradox-touched Olthoi Infested Area Recall",
RouteRecallKind.CallOfTheMhoireForge => "Call of the Mhoire Forge",
RouteRecallKind.ColosseumRecall => "Colosseum Recall",
RouteRecallKind.FacilityHubRecall => "Facility Hub Recall",
RouteRecallKind.GearKnightInvasionAreaCampRecall =>
"Gear Knight Invasion Area Camp Recall",
RouteRecallKind.LostCityOfNeftetRecall => "Lost City of Neftet Recall",
RouteRecallKind.ReturnToTheKeep => "Return to the Keep",
RouteRecallKind.RynthidRecall => "Rynthid Recall",
RouteRecallKind.ViridianRiseRecall => "Viridian Rise Recall",
RouteRecallKind.ViridianRiseGreatTreeRecall => "Viridian Rise Great Tree Recall",
RouteRecallKind.CelestialHandStrongholdRecall => "Celestial Hand Stronghold Recall",
RouteRecallKind.RadiantBloodStrongholdRecall => "Radiant Blood Stronghold Recall",
RouteRecallKind.EldrytchWebStrongholdRecall => "Eldrytch Web Stronghold Recall",
RouteRecallKind.Marketplace => "Marketplace Recall",
RouteRecallKind.PrimaryPortal => "Primary Portal Recall",
RouteRecallKind.SecondaryPortal => "Secondary Portal Recall",
_ => value.ToString(),
};
/// <summary>
/// The real retail spell id VTank casts for each recall (metaf's own
/// <c>NRecall</c> table). <see cref="RouteRecallKind.Marketplace"/> has
/// no spell — it returns 0, the same "no spell, use the slash command"
/// sentinel <see cref="RouteWaypoint.RecallSpellId"/> and
/// <c>NavigationController.SubmitRecall</c> already treat as
/// "unset/none" everywhere else in this file.
/// </summary>
internal static uint SpellIdForRecall(RouteRecallKind value) => value switch
{
RouteRecallKind.PrimaryPortalRecall => 48u,
RouteRecallKind.SecondaryPortalRecall => 2647u,
RouteRecallKind.LifestoneRecall => 1635u,
RouteRecallKind.LifestoneSending => 1636u,
RouteRecallKind.PortalRecall => 2645u,
RouteRecallKind.RecallAphusLassel => 2931u,
RouteRecallKind.RecallTheSanctuary => 2023u,
RouteRecallKind.RecallToTheSingularityCaul => 2943u,
RouteRecallKind.GlendenWoodRecall => 3865u,
RouteRecallKind.AerlintheRecall => 2041u,
RouteRecallKind.MountLetheRecall => 2813u,
RouteRecallKind.UlgrimsRecall => 2941u,
RouteRecallKind.BurRecall => 4084u,
RouteRecallKind.ParadoxTouchedOlthoiInfestedAreaRecall => 4198u,
RouteRecallKind.CallOfTheMhoireForge => 4128u,
RouteRecallKind.ColosseumRecall => 4213u,
RouteRecallKind.FacilityHubRecall => 5175u,
RouteRecallKind.GearKnightInvasionAreaCampRecall => 5330u,
RouteRecallKind.LostCityOfNeftetRecall => 5541u,
RouteRecallKind.ReturnToTheKeep => 4214u,
RouteRecallKind.RynthidRecall => 6150u,
RouteRecallKind.ViridianRiseRecall => 6321u,
RouteRecallKind.ViridianRiseGreatTreeRecall => 6322u,
RouteRecallKind.CelestialHandStrongholdRecall => 6325u,
RouteRecallKind.RadiantBloodStrongholdRecall => 6327u,
RouteRecallKind.EldrytchWebStrongholdRecall => 6326u,
_ => 0u,
};
private static string JumpDirectionDisplayName(RouteJumpDirection value) =>
value switch
{
@ -927,30 +1045,30 @@ internal sealed class NavigationController
return true;
}
/// <summary>
/// Round D item 3: every recall except <see cref="RouteRecallKind.Marketplace"/>
/// now has a real spell id (either recorded directly on the waypoint —
/// AddRouteRecallCore populates it for anything added through the Route
/// 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).
/// </summary>
private bool SubmitRecall(RouteWaypoint waypoint)
{
if (waypoint.RecallSpellId != 0u)
return _host.Automation.Magic.Cast(waypoint.RecallSpellId);
RouteRecallKind recall = waypoint.Recall;
string? command = recall switch
{
RouteRecallKind.Lifestone => "/lifestone",
RouteRecallKind.Marketplace => "/marketplace",
_ => null,
};
if (command is not null)
return _host.Automation.Chat.Submit(command);
if (waypoint.Recall == RouteRecallKind.Marketplace)
return _host.Automation.Chat.Submit("/marketplace");
string needle = recall == RouteRecallKind.PrimaryPortal
? "Primary Portal Recall"
: "Secondary Portal Recall";
PluginSpellInfo? spell = _host.Automation.Spells.KnownSelfBuffs
.FirstOrDefault(value => value.Name.Equals(
needle,
StringComparison.OrdinalIgnoreCase));
return spell is { SpellId: not 0u } found
&& _host.Automation.Magic.Cast(found.SpellId);
uint spellId = RouteWaypoint.SpellIdForRecall(waypoint.Recall);
return spellId != 0u && _host.Automation.Magic.Cast(spellId);
}
private bool TickJump(

View file

@ -619,16 +619,16 @@
<button x="380" y="38" w="88" h="16" text="Add Portal/NPC" onclick="{AddRoutePortal}" />
<button x="474" y="38" w="88" h="16" text="Add NPC Talk" onclick="{AddRouteUseSelected}" />
<button x="380" y="60" w="88" h="16" text="Add Recall" onclick="{AddRouteRecall}" />
<!-- VTank's own cmbRecallType lists 27 named retail recalls
(docs/research/vtank-kb/08-ui-views.md §1's Route table);
MossTank's RouteRecallKind models 4 (Lifestone/Marketplace/
Primary/Secondary Portal) — expanding to 27 needs real per-recall
spell-id data, out of this UI-parity slice's scope. scroll="true"
is applied for grammar parity even though 4 items never need to
scroll. -->
<!-- Round D item 3: VTank's own cmbRecallType (docs/research/
vtank-kb/08-ui-views.md §1's Route table) lists 27 named retail
recalls; RouteRecallKind now models the same 27 (26 real castable
spells from metaf's own NRecall table plus Marketplace Recall,
VTank's one slash-command entry) in VTank's own combo order —
see RouteRecallKind's own doc comment for the deviation from the
earlier 4-entry model. -->
<menu x="474" y="60" w="120" h="16" items="{RouteRecallNames}"
selected="{SelectedRouteRecall}" onchange="{SelectRouteRecall}"
rows="4" openupward="false" scroll="true"
rows="7" openupward="false" scroll="true"
tooltip="Choose the recall type added by Add Recall." />
<button x="380" y="82" w="88" h="16" text="Add Pause" onclick="{AddRoutePause}" />
<field x="474" y="82" w="68" h="16" text="{RoutePauseSecondsFieldText}"

View file

@ -647,6 +647,58 @@ public sealed class MetafSerializerTests
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);
@ -754,4 +806,30 @@ public sealed class MetafSerializerTests
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;
}
}
}

View file

@ -436,7 +436,7 @@ public sealed class NavigationTests
ObjectName = "Portal",
Text = "/say hello",
DurationMilliseconds = 1234,
Recall = RouteRecallKind.SecondaryPortal,
Recall = RouteRecallKind.SecondaryPortalRecall,
JumpHeadingDegrees = 271.5f,
JumpRun = true,
JumpChargeMilliseconds = 875,
@ -806,6 +806,157 @@ public sealed class NavigationTests
Assert.Equal("Leader", target.FollowTargetName);
}
// ── Round D item 3: RouteRecallKind's full VTank table ──────────────
/// <summary>
/// The exact order VTank's own cmbRecallType combo lists its 26 real
/// recall spells (metaf's own NRecall table,
/// docs/plans/2026-09-07-campaign-vt-slice7-tabs.md's Round D task
/// list), with Marketplace Recall appended last (the one entry with
/// no spell — see RouteRecallKind's own doc comment for why the old
/// Lifestone slash-command member is gone instead of duplicated).
/// </summary>
[Fact]
public void RouteRecallKindListsVTanksTwentySixRecallsInOrderPlusMarketplaceLast()
{
Assert.Equal(
[
"PrimaryPortalRecall", "SecondaryPortalRecall", "LifestoneRecall",
"LifestoneSending", "PortalRecall", "RecallAphusLassel",
"RecallTheSanctuary", "RecallToTheSingularityCaul", "GlendenWoodRecall",
"AerlintheRecall", "MountLetheRecall", "UlgrimsRecall", "BurRecall",
"ParadoxTouchedOlthoiInfestedAreaRecall", "CallOfTheMhoireForge",
"ColosseumRecall", "FacilityHubRecall", "GearKnightInvasionAreaCampRecall",
"LostCityOfNeftetRecall", "ReturnToTheKeep", "RynthidRecall",
"ViridianRiseRecall", "ViridianRiseGreatTreeRecall",
"CelestialHandStrongholdRecall", "RadiantBloodStrongholdRecall",
"EldrytchWebStrongholdRecall", "Marketplace",
],
Enum.GetNames<RouteRecallKind>());
}
/// <summary>
/// The name<->id table both ways: every non-Marketplace kind's display
/// name (RecallDisplayName) round-trips back to the SAME kind via its
/// spell id (RecallSpellId is exposed nowhere to parse by name, so
/// this proves the two lookups agree with each other rather than one
/// silently drifting).
/// </summary>
[Theory]
[InlineData((int)RouteRecallKind.PrimaryPortalRecall, "Primary Portal Recall", 48u)]
[InlineData((int)RouteRecallKind.SecondaryPortalRecall, "Secondary Portal Recall", 2647u)]
[InlineData((int)RouteRecallKind.LifestoneRecall, "Lifestone Recall", 1635u)]
[InlineData((int)RouteRecallKind.LifestoneSending, "Lifestone Sending", 1636u)]
[InlineData((int)RouteRecallKind.PortalRecall, "Portal Recall", 2645u)]
[InlineData((int)RouteRecallKind.RecallAphusLassel, "Recall Aphus Lassel", 2931u)]
[InlineData((int)RouteRecallKind.RecallTheSanctuary, "Recall the Sanctuary", 2023u)]
[InlineData((int)RouteRecallKind.RecallToTheSingularityCaul, "Recall to the Singularity Caul", 2943u)]
[InlineData((int)RouteRecallKind.GlendenWoodRecall, "Glenden Wood Recall", 3865u)]
[InlineData((int)RouteRecallKind.AerlintheRecall, "Aerlinthe Recall", 2041u)]
[InlineData((int)RouteRecallKind.MountLetheRecall, "Mount Lethe Recall", 2813u)]
[InlineData((int)RouteRecallKind.UlgrimsRecall, "Ulgrim's Recall", 2941u)]
[InlineData((int)RouteRecallKind.BurRecall, "Bur Recall", 4084u)]
[InlineData((int)RouteRecallKind.ParadoxTouchedOlthoiInfestedAreaRecall,
"Paradox-touched Olthoi Infested Area Recall", 4198u)]
[InlineData((int)RouteRecallKind.CallOfTheMhoireForge, "Call of the Mhoire Forge", 4128u)]
[InlineData((int)RouteRecallKind.ColosseumRecall, "Colosseum Recall", 4213u)]
[InlineData((int)RouteRecallKind.FacilityHubRecall, "Facility Hub Recall", 5175u)]
[InlineData((int)RouteRecallKind.GearKnightInvasionAreaCampRecall,
"Gear Knight Invasion Area Camp Recall", 5330u)]
[InlineData((int)RouteRecallKind.LostCityOfNeftetRecall, "Lost City of Neftet Recall", 5541u)]
[InlineData((int)RouteRecallKind.ReturnToTheKeep, "Return to the Keep", 4214u)]
[InlineData((int)RouteRecallKind.RynthidRecall, "Rynthid Recall", 6150u)]
[InlineData((int)RouteRecallKind.ViridianRiseRecall, "Viridian Rise Recall", 6321u)]
[InlineData((int)RouteRecallKind.ViridianRiseGreatTreeRecall, "Viridian Rise Great Tree Recall", 6322u)]
[InlineData((int)RouteRecallKind.CelestialHandStrongholdRecall, "Celestial Hand Stronghold Recall", 6325u)]
[InlineData((int)RouteRecallKind.RadiantBloodStrongholdRecall, "Radiant Blood Stronghold Recall", 6327u)]
[InlineData((int)RouteRecallKind.EldrytchWebStrongholdRecall, "Eldrytch Web Stronghold Recall", 6326u)]
[InlineData((int)RouteRecallKind.Marketplace, "Marketplace Recall", 0u)]
public void RecallNameAndSpellIdTablesAgree(int kindOrdinal, string name, uint spellId)
{
// xunit only discovers PUBLIC [Theory] methods, and RouteRecallKind
// is internal — a public parameter of that type is a compile error
// (CS0051), so InlineData passes the ordinal (a public int) and the
// enum is recovered here instead.
var kind = (RouteRecallKind)kindOrdinal;
Assert.Equal(name, RouteWaypoint.RecallDisplayName(kind));
Assert.Equal(spellId, RouteWaypoint.SpellIdForRecall(kind));
}
/// <summary>
/// Round D item 3 execution test: a recall waypoint whose spell id is
/// non-zero must actually issue the cast for that id (owner: "they do
/// not work in routes yet") — the FakeAutomation's Magic is now a
/// tracking fake instead of NoOpAutomationSurface's always-refuse
/// stub, so this is the first real coverage of the recall action
/// dispatch path.
/// </summary>
[Fact]
public void RecallWaypointWithNonZeroSpellIdCastsThatSpell()
{
var magic = new FakeMagic();
var automation = new FakeAutomation
{
NavigationSnapshot = Snapshot(Position(0d, 0d)),
Magic = magic,
};
var waypoint = new RouteWaypoint
{
Type = RouteWaypointType.Recall,
Recall = RouteRecallKind.AerlintheRecall,
RecallSpellId = RouteWaypoint.SpellIdForRecall(RouteRecallKind.AerlintheRecall),
RecallSpellName = RouteWaypoint.RecallDisplayName(RouteRecallKind.AerlintheRecall),
Position = Position(0d, 0d),
};
NavigationController controller = Controller(automation, RouteMode.Once, waypoint);
Assert.True(controller.Tick(0.1d, canAct: true));
Assert.Contains(2041u, magic.CastSpellIds);
}
/// <summary>
/// Marketplace has no spell (RecallSpellId stays 0) — it must still
/// dispatch through the chat command, exactly as it did before this
/// round, never attempt a cast.
/// </summary>
[Fact]
public void RecallWaypointForMarketplaceSubmitsTheSlashCommandNotACast()
{
var magic = new FakeMagic();
var automation = new FakeAutomation
{
NavigationSnapshot = Snapshot(Position(0d, 0d)),
Magic = magic,
};
var waypoint = new RouteWaypoint
{
Type = RouteWaypointType.Recall,
Recall = RouteRecallKind.Marketplace,
RecallSpellId = RouteWaypoint.SpellIdForRecall(RouteRecallKind.Marketplace),
RecallSpellName = RouteWaypoint.RecallDisplayName(RouteRecallKind.Marketplace),
Position = Position(0d, 0d),
};
NavigationController controller = Controller(automation, RouteMode.Once, waypoint);
Assert.True(controller.Tick(0.1d, canAct: true));
Assert.Empty(magic.CastSpellIds);
Assert.Contains("/marketplace", automation.SubmittedChat);
}
private sealed class FakeMagic : IMagicCommands
{
public List<uint> CastSpellIds { get; } = [];
public bool IsCasting => false;
public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Refused;
public bool Cast(uint spellId)
{
CastSpellIds.Add(spellId);
return true;
}
}
private static NavigationController Controller(
FakeAutomation automation,
RouteMode mode,
@ -877,7 +1028,9 @@ public sealed class NavigationTests
public bool IsAvailable => true;
public ICharacterInfo Character => NoOpAutomationSurface.Instance;
public ISpellCatalog Spells => NoOpAutomationSurface.Instance;
public IMagicCommands Magic => NoOpAutomationSurface.Instance;
// Round D item 3: settable so a recall-execution test can inject a
// tracking fake instead of NoOpAutomationSurface's always-false Cast.
public IMagicCommands Magic { get; set; } = NoOpAutomationSurface.Instance;
public IPluginChat Chat => this;
public IItemAutomation Items => this;
public INavigationAutomation Navigation => this;