Five small fixes bundled per the round's cleanup item:
- VtankNavRouteSerializer.cs's doc comment cited a "WriteBinaryNavBlob"
method that no longer exists anywhere in the codebase (MetaEngine's
embedded-navigation contract moved to the typed MetaAction.EmbeddedRoute
NavigationSettings, saved/loaded through MetafSerializer.SaveNav/
TryLoadNav, back at round 2 step B) — corrected to name the real
mechanism.
- MossTankCommands.cs:274's comment referenced an "exports/nav/" mirror
directory that stopped existing when route profiles cut over to writing
their real .af file directly (round 2 steps 2-3) — corrected.
- docs/research/vtank-kb/07-meta-and-expressions.md section 5.2 row 6
described the pre-cutover "MossTankMetaProfileStore.WriteLegacyExport
convenience mirror" design; .af is now the SOLE authoritative Meta
store, so a disabled rule's save refusal now blocks the profile itself
— the row now says a disabled rule makes the profile file genuinely
unsaveable, not that a mirror goes stale.
- The two bare `catch (FormatException) { }` blocks that silently dropped
a corrupt monster-rule expression (one in SideCarDocument.Apply, reached
from a corrupt side-car; one in LegacyCombatProfileDocument.Apply,
reached during legacy-JSON migration) now log a warning via the host's
IPluginLogger, threaded through as an optional parameter from every call
site.
- VtankDatabase.Render()'s table-sort doc comment now states explicitly
that StringComparer.Ordinal matching .NET Framework's SortedDictionary
default order is confirmed only for the plain-ASCII table names VTank
ships (AntiExtraBuffSpells, MyMonsters, Settings, …), not as a general
claim for any string — comment only, no behavior change.
Added CorruptSideCarMonsterRuleIsLoggedNotSilentlySwallowed (FakeLogger
now captures Warn() calls via a new FakeHost.Logger property) pinning the
swallow-to-log fix.
Mutation: reverted MossTankProfileStore.cs to HEAD (keeping only the new
test) and ran it — failed with an empty Warnings collection, confirming
the silent-swallow bug before the fix.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1149 lines
45 KiB
C#
1149 lines
45 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using AcDream.Plugin.Abstractions;
|
|
using AcDream.Plugins.MossTank.Expressions;
|
|
|
|
namespace AcDream.Plugins.MossTank;
|
|
|
|
/// <summary>VTank's documented <c>/vt</c> chat surface.</summary>
|
|
internal sealed partial class MossTankPanel
|
|
{
|
|
private static readonly string[] VtankHelp =
|
|
[
|
|
"/vt commands (profiles): settings nav loot meta opt testitem propertydump addnavpt refresh getdb addnavjump addnavcheckpoint",
|
|
"/vt commands (actions): start stop forcebuff cancelforcebuff setmetastate fakedeath deletemonster reverseroute reverseroutequery equipitemsfor mexec echo tapjump jump setattackbar",
|
|
"/vt commands (game info): dumpspells dumpspecies dumpmats dumpskills",
|
|
"/vt commands (debug): log testmonster lockdump dumptracker clearlocks clearbusy listmonstervariables dumpmetavars listmetafunctions metafunchelp fakeimp pscount testspell testpet",
|
|
];
|
|
|
|
private readonly HashSet<string> _commandLogTypes =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
private bool _commandJumpActive;
|
|
private bool _commandJumpReleased;
|
|
private bool _commandJumpCharging;
|
|
private double _commandJumpElapsed;
|
|
private double _commandJumpTurnElapsed;
|
|
private double _commandJumpChargeSeconds;
|
|
private float _commandJumpHeading;
|
|
private PluginMovementIntent _commandJumpIntent;
|
|
private bool _commandPortalState;
|
|
private int _commandPortalCount;
|
|
|
|
internal void ExecuteVtankCommand(PluginCommand command)
|
|
{
|
|
try
|
|
{
|
|
ExecuteVtankCommandCore(command.Arguments);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
WriteVtank($"Command failed: {error.GetBaseException().Message}");
|
|
_host.Log.Error("MossTank /vt command failed.", error);
|
|
}
|
|
}
|
|
|
|
private void ExecuteVtankCommandCore(string input)
|
|
{
|
|
(string verb, string arguments) = SplitHead(input);
|
|
switch (verb.ToLowerInvariant())
|
|
{
|
|
case "":
|
|
case "help":
|
|
foreach (string line in VtankHelp)
|
|
WriteVtank(line);
|
|
return;
|
|
|
|
case "start":
|
|
if (!_combat.Enabled)
|
|
SetMacroRunning(true);
|
|
else
|
|
WriteVtank("Macro is already running.");
|
|
return;
|
|
case "stop":
|
|
if (_combat.Enabled)
|
|
SetMacroRunning(false);
|
|
if (_running)
|
|
Stop("Force buff canceled.");
|
|
WriteVtank("Macro stopped.");
|
|
return;
|
|
case "forcebuff":
|
|
if (!_running)
|
|
StartOrStop();
|
|
else
|
|
WriteVtank("Force buff is already enabled.");
|
|
return;
|
|
case "cancelforcebuff":
|
|
if (_running)
|
|
Stop("Force buff canceled.");
|
|
WriteVtank("Force buff canceled.");
|
|
return;
|
|
case "settings":
|
|
HandleSettingsCommand(arguments);
|
|
return;
|
|
case "nav":
|
|
HandleRouteProfileCommand(arguments);
|
|
return;
|
|
case "loot":
|
|
HandleLootProfileCommand(arguments);
|
|
return;
|
|
case "meta":
|
|
HandleMetaProfileCommand(arguments);
|
|
return;
|
|
case "opt":
|
|
HandleOptionCommand(arguments);
|
|
return;
|
|
case "setmetastate":
|
|
SetMetaStateFromCommand(arguments);
|
|
return;
|
|
case "mexec":
|
|
ExecuteExpression(arguments);
|
|
return;
|
|
case "echo":
|
|
WriteVtank(arguments);
|
|
return;
|
|
case "setattackbar":
|
|
SetAttackBar(arguments);
|
|
return;
|
|
case "tapjump":
|
|
StartCommandJump(
|
|
_host.Automation.Navigation.Snapshot.Position.HeadingDegrees,
|
|
shift: false,
|
|
milliseconds: 100,
|
|
null);
|
|
return;
|
|
case "jump":
|
|
HandleJumpCommand(arguments, addToRoute: false);
|
|
return;
|
|
case "addnavjump":
|
|
HandleJumpCommand(arguments, addToRoute: true);
|
|
return;
|
|
case "addnavpt":
|
|
AddCommandRoutePoint(arguments, checkpoint: false);
|
|
return;
|
|
case "addnavcheckpoint":
|
|
AddCommandRoutePoint(arguments, checkpoint: true);
|
|
return;
|
|
case "reverseroute":
|
|
_navigation.ToggleReverse();
|
|
WriteVtank($"Setting nav backwards to: {_navigation.Reversing}");
|
|
return;
|
|
case "reverseroutequery":
|
|
WriteVtank($"Nav backwards is: {_navigation.Reversing}");
|
|
return;
|
|
case "deletemonster":
|
|
DeleteSelectedMonster();
|
|
return;
|
|
case "equipitemsfor":
|
|
EquipItemsFor(arguments);
|
|
return;
|
|
case "testitem":
|
|
TestSelectedItem();
|
|
return;
|
|
case "propertydump":
|
|
DumpSelectedProperties();
|
|
return;
|
|
case "testmonster":
|
|
TestSelectedMonster();
|
|
return;
|
|
case "testspell":
|
|
TestSpell(arguments);
|
|
return;
|
|
case "testpet":
|
|
TestPet();
|
|
return;
|
|
case "listmonstervariables":
|
|
WriteVtank("Supported monster expression variables:");
|
|
WriteVtank("true, false, name, typeid, species, maxhp, range, hasshield, metastate, setting_<OptionName>");
|
|
return;
|
|
case "dumpmetavars":
|
|
DumpMetaVariables();
|
|
return;
|
|
case "listmetafunctions":
|
|
ListMetaFunctions();
|
|
return;
|
|
case "metafunchelp":
|
|
MetaFunctionHelp(arguments);
|
|
return;
|
|
case "fakedeath":
|
|
_meta.TriggerFakeDeath();
|
|
WriteVtank("Fake character death trigger fired.");
|
|
return;
|
|
case "pscount":
|
|
WriteVtank($"Portal space toggle count: {_commandPortalCount}");
|
|
return;
|
|
case "refresh":
|
|
RefreshMonsterEditor();
|
|
RefreshItemEditors();
|
|
RefreshLootEditor();
|
|
RefreshRouteEditor();
|
|
RefreshMetaEditor();
|
|
WriteVtank("Refreshed settings pages.");
|
|
return;
|
|
case "getdb":
|
|
WriteVtank("Game information uses acdream's installed DAT catalog and bundled VTank tables; no remote database download is required.");
|
|
return;
|
|
case "log":
|
|
HandleLogCommand(arguments);
|
|
return;
|
|
case "lockdump":
|
|
WriteVtank($"Action busy: magic={_host.Automation.Magic.IsCasting}, items={_host.Automation.Items.IsBusy}, equipment={_host.Automation.Equipment.IsBusy}");
|
|
return;
|
|
case "dumptracker":
|
|
DumpObjectTracker();
|
|
return;
|
|
case "clearlocks":
|
|
ClearMossTankActionLocks();
|
|
WriteVtank("Action locks cleared.");
|
|
return;
|
|
case "clearbusy":
|
|
PluginRecoveryResult recovery = _host.Automation.Recovery
|
|
.ClearOneBusyReference();
|
|
WriteVtank(recovery.Accepted
|
|
? $"Action busy: {recovery.PreviousCount} -> {recovery.CurrentCount}."
|
|
: recovery.Message);
|
|
return;
|
|
case "fakeimp":
|
|
FakeImperil();
|
|
return;
|
|
case "dumpspells":
|
|
DumpSpells();
|
|
return;
|
|
case "dumpspecies":
|
|
DumpSpecies();
|
|
return;
|
|
case "dumpmats":
|
|
DumpMaterials();
|
|
return;
|
|
case "dumpskills":
|
|
DumpSkills();
|
|
return;
|
|
default:
|
|
WriteVtank("Unknown /vt command. Use /vt help.");
|
|
return;
|
|
}
|
|
}
|
|
|
|
private void HandleSettingsCommand(string arguments)
|
|
{
|
|
(string operation, string name) = SplitHead(arguments);
|
|
operation = operation.ToLowerInvariant();
|
|
if (name.Length == 0
|
|
|| operation is not ("save" or "load" or "savechar" or "loadchar"))
|
|
{
|
|
WriteVtank("Usage: /vt settings [save/load/savechar/loadchar] [filename]");
|
|
return;
|
|
}
|
|
name = StripExtension(name, ".usd", ".settings");
|
|
if (operation is "save" or "savechar")
|
|
{
|
|
if (operation == "savechar")
|
|
_profiles.SetMineOnly(true);
|
|
if (_profiles.Create(
|
|
name,
|
|
copyCurrent: true,
|
|
_allSettings,
|
|
_noBuffItemNames,
|
|
out string notice))
|
|
{
|
|
ResetProfileConsumers();
|
|
}
|
|
WriteVtank(notice);
|
|
return;
|
|
}
|
|
if (!_profiles.Select(name))
|
|
{
|
|
WriteVtank($"Settings profile '{name}' was not found.");
|
|
return;
|
|
}
|
|
LoadSelectedProfile();
|
|
WriteVtank($"Loaded settings profile {_profiles.Selected}.");
|
|
}
|
|
|
|
private void HandleRouteProfileCommand(string arguments)
|
|
{
|
|
(string operation, string name) = SplitHead(arguments);
|
|
operation = operation.ToLowerInvariant();
|
|
if (name.Length == 0 || operation is not ("save" or "load"))
|
|
{
|
|
WriteVtank("Usage: /vt nav [save/load] [filename]");
|
|
return;
|
|
}
|
|
// Item J (slice-1 fix round): .af is the only VTank-compatible
|
|
// storage format now (Campaign VT slice 1 Part A); ".nav" is
|
|
// stripped for legacy-typed names, ".af" for names copy-pasted
|
|
// from a real .af file (the VtankProfiles directory, or an
|
|
// imports/ drop-in for TryImportLegacy below) — without both,
|
|
// "/vt nav save Foo.af" would have produced a doubled "Foo.af.af"
|
|
// file. Round 3 item 12: this comment previously cited an
|
|
// "exports/nav/" mirror directory that no longer exists — route
|
|
// profiles have written directly to their real .af file since the
|
|
// round 2 step 2-3 cutover, with no separate export copy.
|
|
name = StripExtension(name, ".nav", ".af");
|
|
if (operation == "save")
|
|
{
|
|
_routeProfiles.Create(
|
|
name,
|
|
copyCurrent: true,
|
|
_navigationSettings,
|
|
out string notice);
|
|
RefreshRouteEditor();
|
|
WriteVtank(notice);
|
|
return;
|
|
}
|
|
if (!_routeProfiles.Select(name))
|
|
{
|
|
if (!_routeProfiles.TryImportLegacy(
|
|
name,
|
|
_navigationSettings,
|
|
_host.Automation.Spells,
|
|
out string importNotice))
|
|
{
|
|
WriteVtank(importNotice);
|
|
return;
|
|
}
|
|
_navigation.Reset();
|
|
RefreshRouteEditor();
|
|
WriteVtank(importNotice);
|
|
return;
|
|
}
|
|
LoadRouteProfile();
|
|
WriteVtank($"Loaded navigation profile {_routeProfiles.Selected}.");
|
|
}
|
|
|
|
private void HandleLootProfileCommand(string arguments)
|
|
{
|
|
(string operation, string name) = SplitHead(arguments);
|
|
operation = operation.ToLowerInvariant();
|
|
if (name.Length == 0 || operation is not ("new" or "save" or "load"))
|
|
{
|
|
WriteVtank("Usage: /vt loot [load/new] [filename]");
|
|
return;
|
|
}
|
|
name = StripExtension(name, ".utl", ".json");
|
|
if (operation is "new" or "save")
|
|
{
|
|
_lootProfiles.Create(
|
|
name,
|
|
copyCurrent: operation == "save",
|
|
_inventorySettings.Loot.Rules,
|
|
out string notice);
|
|
LoadLootProfile();
|
|
WriteVtank(notice);
|
|
return;
|
|
}
|
|
if (!_lootProfiles.Select(name))
|
|
{
|
|
if (!_lootProfiles.TryImportLegacy(
|
|
name,
|
|
_inventorySettings.Loot.Rules,
|
|
_inventorySettings.Loot,
|
|
out string importNotice))
|
|
{
|
|
WriteVtank(importNotice);
|
|
return;
|
|
}
|
|
_loot.Reset();
|
|
RefreshLootEditor();
|
|
WriteVtank(importNotice);
|
|
return;
|
|
}
|
|
LoadLootProfile();
|
|
WriteVtank($"Loaded loot profile {_lootProfiles.Selected}.");
|
|
}
|
|
|
|
private void HandleMetaProfileCommand(string arguments)
|
|
{
|
|
(string operation, string name) = SplitHead(arguments);
|
|
operation = operation.ToLowerInvariant();
|
|
if (name.Length == 0 || operation is not ("save" or "load"))
|
|
{
|
|
WriteVtank("Usage: /vt meta [save/load] [filename]");
|
|
return;
|
|
}
|
|
// Item J (slice-1 fix round): .af is the only VTank-compatible
|
|
// storage format now — see the analogous nav-command comment above.
|
|
name = StripExtension(name, ".met", ".json", ".af");
|
|
if (operation == "save")
|
|
{
|
|
_metaProfiles.Create(
|
|
name,
|
|
copyCurrent: true,
|
|
_metaProfile,
|
|
out string notice);
|
|
LoadMetaProfile();
|
|
WriteVtank(notice);
|
|
return;
|
|
}
|
|
if (!_metaProfiles.Select(name))
|
|
{
|
|
if (!_metaProfiles.TryImportLegacy(
|
|
name,
|
|
out MetaProfile imported,
|
|
out string importNotice))
|
|
{
|
|
WriteVtank(importNotice);
|
|
return;
|
|
}
|
|
_metaProfile = imported;
|
|
_meta.ReplaceProfile(_metaProfile);
|
|
if (_initialized)
|
|
ApplyPersistedOptionOverrides();
|
|
_selectedMetaRule = 0;
|
|
RefreshMetaEditor();
|
|
WriteVtank(importNotice);
|
|
return;
|
|
}
|
|
LoadMetaProfile();
|
|
WriteVtank($"Loaded Meta profile {_metaProfiles.Selected}.");
|
|
}
|
|
|
|
private void HandleOptionCommand(string arguments)
|
|
{
|
|
(string operation, string tail) = SplitHead(arguments);
|
|
switch (operation.ToLowerInvariant())
|
|
{
|
|
case "list":
|
|
if (tail.Length != 0)
|
|
{
|
|
WriteVtank("Usage: /vt opt list");
|
|
return;
|
|
}
|
|
WriteVtank($"Available options: ({VtankOptionCatalog.Names.Length})");
|
|
for (int index = 0; index < VtankOptionCatalog.Names.Length; index += 4)
|
|
WriteVtank(" " + string.Join(" ", VtankOptionCatalog.Names.Skip(index).Take(4)));
|
|
return;
|
|
case "get":
|
|
if (!VtankOptionCatalog.IsKnown(tail))
|
|
{
|
|
WriteVtank("Option get: Invalid option specified.");
|
|
return;
|
|
}
|
|
string canonical = VtankOptionCatalog.Canonical(tail);
|
|
WriteVtank($"Option {canonical} = {GetMetaOption(canonical).ToDisplayString()}");
|
|
return;
|
|
case "set":
|
|
case "setinall":
|
|
(string name, string rawValue) = SplitHead(tail);
|
|
if (!VtankOptionCatalog.IsKnown(name))
|
|
{
|
|
WriteVtank("Option set: Invalid option specified.");
|
|
return;
|
|
}
|
|
canonical = VtankOptionCatalog.Canonical(name);
|
|
VtankSettingValueType declaredType = VtankOptionCatalog.DeclaredType(canonical);
|
|
if (rawValue.Length == 0 || !TryParseOptionValue(rawValue, declaredType, out ExpressionValue value))
|
|
{
|
|
// Round 3 item 6: retail's exact failure text
|
|
// (refs/vtank/decompiled/uTank2/PluginCore.cs:5501,5508,5612)
|
|
// names the option and the CLR type its Settings row
|
|
// actually declares (gy's own m_a, ToString()'d).
|
|
WriteVtank(
|
|
"Option set: Invalid value specified. Proper type of "
|
|
+ canonical + " is " + DeclaredClrTypeName(declaredType) + ".");
|
|
return;
|
|
}
|
|
SetMetaOption(canonical, value);
|
|
if (operation.Equals("setinall", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// Round 3 item 6: retail's exact bk.a text
|
|
// (refs/vtank/decompiled/bk.cs:32) — no "= value" suffix,
|
|
// and the count is every .usd file scanned, not just the
|
|
// ones that already had a row for this name.
|
|
int count = _profiles.SetOptionInAll(canonical, _allSettings);
|
|
WriteVtank($"Done saving setting {canonical} to all profiles. (Changed {count} profiles)");
|
|
}
|
|
else
|
|
{
|
|
WriteVtank($"Set option {canonical} = {GetMetaOption(canonical).ToDisplayString()}");
|
|
}
|
|
return;
|
|
default:
|
|
WriteVtank("Usage: /vt opt [list/get/set/setinall]");
|
|
return;
|
|
}
|
|
}
|
|
|
|
private void SetMetaStateFromCommand(string state)
|
|
{
|
|
if (state.Length == 0)
|
|
{
|
|
WriteVtank("Usage: /vt setmetastate [somestate]");
|
|
WriteVtank("NOTE: States are case sensitive.");
|
|
return;
|
|
}
|
|
string target = _meta.States.FirstOrDefault(value =>
|
|
value.Equals(state, StringComparison.Ordinal)) ?? MetaEngine.DefaultState;
|
|
if (!target.Equals(state, StringComparison.Ordinal))
|
|
WriteVtank("Warning: Attempted to set an unused state. Setting to default instead.");
|
|
_meta.Transition(target);
|
|
_combatSettings.MetaState = _meta.CurrentState;
|
|
WriteVtank($"Meta state is now {_meta.CurrentState}.");
|
|
}
|
|
|
|
private void ExecuteExpression(string source)
|
|
{
|
|
WriteVtank($"MExec evaluating expression: \"{source.Trim()}\"");
|
|
try
|
|
{
|
|
WriteVtank("Result: " + _expressions.Evaluate(source).ToDisplayString());
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
WriteVtank("Expression error: " + error.Message);
|
|
}
|
|
}
|
|
|
|
private void SetAttackBar(string value)
|
|
{
|
|
if (!float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out float power)
|
|
|| power is < 0f or > 1f)
|
|
{
|
|
WriteVtank("Usage: /vt setattackbar [0 to 1]");
|
|
return;
|
|
}
|
|
_combatSettings.AttackPower = power;
|
|
SaveProfile();
|
|
WriteVtank($"Attack bar set to {power.ToString("0.###", CultureInfo.InvariantCulture)}.");
|
|
}
|
|
|
|
private void HandleJumpCommand(string arguments, bool addToRoute)
|
|
{
|
|
string[] parts = arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length is < 3 or > 4
|
|
|| !float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out float heading)
|
|
|| !bool.TryParse(parts[1], out bool shift)
|
|
|| !int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out int milliseconds)
|
|
|| milliseconds is < 0 or > 5000
|
|
|| !TryParseJumpDirection(parts.Length == 4 ? parts[3] : null, out RouteJumpDirection direction))
|
|
{
|
|
WriteVtank(addToRoute
|
|
? "Usage: /vt addnavjump [heading] [shift: true or false] [milliseconds]"
|
|
: "Usage: /vt jump [heading] [shift: true or false] [milliseconds]");
|
|
return;
|
|
}
|
|
if (addToRoute)
|
|
{
|
|
PluginNavigationPosition position = _host.Automation.Navigation.Snapshot.Position;
|
|
_navigationSettings.Waypoints.Add(new RouteWaypoint
|
|
{
|
|
Type = RouteWaypointType.Jump,
|
|
Position = position,
|
|
JumpHeadingDegrees = NormalizeHeading(heading),
|
|
JumpRun = shift,
|
|
JumpChargeMilliseconds = milliseconds,
|
|
JumpDirection = direction,
|
|
});
|
|
SaveRouteProfile();
|
|
RefreshRouteEditor();
|
|
WriteVtank("Added jump to the current route.");
|
|
return;
|
|
}
|
|
StartCommandJump(heading, shift, milliseconds, direction);
|
|
}
|
|
|
|
private void StartCommandJump(
|
|
float heading,
|
|
bool shift,
|
|
int milliseconds,
|
|
RouteJumpDirection? direction)
|
|
{
|
|
PluginNavigationSnapshot snapshot = _host.Automation.Navigation.Snapshot;
|
|
if (!snapshot.IsAvailable || snapshot.IsPortalSpace)
|
|
{
|
|
WriteVtank("Jump unavailable outside the world.");
|
|
return;
|
|
}
|
|
RouteJumpDirection resolved = direction ?? RouteJumpDirection.Forward;
|
|
_commandJumpHeading = NormalizeHeading(heading);
|
|
_commandJumpIntent = new PluginMovementIntent(
|
|
Forward: resolved == RouteJumpDirection.Forward,
|
|
StrafeLeft: resolved == RouteJumpDirection.StrafeLeft,
|
|
StrafeRight: resolved == RouteJumpDirection.StrafeRight,
|
|
Run: shift,
|
|
Jump: true);
|
|
_commandJumpChargeSeconds = Math.Clamp(milliseconds / 1000d, 0.05d, 5d);
|
|
_commandJumpElapsed = 0d;
|
|
_commandJumpTurnElapsed = 0d;
|
|
_commandJumpReleased = false;
|
|
_commandJumpCharging = false;
|
|
_commandJumpActive = true;
|
|
WriteVtank($"Turning to heading {_commandJumpHeading:0.#} for jump.");
|
|
}
|
|
|
|
private bool TickCommandJump(double elapsedSeconds)
|
|
{
|
|
if (!_commandJumpActive)
|
|
return false;
|
|
|
|
INavigationAutomation navigation = _host.Automation.Navigation;
|
|
PluginNavigationSnapshot snapshot = navigation.Snapshot;
|
|
if (!snapshot.IsAvailable || snapshot.IsPortalSpace)
|
|
{
|
|
navigation.ClearMovementIntent();
|
|
_commandJumpActive = false;
|
|
WriteVtank("Jump canceled because the character left the world.");
|
|
return false;
|
|
}
|
|
|
|
if (!_commandJumpCharging)
|
|
{
|
|
_commandJumpTurnElapsed += Math.Max(0d, elapsedSeconds);
|
|
float delta = NavigationController.SignedHeadingDelta(
|
|
snapshot.Position.HeadingDegrees,
|
|
_commandJumpHeading);
|
|
if (Math.Abs(delta) > 4f)
|
|
{
|
|
if (_commandJumpTurnElapsed > 10d
|
|
|| navigation.SetMovementIntent(new PluginMovementIntent(
|
|
TurnLeft: delta < 0f,
|
|
TurnRight: delta > 0f,
|
|
Run: _commandJumpIntent.Run))
|
|
!= PluginNavigationCommandStatus.Accepted)
|
|
{
|
|
navigation.ClearMovementIntent();
|
|
_commandJumpActive = false;
|
|
WriteVtank("Jump command could not align to the requested heading.");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
_commandJumpCharging = navigation.SetMovementIntent(_commandJumpIntent)
|
|
== PluginNavigationCommandStatus.Accepted;
|
|
if (!_commandJumpCharging)
|
|
{
|
|
navigation.ClearMovementIntent();
|
|
_commandJumpActive = false;
|
|
WriteVtank("Jump command was refused by the host.");
|
|
return false;
|
|
}
|
|
_commandJumpElapsed = 0d;
|
|
WriteVtank($"Jump charging at heading {_commandJumpHeading:0.#}.");
|
|
}
|
|
|
|
_commandJumpElapsed += Math.Max(0d, elapsedSeconds);
|
|
if (!_commandJumpReleased && _commandJumpElapsed >= _commandJumpChargeSeconds)
|
|
{
|
|
_commandJumpReleased = true;
|
|
navigation.SetMovementIntent(_commandJumpIntent with { Jump = false });
|
|
}
|
|
if (_commandJumpElapsed < _commandJumpChargeSeconds + 0.25d)
|
|
return true;
|
|
navigation.ClearMovementIntent();
|
|
_commandJumpActive = false;
|
|
_commandJumpCharging = false;
|
|
return false;
|
|
}
|
|
|
|
private void AddCommandRoutePoint(string coordinates, bool checkpoint)
|
|
{
|
|
PluginNavigationPosition position;
|
|
if (coordinates.Length == 0)
|
|
{
|
|
position = _host.Automation.Navigation.Snapshot.Position;
|
|
}
|
|
else if (!TryParseCoordinates(coordinates, out position))
|
|
{
|
|
WriteVtank(checkpoint
|
|
? "Usage: /vt addnavcheckpoint [coords] OR /vt addnavcheckpoint"
|
|
: "Usage: /vt addnavpt [coords] OR /vt addnavpt");
|
|
return;
|
|
}
|
|
_navigationSettings.Waypoints.Add(new RouteWaypoint
|
|
{
|
|
Type = checkpoint ? RouteWaypointType.Checkpoint : RouteWaypointType.Point,
|
|
Position = position,
|
|
});
|
|
SaveRouteProfile();
|
|
RefreshRouteEditor();
|
|
WriteVtank(checkpoint ? "Added navigation checkpoint." : "Added navigation point.");
|
|
}
|
|
|
|
private void DeleteSelectedMonster()
|
|
{
|
|
uint selected = _host.Selection.SelectedObjectId ?? 0u;
|
|
PluginCombatTarget target = _host.Automation.Combat
|
|
.CaptureHostileTargets(float.MaxValue)
|
|
.FirstOrDefault(value => value.ObjectId == selected);
|
|
if (target.ObjectId == 0u)
|
|
{
|
|
WriteVtank("Select a monster, then do /vt deletemonster");
|
|
return;
|
|
}
|
|
PluginCombatCommandResult result =
|
|
_host.Automation.Combat.DismissGhostTarget(selected);
|
|
WriteVtank(result.Accepted
|
|
? $"Forcing the client to delete {target.Name} ({target.ObjectId})!!"
|
|
: $"Unable to delete {target.Name}: {result.Status}");
|
|
}
|
|
|
|
private void EquipItemsFor(string monsterName)
|
|
{
|
|
if (monsterName.Length == 0)
|
|
{
|
|
WriteVtank("Usage: /vt equipitemsfor [monster name]");
|
|
WriteVtank("NOTE: each use of this command invokes one equipment step; multiple calls may be required.");
|
|
return;
|
|
}
|
|
bool ready = _combat.EquipOneStepForMonster(monsterName);
|
|
WriteVtank($"Changing items for monster \"{monsterName}\", ready: {ready}");
|
|
}
|
|
|
|
private void TestSelectedItem()
|
|
{
|
|
if (!TryGetSelectedInventoryItem(out PluginInventoryItem item))
|
|
{
|
|
WriteVtank("TestItem: No item selected.");
|
|
return;
|
|
}
|
|
if (!_host.Automation.Items.TryCaptureProperties(item.ObjectId, out PluginItemProperties properties))
|
|
{
|
|
_host.Automation.Objects.Identify(item.ObjectId);
|
|
WriteVtank("TestItem: Waiting for appraisal data.");
|
|
return;
|
|
}
|
|
LootDecision? decision = LootRuleEngine.Decide(
|
|
item,
|
|
properties,
|
|
_inventorySettings.Loot.Rules,
|
|
_host.Automation.Items.CaptureOwnedItems(),
|
|
host: _host);
|
|
WriteVtank(decision is { } match
|
|
? $"TestItem: {item.Name} => {match.Action} ({match.RuleName}, priority {match.Priority})."
|
|
: $"TestItem: {item.Name} => NoLoot.");
|
|
}
|
|
|
|
private void DumpSelectedProperties()
|
|
{
|
|
uint selected = _host.Selection.SelectedObjectId ?? 0u;
|
|
if (selected == 0u
|
|
|| !_host.Automation.Objects.TryGet(selected, out PluginWorldObject item)
|
|
|| !_host.Automation.Objects.TryCaptureProperties(selected, out PluginItemProperties properties))
|
|
{
|
|
WriteVtank("Propertydump: Either no object selected or current selection object is invalid or not appraised.");
|
|
return;
|
|
}
|
|
WriteVtank($"Object 0x{item.ObjectId:X8}: {item.Name}, class={(int)item.ObjectClass}, WCID={item.WeenieClassId}");
|
|
DumpPropertyTable("Int", properties.Ints);
|
|
DumpPropertyTable("Int64", properties.Int64s);
|
|
DumpPropertyTable("Bool", properties.Bools);
|
|
DumpPropertyTable("Float", properties.Floats);
|
|
DumpPropertyTable("String", properties.Strings);
|
|
DumpPropertyTable("DataId", properties.DataIds);
|
|
DumpPropertyTable("InstanceId", properties.InstanceIds);
|
|
}
|
|
|
|
private void TestSelectedMonster()
|
|
{
|
|
uint selected = _host.Selection.SelectedObjectId ?? 0u;
|
|
PluginCombatTarget target = _host.Automation.Combat
|
|
.CaptureHostileTargets(float.MaxValue)
|
|
.FirstOrDefault(value => value.ObjectId == selected);
|
|
if (target.ObjectId == 0u)
|
|
{
|
|
WriteVtank("TestMonster: No monster selected.");
|
|
return;
|
|
}
|
|
ResolvedMonsterRule resolved = _combatSettings.ResolveRule(target);
|
|
WriteVtank($"TestMonster: evaluating monster rules for monster {target.Name}, type {target.WeenieClassId}");
|
|
WriteVtank($"Matched '{resolved.Rule.Expression}', priority {resolved.Priority}, damage {resolved.Actions.DamageType}, attack={resolved.Actions.UsesPrimaryAttack}.");
|
|
if (resolved.EvaluationError is { Length: > 0 } error)
|
|
WriteVtank("Expression warning: " + error);
|
|
}
|
|
|
|
private void TestSpell(string argument)
|
|
{
|
|
if (!uint.TryParse(argument, NumberStyles.Integer, CultureInfo.InvariantCulture, out uint spellId))
|
|
{
|
|
WriteVtank("Usage: /vt testspell [spellid]");
|
|
return;
|
|
}
|
|
if (!_host.Automation.Spells.TryGet(spellId, out PluginSpellInfo spell))
|
|
{
|
|
WriteVtank("Invalid spellid.");
|
|
return;
|
|
}
|
|
WriteVtank("---------------------------------");
|
|
WriteVtank($"Testing ability to cast spell {spell.Name}, family {spell.Family}, quality {spell.Quality}, diff {spell.Difficulty}");
|
|
WriteVtank($"Known: {_host.Automation.Spells.IsKnown(spellId)}, cast gate: {_host.Automation.Magic.EvaluateGate(spellId)}");
|
|
WriteVtank("---------------------------------");
|
|
}
|
|
|
|
private void TestPet()
|
|
{
|
|
IReadOnlyList<PluginCombatTarget> targets = _host.Automation.Combat
|
|
.CaptureHostileTargets((float)(_combatSettings.PetRangeMode == PetRangeMode.Custom
|
|
? _combatSettings.PetCustomRange
|
|
: _combatSettings.MaximumRange));
|
|
PetAutomationChoice choice = PetAutomation.Select(
|
|
_host.Automation.Items.CaptureOwnedItems(),
|
|
targets,
|
|
_host.Automation.Character,
|
|
_combatSettings,
|
|
_host.Automation.Items.ActiveOwnedPetCount,
|
|
allowRefill: true,
|
|
allowSummon: true);
|
|
WriteVtank(choice.Kind == PetAutomationActionKind.None
|
|
? "Pet can spawn: False"
|
|
: $"Pet can spawn: True, action: {choice.Kind}, device: {choice.Device.Name}");
|
|
}
|
|
|
|
private void DumpMetaVariables()
|
|
{
|
|
WriteVtank("Assigned meta variables:");
|
|
foreach (ExpressionVariableScope scope in Enum.GetValues<ExpressionVariableScope>())
|
|
{
|
|
foreach ((string name, ExpressionValue value) in _expressions.State.Capture(scope))
|
|
WriteVtank($"{scope}.{name} = {value.ToDisplayString()}");
|
|
}
|
|
}
|
|
|
|
private void ListMetaFunctions()
|
|
{
|
|
WriteVtank("Available builtin meta functions:");
|
|
WriteChunks(_expressions.Functions
|
|
.OrderBy(static function => function.Name, StringComparer.OrdinalIgnoreCase)
|
|
.Select(static function => function.Name));
|
|
}
|
|
|
|
private void MetaFunctionHelp(string name)
|
|
{
|
|
ExpressionFunction? function = _expressions.Functions.FirstOrDefault(value =>
|
|
value.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
|
if (function is null)
|
|
{
|
|
WriteVtank($"Function not defined \"{name}\"");
|
|
return;
|
|
}
|
|
WriteVtank("-------------------------------");
|
|
WriteVtank("Function: " + function.Name);
|
|
WriteVtank("Signature: " + function.Signature);
|
|
WriteVtank("Description: " + function.Description);
|
|
string count = function.MinimumArguments == function.MaximumArguments
|
|
? function.MinimumArguments.ToString(CultureInfo.InvariantCulture)
|
|
: $"{function.MinimumArguments}..{function.MaximumArguments}";
|
|
WriteVtank("Parameter count: " + count);
|
|
WriteVtank("-------------------------------");
|
|
}
|
|
|
|
private void HandleLogCommand(string arguments)
|
|
{
|
|
string[] parts = arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length == 0)
|
|
{
|
|
WriteVtank(_commandLogTypes.Count == 0
|
|
? "Not currently logging."
|
|
: "Log state: " + string.Join(' ', _commandLogTypes.Order(StringComparer.OrdinalIgnoreCase)));
|
|
WriteVtank("Valid logtypes: ActiveRule SalvageList SpellCast RuleInfo Timers CastInfo DebuffChoice Loot CharProps Misc BusyState");
|
|
return;
|
|
}
|
|
if (parts.Length == 2)
|
|
parts[1] = parts[1].ToLowerInvariant();
|
|
if (parts.Length != 2 || parts[1] is not ("on" or "off"))
|
|
{
|
|
WriteVtank("Usage: /vt log [type] [on/off]");
|
|
return;
|
|
}
|
|
string type = parts[0];
|
|
if (parts[1] == "on")
|
|
_commandLogTypes.Add(type);
|
|
else
|
|
_commandLogTypes.Remove(type);
|
|
WriteVtank((parts[1] == "on" ? "Set " : "Reset ") + type);
|
|
}
|
|
|
|
private void DumpObjectTracker()
|
|
{
|
|
IReadOnlyList<PluginWorldObject> objects = _host.Automation.Objects.CaptureObjects();
|
|
WriteVtank($"Object tracker count: {objects.Count}");
|
|
foreach (PluginWorldObject item in objects.Take(100))
|
|
WriteVtank($"0x{item.ObjectId:X8} {item.ObjectClass} {item.Name}");
|
|
if (objects.Count > 100)
|
|
WriteVtank($"... {objects.Count - 100} more objects omitted from chat.");
|
|
}
|
|
|
|
private void DumpSpells()
|
|
{
|
|
PluginSpellInfo[] spells = _host.Automation.Spells.KnownCombatSpells
|
|
.Concat(_host.Automation.Spells.KnownSelfBuffs)
|
|
.GroupBy(static spell => spell.SpellId)
|
|
.Select(static group => group.First())
|
|
.OrderBy(static spell => spell.SpellId)
|
|
.ToArray();
|
|
WriteVtank($"Known spell table ({spells.Length}):");
|
|
foreach (PluginSpellInfo spell in spells)
|
|
WriteVtank($"{spell.SpellId}\t{spell.Name}\t{spell.Family}\t{spell.Difficulty}");
|
|
}
|
|
|
|
private void DumpSpecies()
|
|
{
|
|
var species = _host.Automation.Combat.CaptureHostileTargets(float.MaxValue)
|
|
.Where(static target => target.SpeciesId != 0)
|
|
.GroupBy(static target => target.SpeciesId)
|
|
.Select(static group => (Id: group.Key, Name: group.First().SpeciesName))
|
|
.OrderBy(static value => value.Id)
|
|
.ToArray();
|
|
WriteVtank($"Currently observed species ({species.Length}):");
|
|
foreach (var entry in species)
|
|
WriteVtank($"{entry.Id}\t{entry.Name}");
|
|
}
|
|
|
|
private void DumpMaterials()
|
|
{
|
|
var materials = _host.Automation.Items.CaptureOwnedItems()
|
|
.Where(static item => item.MaterialType != 0u)
|
|
.GroupBy(static item => item.MaterialType)
|
|
.OrderBy(static group => group.Key);
|
|
WriteVtank("Materials present in owned inventory:");
|
|
foreach (IGrouping<uint, PluginInventoryItem> group in materials)
|
|
WriteVtank($"{group.Key}\t{group.First().Name}");
|
|
}
|
|
|
|
private void DumpSkills()
|
|
{
|
|
WriteVtank($"Character skills ({_host.Automation.Character.Skills.Count}):");
|
|
foreach (PluginSkillInfo skill in _host.Automation.Character.Skills.OrderBy(static value => value.SkillId))
|
|
WriteVtank($"{skill.SkillId}\t{skill.Name}\t{skill.Base}\t{skill.Current}\t{skill.Training}");
|
|
}
|
|
|
|
private void ObserveCommandPortalState()
|
|
{
|
|
bool current = _host.Automation.Navigation.Snapshot.IsPortalSpace;
|
|
if (current != _commandPortalState)
|
|
{
|
|
_commandPortalState = current;
|
|
_commandPortalCount++;
|
|
}
|
|
}
|
|
|
|
private void ResetCommandSession()
|
|
{
|
|
if (_commandJumpActive || _commandJumpCharging)
|
|
_host.Automation.Navigation.ClearMovementIntent();
|
|
_commandJumpActive = false;
|
|
_commandJumpReleased = false;
|
|
_commandJumpCharging = false;
|
|
_commandJumpElapsed = 0d;
|
|
_commandJumpTurnElapsed = 0d;
|
|
_commandJumpChargeSeconds = 0d;
|
|
_commandJumpHeading = 0f;
|
|
_commandJumpIntent = default;
|
|
_commandPortalState = _host.Automation.Navigation.Snapshot.IsPortalSpace;
|
|
_commandPortalCount = 0;
|
|
}
|
|
|
|
private void WriteVtank(string text) =>
|
|
_host.Automation.Chat.PostSystemMessage(text);
|
|
|
|
private void WriteChunks(IEnumerable<string> values)
|
|
{
|
|
var line = new StringBuilder();
|
|
foreach (string value in values)
|
|
{
|
|
int extra = line.Length == 0 ? value.Length : value.Length + 2;
|
|
if (line.Length != 0 && line.Length + extra > 240)
|
|
{
|
|
WriteVtank(line.ToString());
|
|
line.Clear();
|
|
}
|
|
if (line.Length != 0)
|
|
line.Append(", ");
|
|
line.Append(value);
|
|
}
|
|
if (line.Length != 0)
|
|
WriteVtank(line.ToString());
|
|
}
|
|
|
|
private void DumpPropertyTable<T>(string kind, IReadOnlyDictionary<uint, T> values)
|
|
{
|
|
foreach ((uint key, T value) in values.OrderBy(static pair => pair.Key))
|
|
WriteVtank($"{kind}[{key}] = {value}");
|
|
}
|
|
|
|
private static (string Head, string Tail) SplitHead(string value)
|
|
{
|
|
string trimmed = value.Trim();
|
|
int separator = trimmed.IndexOfAny([' ', '\t']);
|
|
return separator < 0
|
|
? (trimmed, string.Empty)
|
|
: (trimmed[..separator], trimmed[(separator + 1)..].Trim());
|
|
}
|
|
|
|
private static string StripExtension(string name, params string[] extensions)
|
|
{
|
|
string result = name.Trim();
|
|
foreach (string extension in extensions)
|
|
{
|
|
if (result.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
|
|
return result[..^extension.Length];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Free-form parse (no catalog type to check against) for callers like
|
|
/// the Advanced Options editor, which can target names outside the 137-
|
|
/// row catalog: best-effort bool, then number, then string.
|
|
/// </summary>
|
|
private static bool TryParseOptionValue(string source, out ExpressionValue value) =>
|
|
TryParseOptionValue(source, declaredType: null, out value);
|
|
|
|
/// <summary>
|
|
/// Round 3 item 6: <c>/vt opt set</c>/<c>setinall</c> validate the typed
|
|
/// value against the catalog's OWN declared <see cref="VtankSettingValueType"/>
|
|
/// (retail's <c>eSettingValueType</c>) rather than accepting anything —
|
|
/// a value that does not parse as that type fails with retail's exact
|
|
/// "Proper type of X is Y." text (<see cref="HandleOptionCommand"/>).
|
|
/// <paramref name="declaredType"/> is <see langword="null"/> only for
|
|
/// the free-form Advanced Options editor, which keeps the original lax
|
|
/// best-effort behavior.
|
|
/// </summary>
|
|
private static bool TryParseOptionValue(
|
|
string source,
|
|
VtankSettingValueType? declaredType,
|
|
out ExpressionValue value)
|
|
{
|
|
if (source.Length == 0)
|
|
{
|
|
value = default;
|
|
return false;
|
|
}
|
|
switch (declaredType)
|
|
{
|
|
case VtankSettingValueType.Bool:
|
|
if (bool.TryParse(source, out bool boolean))
|
|
{
|
|
value = ExpressionValue.Boolean(boolean);
|
|
return true;
|
|
}
|
|
value = default;
|
|
return false;
|
|
case VtankSettingValueType.Int:
|
|
case VtankSettingValueType.Enum:
|
|
if (int.TryParse(
|
|
source, NumberStyles.Integer, CultureInfo.InvariantCulture, out int integer))
|
|
{
|
|
value = ExpressionValue.Number(integer);
|
|
return true;
|
|
}
|
|
value = default;
|
|
return false;
|
|
case VtankSettingValueType.Double:
|
|
case VtankSettingValueType.Single:
|
|
if (double.TryParse(
|
|
source, NumberStyles.Float, CultureInfo.InvariantCulture, out double number))
|
|
{
|
|
value = ExpressionValue.Number(number);
|
|
return true;
|
|
}
|
|
value = default;
|
|
return false;
|
|
case VtankSettingValueType.String:
|
|
value = ExpressionValue.String(source);
|
|
return true;
|
|
default:
|
|
// No declared type (free-form editor) or VtankSettingValueType.Custom
|
|
// (the one non-scalar row, RechargeHandlerSet, never reached
|
|
// through /vt opt set): keep the original lax parse.
|
|
if (bool.TryParse(source, out bool freeBoolean))
|
|
{
|
|
value = ExpressionValue.Boolean(freeBoolean);
|
|
return true;
|
|
}
|
|
if (double.TryParse(
|
|
source, NumberStyles.Float, CultureInfo.InvariantCulture, out double freeNumber))
|
|
{
|
|
value = ExpressionValue.Number(freeNumber);
|
|
return true;
|
|
}
|
|
value = ExpressionValue.String(source);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The CLR type name retail's exact failure text names
|
|
/// (<c>refs/vtank/decompiled/uTank2/PluginCore.cs:5501,5508,5612</c>:
|
|
/// <c>cw2[1].b().ToString()</c> — the Settings row's underlying
|
|
/// <c>gy</c> cell type, <see cref="Type.ToString"/>'d). Retail stores an
|
|
/// Enum-declared row as a plain int cell (confirmed against the
|
|
/// UseArcs row in the shipped defaultsettings.usd fixture), so it also
|
|
/// reads "System.Int32".
|
|
/// </summary>
|
|
private static string DeclaredClrTypeName(VtankSettingValueType type) => type switch
|
|
{
|
|
VtankSettingValueType.Bool => "System.Boolean",
|
|
VtankSettingValueType.Double => "System.Double",
|
|
VtankSettingValueType.Int => "System.Int32",
|
|
VtankSettingValueType.Single => "System.Single",
|
|
VtankSettingValueType.String => "System.String",
|
|
VtankSettingValueType.Enum => "System.Int32",
|
|
_ => "System.Object",
|
|
};
|
|
|
|
private bool TryParseCoordinates(string source, out PluginNavigationPosition position)
|
|
{
|
|
position = default;
|
|
string[] parts = source.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length < 2
|
|
|| !TryParseCompass(parts[0], northSouth: true, out double northSouth)
|
|
|| !TryParseCompass(parts[1], northSouth: false, out double eastWest))
|
|
{
|
|
return false;
|
|
}
|
|
double elevation = 0d;
|
|
if (parts.Length >= 3
|
|
&& !double.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out elevation))
|
|
{
|
|
return false;
|
|
}
|
|
PluginNavigationPosition current = _host.Automation.Navigation.Snapshot.Position;
|
|
position = new PluginNavigationPosition(
|
|
current.CellId,
|
|
eastWest,
|
|
northSouth,
|
|
elevation,
|
|
current.HeadingDegrees,
|
|
IsOutdoor: true);
|
|
return true;
|
|
}
|
|
|
|
private static bool TryParseCompass(string source, bool northSouth, out double value)
|
|
{
|
|
value = 0d;
|
|
string trimmed = source.Trim();
|
|
if (trimmed.Length < 2)
|
|
return false;
|
|
char direction = char.ToUpperInvariant(trimmed[^1]);
|
|
if (northSouth ? direction is not ('N' or 'S') : direction is not ('E' or 'W'))
|
|
return false;
|
|
if (!double.TryParse(trimmed[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out double magnitude))
|
|
return false;
|
|
value = direction is 'S' or 'W' ? -Math.Abs(magnitude) : Math.Abs(magnitude);
|
|
return true;
|
|
}
|
|
|
|
private static bool TryParseJumpDirection(string? value, out RouteJumpDirection direction)
|
|
{
|
|
direction = RouteJumpDirection.Forward;
|
|
if (string.IsNullOrWhiteSpace(value) || value.Equals("forward", StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
if (value.Equals("strafeleft", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
direction = RouteJumpDirection.StrafeLeft;
|
|
return true;
|
|
}
|
|
if (value.Equals("straferight", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
direction = RouteJumpDirection.StrafeRight;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static float NormalizeHeading(float heading)
|
|
{
|
|
float result = heading % 360f;
|
|
return result < 0f ? result + 360f : result;
|
|
}
|
|
}
|