acdream/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs
Erik 3ff9461efe fix(vt): demote VtankMetaProfileSerializer/VtankNavRouteSerializer to import-only
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>
2026-09-06 20:59:49 +02:00

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,
};
}
}