diff --git a/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs index 55a78fb0f..6d9a64ffb 100644 --- a/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs +++ b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs @@ -40,6 +40,7 @@ internal sealed class MossTankMetaProfileStore private string _selected = ByCharacter; private string? _pendingLegacyBareName; private bool _rosterSwept; + private bool _flatFolderMigrationSwept; public MossTankMetaProfileStore(IPluginHost host) { @@ -109,6 +110,7 @@ internal sealed class MossTankMetaProfileStore public MetaProfile LoadCurrent() { + MigrateFlatFilesToMetasFolderIfNeeded(); SweepLegacyRosterIfNeeded(); MigrateLegacyIfNeeded(); string fileName = CurrentFileName(); @@ -308,6 +310,59 @@ internal sealed class MossTankMetaProfileStore return empty; } + // ------------------------------------------------------------------ + // Slice 1c step 2: one-time flat-file -> metas/ folder migration. + // ------------------------------------------------------------------ + + /// + /// Owner decision 2026-09-07: a ONE-TIME sweep (guarded by + /// ) that moves every flat + /// root-level .af file that is NOT a legacy route file (see + /// — those + /// belong to 's own sweep) into + /// , unmarked, since Meta + /// files never carried a marker. Runs BEFORE the older + /// / + /// JSON migrations so those see the real .af content already at + /// its new folder-qualified path rather than treating it as missing. + /// When the real destination already exists, the flat file is left in + /// place untouched (never overwritten) and the collision is logged — + /// this can only happen from an external/manual file drop, since this + /// store itself never wrote a flat file. + /// + private void MigrateFlatFilesToMetasFolderIfNeeded() + { + if (_flatFolderMigrationSwept) + return; + _flatFolderMigrationSwept = true; + if (!VtankStorage.IsAvailable) + return; + int migrated = 0; + foreach (string bareName in VtankProfileDirectory.ListFlatAfFileNames(VtankStorage)) + { + if (VtankProfileDirectory.IsLegacyFlatRouteFileName(bareName)) + continue; // MossTankRouteProfileStore's own sweep owns this one. + string destination = $"{VtankProfileDirectory.MetaFolder}/{bareName}"; + if (VtankStorage.ReadText(destination) is not null) + { + _host.Log.Warn( + $"MossTank left flat Meta profile '{bareName}' in place: '{destination}' already exists."); + continue; + } + string? content = VtankStorage.ReadText(bareName); + if (content is null) + continue; // listed but unreadable; skip defensively. + VtankStorage.WriteText(destination, content); + VtankStorage.Delete(bareName); + migrated++; + } + if (migrated > 0) + { + _host.Log.Warn( + $"Migrated {migrated} flat MossTank Meta profile(s) into {VtankProfileDirectory.MetaFolder}/."); + } + } + // ------------------------------------------------------------------ // Legacy JSON -> .af migration. // ------------------------------------------------------------------ diff --git a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs index 938e2a5b4..d07eda500 100644 --- a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs +++ b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs @@ -49,6 +49,7 @@ internal sealed class MossTankRouteProfileStore private string _selected = ByCharacter; private string? _pendingLegacyBareName; private bool _rosterSwept; + private bool _flatFolderMigrationSwept; public MossTankRouteProfileStore(IPluginHost host) { @@ -173,6 +174,7 @@ internal sealed class MossTankRouteProfileStore { ArgumentNullException.ThrowIfNull(target); ArgumentNullException.ThrowIfNull(spells); + MigrateFlatFilesToNavsFolderIfNeeded(); SweepLegacyRosterIfNeeded(); MigrateLegacyIfNeeded(target, spells); string fileName = CurrentFileName(); @@ -269,6 +271,60 @@ internal sealed class MossTankRouteProfileStore SaveCurrent(target); } + // ------------------------------------------------------------------ + // Slice 1c step 2: one-time flat-file -> navs/ folder migration. + // ------------------------------------------------------------------ + + /// + /// Owner decision 2026-09-07: a ONE-TIME sweep (guarded by + /// ) that moves every flat + /// root-level route file recognized by + /// + /// (nav_Name.af, hidden --nav_Name_Server.af) into + /// with the marker + /// stripped (). + /// Every other flat .af file is left alone — it belongs to + /// 's own sweep. Runs BEFORE the + /// older / + /// JSON migrations so those see the real .af content already at + /// its new folder-qualified path rather than treating it as missing. + /// When the real destination already exists, the flat file is left in + /// place untouched (never overwritten) and the collision is logged. + /// + private void MigrateFlatFilesToNavsFolderIfNeeded() + { + if (_flatFolderMigrationSwept) + return; + _flatFolderMigrationSwept = true; + if (!VtankStorage.IsAvailable) + return; + int migrated = 0; + foreach (string bareName in VtankProfileDirectory.ListFlatAfFileNames(VtankStorage)) + { + if (!VtankProfileDirectory.IsLegacyFlatRouteFileName(bareName)) + continue; // MossTankMetaProfileStore's own sweep owns this one. + string strippedName = VtankProfileDirectory.StripLegacyNavMarker(bareName); + string destination = $"{VtankProfileDirectory.NavFolder}/{strippedName}"; + if (VtankStorage.ReadText(destination) is not null) + { + _host.Log.Warn( + $"MossTank left flat route profile '{bareName}' in place: '{destination}' already exists."); + continue; + } + string? content = VtankStorage.ReadText(bareName); + if (content is null) + continue; // listed but unreadable; skip defensively. + VtankStorage.WriteText(destination, content); + VtankStorage.Delete(bareName); + migrated++; + } + if (migrated > 0) + { + _host.Log.Warn( + $"Migrated {migrated} flat MossTank route profile(s) into {VtankProfileDirectory.NavFolder}/."); + } + } + // ------------------------------------------------------------------ // Legacy JSON -> .af migration. // ------------------------------------------------------------------ diff --git a/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs index 368f92d66..24b01825d 100644 --- a/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs +++ b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs @@ -51,6 +51,58 @@ internal static class VtankProfileDirectory /// See . internal const string NavFolder = "navs"; + /// + /// The pre-slice-1c flat-directory route marker (round 3 items 3/4): + /// used ONLY by the one-time flat-file migration sweep + /// (/) + /// to recognize an old nav_Name.af/--nav_Name_Server.af + /// file left over from before the two-folder cutover. No code writes + /// this marker anymore. + /// + private const string LegacyNavMarker = "nav_"; + + /// + /// True when (a flat, un-prefixed root + /// key with no folder) is a pre-slice-1c route file: nav_*.af, + /// or the hidden per-character auto shape --nav_*.af (hidden + /// prefix first, marker second — the old + /// AutoCharacterFileName(..., NavMarker) overload's output). + /// Every OTHER flat .af file is a Meta profile. Used ONLY to + /// partition old root-level files between and + /// during the one-time migration sweep. + /// + internal static bool IsLegacyFlatRouteFileName(string bareFileName) => + bareFileName.StartsWith(LegacyNavMarker, StringComparison.Ordinal) + || (bareFileName.StartsWith(HiddenPrefix, StringComparison.Ordinal) + && bareFileName[HiddenPrefix.Length..].StartsWith( + LegacyNavMarker, StringComparison.Ordinal)); + + /// + /// Strips the pre-slice-1c nav_ marker from a flat route file + /// name recognized by , keeping + /// a leading hidden in place ahead of the + /// stripped remainder: nav_Hunt.afHunt.af, + /// --nav_Name_Server.af--Name_Server.af. Not meaningful + /// (and not called) for a file + /// returns for. + /// + internal static string StripLegacyNavMarker(string bareFileName) + { + bool isHidden = bareFileName.StartsWith(HiddenPrefix, StringComparison.Ordinal); + string rest = isHidden ? bareFileName[HiddenPrefix.Length..] : bareFileName; + rest = rest[LegacyNavMarker.Length..]; + return isHidden ? HiddenPrefix + rest : rest; + } + + /// + /// Lists every flat (root-level, un-prefixed) .af file name — + /// the one-time flat-file migration sweep's own input, materialized + /// eagerly since both stores mutate (move + /// files out of the root) while consuming this list. + /// + internal static IReadOnlyList ListFlatAfFileNames(IPluginStorage storage) => + EnumerateFileNames(storage, ".af").ToList(); + /// /// VTank's single per-character default filename /// (uTank2/PluginCore.cs:3863-3865): --Name_Server.ext. diff --git a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs index 4b51e8d70..1da9d6d95 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs @@ -326,6 +326,97 @@ public sealed class MossTankPanelTests Assert.Equal("/say real", Assert.Single(loaded.Rules).Action.Text); } + // ------------------------------------------------------------------ + // Slice 1c step 2: one-time flat-file -> metas/ folder migration. + // ------------------------------------------------------------------ + + /// + /// Owner decision 2026-09-07: an existing flat root-level + /// Name.af (the pre-slice-1c naming) moves to + /// metas/Name.af on first load — no marker to strip, since Meta + /// files never carried one. + /// + [Fact] + public void MetaStoreMigratesFlatAfFileIntoMetasFolder() + { + var storage = new MemoryStorage(); + storage.Text["Shared.af"] = MetafSerializer.SaveMeta(new MetaProfile + { + Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say shared" } }], + }); + + var store = new MossTankMetaProfileStore( + new FakeHost(new FakeAutomation { Name = "Barris" }, storage)); + store.BindCharacter("Barris"); + store.LoadCurrent(); + + Assert.True(storage.Text.ContainsKey("metas/Shared.af")); + Assert.False(storage.Text.ContainsKey("Shared.af")); + + // Idempotent second run: nothing left at the root to migrate. + var reopened = new MossTankMetaProfileStore( + new FakeHost(new FakeAutomation { Name = "Barris" }, storage)); + reopened.BindCharacter("Barris"); + reopened.LoadCurrent(); + Assert.True(storage.Text.ContainsKey("metas/Shared.af")); + Assert.False(storage.Text.ContainsKey("Shared.af")); + } + + /// + /// A flat nav_*.af/--nav_*.af file belongs to + /// 's own sweep, not this one — + /// it must be left alone by the Meta store. + /// + [Fact] + public void MetaStoreLeavesFlatNavMarkedFilesForTheRouteStore() + { + var storage = new MemoryStorage(); + storage.Text["nav_Hunt.af"] = "1\r\n"; + storage.Text["--nav_Barris_Coldeve.af"] = "1\r\n"; + + var store = new MossTankMetaProfileStore( + new FakeHost(new FakeAutomation { Name = "Barris" }, storage)); + store.BindCharacter("Barris"); + store.LoadCurrent(); + + Assert.True(storage.Text.ContainsKey("nav_Hunt.af")); + Assert.True(storage.Text.ContainsKey("--nav_Barris_Coldeve.af")); + Assert.False(storage.Text.ContainsKey("metas/nav_Hunt.af")); + Assert.False(storage.Text.ContainsKey("metas/--nav_Barris_Coldeve.af")); + } + + /// + /// Owner decision 2026-09-07, collision rule: when the real destination + /// already exists, the leftover flat file is left in place untouched + /// (never overwritten) and the collision is logged. + /// + [Fact] + public void MetaStoreLeavesFlatFileInPlaceWhenMetasDestinationAlreadyExists() + { + var storage = new MemoryStorage(); + string canonicalContent = MetafSerializer.SaveMeta(new MetaProfile + { + Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say canonical" } }], + }); + storage.Text["metas/Shared.af"] = canonicalContent; + storage.Text["Shared.af"] = MetafSerializer.SaveMeta(new MetaProfile + { + Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say stale-flat" } }], + }); + + var host = new FakeHost(new FakeAutomation { Name = "Barris" }, storage); + var store = new MossTankMetaProfileStore(host); + store.BindCharacter("Barris"); + store.LoadCurrent(); + + Assert.Equal(canonicalContent, storage.Text["metas/Shared.af"]); + Assert.True(storage.Text.ContainsKey("Shared.af")); + Assert.Contains( + host.Logger.Warnings, + message => message.Contains("Shared.af", StringComparison.Ordinal) + && message.Contains("metas/Shared.af", StringComparison.Ordinal)); + } + /// /// Reproduces MossTankMetaProfileStore's pre-cutover named-profile JSON /// hash key (its own LegacyNamedKey/Hash(string) are diff --git a/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs b/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs index ed4b17b0a..467738593 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs @@ -628,6 +628,128 @@ public sealed class NavigationTests Assert.True(storage.Text.ContainsKey("navs/Buffing.af")); } + // ------------------------------------------------------------------ + // Slice 1c step 2: one-time flat-file -> navs/ folder migration. + // ------------------------------------------------------------------ + + /// + /// Owner decision 2026-09-07: an existing flat root-level + /// nav_Name.af (the pre-slice-1c naming) moves to + /// navs/Name.af with the marker stripped, on first load. + /// + [Fact] + public void RouteStoreMigratesFlatNavMarkedFileIntoNavsFolderWithMarkerStripped() + { + var storage = new MemoryStorage(); + var route = new NavigationSettings(); + route.Waypoints.Add(new RouteWaypoint + { + Type = RouteWaypointType.Point, + Position = new PluginNavigationPosition(0x00010001u, 7d, 8d, 0d, 0f, true), + }); + storage.Text["nav_Hunt.af"] = MetafSerializer.SaveNav(route); + + 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.True(storage.Text.ContainsKey("navs/Hunt.af")); + Assert.False(storage.Text.ContainsKey("nav_Hunt.af")); + + // Idempotent second run: nothing left at the root to migrate. + var reopened = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage)); + Assert.True(reopened.BindCharacter("Barris")); + reopened.LoadCurrent(new NavigationSettings(), MetafSerializer.NoOpSpells.Instance); + Assert.True(storage.Text.ContainsKey("navs/Hunt.af")); + Assert.False(storage.Text.ContainsKey("nav_Hunt.af")); + } + + /// + /// Owner decision 2026-09-07: the hidden per-character auto route file + /// used the combined --nav_Name_Server.af shape before slice 1c + /// (hidden prefix first, marker second) — the sweep must recognize this + /// shape too and move it to navs/--Name_Server.af (hidden prefix + /// kept, marker dropped). + /// + [Fact] + public void RouteStoreMigratesFlatHiddenAutoRouteFileWithMarkerStripped() + { + var storage = new MemoryStorage(); + storage.Text["--Barris_Coldeve.af"] = MetafSerializer.SaveNav(new NavigationSettings()); + // meta's own (unmarked) flat auto file — must be left for the Meta + // store's own sweep, not touched here. + var route = new NavigationSettings(); + route.Waypoints.Add(new RouteWaypoint + { + Type = RouteWaypointType.Point, + Position = new PluginNavigationPosition(0x00010001u, 1d, 2d, 0d, 0f, true), + }); + storage.Text["--nav_Barris_Coldeve.af"] = MetafSerializer.SaveNav(route); + + 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.True(storage.Text.ContainsKey("navs/--Barris_Coldeve.af")); + Assert.False(storage.Text.ContainsKey("--nav_Barris_Coldeve.af")); + // Meta's own flat auto file (no nav_ marker) is NOT this store's + // concern — left exactly as found. + Assert.True(storage.Text.ContainsKey("--Barris_Coldeve.af")); + } + + /// + /// Owner decision 2026-09-07, collision rule: when the real destination + /// already exists, the leftover flat file is left in place untouched + /// (never overwritten) and the collision is logged. + /// + [Fact] + public void RouteStoreLeavesFlatFileInPlaceWhenNavsDestinationAlreadyExists() + { + var storage = new MemoryStorage(); + var canonical = new NavigationSettings(); + canonical.Waypoints.Add(new RouteWaypoint + { + Type = RouteWaypointType.Point, + Position = new PluginNavigationPosition(0x00010001u, 9d, 9d, 0d, 0f, true), + }); + string canonicalContent = MetafSerializer.SaveNav(canonical); + storage.Text["navs/Hunt.af"] = canonicalContent; + storage.Text["nav_Hunt.af"] = MetafSerializer.SaveNav(new NavigationSettings()); + + var host = new FakeHost(new FakeAutomation(), storage); + var store = new MossTankRouteProfileStore(host); + Assert.True(store.BindCharacter("Barris")); + store.LoadCurrent(new NavigationSettings(), MetafSerializer.NoOpSpells.Instance); + + Assert.Equal(canonicalContent, storage.Text["navs/Hunt.af"]); + Assert.True(storage.Text.ContainsKey("nav_Hunt.af")); + Assert.Contains( + host.Logger.Warnings, + message => message.Contains("nav_Hunt.af", StringComparison.Ordinal) + && message.Contains("navs/Hunt.af", StringComparison.Ordinal)); + } + + /// + /// Every OTHER root-level .af file (no nav_/--nav_ + /// marker) belongs to 's own + /// sweep, not this store's — it must be left alone. + /// + [Fact] + public void RouteStoreLeavesNonMarkedFlatAfFilesForTheMetaStore() + { + var storage = new MemoryStorage(); + storage.Text["SharedMeta.af"] = "1\r\n"; + + var store = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage)); + Assert.True(store.BindCharacter("Barris")); + store.LoadCurrent(new NavigationSettings(), MetafSerializer.NoOpSpells.Instance); + + Assert.True(storage.Text.ContainsKey("SharedMeta.af")); + Assert.False(storage.Text.ContainsKey("navs/SharedMeta.af")); + } + [Fact] public void FollowModeRouteRoundTripsTheFollowTargetThroughAf() { @@ -702,7 +824,11 @@ public sealed class NavigationTests IPluginStorage? storage = null) : IPluginHost { public bool HasUi => false; - public IPluginLogger Log { get; } = new FakeLogger(); + // Exposed as the concrete FakeLogger (not just the IPluginLogger + // interface view) so a test can inspect Warnings, matching + // MossTankPanelTests.FakeHost's own pattern. + public FakeLogger Logger { get; } = new(); + public IPluginLogger Log => Logger; public IGameState State { get; } = new FakeState(); public IEvents Events { get; } = new FakeEvents(); public ISelectionService Selection { get; } = new FakeSelection(); @@ -804,8 +930,9 @@ public sealed class NavigationTests private sealed class FakeLogger : IPluginLogger { + public List Warnings { get; } = []; public void Info(string message) { } - public void Warn(string message) { } + public void Warn(string message) => Warnings.Add(message); public void Error(string message, Exception? exception = null) { } }