Retail's real 2000 ms jump-charge ceiling (refs/vtank/decompiled/bi.cs:502-505, bi.a) is enforced at the moment a jump STARTS charging, not by the storage format — metaf's own NJump (metaf_monolithic.py:11708-11820) and VTank's .nav both round-trip the authored value unclamped. The prior slice-1 port misread this as a LOAD-time clamp: MetafSerializer's .af "jmp" parser and the legacy-JSON route migration both clamped JumpChargeMilliseconds on read, silently rewriting an authored 5000 ms waypoint down to 2000 ms even when the route is never executed. Removed both load-time clamps; NavigationController.TickJump now clamps the EFFECTIVE charge duration (Math.Clamp(..., 0, 2000)) only at the one place retail actually enforces it — the charge-hold comparison during execution — leaving the stored/authored value untouched. Renamed MetafSerializerTests.JumpNodeClampsChargeMillisecondsTo2000 to JumpNodeLoadPreservesAuthoredChargeMillisecondsAboveRetailCeiling (now asserts the 5000 ms value survives the .af load) and added a save+load round-trip test and a Navigation execution test asserting the jump releases at ~2000 ms of in-game charging despite a 5000 ms authored value. Mutation: reverted MetafSerializer.cs/MossTankRouteProfileStore.cs/ Navigation.cs to HEAD (keeping only the new/changed tests) and ran the three new/renamed tests — all three failed (load clamped to 2000, execution never released before 5000 ms) — confirming they exercise the bug before the fix. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
844 lines
32 KiB
C#
844 lines
32 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.SecondaryPortal,
|
|
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": [] }""";
|
|
// Round 3 item 3: hidden prefix first, nav_ marker second.
|
|
string realKey = VtankProfileDirectory.AutoCharacterFileName(
|
|
"Barris", string.Empty, "af", VtankProfileDirectory.NavMarker);
|
|
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>
|
|
/// 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["nav_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["nav_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("nav_Farming.af"));
|
|
Assert.True(storage.Text.ContainsKey("nav_Buffing.af"));
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
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;
|
|
public IPluginLogger Log { get; } = new FakeLogger();
|
|
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;
|
|
public IMagicCommands Magic => 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 void Info(string message) { }
|
|
public void Warn(string 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;
|
|
}
|
|
}
|