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