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:
parent
435ced86f7
commit
5318adbb30
6 changed files with 401 additions and 37 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue