acdream/src/AcDream.Plugins.MossTank/Navigation.cs
Erik 6ef6eb7514 fix(vtank): slice 7 round F item 4 — recall combo at VTank's terse-caption shape
Resemblance re-check: "the recall combo's full spell names overflow the
120 px box on most of the 27 entries — VTank's cmbRecallType uses terse
captions ('Primary', 'LS Sending', 'PtOIA', 'Fac. Hub'...): show those,
keep the full name in the waypoint and .af." Round E's D-2 had widened
the combo 120->300 to fit RecallDisplayName's full spell names; this
reverts the widening and shows VTank's own cmbRecallType captions
instead, restoring the box to VTank's real 120 width.

Added RouteWaypoint.RecallShortCaption(RouteRecallKind) — VTank's 27
terse cmbRecallType strings transcribed from docs/research/vtank-kb/
08-ui-views.md §1 (this worktree has no refs/vtank/ checkout to read
mainView.xml directly; the KB doc is its own faithful transcription of
that exact file, duplicate quirk included). The UI file's own caption
list carries a genuine VTank authoring duplicate — "Fac. Hub" then
"FacHub" again four slots later — that pushes every later caption one
position past the 26 real castable recalls it captions; removing that
duplicate restores an exact 1:1, in-order match onto RouteRecallKind's
26 real spells (confirmed by meaning, not just position — "Candeth" for
Return to the Keep is Candeth Keep, the literal destination; the three
"Soc. *" captions are the Society stronghold recalls). Marketplace was
never a cmbRecallType option (VTank's one slash-only recall) and keeps
its own literal caption since this combo still lists it.

RouteRecallNames/SelectedRouteRecall/SelectRouteRecall now read/parse
RecallShortCaption instead of RecallDisplayName — this is the ONLY
thing that changed. AddRouteRecallCore/RecallLabel still write the full
name (RecallDisplayName) onto the waypoint, the Route grid row, and the
.af file, completely untouched by this commit.

27 captions used, in RouteRecallKind order: Primary, Secondary, LS,
LS Sending, Portal, Aphus, Sanctuary, Caul, GW, Aerlinthe, Mt. Lethe,
Ulgrim's, Bur, PtOIA, Graveyard, Colosseum, Fac. Hub, Gear K. Camp,
Neftet, Candeth, Rynthid, VR Rocks, VR Tree, Soc. CH, Soc. RB, Soc. EW,
Marketplace.

Mutation shown to fail first: RouteRecallComboShowsVtanksTerseCaptionsButTheWaypointKeepsTheFullName
replaces RouteRecallComboShowsVtankNamesNotEnumIdentifiers with the new
expected terse-caption list. Verified by temporarily reverting
RouteRecallNames/SelectedRouteRecall back to RecallDisplayName (a
targeted mutation, then reverted) — the test failed with "Expected:
[Primary, Secondary, LS, ...] Actual: [Primary Portal Recall, Secondary
Portal Recall, Lifestone Recall, ...]" — then passed once
RecallShortCaption was restored.

MossTank suite 724/724 (one test replaced, net count unchanged); App
markup/plugin filter 258/258 (unaffected — no App-side markup change).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 18:54:54 +02:00

1353 lines
52 KiB
C#

using System.Globalization;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
internal enum RouteMode
{
Circular,
Linear,
Target,
Once,
}
internal enum RouteWaypointType
{
Point = 0,
Portal = 1,
Recall = 2,
Pause = 3,
ChatCommand = 4,
OpenVendor = 5,
PortalByName = 6,
UseNpc = 7,
Checkpoint = 8,
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
{
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,
}
internal enum RouteJumpDirection
{
Forward,
StrafeLeft,
StrafeRight,
}
/// <summary>
/// VTank's own cmbNavInsertMode (docs/research/vtank-kb/08-ui-views.md §1
/// "Tab: Route": "options: Add to End / Insert Above / Insert Below").
/// Replaces the old two-state _routeAddToEnd bool (fix round B item 8) —
/// InsertAbove/InsertBelow are both relative to the currently selected
/// waypoint, distinguishing "before" from "after" rather than collapsing
/// both non-append cases into one.
/// </summary>
internal enum RouteInsertMode
{
AddToEnd,
InsertAbove,
InsertBelow,
}
internal sealed class RouteWaypoint
{
public RouteWaypointType Type { get; set; }
public PluginNavigationPosition Position { get; set; }
/// <summary>
/// The second of the two coordinate triples VTank's own Portal2/UseNPC
/// waypoint records carry (metaf's "ptl"/"tlk" nodes: "myx myy myz tgtx
/// tgty tgtz" — <c>metaf_monolithic.py:356-357,11482,11618</c>; the
/// binary <c>.nav</c> record's own trailing xyz). <see cref="Position"/>
/// is where the bot stands to use the object ("myxyz"); this is the
/// portal/NPC object's own recorded position ("objxyz"), used to
/// disambiguate which object at that name to interact with. Unused for
/// every other waypoint type.
/// </summary>
public PluginNavigationPosition ReferencePosition { get; set; }
public uint ObjectId { get; set; }
public string ObjectName { get; set; } = string.Empty;
/// <summary>Decal ObjectClass retained for exact VTank NAV interchange.</summary>
public int LegacyObjectClass { get; set; }
/// <summary>VTank's serialized coordinate-valid bit for Portal2/UseNPC.</summary>
public bool LegacyReferenceValid { get; set; } = true;
public string Text { get; set; } = string.Empty;
public int DurationMilliseconds { get; set; } = 5000;
public RouteRecallKind Recall { get; set; }
public uint RecallSpellId { get; set; }
public string RecallSpellName { get; set; } = string.Empty;
public float JumpHeadingDegrees { get; set; }
public bool JumpRun { get; set; }
public int JumpChargeMilliseconds { get; set; } = 1000;
public RouteJumpDirection JumpDirection { get; set; }
public RouteWaypoint Clone() => new()
{
Type = Type,
Position = Position,
ReferencePosition = ReferencePosition,
ObjectId = ObjectId,
ObjectName = ObjectName,
LegacyObjectClass = LegacyObjectClass,
LegacyReferenceValid = LegacyReferenceValid,
Text = Text,
DurationMilliseconds = DurationMilliseconds,
Recall = Recall,
RecallSpellId = RecallSpellId,
RecallSpellName = RecallSpellName,
JumpHeadingDegrees = JumpHeadingDegrees,
JumpRun = JumpRun,
JumpChargeMilliseconds = JumpChargeMilliseconds,
JumpDirection = JumpDirection,
};
public string DisplayText => Type switch
{
RouteWaypointType.Point => $"Point: {FormatPosition(Position)}",
RouteWaypointType.Portal => $"Portal: {ObjectLabel}",
RouteWaypointType.Recall => $"Recall: {RecallLabel}",
RouteWaypointType.Pause => string.Create(
CultureInfo.InvariantCulture,
$"Pause: {DurationMilliseconds / 1000d:0.###} seconds"),
RouteWaypointType.ChatCommand => $"Chat command: {Text}",
RouteWaypointType.OpenVendor => ObjectId == 0u
? "Close Vendor"
: $"Open Vendor: {ObjectLabel}",
RouteWaypointType.PortalByName => $"Portal: {ObjectLabel}",
RouteWaypointType.UseNpc => $"Use NPC: {ObjectLabel}",
RouteWaypointType.Checkpoint => $"Checkpoint: {FormatPosition(Position)}",
RouteWaypointType.Jump =>
$"Jump: {JumpHeadingDegrees.ToString("0.0", CultureInfo.InvariantCulture)}d, "
+ $"{JumpChargeMilliseconds.ToString(CultureInfo.InvariantCulture)}ms"
+ (JumpRun ? ", Shift" : string.Empty)
+ $", {JumpDirectionDisplayName(JumpDirection)}",
_ => Type.ToString(),
};
private string ObjectLabel => string.IsNullOrWhiteSpace(ObjectName)
? $"0x{ObjectId:X8}"
: ObjectName;
internal string RecallLabel => !string.IsNullOrWhiteSpace(RecallSpellName)
? RecallSpellName
: RecallSpellId != 0u
? RecallSpellId.ToString(CultureInfo.InvariantCulture)
: RecallDisplayName(Recall);
internal static string FormatPosition(in PluginNavigationPosition value)
{
string northSouth = value.NorthSouth >= 0d ? "N" : "S";
string eastWest = value.EastWest >= 0d ? "E" : "W";
return "("
+ Math.Abs(value.NorthSouth).ToString("0.###", CultureInfo.InvariantCulture)
+ northSouth
+ ", "
+ Math.Abs(value.EastWest).ToString("0.###", CultureInfo.InvariantCulture)
+ eastWest
+ ")";
}
/// <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.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",
_ => value.ToString(),
};
/// <summary>
/// VTank's own <c>cmbRecallType</c> combo CAPTION (docs/research/
/// vtank-kb/08-ui-views.md §1's Route table, transcribed from
/// <c>refs/vtank/uTank2.ViewXML.mainView.xml</c>'s 27-string Choice
/// list) for each <see cref="RouteRecallKind"/> — used ONLY by the
/// combo's own display/parse round-trip (MossTankPanel.RouteRecallNames/
/// SelectedRouteRecall/SelectRouteRecall); the waypoint text, the
/// Route grid row, and the .af file keep showing <see
/// cref="RecallDisplayName"/>'s full name via <see cref="RecallLabel"/>,
/// untouched by this method (round F item 4 — resemblance re-check:
/// "the recall combo's full spell names overflow the 120 px box...
/// VTank's cmbRecallType uses terse captions").
///
/// The UI file's own 27 caption strings carry a genuine VTank
/// authoring duplicate — "Fac. Hub" at position 17 and "FacHub"
/// again at position 21 (KB's own noted "authoring duplicate") —
/// which pushes every later caption one slot further than the 26
/// real (unique) castable recalls it captions. Removing that
/// duplicate restores an exact 1:1, in-order match onto
/// <see cref="RouteRecallKind"/>'s 26 real spells: "Candeth" for
/// Return to the Keep (Candeth Keep IS the keep that spell returns
/// to), "Graveyard" for Call of the Mhoire Forge, and the three
/// "Soc. *" captions for the Society stronghold recalls.
/// <see cref="RouteRecallKind.Marketplace"/> was never a
/// cmbRecallType option at all (it is VTank's one slash-command-only
/// recall, per RecallDisplayName's own doc history) — it keeps its
/// own literal caption here since MossTank's combo still lists it.
/// </summary>
internal static string RecallShortCaption(RouteRecallKind value) => value switch
{
RouteRecallKind.PrimaryPortalRecall => "Primary",
RouteRecallKind.SecondaryPortalRecall => "Secondary",
RouteRecallKind.LifestoneRecall => "LS",
RouteRecallKind.LifestoneSending => "LS Sending",
RouteRecallKind.PortalRecall => "Portal",
RouteRecallKind.RecallAphusLassel => "Aphus",
RouteRecallKind.RecallTheSanctuary => "Sanctuary",
RouteRecallKind.RecallToTheSingularityCaul => "Caul",
RouteRecallKind.GlendenWoodRecall => "GW",
RouteRecallKind.AerlintheRecall => "Aerlinthe",
RouteRecallKind.MountLetheRecall => "Mt. Lethe",
RouteRecallKind.UlgrimsRecall => "Ulgrim's",
RouteRecallKind.BurRecall => "Bur",
RouteRecallKind.ParadoxTouchedOlthoiInfestedAreaRecall => "PtOIA",
RouteRecallKind.CallOfTheMhoireForge => "Graveyard",
RouteRecallKind.ColosseumRecall => "Colosseum",
RouteRecallKind.FacilityHubRecall => "Fac. Hub",
RouteRecallKind.GearKnightInvasionAreaCampRecall => "Gear K. Camp",
RouteRecallKind.LostCityOfNeftetRecall => "Neftet",
RouteRecallKind.ReturnToTheKeep => "Candeth",
RouteRecallKind.RynthidRecall => "Rynthid",
RouteRecallKind.ViridianRiseRecall => "VR Rocks",
RouteRecallKind.ViridianRiseGreatTreeRecall => "VR Tree",
RouteRecallKind.CelestialHandStrongholdRecall => "Soc. CH",
RouteRecallKind.RadiantBloodStrongholdRecall => "Soc. RB",
RouteRecallKind.EldrytchWebStrongholdRecall => "Soc. EW",
RouteRecallKind.Marketplace => "Marketplace",
_ => 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
{
RouteJumpDirection.StrafeLeft => "Strafe Left",
RouteJumpDirection.StrafeRight => "Strafe Right",
_ => "Forward",
};
}
internal sealed class NavigationSettings
{
public bool Enabled { get; set; }
public bool Priority { get; set; }
public RouteMode Mode { get; set; } = RouteMode.Circular;
public double MinimumDistanceMeters { get; set; } = 2d;
/// <summary>
/// VTank's far stop range is the outer validity bound for a navigation
/// rule, not a second arrival threshold.
/// </summary>
public double MaximumDistanceMeters { get; set; } = 999999d * 240d;
public double PortalUseDistanceMeters { get; set; } = 4d;
public uint FollowTargetObjectId { get; set; }
public string FollowTargetName { get; set; } = string.Empty;
public bool FollowAroundCorners { get; set; } = true;
public bool OpenDoors { get; set; }
public double DoorIdentifyRangeMeters { get; set; } = 20d;
public double DoorOpenRangeMeters { get; set; } = 4d;
public int DoorLockpickExcessThreshold { get; set; } = -50;
public List<RouteWaypoint> Waypoints { get; } = [];
}
/// <summary>
/// VTank's navigation state machine over acdream's canonical movement input.
/// Its steering constants are the official fd.cs behavior: turn outside four
/// degrees, continue forward while turning only inside 45 degrees when farther
/// than three metres (15 degrees when nearer), and stop at NavCloseStopRange.
/// </summary>
internal sealed class NavigationController
{
private const float HeadingToleranceDegrees = 4f;
private const float FarMovingTurnLimitDegrees = 45f;
private const float NearMovingTurnLimitDegrees = 15f;
private const double NearTargetMeters = 3d;
private const double ChatInitialDelaySeconds = 0.2d;
private const double UseRetrySeconds = 2d;
private const double PortalTimeoutSeconds = 30d;
private const double ObjectReacquireRadiusMeters = 2.5d;
private const double PortalExitDistanceMeters = 15d;
private const double RecallExitDistanceMeters = 2.4d;
private const double JumpLaunchGraceSeconds = 0.25d;
private const double JumpCompletionTimeoutSeconds = 3d;
// Round 3 item 5: retail's real jump-charge ceiling
// (refs/vtank/decompiled/bi.cs:502-505, bi.a — "if (A_2 > 2000.0) A_2 =
// 2000.0" at the moment the jump starts charging) belongs HERE, at
// execution, not at .af/legacy-JSON load — a waypoint's authored
// JumpChargeMilliseconds round-trips through storage unclamped; only
// the actual in-game charge duration is capped.
private const int JumpChargeCeilingMilliseconds = 2000;
private const double CheckpointRetrySeconds = 15d;
private const double FollowBreadcrumbSpacingMeters = 0.096d;
private const double FollowPathCaptureRangeMeters = 240d;
private const double FollowPathArrivalMeters = 2.4d;
private const double DoorActionTimeoutSeconds = 5d;
private const uint LockpickPublicFlag = 0x00020000u;
private const uint LockpickSkillId = 23u;
private const uint LockpickModifierProperty = 40u;
private readonly IPluginHost _host;
private readonly NavigationSettings _settings;
private int _index;
private bool _reverse;
private bool _onceComplete;
private RouteWaypoint? _activeAction;
private double _actionElapsed;
private double _retryElapsed;
private long _useCompletionBaseline;
private ulong _chatBaseline;
private bool _actionSent;
private bool _sawPortalSpace;
private bool _jumpReleased;
private bool _jumpAligned;
private bool _jumpSawAirborne;
private double _jumpChargeElapsed;
private double _jumpReleaseElapsed;
private double _checkpointElapsed;
private readonly List<PluginNavigationPosition> _followPath = [];
private uint _activeDoorObjectId;
private uint _activeLockpickObjectId;
private double _doorElapsed;
private double _doorRetryElapsed;
private PluginNavigationPosition _portalOrigin;
private bool _hasPortalOrigin;
private bool _hadMovementIntent;
private string _status = "Navigation disabled.";
public NavigationController(IPluginHost host, NavigationSettings settings)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
}
public string Status => _status;
public int CurrentWaypointIndex => _index;
public bool Reversing => _reverse;
public void ToggleReverse()
{
_reverse = !_reverse;
_status = $"Nav backwards is {_reverse}.";
}
public void Reset()
{
StopMovement();
_index = 0;
_reverse = false;
_onceComplete = false;
_checkpointElapsed = 0d;
_followPath.Clear();
ClearDoor();
ClearAction();
_status = _settings.Enabled
? "Route ready."
: "Navigation disabled.";
}
public void ClearActionLocks()
{
StopMovement();
_checkpointElapsed = 0d;
ClearDoor();
ClearAction();
_status = _settings.Enabled
? "Route action locks cleared."
: "Navigation disabled.";
}
public bool Tick(double elapsedSeconds, bool canAct)
{
elapsedSeconds = double.IsFinite(elapsedSeconds)
? Math.Max(0d, elapsedSeconds)
: 0d;
INavigationAutomation navigation = _host.Automation.Navigation;
PluginNavigationSnapshot snapshot = navigation.Snapshot;
if (!_settings.Enabled || !snapshot.IsAvailable)
{
StopMovement();
_status = _settings.Enabled
? "Waiting for the world."
: "Navigation disabled.";
return false;
}
if (snapshot.IsPortalSpace)
{
StopMovement();
if (_activeAction?.Type is RouteWaypointType.Portal
or RouteWaypointType.PortalByName
or RouteWaypointType.Recall)
{
_sawPortalSpace = true;
}
_status = "Waiting for portal space.";
return _activeAction is not null;
}
if (!canAct)
{
StopMovement();
_status = "Navigation paused.";
return false;
}
if (TickDoor(navigation, snapshot, elapsedSeconds))
return true;
if (_settings.Mode == RouteMode.Target)
return TickFollow(navigation, snapshot);
if (_onceComplete || _settings.Waypoints.Count == 0)
{
StopMovement();
_status = _onceComplete ? "Once route complete." : "Route is empty.";
return false;
}
_index = Math.Clamp(_index, 0, _settings.Waypoints.Count - 1);
RouteWaypoint waypoint = _settings.Waypoints[_index];
if (waypoint.Type == RouteWaypointType.Point)
{
double distance = snapshot.Position.HorizontalDistanceMeters(
waypoint.Position);
if (distance > BoundedMaximumDistance())
{
StopMovement();
_status = $"Waypoint is outside NavFarStopRange ({distance:0.0}m).";
return false;
}
if (distance <= BoundedMinimumDistance())
{
StopMovement();
AdvanceWaypoint();
return true;
}
_status = string.Create(
CultureInfo.InvariantCulture,
$"Waypoint {_index + 1}/{_settings.Waypoints.Count}: {distance:0.0}m");
return Steer(navigation, snapshot.Position, waypoint.Position, distance);
}
if (waypoint.Type == RouteWaypointType.Checkpoint)
return TickCheckpoint(navigation, snapshot, waypoint, elapsedSeconds);
StopMovement();
return TickAction(waypoint, elapsedSeconds, snapshot);
}
private bool TickFollow(
INavigationAutomation navigation,
in PluginNavigationSnapshot snapshot)
{
if (_settings.FollowTargetObjectId == 0u
|| !navigation.TryGetObject(
_settings.FollowTargetObjectId,
out PluginNavigationObject target))
{
StopMovement();
_status = "Follow target unavailable.";
return false;
}
double distance = snapshot.Position.HorizontalDistanceMeters(
target.Position);
if (distance > BoundedMaximumDistance())
{
StopMovement();
_status = $"Follow target is outside NavFarStopRange ({distance:0.0}m).";
return false;
}
if (distance <= BoundedMinimumDistance())
{
StopMovement();
_status = $"Following {target.Name}: holding {distance:0.0}m.";
return false;
}
PluginNavigationPosition destination = CaptureFollowDestination(
snapshot.Position,
target.Position);
double destinationDistance = snapshot.Position.HorizontalDistanceMeters(
destination);
_status = $"Following {target.Name}: {distance:0.0}m.";
return Steer(navigation, snapshot.Position, destination, destinationDistance);
}
private PluginNavigationPosition CaptureFollowDestination(
in PluginNavigationPosition current,
in PluginNavigationPosition target)
{
if (!_settings.FollowAroundCorners)
{
_followPath.Clear();
return target;
}
if (_followPath.Count == 0
|| _followPath[^1].HorizontalDistanceMeters(target)
>= FollowBreadcrumbSpacingMeters)
{
_followPath.Add(target);
}
for (int index = _followPath.Count - 1; index >= 1; index--)
{
if (DistanceToSegmentMeters(
current,
_followPath[index - 1],
_followPath[index]) < FollowPathArrivalMeters
&& current.HorizontalDistanceMeters(_followPath[index])
< FollowPathCaptureRangeMeters)
{
_followPath.RemoveRange(0, index);
break;
}
}
return _followPath.Count == 0 ? target : _followPath[0];
}
private bool TickDoor(
INavigationAutomation navigation,
in PluginNavigationSnapshot snapshot,
double elapsedSeconds)
{
if (!_settings.OpenDoors)
{
ClearDoor();
return false;
}
IReadOnlyList<PluginNavigationObject> objects = navigation.CaptureObjects();
PluginNavigationObject door = default;
bool found = false;
double nearest = _settings.DoorIdentifyRangeMeters;
foreach (PluginNavigationObject candidate in objects)
{
if (!candidate.IsDoor || candidate.IsOpen)
continue;
double distance = snapshot.Position.HorizontalDistanceMeters(
candidate.Position);
if (_activeDoorObjectId != 0u
&& candidate.ObjectId == _activeDoorObjectId)
{
door = candidate;
nearest = distance;
found = true;
break;
}
if (_activeDoorObjectId == 0u && distance <= nearest)
{
door = candidate;
nearest = distance;
found = true;
}
}
if (!found)
{
ClearDoor();
return false;
}
if (!door.HasLockState)
{
if (nearest <= _settings.DoorIdentifyRangeMeters
&& _host.Automation.Loot.Appraisal.AwaitingObjectId == 0u)
{
_ = _host.Automation.Loot.Identify(door.ObjectId);
}
if (nearest <= _settings.DoorOpenRangeMeters)
{
StopMovement();
_status = $"Identifying door: {door.Name}.";
return true;
}
return false;
}
if (nearest > _settings.DoorOpenRangeMeters)
{
ClearDoor();
return false;
}
if (_activeDoorObjectId == 0u)
{
_activeDoorObjectId = door.ObjectId;
_activeLockpickObjectId = door.IsLocked
? SelectLockpick(door.LockDifficulty)
: 0u;
if (door.IsLocked && _activeLockpickObjectId == 0u)
{
_status = $"Locked door skipped: {door.Name}.";
ClearDoor();
return false;
}
}
StopMovement();
_doorElapsed += elapsedSeconds;
_doorRetryElapsed += elapsedSeconds;
if (_doorElapsed >= DoorActionTimeoutSeconds)
{
_status = $"Door timed out: {door.Name}.";
ClearDoor();
return false;
}
if (_doorRetryElapsed == elapsedSeconds || _doorRetryElapsed >= UseRetrySeconds)
{
PluginItemCommandResult result = _activeLockpickObjectId == 0u
? _host.Automation.Items.Use(door.ObjectId)
: _host.Automation.Items.Apply(
_activeLockpickObjectId,
door.ObjectId);
_doorRetryElapsed = 0d;
_status = result.Accepted
? _activeLockpickObjectId == 0u
? $"Opening door: {door.Name}."
: $"Picking lock: {door.Name}."
: $"Waiting for door: {door.Name}.";
}
return true;
}
private uint SelectLockpick(int difficulty)
{
if (!_host.Automation.Character.TryGetSkill(
LockpickSkillId,
out PluginSkillInfo skill)
|| skill.Current < Math.Max(
0,
difficulty + _settings.DoorLockpickExcessThreshold))
{
return 0u;
}
uint selected = 0u;
double bestModifier = double.MinValue;
foreach (PluginInventoryItem item in _host.Automation.Items.CaptureOwnedItems())
{
if ((item.PublicFlags & LockpickPublicFlag) == 0u)
continue;
double modifier = 0d;
if (_host.Automation.Items.TryCaptureProperties(
item.ObjectId,
out PluginItemProperties properties)
&& properties.Floats.TryGetValue(
LockpickModifierProperty,
out double current))
{
modifier = current;
}
if (modifier <= bestModifier)
continue;
bestModifier = modifier;
selected = item.ObjectId;
}
return selected;
}
private void ClearDoor()
{
_activeDoorObjectId = 0u;
_activeLockpickObjectId = 0u;
_doorElapsed = 0d;
_doorRetryElapsed = 0d;
}
private bool TickCheckpoint(
INavigationAutomation navigation,
in PluginNavigationSnapshot snapshot,
RouteWaypoint waypoint,
double elapsedSeconds)
{
double liveDistance = snapshot.Position.HorizontalDistanceMeters(
waypoint.Position);
if (liveDistance > BoundedMaximumDistance())
{
StopMovement();
_checkpointElapsed = 0d;
_status = $"Checkpoint is outside NavFarStopRange ({liveDistance:0.0}m).";
return false;
}
if (liveDistance > BoundedMinimumDistance())
{
_checkpointElapsed = 0d;
_status = $"Checkpoint {_index + 1}/{_settings.Waypoints.Count}: {liveDistance:0.0}m";
return Steer(
navigation,
snapshot.Position,
waypoint.Position,
liveDistance);
}
StopMovement();
PluginNavigationPosition confirmed = snapshot.ConfirmedPositionRevision == 0UL
? snapshot.Position
: snapshot.ConfirmedPosition;
double confirmedDistance = confirmed.HorizontalDistanceMeters(
waypoint.Position);
if (confirmedDistance <= BoundedMinimumDistance())
{
_checkpointElapsed = 0d;
AdvanceWaypoint();
return true;
}
_checkpointElapsed += elapsedSeconds;
_status = $"Checkpoint: waiting for server ({confirmedDistance:0.0}m).";
if (_checkpointElapsed >= CheckpointRetrySeconds)
{
_checkpointElapsed = 0d;
_hadMovementIntent = navigation.SetMovementIntent(
new PluginMovementIntent(Forward: true, Run: false))
== PluginNavigationCommandStatus.Accepted;
_status = "Checkpoint: nudging for server confirmation.";
}
return true;
}
private bool Steer(
INavigationAutomation navigation,
in PluginNavigationPosition current,
in PluginNavigationPosition target,
double distanceMeters)
{
float desired = DesiredHeading(current, target);
float delta = SignedHeadingDelta(current.HeadingDegrees, desired);
float absolute = Math.Abs(delta);
bool turnRight = delta > HeadingToleranceDegrees;
bool turnLeft = delta < -HeadingToleranceDegrees;
bool forward = absolute <= HeadingToleranceDegrees
|| (distanceMeters > NearTargetMeters
? absolute <= FarMovingTurnLimitDegrees
: absolute <= NearMovingTurnLimitDegrees);
var intent = new PluginMovementIntent(
Forward: forward,
TurnLeft: turnLeft,
TurnRight: turnRight,
Run: true);
PluginNavigationCommandStatus result =
navigation.SetMovementIntent(intent);
_hadMovementIntent = result == PluginNavigationCommandStatus.Accepted;
return _hadMovementIntent;
}
private bool TickAction(
RouteWaypoint waypoint,
double elapsedSeconds,
in PluginNavigationSnapshot navigation)
{
if (!ReferenceEquals(_activeAction, waypoint))
{
ClearAction();
_activeAction = waypoint;
_useCompletionBaseline = _host.Automation.Items.LastCompletion.Revision;
_chatBaseline = _host.Automation.Chat.CaptureMessages(0)
.Select(static message => message.Sequence)
.DefaultIfEmpty()
.Max();
}
_actionElapsed += elapsedSeconds;
_retryElapsed += elapsedSeconds;
switch (waypoint.Type)
{
case RouteWaypointType.Pause:
_status = $"Pause: {Math.Max(0d, waypoint.DurationMilliseconds / 1000d - _actionElapsed):0.0}s";
if (_actionElapsed * 1000d >= Math.Max(0, waypoint.DurationMilliseconds))
CompleteAction();
return true;
case RouteWaypointType.ChatCommand:
_status = $"Chat command: {waypoint.Text}";
if (_actionElapsed < ChatInitialDelaySeconds)
return true;
if (!_actionSent)
{
_actionSent = _host.Automation.Chat.Submit(waypoint.Text);
if (!_actionSent)
{
_status = "Chat command was refused.";
return true;
}
}
CompleteAction();
return true;
case RouteWaypointType.Recall:
return TickRecall(waypoint, navigation);
case RouteWaypointType.Portal:
case RouteWaypointType.PortalByName:
case RouteWaypointType.UseNpc:
case RouteWaypointType.OpenVendor:
return TickUse(waypoint, navigation);
case RouteWaypointType.Jump:
return TickJump(waypoint, elapsedSeconds, navigation);
default:
CompleteAction();
return true;
}
}
private bool TickUse(
RouteWaypoint waypoint,
in PluginNavigationSnapshot navigation)
{
if (waypoint.Type is RouteWaypointType.PortalByName
or RouteWaypointType.UseNpc)
{
bool currentStillExists = waypoint.ObjectId != 0u
&& _host.Automation.Navigation.TryGetObject(
waypoint.ObjectId,
out PluginNavigationObject current)
&& (string.IsNullOrWhiteSpace(waypoint.ObjectName)
|| current.Name.Equals(
waypoint.ObjectName,
StringComparison.OrdinalIgnoreCase));
if (!currentStillExists)
{
// Search near the object's own recorded position ("tgtxyz"
// in metaf's ptl/tlk grammar), not where the bot was
// standing when the waypoint was authored ("myxyz" —
// waypoint.Position): the object can be meaningfully far
// from the approach point (e.g. a portal at the far end of
// a room).
if (!_host.Automation.Navigation.TryFindObject(
waypoint.ObjectName,
waypoint.ReferencePosition,
ObjectReacquireRadiusMeters,
out PluginNavigationObject reacquired))
{
_status = $"Finding {waypoint.ObjectName}.";
if (_actionElapsed >= PortalTimeoutSeconds)
CompleteAction();
return true;
}
waypoint.ObjectId = reacquired.ObjectId;
_actionSent = false;
_retryElapsed = UseRetrySeconds;
}
}
if (waypoint.Type == RouteWaypointType.OpenVendor
&& waypoint.ObjectId != 0u
&& _host.Automation.Items.ActiveVendorObjectId == waypoint.ObjectId)
{
CompleteAction();
return true;
}
if (waypoint.ObjectId == 0u)
{
_status = "Waypoint object is unavailable; continuing.";
CompleteAction();
return true;
}
if ((waypoint.Type is RouteWaypointType.Portal
or RouteWaypointType.PortalByName)
&& _host.Automation.Navigation.TryGetObject(
waypoint.ObjectId,
out PluginNavigationObject portal))
{
double distance = navigation.Position.HorizontalDistanceMeters(
portal.Position);
if (distance > Math.Clamp(
_settings.PortalUseDistanceMeters,
0.5d,
50d))
{
_status = $"Approaching {waypoint.ObjectName} ({distance:0.0}m).";
return Steer(
_host.Automation.Navigation,
navigation.Position,
portal.Position,
distance);
}
}
if (waypoint.Type == RouteWaypointType.UseNpc
&& HasNpcResponse(waypoint.ObjectName))
{
CompleteAction();
return true;
}
PluginItemUseCompletion completion =
_host.Automation.Items.LastCompletion;
if (_actionSent
&& completion.Revision > _useCompletionBaseline
&& completion.SourceObjectId == waypoint.ObjectId)
{
_useCompletionBaseline = completion.Revision;
if (!completion.IsSuccess)
{
_actionSent = false;
_retryElapsed = UseRetrySeconds;
}
}
if (navigation.IsPortalSpace)
_sawPortalSpace = true;
if (_sawPortalSpace && !navigation.IsPortalSpace)
{
if (waypoint.Type == RouteWaypointType.PortalByName
&& _hasPortalOrigin
&& navigation.Position.HorizontalDistanceMeters(_portalOrigin)
<= PortalExitDistanceMeters)
{
_sawPortalSpace = false;
_actionSent = false;
_retryElapsed = UseRetrySeconds;
_status = "Portal exit stayed near its origin; retrying.";
return true;
}
CompleteAction();
return true;
}
if (_actionElapsed >= PortalTimeoutSeconds)
{
_status = $"Use timed out: {waypoint.ObjectName}.";
CompleteAction();
return true;
}
if (!_actionSent || _retryElapsed >= UseRetrySeconds)
{
PluginItemCommandResult result =
_host.Automation.Items.Use(waypoint.ObjectId);
_actionSent |= result.Accepted;
if (result.Accepted && !_hasPortalOrigin)
{
_portalOrigin = navigation.Position;
_hasPortalOrigin = true;
}
_retryElapsed = 0d;
_status = result.Accepted
? $"Using {waypoint.ObjectName}."
: $"Waiting to use {waypoint.ObjectName}.";
}
return true;
}
private bool HasNpcResponse(string npcName)
{
IReadOnlyList<PluginChatMessage> messages =
_host.Automation.Chat.CaptureMessages(_chatBaseline);
foreach (PluginChatMessage message in messages)
{
_chatBaseline = Math.Max(_chatBaseline, message.Sequence);
if (message.Sender.Equals(npcName, StringComparison.OrdinalIgnoreCase)
|| message.Text.StartsWith(
npcName + " tells you, ",
StringComparison.OrdinalIgnoreCase)
|| message.Text.StartsWith(
npcName + " gives you",
StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private bool TickRecall(
RouteWaypoint waypoint,
in PluginNavigationSnapshot navigation)
{
// D-4 (round E architecture re-check): an .af "rcl" node whose
// spell name did not resolve against the character's known spells
// (MetafSerializer.ReadNavNode's "rcl" case, MetafSerializer.cs:
// 883-885) leaves RecallSpellId at 0 and Recall at its default
// ordinal (RouteRecallKind.PrimaryPortalRecall) — it never touches
// the enum. Every path that DOES intend a real cast
// (AddRouteRecallCore, the binary .nav loader, the D-1 legacy-
// JSON-route migration) always writes RecallSpellId directly, so
// no waypoint can legitimately reach this point with
// RecallSpellId == 0 unless it is Marketplace (which has no spell
// by design) or an unresolved import. Refuse the cast outright —
// SubmitRecall's old fallback (RouteWaypoint.SpellIdForRecall
// (waypoint.Recall)) would silently cast Primary Portal Recall
// (spell 48) for a waypoint that was never authored as one — and
// skip it the same way TickUse already refuses an unavailable
// object (waypoint.ObjectId == 0u, above).
if (waypoint.RecallSpellId == 0u && waypoint.Recall != RouteRecallKind.Marketplace)
{
_status = string.IsNullOrWhiteSpace(waypoint.RecallSpellName)
? "Recall waypoint has no spell; skipping."
: $"Recall spell '{waypoint.RecallSpellName}' not found; skipping waypoint.";
CompleteAction();
return true;
}
if (navigation.IsPortalSpace)
_sawPortalSpace = true;
if (_sawPortalSpace && !navigation.IsPortalSpace)
{
if (!_hasPortalOrigin
|| navigation.Position.HorizontalDistanceMeters(_portalOrigin)
> RecallExitDistanceMeters)
{
CompleteAction();
return true;
}
_sawPortalSpace = false;
_actionSent = false;
_retryElapsed = UseRetrySeconds;
}
if (_actionElapsed >= PortalTimeoutSeconds)
{
_status = "Recall timed out; continuing route.";
CompleteAction();
return true;
}
if (!_actionSent || _retryElapsed >= UseRetrySeconds)
{
bool accepted = SubmitRecall(waypoint);
_actionSent |= accepted;
if (accepted && !_hasPortalOrigin)
{
_portalOrigin = navigation.Position;
_hasPortalOrigin = true;
}
_retryElapsed = 0d;
}
_status = $"Recall: {waypoint.RecallLabel}.";
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.
/// Round E item D-4 removed the Recall-enum fallback that used to run
/// here for a zero RecallSpellId (it silently cast Primary Portal
/// Recall for any waypoint whose recorded name failed to resolve,
/// since <see cref="RouteRecallKind"/>'s default ordinal IS
/// <see cref="RouteRecallKind.PrimaryPortalRecall"/>) — TickRecall now
/// refuses and skips that case before this method is ever called, so
/// the only way to reach here with RecallSpellId == 0 is Marketplace,
/// which has no spell to cast (VTank's own combo issues it as a slash
/// command too).
/// </summary>
private bool SubmitRecall(RouteWaypoint waypoint)
{
if (waypoint.RecallSpellId != 0u)
return _host.Automation.Magic.Cast(waypoint.RecallSpellId);
return _host.Automation.Chat.Submit("/marketplace");
}
private bool TickJump(
RouteWaypoint waypoint,
double elapsedSeconds,
in PluginNavigationSnapshot navigation)
{
if (!_jumpReleased)
{
if (!_jumpAligned)
{
float delta = SignedHeadingDelta(
navigation.Position.HeadingDegrees,
waypoint.JumpHeadingDegrees);
if (Math.Abs(delta) > HeadingToleranceDegrees)
{
PluginMovementIntent turn = new(
TurnLeft: delta < 0f,
TurnRight: delta > 0f,
Run: waypoint.JumpRun);
_hadMovementIntent = _host.Automation.Navigation
.SetMovementIntent(turn)
== PluginNavigationCommandStatus.Accepted;
_status = $"Aligning jump: {Math.Abs(delta):0.0}d.";
return true;
}
_jumpAligned = true;
}
_jumpChargeElapsed += elapsedSeconds;
int effectiveChargeMilliseconds = Math.Clamp(
waypoint.JumpChargeMilliseconds, 0, JumpChargeCeilingMilliseconds);
bool hold = _jumpChargeElapsed * 1000d < effectiveChargeMilliseconds;
if (hold)
{
PluginMovementIntent intent = JumpIntent(waypoint, jump: true);
_hadMovementIntent = _host.Automation.Navigation
.SetMovementIntent(intent)
== PluginNavigationCommandStatus.Accepted;
_status = $"Charging jump: {effectiveChargeMilliseconds}ms.";
return true;
}
PluginMovementIntent release = JumpIntent(waypoint, jump: false);
_ = _host.Automation.Navigation.SetMovementIntent(release);
_jumpReleased = true;
_jumpReleaseElapsed = 0d;
_status = "Jump released.";
return true;
}
_jumpReleaseElapsed += elapsedSeconds;
_jumpSawAirborne |= navigation.IsAirborne;
if (navigation.IsAirborne
|| (!_jumpSawAirborne
&& _jumpReleaseElapsed < JumpCompletionTimeoutSeconds)
|| (_jumpSawAirborne
&& _jumpReleaseElapsed < JumpLaunchGraceSeconds))
return true;
CompleteAction();
return true;
}
private static PluginMovementIntent JumpIntent(
RouteWaypoint waypoint,
bool jump) => waypoint.JumpDirection switch
{
RouteJumpDirection.StrafeLeft => new PluginMovementIntent(
StrafeLeft: true, Run: waypoint.JumpRun, Jump: jump),
RouteJumpDirection.StrafeRight => new PluginMovementIntent(
StrafeRight: true, Run: waypoint.JumpRun, Jump: jump),
_ => new PluginMovementIntent(
Forward: true, Run: waypoint.JumpRun, Jump: jump),
};
private void CompleteAction()
{
ClearAction();
AdvanceWaypoint();
}
private void AdvanceWaypoint()
{
ClearAction();
_checkpointElapsed = 0d;
int count = _settings.Waypoints.Count;
if (count == 0)
return;
switch (_settings.Mode)
{
case RouteMode.Circular:
_index = !_reverse
? (_index + 1) % count
: (_index - 1 + count) % count;
break;
case RouteMode.Linear:
if (!_reverse)
{
_index++;
if (_index >= count)
{
_index = Math.Max(0, count - 1);
_reverse = true;
}
}
else
{
_index--;
if (_index < 0)
{
_index = 0;
_reverse = false;
}
}
break;
case RouteMode.Once:
_settings.Waypoints.RemoveAt(_index);
_index = 0;
_onceComplete = _settings.Waypoints.Count == 0;
break;
}
}
private void ClearAction()
{
_activeAction = null;
_actionElapsed = 0d;
_retryElapsed = 0d;
_useCompletionBaseline = 0;
_chatBaseline = 0;
_actionSent = false;
_sawPortalSpace = false;
_jumpReleased = false;
_jumpAligned = false;
_jumpSawAirborne = false;
_jumpChargeElapsed = 0d;
_jumpReleaseElapsed = 0d;
_portalOrigin = default;
_hasPortalOrigin = false;
}
private void StopMovement()
{
if (!_hadMovementIntent)
return;
_ = _host.Automation.Navigation.ClearMovementIntent();
_hadMovementIntent = false;
}
private double BoundedMinimumDistance() => Math.Clamp(
_settings.MinimumDistanceMeters,
0.5d,
50d);
private double BoundedMaximumDistance() => Math.Max(
BoundedMinimumDistance(),
_settings.MaximumDistanceMeters);
internal static float DesiredHeading(
in PluginNavigationPosition from,
in PluginNavigationPosition to)
{
double dx = to.EastWest - from.EastWest;
double dy = to.NorthSouth - from.NorthSouth;
double heading = Math.Atan2(dx, dy) * 180d / Math.PI;
if (heading < 0d)
heading += 360d;
return (float)heading;
}
internal static float SignedHeadingDelta(float current, float desired)
{
float delta = (desired - current) % 360f;
if (delta > 180f)
delta -= 360f;
else if (delta < -180f)
delta += 360f;
return delta;
}
internal static double DistanceToSegmentMeters(
in PluginNavigationPosition point,
in PluginNavigationPosition start,
in PluginNavigationPosition end)
{
double dx = end.EastWest - start.EastWest;
double dy = end.NorthSouth - start.NorthSouth;
double lengthSquared = dx * dx + dy * dy;
if (lengthSquared <= double.Epsilon)
return point.HorizontalDistanceMeters(start);
double projection = ((point.EastWest - start.EastWest) * dx
+ (point.NorthSouth - start.NorthSouth) * dy) / lengthSquared;
projection = Math.Clamp(projection, 0d, 1d);
double nearestX = start.EastWest + projection * dx;
double nearestY = start.NorthSouth + projection * dy;
double deltaX = point.EastWest - nearestX;
double deltaY = point.NorthSouth - nearestY;
return Math.Sqrt(deltaX * deltaX + deltaY * deltaY) * 240d;
}
}