diff --git a/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs
index c29c31b4c..ce1e1e003 100644
--- a/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs
+++ b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs
@@ -5,47 +5,66 @@ using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
-/// Independent VTank-style By-char/named Meta profile lifetime.
+///
+/// VTank-compatible Meta profile lifecycle. .af (metaf's human-
+/// readable grammar, see ) is the ONLY storage
+/// and authoring format — real VTank's own ac() meta-profile picker
+/// (docs/research/vtank-kb/01-settings-and-profiles.md section 3)
+/// seeds [None]/[By char] then every non--- file: named
+/// Meta profiles are plain shared files (unlike the Settings picker, there
+/// is no per-character [Char] suffix sub-profile carve-out here),
+/// while the per-character auto file uses VTank's --Name_Server.af
+/// naming and is therefore hidden from that listing by construction.
+///
internal sealed class MossTankMetaProfileStore
{
public const string ByCharacter = "By char";
- private const string IndexKey = "profiles/meta/index.json";
- private static readonly JsonSerializerOptions Options = new()
- {
- WriteIndented = true,
- PropertyNameCaseInsensitive = true,
- };
private readonly IPluginHost _host;
- private IndexDocument _index;
private string _character = string.Empty;
private string _selected = ByCharacter;
+ private string? _pendingLegacyBareName;
public MossTankMetaProfileStore(IPluginHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
- _index = Read(IndexKey) ?? new IndexDocument();
- _index.Names ??= [];
- _index.SelectedByCharacter = new Dictionary(
- _index.SelectedByCharacter ?? [],
- StringComparer.OrdinalIgnoreCase);
}
- private List Names => _index.Names ??= [];
-
- private Dictionary SelectedByCharacter =>
- _index.SelectedByCharacter ??= new Dictionary(
- StringComparer.OrdinalIgnoreCase);
-
- public string Selected => _selected;
+ ///
+ /// The bare name a user typed to select/create this profile (the file
+ /// name minus its .af extension) — internal identity ()
+ /// always carries the real, on-disk file name; only display strips it.
+ ///
+ public string Selected => StripAf(_selected);
public string? RecoveryNotice { get; private set; }
- public IReadOnlyList AvailableNames => new[] { ByCharacter }
- .Concat(Names)
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .OrderBy(static name => name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
- ? 0 : 1)
- .ThenBy(static name => name, StringComparer.OrdinalIgnoreCase)
- .ToArray();
+ public string? SaveNotice { get; private set; }
+
+ private string Server => _host.Automation.Character.WorldName;
+ private IPluginStorage VtankStorage => _host.VtankProfiles;
+ private bool CanBindFiles => _character.Length > 0 && Server.Length > 0;
+
+ private static string StripAf(string name) => name.Equals(
+ ByCharacter, StringComparison.OrdinalIgnoreCase)
+ ? name
+ : name.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
+ ? name[..^3]
+ : name;
+
+ public IReadOnlyList AvailableNames
+ {
+ get
+ {
+ var names = new List { ByCharacter };
+ foreach (VtankProfileDirectory.ProfileEntry entry in
+ VtankProfileDirectory.ListMetaProfiles(VtankStorage))
+ {
+ if (entry.FileName.Length == 0)
+ continue; // VTank's own "[None]"/"[By char]" sentinels.
+ names.Add(StripAf(entry.FileName));
+ }
+ return names;
+ }
+ }
public bool BindCharacter(string? characterName)
{
@@ -55,33 +74,105 @@ internal sealed class MossTankMetaProfileStore
if (normalized.Equals(_character, StringComparison.OrdinalIgnoreCase))
return false;
_character = normalized;
- _selected = SelectedByCharacter.TryGetValue(
- CharacterKey(),
- out string? selected)
- && IsKnown(selected)
- ? Canonical(selected)
- : ByCharacter;
+ _pendingLegacyBareName = null;
+ VtankProfileDirectory.VtankCharacterBinding? binding = CanBindFiles
+ ? VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _character, Server)
+ : null;
+ _selected = binding is { MetaFileName.Length: > 0 } bound
+ ? bound.MetaFileName
+ : ByCharacter;
return true;
}
- public MetaProfile LoadCurrent() =>
- Read(CurrentKey()) ?? new MetaProfile();
-
- public void SaveCurrent(MetaProfile profile)
+ public MetaProfile LoadCurrent()
{
- Write(CurrentKey(), profile);
- WriteLegacyExport(LegacyProfileName(), profile);
+ MigrateLegacyIfNeeded();
+ string fileName = CurrentFileName();
+ string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
+ if (text is null)
+ return new MetaProfile();
+ if (!MetafSerializer.TryLoadMeta(text, _host.Automation.Spells, out MetaProfile profile, out string error))
+ {
+ RecoveryNotice = MossTankProfileRecovery.Preserve(
+ _host, "meta", fileName, text, new FormatException(error));
+ _host.Log.Warn(RecoveryNotice);
+ return new MetaProfile();
+ }
+ return profile;
+ }
+
+ ///
+ /// Saves as this selection's .af file.
+ /// Real VTank/metaf has no way to represent
+ /// (a MossTank-only extension) — rather than
+ /// silently dropping a disabled rule, the save is refused entirely (the
+ /// file on disk keeps its last good content) and
+ /// carries a user-visible reason. Returns only
+ /// when the file was actually written.
+ ///
+ public bool SaveCurrent(MetaProfile profile)
+ {
+ string fileName = CurrentFileName();
+ string text;
+ try
+ {
+ text = MetafSerializer.SaveMeta(profile);
+ }
+ catch (InvalidOperationException error)
+ {
+ SaveNotice = $"Meta profile '{fileName}' was NOT saved: {error.Message}";
+ _host.Log.Warn(SaveNotice);
+ return false;
+ }
+ if (!VtankStorage.IsAvailable)
+ return false;
+ try
+ {
+ VtankStorage.WriteText(fileName, text);
+ }
+ catch (Exception error)
+ {
+ SaveNotice = $"Meta profile '{fileName}' could not be saved: {error.Message}";
+ _host.Log.Warn(SaveNotice);
+ return false;
+ }
+ SaveNotice = null;
+ return true;
}
public bool Select(string? name)
{
string normalized = Normalize(name);
- if (!IsKnown(normalized))
+ if (normalized.Length == 0)
return false;
- _selected = Canonical(normalized);
- SelectedByCharacter[CharacterKey()] = _selected;
- SaveIndex();
- return true;
+ if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
+ {
+ _selected = ByCharacter;
+ _pendingLegacyBareName = null;
+ WriteBinding();
+ return true;
+ }
+
+ string plain = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
+ ? normalized
+ : normalized + ".af";
+ if (VtankStorage.IsAvailable && VtankStorage.ReadText(plain) is not null)
+ {
+ _selected = plain;
+ _pendingLegacyBareName = null;
+ WriteBinding();
+ return true;
+ }
+
+ if (_host.Storage.IsAvailable
+ && _host.Storage.ReadText(LegacyNamedKey(normalized)) is not null)
+ {
+ _selected = plain;
+ _pendingLegacyBareName = normalized;
+ WriteBinding();
+ return true;
+ }
+ return false;
}
public bool Create(
@@ -97,19 +188,18 @@ internal sealed class MossTankMetaProfileStore
notice = "Enter a unique Meta profile name (1-64 characters).";
return false;
}
- MetaProfile document = copyCurrent
- ? Clone(current)
- : new MetaProfile();
- Write(NamedKey(normalized), document);
- if (!Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
- Names.Add(normalized);
- _selected = normalized;
- SelectedByCharacter[CharacterKey()] = normalized;
- SaveIndex();
- WriteLegacyExport(normalized, document);
+ string fileName = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
+ ? normalized
+ : normalized + ".af";
+ MetaProfile document = copyCurrent ? Clone(current) : new MetaProfile();
+ if (!SaveTo(fileName, document, out notice))
+ return false;
+ _selected = fileName;
+ _pendingLegacyBareName = null;
+ WriteBinding();
notice = copyCurrent
- ? $"Copied Meta profile to {normalized}."
- : $"Created Meta profile {normalized}.";
+ ? $"Copied Meta profile to {fileName}."
+ : $"Created Meta profile {fileName}.";
return true;
}
@@ -145,15 +235,18 @@ internal sealed class MossTankMetaProfileStore
notice = $"Could not import {Path.GetFileName(key)}: {error}";
return false;
}
- if (!Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
- Names.Add(normalized);
- _selected = Names.First(existing => existing.Equals(
- normalized,
- StringComparison.OrdinalIgnoreCase));
- SelectedByCharacter[CharacterKey()] = _selected;
- SaveIndex();
- SaveCurrent(profile);
- notice = $"Imported VTank Meta profile {_selected}.";
+ string fileName = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
+ ? normalized
+ : normalized + ".af";
+ if (!SaveTo(fileName, profile, out string saveNotice))
+ {
+ notice = saveNotice;
+ return false;
+ }
+ _selected = fileName;
+ _pendingLegacyBareName = null;
+ WriteBinding();
+ notice = $"Imported VTank Meta profile {fileName}.";
return true;
}
@@ -164,142 +257,142 @@ internal sealed class MossTankMetaProfileStore
return empty;
}
- private string CurrentKey() => _selected.Equals(
- ByCharacter,
- StringComparison.OrdinalIgnoreCase)
- ? $"profiles/meta/by-character/{Hash(_character)}.json"
- : NamedKey(_selected);
+ // ------------------------------------------------------------------
+ // Legacy JSON -> .af migration.
+ // ------------------------------------------------------------------
- private static string NamedKey(string name) =>
- $"profiles/meta/named/{Hash(name)}.json";
-
- private string CharacterKey() =>
- string.IsNullOrWhiteSpace(_character) ? "anonymous" : _character;
-
- private bool IsKnown(string name) => name.Equals(
- ByCharacter,
- StringComparison.OrdinalIgnoreCase)
- || Names.Contains(name, StringComparer.OrdinalIgnoreCase);
-
- private string Canonical(string name) => name.Equals(
- ByCharacter,
- StringComparison.OrdinalIgnoreCase)
- ? ByCharacter
- : Names.First(existing => existing.Equals(
- name,
- StringComparison.OrdinalIgnoreCase));
-
- private void SaveIndex() => Write(IndexKey, _index);
-
- ///
- /// .af is the only VTank-compatible export format now (Campaign
- /// VT slice 1 Part A — MossTank no longer authors the binary
- /// .met format at all, matching 's
- /// demotion to a one-shot import). Exports live under
- /// exports/meta/ — a distinct subdirectory from
- /// 's exports/nav/ — so a
- /// Meta profile and a route (Navigation) profile sharing the same name
- /// cannot silently overwrite each other's .af file (both stores
- /// used to write into the same flat exports/ root).
- ///
- private void WriteLegacyExport(string name, MetaProfile profile)
+ private void MigrateLegacyIfNeeded()
{
- if (!_host.Storage.IsAvailable)
+ string fileName = CurrentFileName();
+ if (!VtankStorage.IsAvailable || VtankStorage.ReadText(fileName) is not null)
+ {
+ _pendingLegacyBareName = null;
return;
- try
- {
- // MetafSerializer.SaveMeta(profile) (item H, slice-1 fix round)
- // throws when the profile has any disabled rule — real VTank/
- // metaf has no marker for MossTank's own "disabled" extension, so
- // this .af mirror is deliberately left stale (rather than
- // silently dropping the rule) until the user re-enables or
- // deletes it. The authoritative JSON save above always has full
- // fidelity regardless.
- _host.Storage.WriteText(
- $"exports/meta/{LegacyFileName(name)}.af",
- MetafSerializer.SaveMeta(profile));
}
- catch (Exception error)
+ string legacyKey = _pendingLegacyBareName is { Length: > 0 } bareName
+ ? LegacyNamedKey(bareName)
+ : LegacyByCharacterKey();
+ MetaProfile? legacy = ReadLegacyJson(legacyKey);
+ if (legacy is null)
{
- _host.Log.Warn(
- $"MossTank VTank Meta export could not be saved: {error.Message}");
+ _pendingLegacyBareName = null;
+ return;
}
+ if (!SaveTo(fileName, legacy, out string notice))
+ {
+ // Representational loss (a disabled rule) blocks the migration
+ // outright rather than silently dropping it: the legacy JSON
+ // stays put (still fully readable) until the user resolves it.
+ _host.Log.Warn($"MossTank could not migrate legacy Meta profile: {notice}");
+ _pendingLegacyBareName = null;
+ return;
+ }
+ if (_host.Storage.IsAvailable)
+ _host.Storage.Delete(legacyKey);
+ _pendingLegacyBareName = null;
+ _host.Log.Warn($"Migrated legacy MossTank Meta profile '{legacyKey}' to '{fileName}'.");
}
- private string LegacyProfileName() => _selected.Equals(
- ByCharacter,
- StringComparison.OrdinalIgnoreCase)
- ? string.IsNullOrWhiteSpace(_character) ? ByCharacter : _character
- : _selected;
-
- 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 ? "Meta" : result.ToString();
- }
-
- private T? Read(string key)
+ private MetaProfile? ReadLegacyJson(string key)
{
if (!_host.Storage.IsAvailable)
- return default;
+ return null;
string? json = null;
try
{
json = _host.Storage.ReadText(key);
return string.IsNullOrWhiteSpace(json)
- ? default
- : JsonSerializer.Deserialize(json, Options);
+ ? null
+ : JsonSerializer.Deserialize(json, JsonOptions);
}
catch (Exception error)
{
- RecoveryNotice = MossTankProfileRecovery.Preserve(
- _host,
- "meta",
- key,
- json,
- error);
+ RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "meta", key, json, error);
_host.Log.Error(RecoveryNotice, error);
- return default;
+ return null;
}
}
- private void Write(string key, T value)
+ private bool SaveTo(string fileName, MetaProfile profile, out string notice)
{
- if (!_host.Storage.IsAvailable)
- return;
+ string text;
try
{
- _host.Storage.WriteText(key, JsonSerializer.Serialize(value, Options));
+ text = MetafSerializer.SaveMeta(profile);
+ }
+ catch (InvalidOperationException error)
+ {
+ notice = $"Meta profile '{fileName}' was NOT saved: {error.Message}";
+ SaveNotice = notice;
+ _host.Log.Warn(notice);
+ return false;
+ }
+ if (!VtankStorage.IsAvailable)
+ {
+ notice = "VTank Meta storage is unavailable.";
+ return false;
+ }
+ try
+ {
+ VtankStorage.WriteText(fileName, text);
}
catch (Exception error)
{
- _host.Log.Error($"Unable to save MossTank Meta profile '{key}'.", error);
+ notice = $"Meta profile '{fileName}' could not be saved: {error.Message}";
+ SaveNotice = notice;
+ _host.Log.Warn(notice);
+ return false;
}
+ SaveNotice = null;
+ notice = string.Empty;
+ return true;
+ }
+
+ // ------------------------------------------------------------------
+ // File naming, storage plumbing.
+ // ------------------------------------------------------------------
+
+ private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
+ ? VtankProfileDirectory.AutoCharacterFileName(_character, Server, "af")
+ : _selected;
+
+ private void WriteBinding()
+ {
+ if (!CanBindFiles || !VtankStorage.IsAvailable)
+ return;
+ VtankProfileDirectory.VtankCharacterBinding existing =
+ VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _character, Server)
+ ?? new VtankProfileDirectory.VtankCharacterBinding(
+ string.Empty, string.Empty, string.Empty, CurrentFileName());
+ VtankProfileDirectory.WriteCharacterBinding(
+ VtankStorage,
+ _character,
+ Server,
+ existing with { MetaFileName = CurrentFileName() });
}
private static MetaProfile Clone(MetaProfile profile) =>
JsonSerializer.Deserialize(
- JsonSerializer.Serialize(profile, Options),
- Options) ?? new MetaProfile();
+ JsonSerializer.Serialize(profile, JsonOptions),
+ JsonOptions) ?? new MetaProfile();
private static string Normalize(string? name) => name?.Trim() ?? string.Empty;
+ private string LegacyByCharacterKey() =>
+ $"profiles/meta/by-character/{Hash(_character)}.json";
+
+ private static string LegacyNamedKey(string name) =>
+ $"profiles/meta/named/{Hash(name)}.json";
+
private static string Hash(string value)
{
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(value.ToLowerInvariant()));
return Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant();
}
- private sealed class IndexDocument
+ private static readonly JsonSerializerOptions JsonOptions = new()
{
- public List? Names { get; set; } = [];
- public Dictionary? SelectedByCharacter { get; set; } = [];
- }
+ WriteIndented = true,
+ PropertyNameCaseInsensitive = true,
+ };
}
diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs
index a7dedaade..787abb9c6 100644
--- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs
+++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs
@@ -213,7 +213,7 @@ internal sealed partial class MossTankPanel
}
_routeProfiles = new MossTankRouteProfileStore(host);
_routeProfiles.BindCharacter(host.Automation.Character.Name);
- if (!_routeProfiles.LoadCurrent(_navigationSettings))
+ if (!_routeProfiles.LoadCurrent(_navigationSettings, host.Automation.Spells))
_routeProfiles.SaveCurrent(_navigationSettings);
_combat = new CombatController(host, _combatSettings, _vitalSettings);
_buffCasterPreparer = new BuffCasterPreparer(
@@ -1785,7 +1785,7 @@ internal sealed partial class MossTankPanel
private void LoadRouteProfile()
{
- if (!_routeProfiles.LoadCurrent(_navigationSettings))
+ if (!_routeProfiles.LoadCurrent(_navigationSettings, _host.Automation.Spells))
_routeProfiles.SaveCurrent(_navigationSettings);
if (_initialized)
ApplyPersistedOptionOverrides();
@@ -2244,9 +2244,9 @@ internal sealed partial class MossTankPanel
}
_metaProfile.Rules.Add(rule);
_selectedMetaRule = _metaProfile.Rules.Count - 1;
- SaveMetaProfile();
RefreshMetaEditor();
- _metaNotice = $"Added rule in {rule.State}.";
+ if (SaveMetaProfile())
+ _metaNotice = $"Added rule in {rule.State}.";
}
private void ApplyMetaRuleCore()
@@ -2263,9 +2263,9 @@ internal sealed partial class MossTankPanel
}
replacement.Id = current.Id;
_metaProfile.Rules[_selectedMetaRule] = replacement;
- SaveMetaProfile();
RefreshMetaEditor();
- _metaNotice = $"Updated rule in {replacement.State}.";
+ if (SaveMetaProfile())
+ _metaNotice = $"Updated rule in {replacement.State}.";
}
private bool TryBuildMetaRule(out MetaRule rule, out string error)
@@ -2320,9 +2320,9 @@ internal sealed partial class MossTankPanel
_selectedMetaRule = Math.Min(
_selectedMetaRule,
Math.Max(0, _metaProfile.Rules.Count - 1));
- SaveMetaProfile();
RefreshMetaEditor();
- _metaNotice = $"Removed rule from {selected.State}.";
+ if (SaveMetaProfile())
+ _metaNotice = $"Removed rule from {selected.State}.";
}
private void MoveMetaRule(int direction)
@@ -2337,9 +2337,9 @@ internal sealed partial class MossTankPanel
_metaProfile.Rules.RemoveAt(_selectedMetaRule);
_metaProfile.Rules.Insert(destination, rule);
_selectedMetaRule = destination;
- SaveMetaProfile();
RefreshMetaEditor();
- _metaNotice = $"Moved {rule.State} rule.";
+ if (SaveMetaProfile())
+ _metaNotice = $"Moved {rule.State} rule.";
}
private void SelectMetaProfileCore(string name)
@@ -2389,7 +2389,22 @@ internal sealed partial class MossTankPanel
RefreshMetaEditor();
}
- private void SaveMetaProfile() => _metaProfiles.SaveCurrent(_metaProfile);
+ ///
+ /// Saves the current Meta profile as .af. Returns
+ /// when the save was refused (a disabled rule
+ /// has no metaf-compatible representation) — callers must not overwrite
+ /// with a generic success message in that
+ /// case, since already
+ /// carries the user-visible reason.
+ ///
+ private bool SaveMetaProfile()
+ {
+ if (_metaProfiles.SaveCurrent(_metaProfile))
+ return true;
+ if (_metaProfiles.SaveNotice is { } notice)
+ _metaNotice = notice;
+ return false;
+ }
private static string DescribeMetaCondition(MetaCondition condition) =>
condition.Kind switch
diff --git a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs
index 49c4ae7af..db1432a82 100644
--- a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs
+++ b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs
@@ -6,45 +6,77 @@ using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
///
-/// 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.
+/// VTank-compatible navigation-route lifecycle. .af is the ONLY
+/// storage/authoring format — but only for the fields metaf's own
+/// NAV: grammar actually represents (,
+/// ,
+/// /
+/// — see
+/// /).
+/// Every other field
+/// (Enabled/Priority/MinimumDistanceMeters/FollowAroundCorners/OpenDoors/
+/// Door*) is a real VTank Settings-table row with its own catalog name
+/// (EnableNav, NavPriorityBoost, NavCloseStopRange,
+/// …) already owned end-to-end by 's
+/// .usd profile — this store never touches them, matching real
+/// VTank's own split between the Settings-scoped nav prefs and the
+/// per-route file.
+///
+/// Named route files carry a nav_ prefix (metaf's own observed
+/// convention for a stand-alone nav-only .af, see the committed
+/// nav_*.af fixtures) so a route and a Meta profile sharing the same
+/// user-typed name never collide in the shared
+/// directory.
///
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 const string NavPrefix = "nav_";
private readonly IPluginHost _host;
- private IndexDocument _index;
private string _characterName = string.Empty;
private string _selected = ByCharacter;
+ private string? _pendingLegacyBareName;
public MossTankRouteProfileStore(IPluginHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
- _index = Read(IndexKey) ?? new IndexDocument();
- _index.Names ??= [];
- _index.SelectedByCharacter = new Dictionary(
- _index.SelectedByCharacter ?? new Dictionary(),
- StringComparer.OrdinalIgnoreCase);
}
- public string Selected => _selected;
+ public string Selected => Strip(_selected);
public string? RecoveryNotice { get; private set; }
- public IReadOnlyList 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();
+
+ private string Server => _host.Automation.Character.WorldName;
+ private IPluginStorage VtankStorage => _host.VtankProfiles;
+ private bool CanBindFiles => _characterName.Length > 0 && Server.Length > 0;
+
+ private static string Strip(string fileName)
+ {
+ if (fileName.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
+ return fileName;
+ string value = fileName;
+ if (value.StartsWith(NavPrefix, StringComparison.Ordinal))
+ value = value[NavPrefix.Length..];
+ if (value.EndsWith(".af", StringComparison.OrdinalIgnoreCase))
+ value = value[..^3];
+ return value;
+ }
+
+ public IReadOnlyList AvailableNames
+ {
+ get
+ {
+ var names = new List { ByCharacter };
+ foreach (VtankProfileDirectory.ProfileEntry entry in
+ VtankProfileDirectory.ListNavigationProfiles(VtankStorage))
+ {
+ if (entry.FileName.Length == 0)
+ continue; // VTank's own "[None]"/"[By char]" sentinels.
+ names.Add(Strip(entry.FileName));
+ }
+ return names;
+ }
+ }
public bool BindCharacter(string? characterName)
{
@@ -54,24 +86,46 @@ internal sealed class MossTankRouteProfileStore
if (string.Equals(normalized, _characterName, StringComparison.OrdinalIgnoreCase))
return false;
_characterName = normalized;
- _selected = _index.SelectedByCharacter.TryGetValue(
- SelectionKey(),
- out string? selected)
- && IsKnown(selected)
- ? CanonicalName(selected)
- : ByCharacter;
+ _pendingLegacyBareName = null;
+ VtankProfileDirectory.VtankCharacterBinding? binding = CanBindFiles
+ ? VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server)
+ : null;
+ _selected = binding is { NavFileName.Length: > 0 } bound
+ ? bound.NavFileName
+ : ByCharacter;
return true;
}
public bool Select(string? name)
{
- string normalized = name?.Trim() ?? string.Empty;
- if (!IsKnown(normalized))
+ string normalized = Normalize(name);
+ if (normalized.Length == 0)
return false;
- _selected = CanonicalName(normalized);
- _index.SelectedByCharacter[SelectionKey()] = _selected;
- SaveIndex();
- return true;
+ if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
+ {
+ _selected = ByCharacter;
+ _pendingLegacyBareName = null;
+ WriteBinding();
+ return true;
+ }
+
+ string candidate = ToFileName(normalized);
+ if (VtankStorage.IsAvailable && VtankStorage.ReadText(candidate) is not null)
+ {
+ _selected = candidate;
+ _pendingLegacyBareName = null;
+ WriteBinding();
+ return true;
+ }
+ if (_host.Storage.IsAvailable
+ && _host.Storage.ReadText(LegacyProfileKey(normalized, byCharacter: false)) is not null)
+ {
+ _selected = candidate;
+ _pendingLegacyBareName = normalized;
+ WriteBinding();
+ return true;
+ }
+ return false;
}
public bool Create(
@@ -80,7 +134,7 @@ internal sealed class MossTankRouteProfileStore
NavigationSettings current,
out string notice)
{
- string normalized = name?.Trim() ?? string.Empty;
+ string normalized = Normalize(name);
if (normalized.Length is < 1 or > 64)
{
notice = "Enter a route profile name (1-64 characters).";
@@ -91,46 +145,39 @@ internal sealed class MossTankRouteProfileStore
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());
+ string fileName = ToFileName(normalized);
+ NavigationSettings source = copyCurrent ? current : new NavigationSettings();
+ WriteAf(fileName, MetafSerializer.SaveNav(source));
+ _selected = fileName;
+ _pendingLegacyBareName = null;
+ WriteBinding();
notice = copyCurrent
- ? $"Copied route to {_selected}."
- : $"Created route profile {_selected}.";
+ ? $"Copied route to {Strip(fileName)}."
+ : $"Created route profile {Strip(fileName)}.";
return true;
}
- public bool LoadCurrent(NavigationSettings target)
+ public bool LoadCurrent(NavigationSettings target, ISpellCatalog spells)
{
ArgumentNullException.ThrowIfNull(target);
- RouteDocument? document = Read(CurrentKey());
- if (document is null)
+ ArgumentNullException.ThrowIfNull(spells);
+ MigrateLegacyIfNeeded(target, spells);
+ string fileName = CurrentFileName();
+ string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
+ if (text is null)
return false;
- document.Apply(target);
+ if (!MetafSerializer.TryLoadNav(text, target, spells, out string error))
+ {
+ RecoveryNotice = MossTankProfileRecovery.Preserve(
+ _host, "route", fileName, text, new FormatException(error));
+ _host.Log.Warn(RecoveryNotice);
+ return false;
+ }
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 void SaveCurrent(NavigationSettings settings) =>
+ WriteAf(CurrentFileName(), MetafSerializer.SaveNav(settings));
public bool TryImportLegacy(
string? name,
@@ -138,7 +185,7 @@ internal sealed class MossTankRouteProfileStore
ISpellCatalog spells,
out string notice)
{
- string normalized = name?.Trim() ?? string.Empty;
+ string normalized = Normalize(name);
if (!_host.Storage.IsAvailable || normalized.Length == 0)
{
notice = "Legacy navigation storage is unavailable.";
@@ -162,64 +209,59 @@ internal sealed class MossTankRouteProfileStore
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}.";
+ string fileName = ToFileName(normalized);
+ WriteAf(fileName, MetafSerializer.SaveNav(target));
+ _selected = fileName;
+ _pendingLegacyBareName = null;
+ WriteBinding();
+ notice = $"Imported VTank navigation profile {Strip(fileName)}.";
return true;
}
+ ///
+ /// Resets only the fields this store owns (Mode/Waypoints/FollowTarget)
+ /// — Enabled/Priority/MinimumDistanceMeters/FollowAroundCorners/
+ /// OpenDoors/Door* belong to the Settings profile and are left alone.
+ ///
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));
+ // ------------------------------------------------------------------
+ // Legacy JSON -> .af migration.
+ // ------------------------------------------------------------------
- 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)
+ private void MigrateLegacyIfNeeded(NavigationSettings target, ISpellCatalog spells)
{
- string identity = (byCharacter ? "char:" : "named:")
- + value.Trim().ToUpperInvariant();
- string hash = Convert.ToHexString(
- SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
- return $"profiles/route/{hash}.json";
+ string fileName = CurrentFileName();
+ if (!VtankStorage.IsAvailable || VtankStorage.ReadText(fileName) is not null)
+ {
+ _pendingLegacyBareName = null;
+ return;
+ }
+ string legacyKey = _pendingLegacyBareName is { Length: > 0 } bareName
+ ? LegacyProfileKey(bareName, byCharacter: false)
+ : LegacyProfileKey(_characterName, byCharacter: true);
+ LegacyRouteDocument? legacy = ReadLegacyJson(legacyKey);
+ if (legacy is null)
+ {
+ _pendingLegacyBareName = null;
+ return;
+ }
+ legacy.ApplyRouteOnly(target);
+ WriteAf(fileName, MetafSerializer.SaveNav(target));
+ if (_host.Storage.IsAvailable)
+ _host.Storage.Delete(legacyKey);
+ _pendingLegacyBareName = null;
+ _host.Log.Warn($"Migrated legacy MossTank route profile '{legacyKey}' to '{fileName}'.");
}
- private string SelectionKey() => string.IsNullOrWhiteSpace(_characterName)
- ? "_default"
- : _characterName;
-
- private T? Read(string key) where T : class
+ private LegacyRouteDocument? ReadLegacyJson(string key)
{
if (!_host.Storage.IsAvailable)
return null;
@@ -229,28 +271,49 @@ internal sealed class MossTankRouteProfileStore
json = _host.Storage.ReadText(key);
return string.IsNullOrWhiteSpace(json)
? null
- : JsonSerializer.Deserialize(json, Options);
+ : JsonSerializer.Deserialize(json, JsonOptions);
}
catch (Exception error)
{
- RecoveryNotice = MossTankProfileRecovery.Preserve(
- _host,
- "route",
- key,
- json,
- error);
+ RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "route", key, json, error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
- private void Write(string key, T document)
+ // ------------------------------------------------------------------
+ // File naming, storage plumbing.
+ // ------------------------------------------------------------------
+
+ private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
+ ? NavPrefix + VtankProfileDirectory.AutoCharacterFileName(_characterName, Server, "af")
+ : _selected;
+
+ private static string ToFileName(string bareName) =>
+ NavPrefix + bareName + ".af";
+
+ private void WriteBinding()
{
- if (!_host.Storage.IsAvailable)
+ if (!CanBindFiles || !VtankStorage.IsAvailable)
+ return;
+ VtankProfileDirectory.VtankCharacterBinding existing =
+ VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server)
+ ?? new VtankProfileDirectory.VtankCharacterBinding(
+ string.Empty, string.Empty, CurrentFileName(), null);
+ VtankProfileDirectory.WriteCharacterBinding(
+ VtankStorage,
+ _characterName,
+ Server,
+ existing with { NavFileName = CurrentFileName() });
+ }
+
+ private void WriteAf(string fileName, string text)
+ {
+ if (!VtankStorage.IsAvailable)
return;
try
{
- _host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options));
+ VtankStorage.WriteText(fileName, text);
}
catch (Exception error)
{
@@ -258,58 +321,28 @@ internal sealed class MossTankRouteProfileStore
}
}
- private void SaveIndex() => Write(IndexKey, _index);
+ private static string Normalize(string? name) => name?.Trim() ?? string.Empty;
- ///
- /// .af is the only VTank-compatible export format now (Campaign
- /// VT slice 1 Part A — MossTank no longer authors the binary
- /// .nav format at all, matching 's
- /// demotion to a one-shot import). Exports live under
- /// exports/nav/ — a distinct subdirectory from
- /// 's exports/meta/ — so a
- /// route (Navigation) profile and a Meta profile sharing the same name
- /// cannot silently overwrite each other's .af file (both stores
- /// used to write into the same flat exports/ root).
- ///
- private void WriteLegacyExport(string name, NavigationSettings settings)
+ private static string LegacyProfileKey(string value, bool byCharacter)
{
- if (!_host.Storage.IsAvailable)
- return;
- try
- {
- _host.Storage.WriteText(
- $"exports/nav/{LegacyFileName(name)}.af",
- MetafSerializer.SaveNav(settings));
- }
- catch (Exception error)
- {
- _host.Log.Warn(
- $"MossTank VTank navigation export could not be saved: {error.Message}");
- }
+ string identity = (byCharacter ? "char:" : "named:") + value.Trim().ToUpperInvariant();
+ string hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
+ return $"profiles/route/{hash}.json";
}
- private static string LegacyFileName(string name)
+ private static readonly JsonSerializerOptions JsonOptions = new()
{
- 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();
- }
+ WriteIndented = true,
+ PropertyNameCaseInsensitive = true,
+ };
- private sealed class IndexDocument
- {
- public int Version { get; set; } = 1;
- public List Names { get; set; } = [];
- public Dictionary SelectedByCharacter { get; set; } =
- new(StringComparer.OrdinalIgnoreCase);
- }
-
- private sealed class RouteDocument
+ // ------------------------------------------------------------------
+ // Migration-only: the OLD full route document shape, kept solely so a
+ // not-yet-migrated JSON profile can still be read once and converted.
+ // Only the fields this store still owns (Mode/Waypoints/FollowTarget)
+ // are applied — the rest belonged to the Settings profile even before
+ // the cutover and are left to whatever that profile already holds.
+ private sealed class LegacyRouteDocument
{
public int Version { get; set; } = 1;
public bool Enabled { get; set; }
@@ -323,50 +356,20 @@ internal sealed class MossTankRouteProfileStore
public double DoorIdentifyRangeMeters { get; set; } = 20d;
public double DoorOpenRangeMeters { get; set; } = 4d;
public int DoorLockpickExcessThreshold { get; set; } = -50;
- public WaypointDocument[] Waypoints { get; set; } = [];
+ public LegacyWaypointDocument[] Waypoints { get; set; } = [];
- public static RouteDocument Capture(NavigationSettings value) => new()
+ public void ApplyRouteOnly(NavigationSettings value)
{
- 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 ?? [])
+ foreach (LegacyWaypointDocument waypoint in Waypoints ?? [])
value.Waypoints.Add(waypoint.ToWaypoint());
}
}
- private sealed class WaypointDocument
+ private sealed class LegacyWaypointDocument
{
public RouteWaypointType Type { get; set; }
public uint CellId { get; set; }
@@ -375,12 +378,6 @@ internal sealed class MossTankRouteProfileStore
public double Elevation { get; set; }
public float HeadingDegrees { get; set; }
public bool IsOutdoor { get; set; }
- ///
- /// The second coordinate triple Portal2/UseNPC waypoints carry
- /// (VTank's embedded "d"-record / metaf's "tgtxyz" — see
- /// ). Unused for every
- /// other waypoint type.
- ///
public uint ReferenceCellId { get; set; }
public double ReferenceEastWest { get; set; }
public double ReferenceNorthSouth { get; set; }
@@ -401,46 +398,11 @@ internal sealed class MossTankRouteProfileStore
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,
- ReferenceCellId = value.ReferencePosition.CellId,
- ReferenceEastWest = value.ReferencePosition.EastWest,
- ReferenceNorthSouth = value.ReferencePosition.NorthSouth,
- ReferenceElevation = value.ReferencePosition.Elevation,
- ReferenceHeadingDegrees = value.ReferencePosition.HeadingDegrees,
- ReferenceIsOutdoor = value.ReferencePosition.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),
+ CellId, EastWest, NorthSouth, Elevation, HeadingDegrees, IsOutdoor),
ReferencePosition = new PluginNavigationPosition(
ReferenceCellId,
ReferenceEastWest,
@@ -457,17 +419,10 @@ internal sealed class MossTankRouteProfileStore
Recall = Enum.IsDefined(Recall) ? Recall : RouteRecallKind.Lifestone,
RecallSpellId = RecallSpellId,
RecallSpellName = RecallSpellName ?? string.Empty,
- JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees)
- ? JumpHeadingDegrees
- : 0f,
+ JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees) ? JumpHeadingDegrees : 0f,
JumpRun = JumpRun,
- JumpChargeMilliseconds = Math.Clamp(
- JumpChargeMilliseconds,
- 0,
- 10_000),
- JumpDirection = Enum.IsDefined(JumpDirection)
- ? JumpDirection
- : RouteJumpDirection.Forward,
+ JumpChargeMilliseconds = Math.Clamp(JumpChargeMilliseconds, 0, 10_000),
+ JumpDirection = Enum.IsDefined(JumpDirection) ? JumpDirection : RouteJumpDirection.Forward,
};
}
}
diff --git a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs
index 62ffb2d79..dae614c9a 100644
--- a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs
+++ b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs
@@ -102,6 +102,117 @@ public sealed class MossTankPanelTests
Assert.Equal(0.5d, panel.EvaluateExpression("uboptget['AttackDistance']").AsNumber(), precision: 7);
}
+ ///
+ /// Reproduces MossTankMetaProfileStore's pre-cutover by-character JSON
+ /// hash key (its own Hash(string) — 12-byte truncated SHA256,
+ /// lowercase hex — is private; the format is the migration contract
+ /// itself, reproduced verbatim here).
+ ///
+ private static string LegacyMetaByCharacterKey(string characterName)
+ {
+ byte[] hash = System.Security.Cryptography.SHA256.HashData(
+ System.Text.Encoding.UTF8.GetBytes(characterName.ToLowerInvariant()));
+ return $"profiles/meta/by-character/{Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant()}.json";
+ }
+
+ [Fact]
+ public void MetaStoreMigratesLegacyJsonProfileToAfAndDeletesTheJsonKey()
+ {
+ var storage = new MemoryStorage();
+ string legacyKey = LegacyMetaByCharacterKey("Barris");
+ var legacyProfile = new MetaProfile
+ {
+ Rules =
+ [
+ new MetaRule
+ {
+ State = "Default",
+ Condition = MetaCondition.Always(),
+ Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say hi" },
+ Enabled = true,
+ },
+ ],
+ };
+ storage.Text[legacyKey] = System.Text.Json.JsonSerializer.Serialize(
+ legacyProfile,
+ new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
+
+ var store = new MossTankMetaProfileStore(
+ new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
+ store.BindCharacter("Barris");
+ MetaProfile loaded = store.LoadCurrent();
+
+ Assert.False(storage.Text.ContainsKey(legacyKey));
+ MetaRule rule = Assert.Single(loaded.Rules);
+ Assert.Equal("/say hi", rule.Action.Text);
+
+ // Idempotent second run: nothing left to migrate, loads straight
+ // from the now-real .af file.
+ var reopened = new MossTankMetaProfileStore(
+ new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
+ reopened.BindCharacter("Barris");
+ MetaProfile reloaded = reopened.LoadCurrent();
+ Assert.Single(reloaded.Rules);
+ }
+
+ [Fact]
+ public void MetaStoreLeavesLegacyJsonUntouchedWhenAfCounterpartExists()
+ {
+ var storage = new MemoryStorage();
+ string legacyKey = LegacyMetaByCharacterKey("Barris");
+ storage.Text[legacyKey] = System.Text.Json.JsonSerializer.Serialize(new MetaProfile
+ {
+ Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say stale" } }],
+ });
+ string realKey = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "af");
+ storage.Text[realKey] = MetafSerializer.SaveMeta(new MetaProfile
+ {
+ Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say real" } }],
+ });
+
+ var store = new MossTankMetaProfileStore(
+ new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
+ store.BindCharacter("Barris");
+ MetaProfile loaded = store.LoadCurrent();
+
+ Assert.True(storage.Text.ContainsKey(legacyKey));
+ Assert.Equal("/say real", Assert.Single(loaded.Rules).Action.Text);
+ }
+
+ [Fact]
+ public void MetaStoreRefusesToSaveADisabledRuleAndKeepsThePriorAfContent()
+ {
+ var storage = new MemoryStorage();
+ var store = new MossTankMetaProfileStore(
+ new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
+ store.BindCharacter("Barris");
+ var enabledOnly = new MetaProfile
+ {
+ Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say good" } }],
+ };
+ Assert.True(store.SaveCurrent(enabledOnly));
+ string key = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "af");
+ string goodContent = storage.Text[key];
+
+ var withDisabledRule = new MetaProfile
+ {
+ Rules =
+ [
+ new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say good" } },
+ new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say off" }, Enabled = false },
+ ],
+ };
+
+ bool saved = store.SaveCurrent(withDisabledRule);
+
+ Assert.False(saved);
+ Assert.NotNull(store.SaveNotice);
+ Assert.Contains("disabled", store.SaveNotice, StringComparison.OrdinalIgnoreCase);
+ // The refusal must not silently drop the disabled rule: the file on
+ // disk keeps its last good, fully-representable content.
+ Assert.Equal(goodContent, storage.Text[key]);
+ }
+
[Fact]
public void FirstRunGuidanceExplainsProfilesImportsAndPersistentShelfOnce()
{
@@ -764,22 +875,24 @@ public sealed class MossTankPanelTests
Assert.Single(panel.RouteRows);
Assert.Contains("12.5", panel.RouteRows[0], StringComparison.Ordinal);
- // .af is the only VTank-compatible export format now (Campaign VT
- // slice 1 Part A — the .nav writer was deleted, one-shot import
- // only).
+ // .af is the ONLY storage/authoring format now (Campaign VT slice 1
+ // Part A round 2 — the old "exports/nav/" side-car mirror is gone;
+ // this is the real route file, named with the "nav_" prefix so it
+ // never collides with a Meta profile of the same user-typed name in
+ // the shared VtankProfiles directory).
Command(panel, "nav save Exported.nav");
// Item E (slice-1 fix round): the writer now prepends metaf's own
// navHeader block, so the file no longer STARTS with "NAV: " —
// Contains proves the body is still there.
Assert.Contains(
"NAV: ",
- storage.Text["exports/nav/Exported.af"],
+ storage.Text["nav_Exported.af"],
StringComparison.Ordinal);
}
// Item J (slice-1 fix round): "/vt nav save Foo.af" used to keep the
// ".af" suffix (only ".nav" was stripped), producing a doubled
- // "exports/nav/Foo.af.af" export instead of "exports/nav/Foo.af".
+ // "nav_Foo.af.af" export instead of "nav_Foo.af".
[Fact]
public void NavSaveAcceptsAnAfSuffixedNameWithoutDoublingIt()
{
@@ -788,17 +901,21 @@ public sealed class MossTankPanelTests
Command(panel, "nav save Foo.af");
- Assert.True(storage.Text.ContainsKey("exports/nav/Foo.af"));
- Assert.False(storage.Text.ContainsKey("exports/nav/Foo.af.af"));
+ Assert.True(storage.Text.ContainsKey("nav_Foo.af"));
+ Assert.False(storage.Text.ContainsKey("nav_Foo.af.af"));
}
// Item C (Campaign VT slice-1 fix round): a Meta profile and a route
// (Navigation) profile named identically used to write into the SAME
// flat "exports/" directory — "Same.af" from one silently clobbered
- // "Same.af" from the other. exports/meta/ and exports/nav/ are
- // separate subdirectories so both coexist.
+ // "Same.af" from the other. Campaign VT slice-1 round 2 replaced the
+ // separate exports/meta/ + exports/nav/ side-car mirrors with the real
+ // storage cutover: both stores now write directly into the shared
+ // VtankProfiles directory, so the same collision risk exists there
+ // instead — resolved by the route store's "nav_" file-name prefix
+ // (metaf's own observed convention for a stand-alone nav .af).
[Fact]
- public void MetaAndRouteExportsWithTheSameNameDoNotCollide()
+ public void MetaAndRouteProfilesWithTheSameNameDoNotCollide()
{
var storage = new MemoryStorage();
storage.Text["imports/Same.met"] =
@@ -812,15 +929,15 @@ public sealed class MossTankPanelTests
Command(panel, "meta save Same.met");
Command(panel, "nav save Same.nav");
- Assert.True(storage.Text.ContainsKey("exports/meta/Same.af"));
- Assert.True(storage.Text.ContainsKey("exports/nav/Same.af"));
+ Assert.True(storage.Text.ContainsKey("Same.af"));
+ Assert.True(storage.Text.ContainsKey("nav_Same.af"));
Assert.Contains(
"STATE: ",
- storage.Text["exports/meta/Same.af"],
+ storage.Text["Same.af"],
StringComparison.Ordinal);
Assert.Contains(
"NAV: ",
- storage.Text["exports/nav/Same.af"],
+ storage.Text["nav_Same.af"],
StringComparison.Ordinal);
}
@@ -851,16 +968,17 @@ public sealed class MossTankPanelTests
Assert.Single(panel.MetaRows);
Assert.Contains("/say imported", panel.MetaRows[0], StringComparison.Ordinal);
- // .af is the only VTank-compatible export format now (Campaign VT
- // slice 1 Part A — the .met writer was deleted, one-shot import
- // only).
+ // .af is the ONLY storage/authoring format now (Campaign VT slice 1
+ // Part A round 2 — the .met writer was deleted, one-shot import
+ // only, and the old "exports/meta/" side-car mirror is gone: this
+ // IS the real profile file).
Command(panel, "meta save Exported.met");
Assert.Contains(
"STATE: ",
- storage.Text["exports/meta/Exported.af"],
+ storage.Text["Exported.af"],
StringComparison.Ordinal);
Assert.True(MetafSerializer.TryLoadMeta(
- storage.Text["exports/meta/Exported.af"],
+ storage.Text["Exported.af"],
NoOpSpellCatalogForExport.Instance,
out MetaProfile exported,
out string error), error);
@@ -877,8 +995,8 @@ public sealed class MossTankPanelTests
Command(panel, "meta save Foo.af");
- Assert.True(storage.Text.ContainsKey("exports/meta/Foo.af"));
- Assert.False(storage.Text.ContainsKey("exports/meta/Foo.af.af"));
+ Assert.True(storage.Text.ContainsKey("Foo.af"));
+ Assert.False(storage.Text.ContainsKey("Foo.af.af"));
}
private sealed class NoOpSpellCatalogForExport : ISpellCatalog
diff --git a/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs b/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs
index 6d761fbd1..fdac3af75 100644
--- a/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs
+++ b/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs
@@ -379,24 +379,23 @@ public sealed class NavigationTests
Assert.False(controller.Tick(0.01d, canAct: true));
}
+ // Campaign VT slice 1 Part A round 2: the route (.af) file now carries
+ // ONLY Mode/Waypoints/FollowTarget — Enabled/Priority/
+ // MinimumDistanceMeters/FollowAroundCorners/OpenDoors/Door* are real
+ // VTank Settings-table rows (EnableNav, NavPriorityBoost,
+ // NavCloseStopRange, …) owned end-to-end by MossTankProfileStore's
+ // .usd profile now, matching real VTank's own split between global nav
+ // prefs and the per-route file. Renamed from
+ // RouteProfilesRoundTripEveryWaypointField, which asserted the pre-
+ // cutover behavior where the route JSON carried everything.
[Fact]
- public void RouteProfilesRoundTripEveryWaypointField()
+ public void RouteProfilesRoundTripWaypointFieldsButLeaveSettingsOwnedFieldsAlone()
{
var storage = new MemoryStorage();
var host = new FakeHost(new FakeAutomation(), storage);
var source = new NavigationSettings
{
- Enabled = true,
- Priority = true,
Mode = RouteMode.Linear,
- MinimumDistanceMeters = 4.5d,
- FollowTargetObjectId = 99u,
- FollowTargetName = "Leader",
- FollowAroundCorners = false,
- OpenDoors = true,
- DoorIdentifyRangeMeters = 35d,
- DoorOpenRangeMeters = 3.5d,
- DoorLockpickExcessThreshold = 17,
};
source.Waypoints.Add(new RouteWaypoint
{
@@ -416,28 +415,72 @@ public sealed class NavigationTests
Assert.True(first.BindCharacter("Test Character"));
first.SaveCurrent(source);
- var target = new NavigationSettings();
+ // Pre-seed values a Settings-profile load would already have set —
+ // loading the route must leave every one of them untouched.
+ var target = new NavigationSettings
+ {
+ Enabled = false,
+ Priority = false,
+ MinimumDistanceMeters = 9d,
+ FollowAroundCorners = true,
+ OpenDoors = false,
+ DoorIdentifyRangeMeters = 11d,
+ DoorOpenRangeMeters = 1d,
+ DoorLockpickExcessThreshold = -3,
+ };
var second = new MossTankRouteProfileStore(host);
Assert.True(second.BindCharacter("Test Character"));
- Assert.True(second.LoadCurrent(target));
+ Assert.True(second.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance));
- Assert.True(target.Enabled);
- Assert.True(target.Priority);
Assert.Equal(RouteMode.Linear, target.Mode);
- Assert.Equal(4.5d, target.MinimumDistanceMeters);
- Assert.Equal(99u, target.FollowTargetObjectId);
- Assert.False(target.FollowAroundCorners);
- Assert.True(target.OpenDoors);
- Assert.Equal(35d, target.DoorIdentifyRangeMeters);
- Assert.Equal(3.5d, target.DoorOpenRangeMeters);
- Assert.Equal(17, target.DoorLockpickExcessThreshold);
RouteWaypoint waypoint = Assert.Single(target.Waypoints);
Assert.Equal(RouteWaypointType.Jump, waypoint.Type);
Assert.Equal(271.5f, waypoint.JumpHeadingDegrees);
Assert.True(waypoint.JumpRun);
Assert.Equal(875, waypoint.JumpChargeMilliseconds);
- Assert.Equal(RouteJumpDirection.StrafeRight, waypoint.JumpDirection);
- Assert.Equal(0x7F7F0001u, waypoint.Position.CellId);
+ // .af cannot represent JumpDirection at all (MetafSerializer.cs:924
+ // — a pre-existing, recorded divergence-register gap, not
+ // something this store cutover changes): it always comes back at
+ // the model's own default, Forward, regardless of what was saved.
+ Assert.Equal(RouteJumpDirection.Forward, waypoint.JumpDirection);
+ // metaf's "jmp" node format carries no cell id either (FORMAT: jmp
+ // x y z heading run chargems — six fields, no hex cell component;
+ // MetafSerializer.cs:915-935), so Position.CellId is not asserted
+ // for a Jump waypoint specifically.
+
+ Assert.False(target.Enabled);
+ Assert.False(target.Priority);
+ Assert.Equal(9d, target.MinimumDistanceMeters);
+ Assert.True(target.FollowAroundCorners);
+ Assert.False(target.OpenDoors);
+ Assert.Equal(11d, target.DoorIdentifyRangeMeters);
+ Assert.Equal(1d, target.DoorOpenRangeMeters);
+ Assert.Equal(-3, target.DoorLockpickExcessThreshold);
+ }
+
+ [Fact]
+ public void FollowModeRouteRoundTripsTheFollowTargetThroughAf()
+ {
+ var storage = new MemoryStorage();
+ var host = new FakeHost(new FakeAutomation(), storage);
+ var source = new NavigationSettings
+ {
+ Mode = RouteMode.Target,
+ FollowTargetObjectId = 99u,
+ FollowTargetName = "Leader",
+ };
+ var first = new MossTankRouteProfileStore(host);
+ Assert.True(first.BindCharacter("Test Character"));
+ first.SaveCurrent(source);
+
+ var target = new NavigationSettings();
+ var second = new MossTankRouteProfileStore(host);
+ Assert.True(second.BindCharacter("Test Character"));
+ Assert.True(second.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance));
+
+ Assert.Equal(RouteMode.Target, target.Mode);
+ Assert.Equal(99u, target.FollowTargetObjectId);
+ Assert.Equal("Leader", target.FollowTargetName);
}
private static NavigationController Controller(
@@ -496,6 +539,9 @@ public sealed class NavigationTests
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IPluginStorage Storage { get; } = storage ?? NoOpPluginStorage.Instance;
public IAutomationSurface Automation { get; } = automation;
+ // Real VTank .usd/.af/.cdf storage, same backing store as the
+ // plugin's own JSON storage (key namespaces never collide).
+ public IPluginStorage VtankProfiles { get; } = storage ?? NoOpPluginStorage.Instance;
}
private sealed class FakeAutomation
@@ -576,6 +622,11 @@ public sealed class NavigationTests
public bool IsAvailable => true;
public string? ReadText(string key) =>
_text.TryGetValue(key, out string? value) ? value : null;
+ public IReadOnlyList List(string prefix) => _text.Keys
+ .Where(key => prefix.Length == 0
+ || key.StartsWith(prefix + "/", StringComparison.Ordinal))
+ .OrderBy(static key => key, StringComparer.Ordinal)
+ .ToArray();
public void WriteText(string key, string content) => _text[key] = content;
public bool Delete(string key) => _text.Remove(key);
}