diff --git a/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs index ce1e1e003..975117975 100644 --- a/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs +++ b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs @@ -20,10 +20,20 @@ internal sealed class MossTankMetaProfileStore { public const string ByCharacter = "By char"; + // The pre-cutover roster (round 3 item 2): before the .af cutover this + // store kept a flat Names list here (unlike the settings roster, named + // Meta profiles were never owner-scoped — one shared, globally-hashed + // key per name). The cutover stopped reading this key at all, orphaning + // every named profile except whichever one a character had selected + // (that one alone still converts, via MigrateLegacyIfNeeded). Read-only: + // consulted ONLY by the one-time SweepLegacyRosterIfNeeded. + private const string LegacyRosterKey = "profiles/meta/index.json"; + private readonly IPluginHost _host; private string _character = string.Empty; private string _selected = ByCharacter; private string? _pendingLegacyBareName; + private bool _rosterSwept; public MossTankMetaProfileStore(IPluginHost host) { @@ -86,6 +96,7 @@ internal sealed class MossTankMetaProfileStore public MetaProfile LoadCurrent() { + SweepLegacyRosterIfNeeded(); MigrateLegacyIfNeeded(); string fileName = CurrentFileName(); string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null; @@ -261,6 +272,94 @@ internal sealed class MossTankMetaProfileStore // Legacy JSON -> .af migration. // ------------------------------------------------------------------ + /// + /// Round 3 item 2: a ONE-TIME sweep (guarded by ) + /// over the pre-cutover roster at . Unlike + /// — which only ever converts the + /// ONE currently-selected profile — this converts every OTHER named + /// Meta profile the old roster still lists. Named Meta profiles were + /// never owner-scoped (one shared, globally-hashed key per name), so — + /// unlike the settings roster — every entry converts regardless of + /// which character is currently bound. + /// + private void SweepLegacyRosterIfNeeded() + { + if (_rosterSwept) + return; + _rosterSwept = true; + if (!_host.Storage.IsAvailable) + return; + List? names = ReadRosterNames(); + if (names is not { Count: > 0 }) + return; + + var remaining = new List(); + int migrated = 0; + foreach (string rawName in names) + { + string name = (rawName ?? string.Empty).Trim(); + if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + continue; // stale/invalid row; drop it rather than loop on it forever. + + string legacyKey = LegacyNamedKey(name); + MetaProfile? legacy = ReadLegacyJson(legacyKey); + if (legacy is null) + continue; // already converted (or never existed); drop the row. + + string fileName = name.EndsWith(".af", StringComparison.OrdinalIgnoreCase) + ? name + : name + ".af"; + if (VtankStorage.IsAvailable && VtankStorage.ReadText(fileName) is null) + { + if (!SaveTo(fileName, legacy, out string notice)) + { + // Representational loss (a disabled rule) — same gate as + // MigrateLegacyIfNeeded: keep the row so this can be + // retried once the user resolves it. + _host.Log.Warn($"MossTank could not sweep legacy Meta profile '{name}': {notice}"); + remaining.Add(name); + continue; + } + } + _host.Storage.Delete(legacyKey); + migrated++; + } + + if (migrated == 0) + return; + + if (remaining.Count > 0) + { + _host.Storage.WriteText( + LegacyRosterKey, + JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, JsonOptions)); + } + else + { + _host.Storage.Delete(LegacyRosterKey); + } + _host.Log.Warn($"Migrated {migrated} legacy MossTank named Meta profile(s) from the old roster."); + } + + private List? ReadRosterNames() + { + string? json = null; + try + { + json = _host.Storage.ReadText(LegacyRosterKey); + return string.IsNullOrWhiteSpace(json) + ? null + : JsonSerializer.Deserialize(json, JsonOptions)?.Names; + } + catch (Exception error) + { + RecoveryNotice = MossTankProfileRecovery.Preserve( + _host, "meta", LegacyRosterKey, json, error); + _host.Log.Warn(RecoveryNotice); + return null; + } + } + private void MigrateLegacyIfNeeded() { string fileName = CurrentFileName(); @@ -395,4 +494,11 @@ internal sealed class MossTankMetaProfileStore WriteIndented = true, PropertyNameCaseInsensitive = true, }; + + // Read-only: the pre-cutover roster shape at LegacyRosterKey, kept + // solely so SweepLegacyRosterIfNeeded can recover it once. + private sealed class LegacyRosterDocument + { + public List Names { get; set; } = []; + } } diff --git a/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs index 9ea020150..29fd46fb4 100644 --- a/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs +++ b/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs @@ -23,11 +23,18 @@ internal sealed class MossTankProfileStore // Ancient pre-hash single-profile fallback (the very first shape this // store ever wrote). Read-only: migration consults it, nothing else does. private const string OldSingleProfileKey = "profile.json"; - // Reuses the pre-cutover index key deliberately: this store never reads - // its old "Profiles"/"SelectedByCharacter" shape back (both concepts are - // now resolved from real files + the .cdf binding), so the collision is - // harmless and existing corrupt-recovery behavior keeps its original key. - private const string PreferencesKey = "profiles/index.json"; + // The pre-cutover full index document (round 3 item 2): its OLD shape + // carried the entire named-profile roster (Profiles: [{Name, Owner}]) + // plus MineOnly/SelectedByCharacter. The settings cutover (round 2 step + // 1) started reusing this exact key for a MineOnly-only shape, which + // silently discarded any roster a pre-cutover install still had on disk + // the next time SetMineOnly() saved — read-only here now, and consulted + // ONLY for the one-time roster sweep (SweepLegacyRosterIfNeeded); + // MineOnly itself lives at the NEW PreferencesKey below from now on so + // this key is never again written in the flattened shape that caused + // the loss. + private const string LegacyRosterKey = "profiles/index.json"; + private const string PreferencesKey = "profiles/macro/preferences.json"; private const string SideCarDirectory = "profiles/macro/sidecar/"; private static readonly JsonSerializerOptions Options = new() @@ -43,11 +50,14 @@ internal sealed class MossTankProfileStore private string? _pendingLegacyBareName; private VtankDatabase? _currentDatabase; private string? _currentDatabaseFileName; + private bool _rosterSwept; public MossTankProfileStore(IPluginHost host) { _host = host ?? throw new ArgumentNullException(nameof(host)); - _preferences = ReadJson(PreferencesKey) ?? new PreferencesDocument(); + _preferences = ReadJson(PreferencesKey) + ?? ReadJson(LegacyRosterKey) + ?? new PreferencesDocument(); } public string Selected => _selected; @@ -248,6 +258,7 @@ internal sealed class MossTankProfileStore VtankSettingsProfileSerializer.AllSettings settings, ISet noBuffItemNames) { + SweepLegacyRosterIfNeeded(); MigrateLegacyIfNeeded(settings, noBuffItemNames); string fileName = CurrentFileName(); string? text = ReadUsdText(fileName); @@ -405,6 +416,96 @@ internal sealed class MossTankProfileStore // Legacy JSON -> .usd/.json side-car migration. // ------------------------------------------------------------------ + /// + /// Round 3 item 2: a ONE-TIME sweep (guarded by , + /// so repeat calls from are a no-op) over the + /// pre-cutover roster document at . Unlike + /// — which only ever converts the + /// ONE currently-selected profile — this converts every OTHER named + /// profile the old roster still lists, so a character's whole macro + /// library survives the cutover rather than only whichever profile + /// happened to be selected at the time. + /// + /// A roster entry's real legacy document has no server dimension at all + /// (pre-cutover named profiles were one flat, globally-hashed key), but + /// its NEW .usd home is a per-(character,server) sub-profile + /// () — the same + /// target already uses for the + /// selected profile. That requires a server, which is only known for + /// the CURRENTLY bound character, so an entry owned by a different + /// character is left in the roster (rewritten back minus whatever this + /// pass did convert) for that character's own future session to finish. + /// + private void SweepLegacyRosterIfNeeded() + { + if (_rosterSwept) + return; + _rosterSwept = true; + LegacyRosterDocument? roster = ReadJson(LegacyRosterKey); + if (roster?.Profiles is not { Count: > 0 } profiles) + return; + + var remaining = new List(); + int migrated = 0; + foreach (LegacyRosterEntry entry in profiles) + { + string name = (entry.Name ?? string.Empty).Trim(); + if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + continue; // stale/invalid row; drop it rather than loop on it forever. + + bool ownedByCurrentCharacter = string.IsNullOrWhiteSpace(entry.Owner) + || entry.Owner.Trim().Equals(_characterName, StringComparison.OrdinalIgnoreCase); + if (!ownedByCurrentCharacter) + { + remaining.Add(entry); + continue; + } + + string legacyKey = LegacyProfileKey(name, byCharacter: false); + LegacyProfileDocument? legacy = ReadJson(legacyKey); + if (legacy is null) + continue; // already converted (or never existed); drop the row. + + string fileName = VtankProfileDirectory.SubProfilePrefix(_characterName, Server) + + name + ".usd"; + if (ReadUsdText(fileName) is null) + { + var settings = new VtankSettingsProfileSerializer.AllSettings + { + Combat = new CombatSettings(), + Buffs = new BuffSettings(), + Vitals = new VitalSettings(), + Inventory = new InventorySettings(), + Navigation = new NavigationSettings(), + }; + var noBuffItemNames = new HashSet(StringComparer.Ordinal); + legacy.Apply( + settings.Combat, settings.Buffs, settings.Vitals, settings.Inventory, noBuffItemNames); + VtankDatabase database = VtankSettingsProfileSerializer.CreateNew(settings); + WriteUsdText(fileName, database.Render()); + WriteJson(SideCarKey(fileName), SideCarDocument.Capture(settings, noBuffItemNames)); + } + if (_host.Storage.IsAvailable) + _host.Storage.Delete(legacyKey); + migrated++; + } + + if (migrated == 0) + return; + + if (remaining.Count > 0) + WriteJson(LegacyRosterKey, new LegacyRosterDocument { Profiles = remaining }); + else if (_host.Storage.IsAvailable) + _host.Storage.Delete(LegacyRosterKey); + // MineOnly's own migration to the new PreferencesKey is otherwise + // lazy (only SetMineOnly() writes it) — make it durable the moment + // this sweep runs so a user who never toggles the checkbox still + // stops depending on the old, now-legacy-only key. + WriteJson(PreferencesKey, _preferences); + _host.Log.Warn( + $"Migrated {migrated} legacy MossTank named settings profile(s) from the old roster."); + } + /// /// Runs once per store (per selection) when the resolved .usd /// file does not exist yet but the equivalent old JSON profile document @@ -594,6 +695,24 @@ internal sealed class MossTankProfileStore public bool MineOnly { get; set; } = true; } + // ------------------------------------------------------------------ + // Read-only: the pre-cutover full roster shape at LegacyRosterKey, kept + // solely so SweepLegacyRosterIfNeeded can recover it once. Nothing here + // writes the OLD "Version"/"SelectedByCharacter" fields back — only a + // residual Profiles list (entries this session couldn't yet convert). + // ------------------------------------------------------------------ + + private sealed class LegacyRosterDocument + { + public List Profiles { get; set; } = []; + } + + private sealed class LegacyRosterEntry + { + public string Name { get; set; } = string.Empty; + public string Owner { get; set; } = string.Empty; + } + // ------------------------------------------------------------------ // MossTank-only residual state: everything with no VTank setting name // (item/consumable name lists, the monster-rule table, and a handful of diff --git a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs index db1432a82..94c3505bd 100644 --- a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs +++ b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs @@ -32,11 +32,20 @@ internal sealed class MossTankRouteProfileStore { public const string ByCharacter = "By char"; private const string NavPrefix = "nav_"; + // The pre-cutover roster (round 3 item 2): before the .af cutover this + // store kept a flat Names list here (named routes were never + // owner-scoped — one shared, globally-hashed key per name). The + // cutover stopped reading this key at all, orphaning every named route + // except whichever one a character had selected (that one alone still + // converts, via MigrateLegacyIfNeeded). Read-only: consulted ONLY by + // the one-time SweepLegacyRosterIfNeeded. + private const string LegacyRosterKey = "profiles/route/index.json"; private readonly IPluginHost _host; private string _characterName = string.Empty; private string _selected = ByCharacter; private string? _pendingLegacyBareName; + private bool _rosterSwept; public MossTankRouteProfileStore(IPluginHost host) { @@ -161,6 +170,7 @@ internal sealed class MossTankRouteProfileStore { ArgumentNullException.ThrowIfNull(target); ArgumentNullException.ThrowIfNull(spells); + SweepLegacyRosterIfNeeded(); MigrateLegacyIfNeeded(target, spells); string fileName = CurrentFileName(); string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null; @@ -236,6 +246,86 @@ internal sealed class MossTankRouteProfileStore // Legacy JSON -> .af migration. // ------------------------------------------------------------------ + /// + /// Round 3 item 2: a ONE-TIME sweep (guarded by ) + /// over the pre-cutover roster at . Unlike + /// — which only ever converts the + /// ONE currently-selected route — this converts every OTHER named route + /// the old roster still lists, using a fresh + /// per entry so the live/selected route is never touched. Named routes + /// were never owner-scoped (one shared, globally-hashed key per name), + /// so every entry converts regardless of which character is currently + /// bound. + /// + private void SweepLegacyRosterIfNeeded() + { + if (_rosterSwept) + return; + _rosterSwept = true; + if (!_host.Storage.IsAvailable) + return; + List? names = ReadRosterNames(); + if (names is not { Count: > 0 }) + return; + + var remaining = new List(); + int migrated = 0; + foreach (string rawName in names) + { + string name = (rawName ?? string.Empty).Trim(); + if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + continue; // stale/invalid row; drop it rather than loop on it forever. + + string legacyKey = LegacyProfileKey(name, byCharacter: false); + LegacyRouteDocument? legacy = ReadLegacyJson(legacyKey); + if (legacy is null) + continue; // already converted (or never existed); drop the row. + + string fileName = ToFileName(name); + if (VtankStorage.IsAvailable && VtankStorage.ReadText(fileName) is null) + { + var scratch = new NavigationSettings(); + legacy.ApplyRouteOnly(scratch); + WriteAf(fileName, MetafSerializer.SaveNav(scratch)); + } + _host.Storage.Delete(legacyKey); + migrated++; + } + + if (migrated == 0) + return; + + if (remaining.Count > 0) + { + _host.Storage.WriteText( + LegacyRosterKey, + JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, JsonOptions)); + } + else + { + _host.Storage.Delete(LegacyRosterKey); + } + _host.Log.Warn($"Migrated {migrated} legacy MossTank named route profile(s) from the old roster."); + } + + private List? ReadRosterNames() + { + string? json = null; + try + { + json = _host.Storage.ReadText(LegacyRosterKey); + return string.IsNullOrWhiteSpace(json) + ? null + : JsonSerializer.Deserialize(json, JsonOptions)?.Names; + } + catch (Exception error) + { + RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "route", LegacyRosterKey, json, error); + _host.Log.Warn(RecoveryNotice); + return null; + } + } + private void MigrateLegacyIfNeeded(NavigationSettings target, ISpellCatalog spells) { string fileName = CurrentFileName(); @@ -336,6 +426,13 @@ internal sealed class MossTankRouteProfileStore PropertyNameCaseInsensitive = true, }; + // Read-only: the pre-cutover roster shape at LegacyRosterKey, kept + // solely so SweepLegacyRosterIfNeeded can recover it once. + private sealed class LegacyRosterDocument + { + public List Names { get; set; } = []; + } + // ------------------------------------------------------------------ // Migration-only: the OLD full route document shape, kept solely so a // not-yet-migrated JSON profile can still be read once and converted. diff --git a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs index 55fd81af1..64381a363 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs @@ -136,6 +136,90 @@ public sealed class MossTankPanelTests Assert.True(panel.LootEnabled); } + /// + /// Round 3 item 2: the pre-cutover "profiles/index.json" roster carried + /// EVERY named profile a character had (Profiles: [{Name, Owner}]), + /// not just whichever one happened to be selected. Before the fix, the + /// settings cutover only ever converted the currently-selected profile + /// (via MigrateLegacyIfNeeded) — every OTHER named profile in the + /// roster was silently orphaned: never converted, never listed, and (once + /// SetMineOnly saved) permanently unreachable once the shared key + /// got flattened to a bare {MineOnly} document. + /// + private static string LegacyNamedProfileKey(string name) + { + string identity = "named:" + name.Trim().ToUpperInvariant(); + string hash = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(identity))); + return $"profiles/macro/{hash}.json"; + } + + [Fact] + public void SettingsRosterSweepConvertsEveryNamedLegacyProfileOnce() + { + var storage = new MemoryStorage(); + var automation = new FakeAutomation { Name = "Barris" }; + storage.Text["profiles/index.json"] = """ + { + "Version": 1, + "MineOnly": false, + "Profiles": [ + { "Name": "Farming", "Owner": "Barris" }, + { "Name": "Buffing", "Owner": "Barris" } + ], + "SelectedByCharacter": {} + } + """; + storage.Text[LegacyNamedProfileKey("Farming")] = + """{ "Combat": { "MaximumRange": 42.0 } }"""; + storage.Text[LegacyNamedProfileKey("Buffing")] = + """{ "Combat": { "MaximumRange": 24.0 } }"""; + + var panel = new MossTankPanel(new FakeHost(automation, storage)); + + // MineOnly recovered from the old roster's own shape... + Assert.False(panel.MineOnlyEnabled); + // ...and both named profiles converted to real per-character + // sub-profile .usd files, each keeping its own distinct value. + string farmingUsd = VtankProfileDirectory.SubProfilePrefix("Barris", string.Empty) + + "Farming.usd"; + string buffingUsd = VtankProfileDirectory.SubProfilePrefix("Barris", string.Empty) + + "Buffing.usd"; + Assert.Equal(42d, RangeOf(storage, farmingUsd)); + Assert.Equal(24d, RangeOf(storage, buffingUsd)); + + // Both legacy JSON keys and the whole (now fully-swept) roster key + // are gone; MineOnly now lives at its own dedicated key. + Assert.False(storage.Text.ContainsKey(LegacyNamedProfileKey("Farming"))); + Assert.False(storage.Text.ContainsKey(LegacyNamedProfileKey("Buffing"))); + Assert.False(storage.Text.ContainsKey("profiles/index.json")); + Assert.True(storage.Text.ContainsKey("profiles/macro/preferences.json")); + + // Idempotent: a fresh panel against the same storage sweeps nothing + // more (there is no roster key left to read) and keeps both values. + var reloaded = new MossTankPanel(new FakeHost( + new FakeAutomation { Name = "Barris" }, storage)); + Assert.False(reloaded.MineOnlyEnabled); + Assert.Equal(42d, RangeOf(storage, farmingUsd)); + Assert.Equal(24d, RangeOf(storage, buffingUsd)); + } + + private static double RangeOf(MemoryStorage storage, string usdKey) + { + string text = Assert.Contains(usdKey, (IDictionary)storage.Text); + var settings = new VtankSettingsProfileSerializer.AllSettings + { + Combat = new CombatSettings(), + Buffs = new BuffSettings(), + Vitals = new VitalSettings(), + Inventory = new InventorySettings(), + Navigation = new NavigationSettings(), + }; + VtankSettingsProfileSerializer.Load(text, settings); + return settings.Combat.MaximumRange; + } + /// /// Reproduces MossTankMetaProfileStore's pre-cutover by-character JSON /// hash key (its own Hash(string) — 12-byte truncated SHA256, @@ -213,6 +297,67 @@ public sealed class MossTankPanelTests Assert.Equal("/say real", Assert.Single(loaded.Rules).Action.Text); } + /// + /// Reproduces MossTankMetaProfileStore's pre-cutover named-profile JSON + /// hash key (its own LegacyNamedKey/Hash(string) are + /// private; the format is the migration contract itself, reproduced + /// verbatim here). + /// + private static string LegacyMetaNamedKey(string name) + { + byte[] hash = System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(name.ToLowerInvariant())); + return $"profiles/meta/named/{Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant()}.json"; + } + + /// + /// Round 3 item 2: like the settings roster, MossTankMetaProfileStore's + /// pre-cutover "profiles/meta/index.json" carried EVERY named Meta + /// profile's name (never owner-scoped — one shared, globally-hashed key + /// per name), and the cutover stopped reading it entirely — orphaning + /// every named profile except whichever one happened to be selected. + /// + [Fact] + public void MetaRosterSweepConvertsEveryNamedLegacyProfileOnce() + { + var storage = new MemoryStorage(); + storage.Text["profiles/meta/index.json"] = """{ "Names": ["Farming", "Buffing"] }"""; + storage.Text[LegacyMetaNamedKey("Farming")] = System.Text.Json.JsonSerializer.Serialize( + new MetaProfile + { + Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say farming" } }], + }); + storage.Text[LegacyMetaNamedKey("Buffing")] = System.Text.Json.JsonSerializer.Serialize( + new MetaProfile + { + Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say buffing" } }], + }); + + var store = new MossTankMetaProfileStore( + new FakeHost(new FakeAutomation { Name = "Barris" }, storage)); + store.BindCharacter("Barris"); + store.LoadCurrent(); + + Assert.False(storage.Text.ContainsKey(LegacyMetaNamedKey("Farming"))); + Assert.False(storage.Text.ContainsKey(LegacyMetaNamedKey("Buffing"))); + Assert.False(storage.Text.ContainsKey("profiles/meta/index.json")); + Assert.True(MetafSerializer.TryLoadMeta( + storage.Text["Farming.af"], NoOpSpellCatalogForExport.Instance, out MetaProfile farming, out _)); + Assert.Equal("/say farming", Assert.Single(farming.Rules).Action.Text); + Assert.True(MetafSerializer.TryLoadMeta( + storage.Text["Buffing.af"], NoOpSpellCatalogForExport.Instance, out MetaProfile buffing, out _)); + Assert.Equal("/say buffing", Assert.Single(buffing.Rules).Action.Text); + + // Idempotent: a fresh store against the same storage sweeps nothing + // more (there is no roster key left to read). + var reopened = new MossTankMetaProfileStore( + new FakeHost(new FakeAutomation { Name = "Barris" }, storage)); + reopened.BindCharacter("Barris"); + reopened.LoadCurrent(); + Assert.True(storage.Text.ContainsKey("Farming.af")); + Assert.True(storage.Text.ContainsKey("Buffing.af")); + } + [Fact] public void MetaStoreRefusesToSaveADisabledRuleAndKeepsThePriorAfContent() { diff --git a/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs b/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs index 1c3ebd572..e4c71ff64 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs @@ -534,6 +534,67 @@ public sealed class NavigationTests Assert.Single(target.Waypoints); } + /// + /// Reproduces MossTankRouteProfileStore's pre-cutover named-profile JSON + /// hash key (its own LegacyProfileKey is private; the format is + /// the migration contract itself, reproduced verbatim here). + /// + private static string LegacyRouteNamedKey(string name) + { + string identity = "named:" + name.Trim().ToUpperInvariant(); + string hash = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(identity))); + return $"profiles/route/{hash}.json"; + } + + /// + /// Round 3 item 2: like the settings and Meta rosters, + /// MossTankRouteProfileStore's pre-cutover "profiles/route/index.json" + /// carried EVERY named route's name (never owner-scoped — one shared, + /// globally-hashed key per name), and the cutover stopped reading it + /// entirely — orphaning every named route except whichever one happened + /// to be selected. + /// + [Fact] + public void RouteRosterSweepConvertsEveryNamedLegacyProfileOnce() + { + var storage = new MemoryStorage(); + storage.Text["profiles/route/index.json"] = """{ "Names": ["Farming", "Buffing"] }"""; + storage.Text[LegacyRouteNamedKey("Farming")] = """ + { "Mode": 1, "Waypoints": [ { "Type": 0, "EastWest": 1.0, "NorthSouth": 2.0 } ] } + """; + storage.Text[LegacyRouteNamedKey("Buffing")] = """ + { "Mode": 1, "Waypoints": [ { "Type": 0, "EastWest": 3.0, "NorthSouth": 4.0 } ] } + """; + + var store = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage)); + Assert.True(store.BindCharacter("Barris")); + var target = new NavigationSettings(); + store.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance); + + Assert.False(storage.Text.ContainsKey(LegacyRouteNamedKey("Farming"))); + Assert.False(storage.Text.ContainsKey(LegacyRouteNamedKey("Buffing"))); + Assert.False(storage.Text.ContainsKey("profiles/route/index.json")); + var farming = new NavigationSettings(); + Assert.True(MetafSerializer.TryLoadNav( + storage.Text["nav_Farming.af"], farming, MetafSerializer.NoOpSpells.Instance, out _)); + Assert.Equal(1.0d, Assert.Single(farming.Waypoints).Position.EastWest, precision: 3); + var buffing = new NavigationSettings(); + Assert.True(MetafSerializer.TryLoadNav( + storage.Text["nav_Buffing.af"], buffing, MetafSerializer.NoOpSpells.Instance, out _)); + Assert.Equal(3.0d, Assert.Single(buffing.Waypoints).Position.EastWest, precision: 3); + + // Idempotent: a fresh store against the same storage sweeps nothing + // more (there is no roster key left to read). + var reopened = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage)); + Assert.True(reopened.BindCharacter("Barris")); + var reloadTarget = new NavigationSettings(); + reopened.LoadCurrent(reloadTarget, MetafSerializer.NoOpSpells.Instance); + Assert.True(storage.Text.ContainsKey("nav_Farming.af")); + Assert.True(storage.Text.ContainsKey("nav_Buffing.af")); + } + [Fact] public void FollowModeRouteRoundTripsTheFollowTargetThroughAf() {