The pre-cutover RouteRecallKind was {Lifestone=0, Marketplace=1,
PrimaryPortal=2, SecondaryPortal=3} — completely different kinds at the
SAME ordinals as today's much larger round-D enum (PrimaryPortalRecall=0,
SecondaryPortalRecall=1, LifestoneRecall=2, LifestoneSending=3, ...).
LegacyWaypointDocument.Recall was still typed RouteRecallKind, so
System.Text.Json deserialized a legacy JSON route's bare "Recall"
integer straight into the new enum — silently remapping every one of
the four old ordinals to the wrong new recall. Enum.IsDefined never
caught it (0..3 are all valid members of the new enum too, just for
different spells).
The field is now a bare int, translated through an explicit
MapLegacyRecall(legacyOrdinal) table in ToWaypoint() that also derives
RecallSpellId/RecallSpellName from the mapped kind (the pre-cutover
schema predates both fields entirely).
Mutation shown to fail first: reverting the source fix (git checkout,
patch saved and reapplied) reproduced the bug for all four legacy
ordinals — ordinal 2 (old PrimaryPortal) even landed on LifestoneRecall
instead of PrimaryPortalRecall, confirming the "still IsDefined, still
wrong" failure mode. The new theory asserts against the migrated
route's own written .af "rcl" line (RecallDisplayName) rather than the
value re-parsed back through LoadCurrent, since the .af format's
own name-only round-trip is a separate, pre-existing limitation
(no spell id, and "Marketplace Recall" cannot resolve through any
catalog because it isn't a real spell) unrelated to this fix.
MossTank suite 716 -> 720; App markup/plugin filter holds 243/243.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1268 lines
53 KiB
C#
1268 lines
53 KiB
C#
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Plugins.MossTank.Tests;
|
|
|
|
public sealed class NavigationTests
|
|
{
|
|
[Theory]
|
|
[InlineData(0d, 1d, 0f)]
|
|
[InlineData(1d, 0d, 90f)]
|
|
[InlineData(0d, -1d, 180f)]
|
|
[InlineData(-1d, 0d, 270f)]
|
|
public void DesiredHeadingUsesVtankCompassConvention(
|
|
double eastWest,
|
|
double northSouth,
|
|
float expected)
|
|
{
|
|
PluginNavigationPosition origin = Position(0d, 0d);
|
|
PluginNavigationPosition target = Position(eastWest, northSouth);
|
|
|
|
Assert.Equal(expected, NavigationController.DesiredHeading(origin, target));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(350f, 10f, 20f)]
|
|
[InlineData(10f, 350f, -20f)]
|
|
[InlineData(90f, 270f, 180f)]
|
|
public void SignedHeadingDeltaChoosesShortestRetailTurn(
|
|
float current,
|
|
float desired,
|
|
float expected) =>
|
|
Assert.Equal(expected, NavigationController.SignedHeadingDelta(current, desired));
|
|
|
|
[Fact]
|
|
public void PointSteeringTurnsInPlaceOutsideFortyFiveDegrees()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 0f)),
|
|
};
|
|
NavigationController controller = Controller(
|
|
automation,
|
|
RouteMode.Circular,
|
|
Waypoint(RouteWaypointType.Point, Position(1d, 0d)));
|
|
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
|
|
PluginMovementIntent intent = Assert.Single(automation.Intents);
|
|
Assert.False(intent.Forward);
|
|
Assert.True(intent.TurnRight);
|
|
Assert.False(intent.TurnLeft);
|
|
}
|
|
|
|
[Fact]
|
|
public void PointSteeringMovesWhileTurningInsideFarFortyFiveDegreeCone()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 60f)),
|
|
};
|
|
NavigationController controller = Controller(
|
|
automation,
|
|
RouteMode.Circular,
|
|
Waypoint(RouteWaypointType.Point, Position(1d, 0d)));
|
|
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
|
|
PluginMovementIntent intent = Assert.Single(automation.Intents);
|
|
Assert.True(intent.Forward);
|
|
Assert.True(intent.TurnRight);
|
|
}
|
|
|
|
[Fact]
|
|
public void CircularRouteWrapsAndOnceRouteStops()
|
|
{
|
|
RouteWaypoint first = Waypoint(RouteWaypointType.Point, Position(0d, 0d));
|
|
RouteWaypoint second = Waypoint(RouteWaypointType.Point, Position(1d, 0d));
|
|
var circularAutomation = new FakeAutomation
|
|
{
|
|
NavigationSnapshot = Snapshot(first.Position),
|
|
};
|
|
NavigationController circular = Controller(
|
|
circularAutomation,
|
|
RouteMode.Circular,
|
|
first,
|
|
second);
|
|
|
|
Assert.True(circular.Tick(0.05d, canAct: true));
|
|
Assert.Equal(1, circular.CurrentWaypointIndex);
|
|
circularAutomation.NavigationSnapshot = Snapshot(second.Position);
|
|
Assert.True(circular.Tick(0.05d, canAct: true));
|
|
Assert.Equal(0, circular.CurrentWaypointIndex);
|
|
|
|
var onceAutomation = new FakeAutomation
|
|
{
|
|
NavigationSnapshot = Snapshot(first.Position),
|
|
};
|
|
NavigationController once = Controller(
|
|
onceAutomation,
|
|
RouteMode.Once,
|
|
first);
|
|
|
|
Assert.True(once.Tick(0.05d, canAct: true));
|
|
Assert.False(once.Tick(0.05d, canAct: true));
|
|
Assert.Equal("Once route complete.", once.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public void FollowReadsMovingTargetAndHoldsAtMinimumDistance()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 90f)),
|
|
};
|
|
automation.Objects[7u] = new PluginNavigationObject(
|
|
7u,
|
|
"Leader",
|
|
Position(1d, 0d));
|
|
var settings = new NavigationSettings
|
|
{
|
|
Enabled = true,
|
|
Mode = RouteMode.Target,
|
|
MinimumDistanceMeters = 2d,
|
|
FollowTargetObjectId = 7u,
|
|
};
|
|
var controller = new NavigationController(new FakeHost(automation), settings);
|
|
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
Assert.True(Assert.Single(automation.Intents).Forward);
|
|
|
|
automation.Objects[7u] = new PluginNavigationObject(
|
|
7u,
|
|
"Leader",
|
|
Position(0.005d, 0d));
|
|
Assert.False(controller.Tick(0.05d, canAct: true));
|
|
Assert.Equal(1, automation.ClearCount);
|
|
Assert.Contains("holding", controller.Status, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void FollowAroundCornersUsesOldestUnreachedBreadcrumb()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 90f)),
|
|
};
|
|
automation.Objects[7u] = new PluginNavigationObject(
|
|
7u,
|
|
"Leader",
|
|
Position(0.1d, 0d));
|
|
var settings = new NavigationSettings
|
|
{
|
|
Enabled = true,
|
|
Mode = RouteMode.Target,
|
|
MinimumDistanceMeters = 2d,
|
|
FollowTargetObjectId = 7u,
|
|
FollowAroundCorners = true,
|
|
};
|
|
var controller = new NavigationController(new FakeHost(automation), settings);
|
|
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
automation.Objects[7u] = new PluginNavigationObject(
|
|
7u,
|
|
"Leader",
|
|
Position(0.1d, 0.1d));
|
|
automation.Intents.Clear();
|
|
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
|
|
PluginMovementIntent intent = Assert.Single(automation.Intents);
|
|
Assert.True(intent.Forward);
|
|
Assert.False(intent.TurnLeft);
|
|
Assert.False(intent.TurnRight);
|
|
}
|
|
|
|
[Fact]
|
|
public void CheckpointWaitsForServerAcceptedPosition()
|
|
{
|
|
PluginNavigationPosition point = Position(0d, 0d);
|
|
var automation = new FakeAutomation
|
|
{
|
|
NavigationSnapshot = Snapshot(point) with
|
|
{
|
|
ConfirmedPosition = Position(0.1d, 0d),
|
|
ConfirmedPositionRevision = 4UL,
|
|
},
|
|
};
|
|
NavigationController controller = Controller(
|
|
automation,
|
|
RouteMode.Once,
|
|
Waypoint(RouteWaypointType.Checkpoint, point));
|
|
|
|
Assert.True(controller.Tick(1d, canAct: true));
|
|
Assert.Contains("waiting for server", controller.Status, StringComparison.OrdinalIgnoreCase);
|
|
Assert.True(controller.Tick(14d, canAct: true));
|
|
Assert.True(Assert.Single(automation.Intents).Forward);
|
|
|
|
automation.NavigationSnapshot = Snapshot(point) with
|
|
{
|
|
ConfirmedPosition = point,
|
|
ConfirmedPositionRevision = 5UL,
|
|
};
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
Assert.False(controller.Tick(0.05d, canAct: true));
|
|
}
|
|
|
|
[Fact]
|
|
public void ClosedDoorPausesRouteAndUsesCanonicalItemAction()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 90f)),
|
|
};
|
|
automation.WorldObjects.Add(new PluginNavigationObject(
|
|
55u,
|
|
"Dungeon Door",
|
|
Position(0.01d, 0d))
|
|
{
|
|
IsDoor = true,
|
|
IsOpen = false,
|
|
HasLockState = true,
|
|
});
|
|
var settings = new NavigationSettings
|
|
{
|
|
Enabled = true,
|
|
OpenDoors = true,
|
|
Mode = RouteMode.Circular,
|
|
};
|
|
settings.Waypoints.Add(Waypoint(
|
|
RouteWaypointType.Point,
|
|
Position(1d, 0d)));
|
|
var controller = new NavigationController(new FakeHost(automation), settings);
|
|
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
Assert.Equal([55u], automation.UsedObjects);
|
|
Assert.Empty(automation.Intents);
|
|
|
|
automation.WorldObjects[0] = automation.WorldObjects[0] with
|
|
{
|
|
IsOpen = true,
|
|
};
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
Assert.True(Assert.Single(automation.Intents).Forward);
|
|
}
|
|
|
|
[Fact]
|
|
public void PauseAndChatActionsObserveOfficialInitialDelay()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
NavigationSnapshot = Snapshot(Position(0d, 0d)),
|
|
};
|
|
RouteWaypoint pause = Waypoint(RouteWaypointType.Pause, Position(0d, 0d));
|
|
pause.DurationMilliseconds = 100;
|
|
RouteWaypoint chat = Waypoint(RouteWaypointType.ChatCommand, Position(0d, 0d));
|
|
chat.Text = "/say route";
|
|
NavigationController controller = Controller(
|
|
automation,
|
|
RouteMode.Once,
|
|
pause,
|
|
chat);
|
|
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
Assert.Equal(0, controller.CurrentWaypointIndex);
|
|
Assert.True(controller.Tick(0.19d, canAct: true));
|
|
Assert.Empty(automation.SubmittedChat);
|
|
Assert.True(controller.Tick(0.01d, canAct: true));
|
|
Assert.Equal(["/say route"], automation.SubmittedChat);
|
|
Assert.False(controller.Tick(0.01d, canAct: true));
|
|
}
|
|
|
|
[Fact]
|
|
public void PortalWaypointWaitsForPortalExitRatherThanUseDispatch()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
NavigationSnapshot = Snapshot(Position(0d, 0d)),
|
|
ItemCompletion = new PluginItemUseCompletion(4, 10u, 0u, 0u),
|
|
};
|
|
RouteWaypoint use = Waypoint(RouteWaypointType.Portal, Position(0d, 0d));
|
|
use.ObjectId = 77u;
|
|
use.ObjectName = "Town Crier";
|
|
NavigationController controller = Controller(automation, RouteMode.Once, use);
|
|
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
Assert.Equal([77u], automation.UsedObjects);
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
automation.ItemCompletion = new PluginItemUseCompletion(5, 77u, 0u, 0u);
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
automation.NavigationSnapshot = automation.NavigationSnapshot with
|
|
{
|
|
IsPortalSpace = true,
|
|
};
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
automation.NavigationSnapshot = Snapshot(Position(0.1d, 0d));
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
Assert.False(controller.Tick(0.05d, canAct: true));
|
|
}
|
|
|
|
[Fact]
|
|
public void UseNpcRepeatsUntilTheNpcRespondsInChat()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
NavigationSnapshot = Snapshot(Position(0d, 0d)),
|
|
FoundObject = new PluginNavigationObject(
|
|
91u,
|
|
"Town Crier",
|
|
Position(0.01d, 0d)),
|
|
};
|
|
RouteWaypoint use = Waypoint(RouteWaypointType.UseNpc, Position(0d, 0d));
|
|
use.ObjectName = "Town Crier";
|
|
NavigationController controller = Controller(automation, RouteMode.Once, use);
|
|
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
automation.ChatMessages.Add(new PluginChatMessage(
|
|
1UL,
|
|
91u,
|
|
3,
|
|
"Town Crier",
|
|
"Town Crier tells you, Welcome.",
|
|
string.Empty));
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
Assert.False(controller.Tick(0.05d, canAct: true));
|
|
}
|
|
|
|
[Fact]
|
|
public void NamedNpcWaypointReacquiresChangedObjectId()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
NavigationSnapshot = Snapshot(Position(0d, 0d)),
|
|
FoundObject = new PluginNavigationObject(
|
|
91u,
|
|
"Town Crier",
|
|
Position(0.01d, 0d)),
|
|
};
|
|
RouteWaypoint use = Waypoint(RouteWaypointType.UseNpc, Position(0d, 0d));
|
|
use.ObjectId = 77u;
|
|
use.ObjectName = "Town Crier";
|
|
NavigationController controller = Controller(automation, RouteMode.Once, use);
|
|
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
|
|
Assert.Equal(91u, use.ObjectId);
|
|
Assert.Equal([91u], automation.UsedObjects);
|
|
Assert.Equal("Town Crier", automation.FindName);
|
|
}
|
|
|
|
[Fact]
|
|
public void JumpAlignsBeforeChargingAndWaitsForLanding()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 0f)),
|
|
};
|
|
RouteWaypoint jump = Waypoint(RouteWaypointType.Jump, Position(0d, 0d));
|
|
jump.JumpHeadingDegrees = 90f;
|
|
jump.JumpChargeMilliseconds = 100;
|
|
NavigationController controller = Controller(automation, RouteMode.Once, jump);
|
|
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
PluginMovementIntent turn = Assert.Single(automation.Intents);
|
|
Assert.True(turn.TurnRight);
|
|
Assert.False(turn.Jump);
|
|
|
|
automation.NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 90f));
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
Assert.True(automation.Intents[^1].Jump);
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
Assert.False(automation.Intents[^1].Jump);
|
|
|
|
automation.NavigationSnapshot = Snapshot(
|
|
Position(0d, 0d, heading: 90f),
|
|
airborne: true);
|
|
Assert.True(controller.Tick(0.05d, canAct: true));
|
|
automation.NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 90f));
|
|
Assert.True(controller.Tick(0.25d, canAct: true));
|
|
Assert.False(controller.Tick(0.01d, canAct: true));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 3 item 5: retail's real jump-charge ceiling
|
|
/// (refs/vtank/decompiled/bi.cs:502-505) applies at EXECUTION, not at
|
|
/// storage load — an authored 5000 ms waypoint (which now survives an
|
|
/// .af/legacy-JSON load unclamped) must still release the jump after
|
|
/// at most 2000 ms of in-game charging, not wait for the full 5000.
|
|
/// </summary>
|
|
[Fact]
|
|
public void JumpChargeExecutionClampsAtRetailTwoThousandMillisecondCeiling()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
// Already aligned to the jump heading so the very first tick
|
|
// starts charging immediately (no turn phase to account for).
|
|
NavigationSnapshot = Snapshot(Position(0d, 0d, heading: 90f)),
|
|
};
|
|
RouteWaypoint jump = Waypoint(RouteWaypointType.Jump, Position(0d, 0d));
|
|
jump.JumpHeadingDegrees = 90f;
|
|
jump.JumpChargeMilliseconds = 5000;
|
|
NavigationController controller = Controller(automation, RouteMode.Once, jump);
|
|
|
|
// 1.9 s elapsed < the 2000 ms ceiling: still charging.
|
|
Assert.True(controller.Tick(1.9d, canAct: true));
|
|
Assert.True(automation.Intents[^1].Jump);
|
|
|
|
// +0.2 s -> 2.1 s total, past the 2000 ms ceiling even though the
|
|
// AUTHORED 5000 ms charge has not elapsed: released.
|
|
Assert.True(controller.Tick(0.2d, canAct: true));
|
|
Assert.False(automation.Intents[^1].Jump);
|
|
}
|
|
|
|
// Campaign VT slice 1 Part A round 2: the route (.af) file now carries
|
|
// ONLY Mode/Waypoints/FollowTarget — Enabled/Priority/
|
|
// MinimumDistanceMeters/FollowAroundCorners/OpenDoors/Door* are real
|
|
// VTank Settings-table rows (EnableNav, NavPriorityBoost,
|
|
// NavCloseStopRange, …) owned end-to-end by MossTankProfileStore's
|
|
// .usd profile now, matching real VTank's own split between global nav
|
|
// prefs and the per-route file. Renamed from
|
|
// RouteProfilesRoundTripEveryWaypointField, which asserted the pre-
|
|
// cutover behavior where the route JSON carried everything.
|
|
[Fact]
|
|
public void RouteProfilesRoundTripWaypointFieldsButLeaveSettingsOwnedFieldsAlone()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var host = new FakeHost(new FakeAutomation(), storage);
|
|
var source = new NavigationSettings
|
|
{
|
|
Mode = RouteMode.Linear,
|
|
};
|
|
source.Waypoints.Add(new RouteWaypoint
|
|
{
|
|
Type = RouteWaypointType.Jump,
|
|
Position = new PluginNavigationPosition(0x7F7F0001u, 1.2d, -3.4d, 5.6d, 78f, true),
|
|
ObjectId = 88u,
|
|
ObjectName = "Portal",
|
|
Text = "/say hello",
|
|
DurationMilliseconds = 1234,
|
|
Recall = RouteRecallKind.SecondaryPortalRecall,
|
|
JumpHeadingDegrees = 271.5f,
|
|
JumpRun = true,
|
|
JumpChargeMilliseconds = 875,
|
|
JumpDirection = RouteJumpDirection.StrafeRight,
|
|
});
|
|
var first = new MossTankRouteProfileStore(host);
|
|
Assert.True(first.BindCharacter("Test Character"));
|
|
first.SaveCurrent(source);
|
|
|
|
// Pre-seed values a Settings-profile load would already have set —
|
|
// loading the route must leave every one of them untouched.
|
|
var target = new NavigationSettings
|
|
{
|
|
Enabled = false,
|
|
Priority = false,
|
|
MinimumDistanceMeters = 9d,
|
|
FollowAroundCorners = true,
|
|
OpenDoors = false,
|
|
DoorIdentifyRangeMeters = 11d,
|
|
DoorOpenRangeMeters = 1d,
|
|
DoorLockpickExcessThreshold = -3,
|
|
};
|
|
var second = new MossTankRouteProfileStore(host);
|
|
Assert.True(second.BindCharacter("Test Character"));
|
|
Assert.True(second.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance));
|
|
|
|
Assert.Equal(RouteMode.Linear, target.Mode);
|
|
RouteWaypoint waypoint = Assert.Single(target.Waypoints);
|
|
Assert.Equal(RouteWaypointType.Jump, waypoint.Type);
|
|
Assert.Equal(271.5f, waypoint.JumpHeadingDegrees);
|
|
Assert.True(waypoint.JumpRun);
|
|
Assert.Equal(875, waypoint.JumpChargeMilliseconds);
|
|
// .af cannot represent JumpDirection at all (MetafSerializer.cs:924
|
|
// — a pre-existing, recorded divergence-register gap, not
|
|
// something this store cutover changes): it always comes back at
|
|
// the model's own default, Forward, regardless of what was saved.
|
|
Assert.Equal(RouteJumpDirection.Forward, waypoint.JumpDirection);
|
|
// metaf's "jmp" node format carries no cell id either (FORMAT: jmp
|
|
// x y z heading run chargems — six fields, no hex cell component;
|
|
// MetafSerializer.cs:915-935), so Position.CellId is not asserted
|
|
// for a Jump waypoint specifically.
|
|
|
|
Assert.False(target.Enabled);
|
|
Assert.False(target.Priority);
|
|
Assert.Equal(9d, target.MinimumDistanceMeters);
|
|
Assert.True(target.FollowAroundCorners);
|
|
Assert.False(target.OpenDoors);
|
|
Assert.Equal(11d, target.DoorIdentifyRangeMeters);
|
|
Assert.Equal(1d, target.DoorOpenRangeMeters);
|
|
Assert.Equal(-3, target.DoorLockpickExcessThreshold);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reproduces MossTankRouteProfileStore's pre-cutover by-character JSON
|
|
/// hash key (its own <c>LegacyProfileKey</c> is private; the format is
|
|
/// the migration contract itself, reproduced verbatim here).
|
|
/// </summary>
|
|
private static string LegacyRouteByCharacterKey(string characterName)
|
|
{
|
|
string identity = "char:" + characterName.Trim().ToUpperInvariant();
|
|
string hash = Convert.ToHexString(
|
|
System.Security.Cryptography.SHA256.HashData(
|
|
System.Text.Encoding.UTF8.GetBytes(identity)));
|
|
return $"profiles/route/{hash}.json";
|
|
}
|
|
|
|
[Fact]
|
|
public void RouteStoreMigratesLegacyJsonProfileToAfAndDeletesTheJsonKey()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
string legacyKey = LegacyRouteByCharacterKey("Barris");
|
|
storage.Text[legacyKey] = """
|
|
{
|
|
"Mode": 1,
|
|
"Waypoints": [
|
|
{ "Type": 0, "EastWest": 5.0, "NorthSouth": 6.0 }
|
|
]
|
|
}
|
|
""";
|
|
|
|
var store = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
|
|
Assert.True(store.BindCharacter("Barris"));
|
|
var target = new NavigationSettings();
|
|
Assert.True(store.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance));
|
|
|
|
Assert.False(storage.Text.ContainsKey(legacyKey));
|
|
Assert.Equal(RouteMode.Linear, target.Mode);
|
|
RouteWaypoint waypoint = Assert.Single(target.Waypoints);
|
|
// metaf's "pnt" node format is bare x/y/z (three doubles) — no cell
|
|
// id at all, for any waypoint type — so only the coordinates
|
|
// round-trip.
|
|
Assert.Equal(5.0d, waypoint.Position.EastWest, precision: 3);
|
|
Assert.Equal(6.0d, waypoint.Position.NorthSouth, precision: 3);
|
|
|
|
// Idempotent second run: nothing left to migrate.
|
|
var reopened = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
|
|
Assert.True(reopened.BindCharacter("Barris"));
|
|
var reloaded = new NavigationSettings();
|
|
Assert.True(reopened.LoadCurrent(reloaded, MetafSerializer.NoOpSpells.Instance));
|
|
Assert.Single(reloaded.Waypoints);
|
|
}
|
|
|
|
[Fact]
|
|
public void RouteStoreLeavesLegacyJsonUntouchedWhenAfCounterpartExists()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
string legacyKey = LegacyRouteByCharacterKey("Barris");
|
|
storage.Text[legacyKey] = """{ "Mode": 1, "Waypoints": [] }""";
|
|
// Owner decision 2026-09-07 (slice 1c): the auto route file lives
|
|
// under navs/, no marker needed anymore.
|
|
string realKey = "navs/" + VtankProfileDirectory.AutoCharacterFileName(
|
|
"Barris", string.Empty, "af");
|
|
var real = new NavigationSettings { Mode = RouteMode.Circular };
|
|
real.Waypoints.Add(new RouteWaypoint
|
|
{
|
|
Type = RouteWaypointType.Point,
|
|
Position = new PluginNavigationPosition(0x00010001u, 1d, 2d, 0d, 0f, true),
|
|
});
|
|
storage.Text[realKey] = MetafSerializer.SaveNav(real);
|
|
|
|
var store = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
|
|
Assert.True(store.BindCharacter("Barris"));
|
|
var target = new NavigationSettings();
|
|
Assert.True(store.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance));
|
|
|
|
Assert.True(storage.Text.ContainsKey(legacyKey));
|
|
Assert.Equal(RouteMode.Circular, target.Mode);
|
|
Assert.Single(target.Waypoints);
|
|
}
|
|
|
|
/// <summary>
|
|
/// D-1 (round E architecture re-check): the pre-cutover
|
|
/// RouteRecallKind was {Lifestone=0, Marketplace=1, PrimaryPortal=2,
|
|
/// SecondaryPortal=3} — completely different kinds at the SAME
|
|
/// ordinals as today's much larger enum (PrimaryPortalRecall=0,
|
|
/// SecondaryPortalRecall=1, LifestoneRecall=2, LifestoneSending=3,
|
|
/// ...). Before this fix, deserializing a legacy JSON route's bare
|
|
/// "Recall" integer straight into the new enum type silently remapped
|
|
/// every one of the four old ordinals to the WRONG new recall (and
|
|
/// Enum.IsDefined never caught it, since 0..3 are all still valid new
|
|
/// members). Each of the four legacy ordinals must migrate to its
|
|
/// real VTank recall.
|
|
///
|
|
/// Migration writes the converted route straight to the real .af file
|
|
/// (<c>MigrateLegacyIfNeeded</c>), and <c>LoadCurrent</c> immediately
|
|
/// re-parses THAT file to build the returned <see cref="NavigationSettings"/>
|
|
/// — so asserting against the re-parsed waypoint would really be
|
|
/// testing the .af "rcl" node's own name-only round-trip (a pre-
|
|
/// existing, unrelated limitation: it carries no spell id at all, and
|
|
/// "Marketplace Recall" can never resolve through ANY catalog because
|
|
/// it is not a real spell — see <see cref="RecallWaypointForMarketplaceSubmitsTheSlashCommandNotACast"/>),
|
|
/// not this fix. The direct, catalog-independent proof that
|
|
/// <c>MapLegacyRecall</c> chose the right kind is the WRITTEN .af
|
|
/// text itself: its "rcl" line carries exactly
|
|
/// <see cref="RouteWaypoint.RecallDisplayName"/> for that kind.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(0, (int)RouteRecallKind.LifestoneRecall)] // old Lifestone
|
|
[InlineData(1, (int)RouteRecallKind.Marketplace)] // old Marketplace
|
|
[InlineData(2, (int)RouteRecallKind.PrimaryPortalRecall)] // old PrimaryPortal
|
|
[InlineData(3, (int)RouteRecallKind.SecondaryPortalRecall)] // old SecondaryPortal
|
|
public void LegacyJsonRouteMigratesOldRecallOrdinalToTheRightNewKind(
|
|
int legacyOrdinal,
|
|
int expectedKindOrdinal)
|
|
{
|
|
// xunit only discovers PUBLIC [Theory] methods, and RouteRecallKind
|
|
// is internal — a public parameter of that type is a compile error
|
|
// (CS0051), so InlineData passes ordinals (public ints) and the
|
|
// enum is recovered here instead (matching
|
|
// RecallNameAndSpellIdTablesAgree's own pattern above).
|
|
var expectedKind = (RouteRecallKind)expectedKindOrdinal;
|
|
var storage = new MemoryStorage();
|
|
string legacyKey = LegacyRouteByCharacterKey("Barris");
|
|
storage.Text[legacyKey] = $$"""
|
|
{
|
|
"Mode": 1,
|
|
"Waypoints": [
|
|
{ "Type": 2, "EastWest": 1.0, "NorthSouth": 2.0, "Recall": {{legacyOrdinal}} }
|
|
]
|
|
}
|
|
""";
|
|
|
|
var store = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
|
|
Assert.True(store.BindCharacter("Barris"));
|
|
var target = new NavigationSettings();
|
|
Assert.True(store.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance));
|
|
|
|
RouteWaypoint waypoint = Assert.Single(target.Waypoints);
|
|
Assert.Equal(RouteWaypointType.Recall, waypoint.Type);
|
|
|
|
string fileName = "navs/" + VtankProfileDirectory.AutoCharacterFileName(
|
|
"Barris", string.Empty, "af");
|
|
Assert.True(storage.Text.TryGetValue(fileName, out string? af));
|
|
Assert.Contains($"{{{RouteWaypoint.RecallDisplayName(expectedKind)}}}", af);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reproduces MossTankRouteProfileStore's pre-cutover named-profile JSON
|
|
/// hash key (its own <c>LegacyProfileKey</c> is private; the format is
|
|
/// the migration contract itself, reproduced verbatim here).
|
|
/// </summary>
|
|
private static string LegacyRouteNamedKey(string name)
|
|
{
|
|
string identity = "named:" + name.Trim().ToUpperInvariant();
|
|
string hash = Convert.ToHexString(
|
|
System.Security.Cryptography.SHA256.HashData(
|
|
System.Text.Encoding.UTF8.GetBytes(identity)));
|
|
return $"profiles/route/{hash}.json";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 3 item 2: like the settings and Meta rosters,
|
|
/// MossTankRouteProfileStore's pre-cutover "profiles/route/index.json"
|
|
/// carried EVERY named route's name (never owner-scoped — one shared,
|
|
/// globally-hashed key per name), and the cutover stopped reading it
|
|
/// entirely — orphaning every named route except whichever one happened
|
|
/// to be selected.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RouteRosterSweepConvertsEveryNamedLegacyProfileOnce()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
storage.Text["profiles/route/index.json"] = """{ "Names": ["Farming", "Buffing"] }""";
|
|
storage.Text[LegacyRouteNamedKey("Farming")] = """
|
|
{ "Mode": 1, "Waypoints": [ { "Type": 0, "EastWest": 1.0, "NorthSouth": 2.0 } ] }
|
|
""";
|
|
storage.Text[LegacyRouteNamedKey("Buffing")] = """
|
|
{ "Mode": 1, "Waypoints": [ { "Type": 0, "EastWest": 3.0, "NorthSouth": 4.0 } ] }
|
|
""";
|
|
|
|
var store = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
|
|
Assert.True(store.BindCharacter("Barris"));
|
|
var target = new NavigationSettings();
|
|
store.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance);
|
|
|
|
Assert.False(storage.Text.ContainsKey(LegacyRouteNamedKey("Farming")));
|
|
Assert.False(storage.Text.ContainsKey(LegacyRouteNamedKey("Buffing")));
|
|
Assert.False(storage.Text.ContainsKey("profiles/route/index.json"));
|
|
var farming = new NavigationSettings();
|
|
Assert.True(MetafSerializer.TryLoadNav(
|
|
storage.Text["navs/Farming.af"], farming, MetafSerializer.NoOpSpells.Instance, out _));
|
|
Assert.Equal(1.0d, Assert.Single(farming.Waypoints).Position.EastWest, precision: 3);
|
|
var buffing = new NavigationSettings();
|
|
Assert.True(MetafSerializer.TryLoadNav(
|
|
storage.Text["navs/Buffing.af"], buffing, MetafSerializer.NoOpSpells.Instance, out _));
|
|
Assert.Equal(3.0d, Assert.Single(buffing.Waypoints).Position.EastWest, precision: 3);
|
|
|
|
// Idempotent: a fresh store against the same storage sweeps nothing
|
|
// more (there is no roster key left to read).
|
|
var reopened = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
|
|
Assert.True(reopened.BindCharacter("Barris"));
|
|
var reloadTarget = new NavigationSettings();
|
|
reopened.LoadCurrent(reloadTarget, MetafSerializer.NoOpSpells.Instance);
|
|
Assert.True(storage.Text.ContainsKey("navs/Farming.af"));
|
|
Assert.True(storage.Text.ContainsKey("navs/Buffing.af"));
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Slice 1c step 2: one-time flat-file -> navs/ folder migration.
|
|
// ------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Owner decision 2026-09-07: an existing flat root-level
|
|
/// <c>nav_Name.af</c> (the pre-slice-1c naming) moves to
|
|
/// <c>navs/Name.af</c> with the marker stripped, on first load.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RouteStoreMigratesFlatNavMarkedFileIntoNavsFolderWithMarkerStripped()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var route = new NavigationSettings();
|
|
route.Waypoints.Add(new RouteWaypoint
|
|
{
|
|
Type = RouteWaypointType.Point,
|
|
Position = new PluginNavigationPosition(0x00010001u, 7d, 8d, 0d, 0f, true),
|
|
});
|
|
storage.Text["nav_Hunt.af"] = MetafSerializer.SaveNav(route);
|
|
|
|
var store = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
|
|
Assert.True(store.BindCharacter("Barris"));
|
|
var target = new NavigationSettings();
|
|
store.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance);
|
|
|
|
Assert.True(storage.Text.ContainsKey("navs/Hunt.af"));
|
|
Assert.False(storage.Text.ContainsKey("nav_Hunt.af"));
|
|
|
|
// Idempotent second run: nothing left at the root to migrate.
|
|
var reopened = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
|
|
Assert.True(reopened.BindCharacter("Barris"));
|
|
reopened.LoadCurrent(new NavigationSettings(), MetafSerializer.NoOpSpells.Instance);
|
|
Assert.True(storage.Text.ContainsKey("navs/Hunt.af"));
|
|
Assert.False(storage.Text.ContainsKey("nav_Hunt.af"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Owner decision 2026-09-07: the hidden per-character auto route file
|
|
/// used the combined <c>--nav_Name_Server.af</c> shape before slice 1c
|
|
/// (hidden prefix first, marker second) — the sweep must recognize this
|
|
/// shape too and move it to <c>navs/--Name_Server.af</c> (hidden prefix
|
|
/// kept, marker dropped).
|
|
/// </summary>
|
|
[Fact]
|
|
public void RouteStoreMigratesFlatHiddenAutoRouteFileWithMarkerStripped()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
storage.Text["--Barris_Coldeve.af"] = MetafSerializer.SaveNav(new NavigationSettings());
|
|
// meta's own (unmarked) flat auto file — must be left for the Meta
|
|
// store's own sweep, not touched here.
|
|
var route = new NavigationSettings();
|
|
route.Waypoints.Add(new RouteWaypoint
|
|
{
|
|
Type = RouteWaypointType.Point,
|
|
Position = new PluginNavigationPosition(0x00010001u, 1d, 2d, 0d, 0f, true),
|
|
});
|
|
storage.Text["--nav_Barris_Coldeve.af"] = MetafSerializer.SaveNav(route);
|
|
|
|
var store = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
|
|
Assert.True(store.BindCharacter("Barris"));
|
|
var target = new NavigationSettings();
|
|
store.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance);
|
|
|
|
Assert.True(storage.Text.ContainsKey("navs/--Barris_Coldeve.af"));
|
|
Assert.False(storage.Text.ContainsKey("--nav_Barris_Coldeve.af"));
|
|
// Meta's own flat auto file (no nav_ marker) is NOT this store's
|
|
// concern — left exactly as found.
|
|
Assert.True(storage.Text.ContainsKey("--Barris_Coldeve.af"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Owner decision 2026-09-07, collision rule: when the real destination
|
|
/// already exists, the leftover flat file is left in place untouched
|
|
/// (never overwritten) and the collision is logged.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RouteStoreLeavesFlatFileInPlaceWhenNavsDestinationAlreadyExists()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var canonical = new NavigationSettings();
|
|
canonical.Waypoints.Add(new RouteWaypoint
|
|
{
|
|
Type = RouteWaypointType.Point,
|
|
Position = new PluginNavigationPosition(0x00010001u, 9d, 9d, 0d, 0f, true),
|
|
});
|
|
string canonicalContent = MetafSerializer.SaveNav(canonical);
|
|
storage.Text["navs/Hunt.af"] = canonicalContent;
|
|
storage.Text["nav_Hunt.af"] = MetafSerializer.SaveNav(new NavigationSettings());
|
|
|
|
var host = new FakeHost(new FakeAutomation(), storage);
|
|
var store = new MossTankRouteProfileStore(host);
|
|
Assert.True(store.BindCharacter("Barris"));
|
|
store.LoadCurrent(new NavigationSettings(), MetafSerializer.NoOpSpells.Instance);
|
|
|
|
Assert.Equal(canonicalContent, storage.Text["navs/Hunt.af"]);
|
|
Assert.True(storage.Text.ContainsKey("nav_Hunt.af"));
|
|
Assert.Contains(
|
|
host.Logger.Warnings,
|
|
message => message.Contains("nav_Hunt.af", StringComparison.Ordinal)
|
|
&& message.Contains("navs/Hunt.af", StringComparison.Ordinal));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every OTHER root-level <c>.af</c> file (no <c>nav_</c>/<c>--nav_</c>
|
|
/// marker) belongs to <see cref="MossTankMetaProfileStore"/>'s own
|
|
/// sweep, not this store's — it must be left alone.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RouteStoreLeavesNonMarkedFlatAfFilesForTheMetaStore()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
storage.Text["SharedMeta.af"] = "1\r\n";
|
|
|
|
var store = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
|
|
Assert.True(store.BindCharacter("Barris"));
|
|
store.LoadCurrent(new NavigationSettings(), MetafSerializer.NoOpSpells.Instance);
|
|
|
|
Assert.True(storage.Text.ContainsKey("SharedMeta.af"));
|
|
Assert.False(storage.Text.ContainsKey("navs/SharedMeta.af"));
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Slice 1c step 3: content sanity on load.
|
|
// ------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Owner decision 2026-09-07: a real STATE:-only <c>.af</c> file (a
|
|
/// Meta profile with no embedded route) that ends up at <c>navs/</c>
|
|
/// must NOT silently misload. <c>LoadCurrent</c> must refuse it and
|
|
/// leave a notice naming the <c>metas/</c> folder it actually belongs
|
|
/// in.
|
|
/// </summary>
|
|
[Fact]
|
|
public void RouteStoreRefusesToLoadAMetaOnlyFileWithNoticeNamingMetasFolder()
|
|
{
|
|
string metaOnlyContent = File.ReadAllText(
|
|
Path.Combine(AppContext.BaseDirectory, "Fixtures", "vtank", "af", "bella.af"));
|
|
var storage = new MemoryStorage();
|
|
storage.Text["navs/Misplaced.af"] = metaOnlyContent;
|
|
|
|
var store = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
|
|
Assert.True(store.BindCharacter("Barris"));
|
|
Assert.True(store.Select("Misplaced"));
|
|
|
|
var target = new NavigationSettings();
|
|
bool loaded = store.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance);
|
|
|
|
Assert.False(loaded);
|
|
Assert.NotNull(store.RecoveryNotice);
|
|
Assert.Contains("metas/", store.RecoveryNotice, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void FollowModeRouteRoundTripsTheFollowTargetThroughAf()
|
|
{
|
|
var storage = new MemoryStorage();
|
|
var host = new FakeHost(new FakeAutomation(), storage);
|
|
var source = new NavigationSettings
|
|
{
|
|
Mode = RouteMode.Target,
|
|
FollowTargetObjectId = 99u,
|
|
FollowTargetName = "Leader",
|
|
};
|
|
var first = new MossTankRouteProfileStore(host);
|
|
Assert.True(first.BindCharacter("Test Character"));
|
|
first.SaveCurrent(source);
|
|
|
|
var target = new NavigationSettings();
|
|
var second = new MossTankRouteProfileStore(host);
|
|
Assert.True(second.BindCharacter("Test Character"));
|
|
Assert.True(second.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance));
|
|
|
|
Assert.Equal(RouteMode.Target, target.Mode);
|
|
Assert.Equal(99u, target.FollowTargetObjectId);
|
|
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);
|
|
}
|
|
|
|
/// <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; } = [];
|
|
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,
|
|
params RouteWaypoint[] waypoints)
|
|
{
|
|
var settings = new NavigationSettings
|
|
{
|
|
Enabled = true,
|
|
Mode = mode,
|
|
MinimumDistanceMeters = 2d,
|
|
};
|
|
settings.Waypoints.AddRange(waypoints);
|
|
return new NavigationController(new FakeHost(automation), settings);
|
|
}
|
|
|
|
private static RouteWaypoint Waypoint(
|
|
RouteWaypointType type,
|
|
PluginNavigationPosition position) => new()
|
|
{
|
|
Type = type,
|
|
Position = position,
|
|
};
|
|
|
|
private static PluginNavigationSnapshot Snapshot(
|
|
PluginNavigationPosition position,
|
|
bool airborne = false) => new(
|
|
IsAvailable: true,
|
|
IsPortalSpace: false,
|
|
LocalObjectId: 1u,
|
|
position,
|
|
IsMoving: false,
|
|
IsAirborne: airborne);
|
|
|
|
private static PluginNavigationPosition Position(
|
|
double eastWest,
|
|
double northSouth,
|
|
float heading = 0f) => new(
|
|
0x7F7F0001u,
|
|
eastWest,
|
|
northSouth,
|
|
0d,
|
|
heading,
|
|
IsOutdoor: true);
|
|
|
|
private sealed class FakeHost(
|
|
FakeAutomation automation,
|
|
IPluginStorage? storage = null) : IPluginHost
|
|
{
|
|
public bool HasUi => false;
|
|
// Exposed as the concrete FakeLogger (not just the IPluginLogger
|
|
// interface view) so a test can inspect Warnings, matching
|
|
// MossTankPanelTests.FakeHost's own pattern.
|
|
public FakeLogger Logger { get; } = new();
|
|
public IPluginLogger Log => Logger;
|
|
public IGameState State { get; } = new FakeState();
|
|
public IEvents Events { get; } = new FakeEvents();
|
|
public ISelectionService Selection { get; } = new FakeSelection();
|
|
public IUiRegistry Ui => NoOpUiRegistry.Instance;
|
|
public IPluginStorage Storage { get; } = storage ?? NoOpPluginStorage.Instance;
|
|
public IAutomationSurface Automation { get; } = automation;
|
|
// Real VTank .usd/.af/.cdf storage, same backing store as the
|
|
// plugin's own JSON storage (key namespaces never collide).
|
|
public IPluginStorage VtankProfiles { get; } = storage ?? NoOpPluginStorage.Instance;
|
|
}
|
|
|
|
private sealed class FakeAutomation
|
|
: IAutomationSurface, INavigationAutomation, IPluginChat, IItemAutomation
|
|
{
|
|
public bool IsAvailable => true;
|
|
public ICharacterInfo Character => NoOpAutomationSurface.Instance;
|
|
public ISpellCatalog Spells => 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;
|
|
public PluginNavigationSnapshot NavigationSnapshot { get; set; }
|
|
PluginNavigationSnapshot INavigationAutomation.Snapshot => NavigationSnapshot;
|
|
public Dictionary<uint, PluginNavigationObject> Objects { get; } = [];
|
|
public List<PluginNavigationObject> WorldObjects { get; } = [];
|
|
public List<PluginMovementIntent> Intents { get; } = [];
|
|
public List<string> SubmittedChat { get; } = [];
|
|
public List<PluginChatMessage> ChatMessages { get; } = [];
|
|
public List<uint> UsedObjects { get; } = [];
|
|
public int ClearCount { get; private set; }
|
|
public PluginItemUseCompletion ItemCompletion { get; set; }
|
|
public PluginItemUseCompletion LastCompletion => ItemCompletion;
|
|
public PluginNavigationObject? FoundObject { get; set; }
|
|
public string? FindName { get; private set; }
|
|
|
|
public bool TryGetObject(uint objectId, out PluginNavigationObject value) =>
|
|
Objects.TryGetValue(objectId, out value);
|
|
|
|
public bool TryFindObject(
|
|
string name,
|
|
in PluginNavigationPosition near,
|
|
double maximumDistanceMeters,
|
|
out PluginNavigationObject value)
|
|
{
|
|
FindName = name;
|
|
value = FoundObject ?? default;
|
|
return FoundObject.HasValue;
|
|
}
|
|
|
|
public IReadOnlyList<PluginNavigationObject> CaptureObjects() =>
|
|
WorldObjects;
|
|
|
|
public PluginNavigationCommandStatus SetMovementIntent(
|
|
in PluginMovementIntent intent)
|
|
{
|
|
Intents.Add(intent);
|
|
return PluginNavigationCommandStatus.Accepted;
|
|
}
|
|
|
|
public PluginNavigationCommandStatus ClearMovementIntent()
|
|
{
|
|
ClearCount++;
|
|
return PluginNavigationCommandStatus.Accepted;
|
|
}
|
|
|
|
public bool Submit(string text)
|
|
{
|
|
SubmittedChat.Add(text);
|
|
return true;
|
|
}
|
|
|
|
public IReadOnlyList<PluginChatMessage> CaptureMessages(ulong afterSequence) =>
|
|
ChatMessages.Where(message => message.Sequence > afterSequence).ToArray();
|
|
|
|
public void PostSystemMessage(string text) { }
|
|
|
|
public PluginItemCommandResult Use(uint objectId)
|
|
{
|
|
UsedObjects.Add(objectId);
|
|
return new PluginItemCommandResult(PluginItemCommandStatus.Started);
|
|
}
|
|
}
|
|
|
|
private sealed class MemoryStorage : IPluginStorage
|
|
{
|
|
private readonly Dictionary<string, string> _text = new(StringComparer.Ordinal);
|
|
public Dictionary<string, string> Text => _text;
|
|
public bool IsAvailable => true;
|
|
public string? ReadText(string key) =>
|
|
_text.TryGetValue(key, out string? value) ? value : null;
|
|
public IReadOnlyList<string> List(string prefix) => _text.Keys
|
|
.Where(key => prefix.Length == 0
|
|
|| key.StartsWith(prefix + "/", StringComparison.Ordinal))
|
|
.OrderBy(static key => key, StringComparer.Ordinal)
|
|
.ToArray();
|
|
public void WriteText(string key, string content) => _text[key] = content;
|
|
public bool Delete(string key) => _text.Remove(key);
|
|
}
|
|
|
|
private sealed class FakeLogger : IPluginLogger
|
|
{
|
|
public List<string> Warnings { get; } = [];
|
|
public void Info(string message) { }
|
|
public void Warn(string message) => Warnings.Add(message);
|
|
public void Error(string message, Exception? exception = null) { }
|
|
}
|
|
|
|
private sealed class FakeState : IGameState
|
|
{
|
|
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
|
|
}
|
|
|
|
private sealed class FakeEvents : IEvents
|
|
{
|
|
public event Action<WorldEntitySnapshot> EntitySpawned
|
|
{
|
|
add { }
|
|
remove { }
|
|
}
|
|
|
|
public event Action<double> Tick
|
|
{
|
|
add { }
|
|
remove { }
|
|
}
|
|
}
|
|
|
|
private sealed class FakeSelection : ISelectionService
|
|
{
|
|
public uint? SelectedObjectId => null;
|
|
public uint? PreviousObjectId => null;
|
|
public event Action<SelectionChangedEvent> Changed
|
|
{
|
|
add { }
|
|
remove { }
|
|
}
|
|
|
|
public bool Select(uint objectId) => false;
|
|
public bool Clear() => false;
|
|
}
|
|
}
|