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()
{