Campaign VT slice 1 Part A, per the owner's 2026-09-06 "MossTank does not implement .met and does not author .nav" direction: .af (MetafSerializer) is now the only storage/authoring format for meta profiles and navigation routes. Deletes both classes' Save() writers and every writer-only helper (WriteCondition/WriteAction/WriteEmbeddedNavigation/ConditionType/ActionType/ Number/IntValue/LineWriter in VtankMetaProfileSerializer; WriteWaypoint/ WriteDouble in VtankNavRouteSerializer) - TryLoad and its read-path helpers are untouched, so a real binary .met/.nav still imports one-shot into the in-memory model. MetaEngine's LoadEmbeddedNavigationRoute still needs the "uTank2 NAV 1.2" in-memory blob shape for a resolved EmbedNav action (that's a MossTank runtime contract, not a VTank file on disk), so MetafSerializer gained its own small private WriteBinaryNavBlob - a deliberate, scoped duplicate of what used to be VtankNavRouteSerializer.Save's WriteWaypoint, kept independent of the now-import-only class. MossTankMetaProfileStore/MossTankRouteProfileStore's WriteLegacyExport (the "/vt meta save"/"/vt nav save" sidecar) now writes .af via MetafSerializer.SaveMeta/SaveNav instead of the deleted binary writers. This is a real, if partial, step toward the contract's ".af is the only storage/authoring format" goal - full profile-directory-backed .af storage (A2's VTank-naming-scheme directory) is separate follow-up work, noted in the closeout. Deletes VtankMetaProfileSerializerTests.cs entirely (it only tested the now-deleted Save/round-trip behavior); trims the two writer-only tests out of VtankNavRouteSerializerTests.cs, keeping every reader test intact (LoadsEveryOfficialNav12WaypointPayload's read assertions, LoadsEmbeddedWrapperAndDoesNotMutateOnFailure). Updates MossTankPanelTests.cs's two "/vt meta|nav save" integration tests for the new .af export path (the meta test's synthetic import fixture is now a hand-authored CondAct payload instead of a call to the deleted Save). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
443 lines
16 KiB
C#
443 lines
16 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Plugins.MossTank;
|
|
|
|
/// <summary>
|
|
/// Independent VTank navigation-profile lifecycle. The selected route is
|
|
/// remembered per character; "By char" is a private route document and named
|
|
/// profiles are reusable copies.
|
|
/// </summary>
|
|
internal sealed class MossTankRouteProfileStore
|
|
{
|
|
public const string ByCharacter = "By char";
|
|
private const string IndexKey = "profiles/route/index.json";
|
|
private static readonly JsonSerializerOptions Options = new()
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNameCaseInsensitive = true,
|
|
};
|
|
|
|
private readonly IPluginHost _host;
|
|
private IndexDocument _index;
|
|
private string _characterName = string.Empty;
|
|
private string _selected = ByCharacter;
|
|
|
|
public MossTankRouteProfileStore(IPluginHost host)
|
|
{
|
|
_host = host ?? throw new ArgumentNullException(nameof(host));
|
|
_index = Read<IndexDocument>(IndexKey) ?? new IndexDocument();
|
|
_index.Names ??= [];
|
|
_index.SelectedByCharacter = new Dictionary<string, string>(
|
|
_index.SelectedByCharacter ?? new Dictionary<string, string>(),
|
|
StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
public string Selected => _selected;
|
|
public string? RecoveryNotice { get; private set; }
|
|
public IReadOnlyList<string> AvailableNames => new[] { ByCharacter }
|
|
.Concat(_index.Names)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(name => name.Equals(
|
|
ByCharacter,
|
|
StringComparison.OrdinalIgnoreCase) ? 0 : 1)
|
|
.ThenBy(static name => name, StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
|
|
public bool BindCharacter(string? characterName)
|
|
{
|
|
string normalized = string.IsNullOrWhiteSpace(characterName)
|
|
? string.Empty
|
|
: characterName.Trim();
|
|
if (string.Equals(normalized, _characterName, StringComparison.OrdinalIgnoreCase))
|
|
return false;
|
|
_characterName = normalized;
|
|
_selected = _index.SelectedByCharacter.TryGetValue(
|
|
SelectionKey(),
|
|
out string? selected)
|
|
&& IsKnown(selected)
|
|
? CanonicalName(selected)
|
|
: ByCharacter;
|
|
return true;
|
|
}
|
|
|
|
public bool Select(string? name)
|
|
{
|
|
string normalized = name?.Trim() ?? string.Empty;
|
|
if (!IsKnown(normalized))
|
|
return false;
|
|
_selected = CanonicalName(normalized);
|
|
_index.SelectedByCharacter[SelectionKey()] = _selected;
|
|
SaveIndex();
|
|
return true;
|
|
}
|
|
|
|
public bool Create(
|
|
string? name,
|
|
bool copyCurrent,
|
|
NavigationSettings current,
|
|
out string notice)
|
|
{
|
|
string normalized = name?.Trim() ?? string.Empty;
|
|
if (normalized.Length is < 1 or > 64)
|
|
{
|
|
notice = "Enter a route profile name (1-64 characters).";
|
|
return false;
|
|
}
|
|
if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
notice = "'By char' is the built-in route profile.";
|
|
return false;
|
|
}
|
|
Write(
|
|
ProfileKey(normalized, byCharacter: false),
|
|
copyCurrent
|
|
? RouteDocument.Capture(current)
|
|
: new RouteDocument());
|
|
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
|
|
_index.Names.Add(normalized);
|
|
_selected = _index.Names.First(entry => entry.Equals(
|
|
normalized,
|
|
StringComparison.OrdinalIgnoreCase));
|
|
_index.SelectedByCharacter[SelectionKey()] = _selected;
|
|
SaveIndex();
|
|
WriteLegacyExport(_selected, copyCurrent ? current : new NavigationSettings());
|
|
notice = copyCurrent
|
|
? $"Copied route to {_selected}."
|
|
: $"Created route profile {_selected}.";
|
|
return true;
|
|
}
|
|
|
|
public bool LoadCurrent(NavigationSettings target)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(target);
|
|
RouteDocument? document = Read<RouteDocument>(CurrentKey());
|
|
if (document is null)
|
|
return false;
|
|
document.Apply(target);
|
|
return true;
|
|
}
|
|
|
|
public void SaveCurrent(NavigationSettings settings)
|
|
{
|
|
Write(CurrentKey(), RouteDocument.Capture(settings));
|
|
WriteLegacyExport(
|
|
_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|
|
? string.IsNullOrWhiteSpace(_characterName)
|
|
? ByCharacter
|
|
: _characterName
|
|
: _selected,
|
|
settings);
|
|
}
|
|
|
|
public bool TryImportLegacy(
|
|
string? name,
|
|
NavigationSettings target,
|
|
ISpellCatalog spells,
|
|
out string notice)
|
|
{
|
|
string normalized = name?.Trim() ?? string.Empty;
|
|
if (!_host.Storage.IsAvailable || normalized.Length == 0)
|
|
{
|
|
notice = "Legacy navigation storage is unavailable.";
|
|
return false;
|
|
}
|
|
string? key = _host.Storage.List("imports")
|
|
.Concat(_host.Storage.List("exports"))
|
|
.FirstOrDefault(candidate =>
|
|
candidate.EndsWith(".nav", StringComparison.OrdinalIgnoreCase)
|
|
&& Path.GetFileNameWithoutExtension(candidate).Equals(
|
|
normalized,
|
|
StringComparison.OrdinalIgnoreCase));
|
|
string? source = key is null ? null : _host.Storage.ReadText(key);
|
|
if (string.IsNullOrWhiteSpace(source))
|
|
{
|
|
notice = $"VTank navigation file '{normalized}.nav' was not found in imports.";
|
|
return false;
|
|
}
|
|
if (!VtankNavRouteSerializer.TryLoad(source, target, spells, out string error))
|
|
{
|
|
notice = $"Could not import {Path.GetFileName(key)}: {error}";
|
|
return false;
|
|
}
|
|
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
|
|
_index.Names.Add(normalized);
|
|
_selected = _index.Names.First(entry => entry.Equals(
|
|
normalized,
|
|
StringComparison.OrdinalIgnoreCase));
|
|
_index.SelectedByCharacter[SelectionKey()] = _selected;
|
|
SaveIndex();
|
|
SaveCurrent(target);
|
|
notice = $"Imported VTank navigation profile {_selected}.";
|
|
return true;
|
|
}
|
|
|
|
public void ClearCurrent(NavigationSettings target)
|
|
{
|
|
target.Enabled = false;
|
|
target.Priority = false;
|
|
target.Mode = RouteMode.Circular;
|
|
target.MinimumDistanceMeters = 2d;
|
|
target.FollowTargetObjectId = 0u;
|
|
target.FollowTargetName = string.Empty;
|
|
target.FollowAroundCorners = true;
|
|
target.OpenDoors = false;
|
|
target.Waypoints.Clear();
|
|
SaveCurrent(target);
|
|
}
|
|
|
|
private bool IsKnown(string? name) => name is not null
|
|
&& (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|
|
|| _index.Names.Contains(name, StringComparer.OrdinalIgnoreCase));
|
|
|
|
private string CanonicalName(string name) => name.Equals(
|
|
ByCharacter,
|
|
StringComparison.OrdinalIgnoreCase)
|
|
? ByCharacter
|
|
: _index.Names.First(entry => entry.Equals(
|
|
name,
|
|
StringComparison.OrdinalIgnoreCase));
|
|
|
|
private string CurrentKey() => _selected.Equals(
|
|
ByCharacter,
|
|
StringComparison.OrdinalIgnoreCase)
|
|
? ProfileKey(_characterName, byCharacter: true)
|
|
: ProfileKey(_selected, byCharacter: false);
|
|
|
|
private static string ProfileKey(string value, bool byCharacter)
|
|
{
|
|
string identity = (byCharacter ? "char:" : "named:")
|
|
+ value.Trim().ToUpperInvariant();
|
|
string hash = Convert.ToHexString(
|
|
SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
|
|
return $"profiles/route/{hash}.json";
|
|
}
|
|
|
|
private string SelectionKey() => string.IsNullOrWhiteSpace(_characterName)
|
|
? "_default"
|
|
: _characterName;
|
|
|
|
private T? Read<T>(string key) where T : class
|
|
{
|
|
if (!_host.Storage.IsAvailable)
|
|
return null;
|
|
string? json = null;
|
|
try
|
|
{
|
|
json = _host.Storage.ReadText(key);
|
|
return string.IsNullOrWhiteSpace(json)
|
|
? null
|
|
: JsonSerializer.Deserialize<T>(json, Options);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
RecoveryNotice = MossTankProfileRecovery.Preserve(
|
|
_host,
|
|
"route",
|
|
key,
|
|
json,
|
|
error);
|
|
_host.Log.Warn(RecoveryNotice);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private void Write<T>(string key, T document)
|
|
{
|
|
if (!_host.Storage.IsAvailable)
|
|
return;
|
|
try
|
|
{
|
|
_host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options));
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
_host.Log.Warn($"MossTank route profile could not be saved: {error.Message}");
|
|
}
|
|
}
|
|
|
|
private void SaveIndex() => Write(IndexKey, _index);
|
|
|
|
/// <summary>
|
|
/// <c>.af</c> is the only VTank-compatible export format now (Campaign
|
|
/// VT slice 1 Part A — MossTank no longer authors the binary
|
|
/// <c>.nav</c> format at all, matching <see cref="VtankNavRouteSerializer"/>'s
|
|
/// demotion to a one-shot import).
|
|
/// </summary>
|
|
private void WriteLegacyExport(string name, NavigationSettings settings)
|
|
{
|
|
if (!_host.Storage.IsAvailable)
|
|
return;
|
|
try
|
|
{
|
|
_host.Storage.WriteText(
|
|
$"exports/{LegacyFileName(name)}.af",
|
|
MetafSerializer.SaveNav(settings));
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
_host.Log.Warn(
|
|
$"MossTank VTank navigation export could not be saved: {error.Message}");
|
|
}
|
|
}
|
|
|
|
private static string LegacyFileName(string name)
|
|
{
|
|
char[] invalid = Path.GetInvalidFileNameChars();
|
|
var result = new StringBuilder(name.Length);
|
|
foreach (char value in name.Trim())
|
|
{
|
|
result.Append(value is '/' or '\\' || invalid.Contains(value)
|
|
? '_'
|
|
: value);
|
|
}
|
|
return result.Length == 0 ? "Route" : result.ToString();
|
|
}
|
|
|
|
private sealed class IndexDocument
|
|
{
|
|
public int Version { get; set; } = 1;
|
|
public List<string> Names { get; set; } = [];
|
|
public Dictionary<string, string> SelectedByCharacter { get; set; } =
|
|
new(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private sealed class RouteDocument
|
|
{
|
|
public int Version { get; set; } = 1;
|
|
public bool Enabled { get; set; }
|
|
public bool Priority { get; set; }
|
|
public RouteMode Mode { get; set; } = RouteMode.Circular;
|
|
public double MinimumDistanceMeters { get; set; } = 2d;
|
|
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 WaypointDocument[] Waypoints { get; set; } = [];
|
|
|
|
public static RouteDocument Capture(NavigationSettings value) => new()
|
|
{
|
|
Enabled = value.Enabled,
|
|
Priority = value.Priority,
|
|
Mode = value.Mode,
|
|
MinimumDistanceMeters = value.MinimumDistanceMeters,
|
|
FollowTargetObjectId = value.FollowTargetObjectId,
|
|
FollowTargetName = value.FollowTargetName,
|
|
FollowAroundCorners = value.FollowAroundCorners,
|
|
OpenDoors = value.OpenDoors,
|
|
DoorIdentifyRangeMeters = value.DoorIdentifyRangeMeters,
|
|
DoorOpenRangeMeters = value.DoorOpenRangeMeters,
|
|
DoorLockpickExcessThreshold = value.DoorLockpickExcessThreshold,
|
|
Waypoints = value.Waypoints.Select(WaypointDocument.From).ToArray(),
|
|
};
|
|
|
|
public void Apply(NavigationSettings value)
|
|
{
|
|
value.Enabled = Enabled;
|
|
value.Priority = Priority;
|
|
value.Mode = Enum.IsDefined(Mode) ? Mode : RouteMode.Circular;
|
|
value.MinimumDistanceMeters = Math.Clamp(
|
|
MinimumDistanceMeters,
|
|
0.5d,
|
|
50d);
|
|
value.FollowTargetObjectId = FollowTargetObjectId;
|
|
value.FollowTargetName = FollowTargetName ?? string.Empty;
|
|
value.FollowAroundCorners = FollowAroundCorners;
|
|
value.OpenDoors = OpenDoors;
|
|
value.DoorIdentifyRangeMeters = Math.Clamp(
|
|
DoorIdentifyRangeMeters, 1d, 100d);
|
|
value.DoorOpenRangeMeters = Math.Clamp(
|
|
DoorOpenRangeMeters, 0.5d, value.DoorIdentifyRangeMeters);
|
|
value.DoorLockpickExcessThreshold = Math.Clamp(
|
|
DoorLockpickExcessThreshold, -500, 500);
|
|
value.Waypoints.Clear();
|
|
foreach (WaypointDocument waypoint in Waypoints ?? [])
|
|
value.Waypoints.Add(waypoint.ToWaypoint());
|
|
}
|
|
}
|
|
|
|
private sealed class WaypointDocument
|
|
{
|
|
public RouteWaypointType Type { get; set; }
|
|
public uint CellId { get; set; }
|
|
public double EastWest { get; set; }
|
|
public double NorthSouth { get; set; }
|
|
public double Elevation { get; set; }
|
|
public float HeadingDegrees { get; set; }
|
|
public bool IsOutdoor { get; set; }
|
|
public uint ObjectId { get; set; }
|
|
public string ObjectName { get; set; } = string.Empty;
|
|
public int LegacyObjectClass { get; set; }
|
|
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 static WaypointDocument From(RouteWaypoint value) => new()
|
|
{
|
|
Type = value.Type,
|
|
CellId = value.Position.CellId,
|
|
EastWest = value.Position.EastWest,
|
|
NorthSouth = value.Position.NorthSouth,
|
|
Elevation = value.Position.Elevation,
|
|
HeadingDegrees = value.Position.HeadingDegrees,
|
|
IsOutdoor = value.Position.IsOutdoor,
|
|
ObjectId = value.ObjectId,
|
|
ObjectName = value.ObjectName,
|
|
LegacyObjectClass = value.LegacyObjectClass,
|
|
LegacyReferenceValid = value.LegacyReferenceValid,
|
|
Text = value.Text,
|
|
DurationMilliseconds = value.DurationMilliseconds,
|
|
Recall = value.Recall,
|
|
RecallSpellId = value.RecallSpellId,
|
|
RecallSpellName = value.RecallSpellName,
|
|
JumpHeadingDegrees = value.JumpHeadingDegrees,
|
|
JumpRun = value.JumpRun,
|
|
JumpChargeMilliseconds = value.JumpChargeMilliseconds,
|
|
JumpDirection = value.JumpDirection,
|
|
};
|
|
|
|
public RouteWaypoint ToWaypoint() => new()
|
|
{
|
|
Type = Enum.IsDefined(Type) ? Type : RouteWaypointType.Point,
|
|
Position = new PluginNavigationPosition(
|
|
CellId,
|
|
EastWest,
|
|
NorthSouth,
|
|
Elevation,
|
|
HeadingDegrees,
|
|
IsOutdoor),
|
|
ObjectId = ObjectId,
|
|
ObjectName = ObjectName ?? string.Empty,
|
|
LegacyObjectClass = LegacyObjectClass,
|
|
LegacyReferenceValid = LegacyReferenceValid,
|
|
Text = Text ?? string.Empty,
|
|
DurationMilliseconds = Math.Clamp(DurationMilliseconds, 0, 3_600_000),
|
|
Recall = Enum.IsDefined(Recall) ? Recall : RouteRecallKind.Lifestone,
|
|
RecallSpellId = RecallSpellId,
|
|
RecallSpellName = RecallSpellName ?? string.Empty,
|
|
JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees)
|
|
? JumpHeadingDegrees
|
|
: 0f,
|
|
JumpRun = JumpRun,
|
|
JumpChargeMilliseconds = Math.Clamp(
|
|
JumpChargeMilliseconds,
|
|
0,
|
|
10_000),
|
|
JumpDirection = Enum.IsDefined(JumpDirection)
|
|
? JumpDirection
|
|
: RouteJumpDirection.Forward,
|
|
};
|
|
}
|
|
}
|