feat(vtank): slice 1c step 1 — two-folder metas/navs layout, no nav_ marker

Owner decision 2026-09-07: Meta and Nav profiles both use metaf .af and are
told apart by living in two dedicated VtankProfiles subfolders (metas/,
navs/) instead of the flat-directory nav_/--nav_ marker scheme from slice 1
Part A, which was only ever how the owner happened to name files in their
own metas repo. VtankProfileDirectory.ListMetaProfiles/ListNavigationProfiles
now enumerate metas/ and navs/ respectively via a new folder-scoped
EnumerateFolderFileNames helper; the NavMarker constant and the marker
overload of AutoCharacterFileName are deleted. MossTankMetaProfileStore and
MossTankRouteProfileStore build every real storage key with their folder
prefix (CurrentFileName, Select, Create, TryImportLegacy, the legacy-roster
sweep) and strip it back off for display (StripAf/Strip). The .cdf's Nav/Meta
lines (4-5) now carry the folder-relative key ("metas/Name.af",
"navs/Name.af"); AD-122 and the ACDREAM_VTANK_PROFILE_DIR launch-option row
are updated to describe this.

Mutation demonstrated: reverting VtankProfileDirectory.cs,
MossTankMetaProfileStore.cs, and MossTankRouteProfileStore.cs to HEAD~ (the
flat nav_-marker layout) while keeping the updated tests reproduces 8 test
failures (KeyNotFoundException / Assert.True(false) against the new
"metas/…"/"navs/…" keys the tests now expect, e.g.
MetaSaveAcceptsAnAfSuffixedNameWithoutDoublingIt,
MetaAndRouteProfilesWithTheSameNameDoNotCollide,
NavCommandsImportLegacyAndExportAf, MetaCommandsImportLegacyAndExportAf,
MetaRosterSweepConvertsEveryNamedLegacyProfileOnce,
MetaStoreLeavesLegacyJsonUntouchedWhenAfCounterpartExists,
MetaStoreRefusesToSaveADisabledRuleAndKeepsThePriorAfContent,
NavSaveAcceptsAnAfSuffixedNameWithoutDoublingIt) — confirmed by running the
suite immediately after the production-code edit, before the test-file
updates landed. All 625 tests pass after both sides of the change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 06:32:56 +02:00
parent 8080a99eef
commit 812a533b83
8 changed files with 204 additions and 167 deletions

View file

@ -14,7 +14,13 @@ namespace AcDream.Plugins.MossTank;
/// Meta profiles are plain shared files (unlike the Settings picker, there
/// is no per-character <c>[Char] suffix</c> sub-profile carve-out here),
/// while the per-character auto file uses VTank's <c>--Name_Server.af</c>
/// naming and is therefore hidden from that listing by construction.
/// naming and is therefore hidden from that listing by construction. Owner
/// decision 2026-09-07: every real file this store touches lives under
/// <see cref="VtankProfileDirectory.MetaFolder"/> — <c>metas/Name.af</c>,
/// auto <c>metas/--Name_Server.af</c> — a dedicated subfolder of
/// <see cref="IPluginHost.VtankProfiles"/>, distinct from
/// <see cref="MossTankRouteProfileStore"/>'s <c>navs/</c> folder. No
/// file-name marker distinguishes the two anymore.
/// </summary>
internal sealed class MossTankMetaProfileStore
{
@ -53,12 +59,19 @@ internal sealed class MossTankMetaProfileStore
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;
private const string FolderPrefix = VtankProfileDirectory.MetaFolder + "/";
private static string StripAf(string name)
{
if (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
return name;
string value = name.StartsWith(FolderPrefix, StringComparison.Ordinal)
? name[FolderPrefix.Length..]
: name;
return value.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? value[..^3]
: value;
}
public IReadOnlyList<string> AvailableNames
{
@ -164,9 +177,10 @@ internal sealed class MossTankMetaProfileStore
return true;
}
string plain = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
string bare = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? normalized
: normalized + ".af";
string plain = $"{VtankProfileDirectory.MetaFolder}/{bare}";
if (VtankStorage.IsAvailable && VtankStorage.ReadText(plain) is not null)
{
_selected = plain;
@ -199,9 +213,10 @@ internal sealed class MossTankMetaProfileStore
notice = "Enter a unique Meta profile name (1-64 characters).";
return false;
}
string fileName = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
string bare = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? normalized
: normalized + ".af";
string fileName = $"{VtankProfileDirectory.MetaFolder}/{bare}";
MetaProfile document = copyCurrent ? Clone(current) : new MetaProfile();
if (!SaveTo(fileName, document, out notice))
return false;
@ -209,8 +224,8 @@ internal sealed class MossTankMetaProfileStore
_pendingLegacyBareName = null;
WriteBinding();
notice = copyCurrent
? $"Copied Meta profile to {fileName}."
: $"Created Meta profile {fileName}.";
? $"Copied Meta profile to {bare}."
: $"Created Meta profile {bare}.";
return true;
}
@ -246,9 +261,10 @@ internal sealed class MossTankMetaProfileStore
notice = $"Could not import {Path.GetFileName(key)}: {error}";
return false;
}
string fileName = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
string bare = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? normalized
: normalized + ".af";
string fileName = $"{VtankProfileDirectory.MetaFolder}/{bare}";
if (!SaveTo(fileName, profile, out string saveNotice))
{
notice = saveNotice;
@ -257,7 +273,7 @@ internal sealed class MossTankMetaProfileStore
_selected = fileName;
_pendingLegacyBareName = null;
WriteBinding();
notice = $"Imported VTank Meta profile {fileName}.";
notice = $"Imported VTank Meta profile {bare}.";
return true;
}
@ -281,7 +297,7 @@ internal sealed class MossTankMetaProfileStore
_selected = ByCharacter;
_pendingLegacyBareName = null;
WriteBinding();
notice = $"Deleted Meta profile {fileName}.";
notice = $"Deleted Meta profile {StripAf(fileName)}.";
return true;
}
@ -330,9 +346,10 @@ internal sealed class MossTankMetaProfileStore
if (legacy is null)
continue; // already converted (or never existed); drop the row.
string fileName = name.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
string bare = name.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? name
: name + ".af";
string fileName = $"{VtankProfileDirectory.MetaFolder}/{bare}";
if (VtankStorage.IsAvailable && VtankStorage.ReadText(fileName) is null)
{
if (!SaveTo(fileName, legacy, out string notice))
@ -476,7 +493,7 @@ internal sealed class MossTankMetaProfileStore
// ------------------------------------------------------------------
private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? VtankProfileDirectory.AutoCharacterFileName(_character, Server, "af")
? $"{VtankProfileDirectory.MetaFolder}/{VtankProfileDirectory.AutoCharacterFileName(_character, Server, "af")}"
: _selected;
private void WriteBinding()

View file

@ -22,16 +22,19 @@ namespace AcDream.Plugins.MossTank;
/// VTank's own split between the Settings-scoped nav prefs and the
/// per-route file.
///
/// Named route files carry a <c>nav_</c> prefix (metaf's own observed
/// convention for a stand-alone nav-only <c>.af</c>, see the committed
/// <c>nav_*.af</c> fixtures) so a route and a Meta profile sharing the same
/// user-typed name never collide in the shared <see cref="IPluginHost.VtankProfiles"/>
/// directory.
/// Owner decision 2026-09-07: every real file this store touches lives
/// under <see cref="VtankProfileDirectory.NavFolder"/> — <c>navs/Name.af</c>,
/// auto <c>navs/--Name_Server.af</c> — a dedicated subfolder of
/// <see cref="IPluginHost.VtankProfiles"/>, distinct from
/// <see cref="MossTankMetaProfileStore"/>'s <c>metas/</c> folder. A route
/// and a Meta profile sharing the same user-typed name never collide
/// because they live in different folders; no file-name marker (the
/// earlier flat-directory <c>nav_</c> prefix) is needed anymore.
/// </summary>
internal sealed class MossTankRouteProfileStore
{
public const string ByCharacter = "By char";
private const string NavPrefix = "nav_";
private const string FolderPrefix = VtankProfileDirectory.NavFolder + "/";
// 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
@ -63,12 +66,12 @@ internal sealed class MossTankRouteProfileStore
{
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;
string value = fileName.StartsWith(FolderPrefix, StringComparison.Ordinal)
? fileName[FolderPrefix.Length..]
: fileName;
return value.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? value[..^3]
: value;
}
public IReadOnlyList<string> AvailableNames
@ -399,19 +402,16 @@ internal sealed class MossTankRouteProfileStore
// File naming, storage plumbing.
// ------------------------------------------------------------------
// Round 3 item 3: the hidden "--" prefix MUST come before the "nav_"
// kind marker (--nav_Name_Server.af) — putting the marker first
// (nav_--Name_Server.af, the pre-fix shape) means the filename does not
// start with "--" at all, defeating the StartsWith("--") hidden-file
// check both ListNavigationProfiles and ListMetaProfiles rely on and
// leaking this character's private per-character route to every other
// character's picker.
// Owner decision 2026-09-07: the auto per-character route file is the
// bare "--Name_Server.af" hidden-prefix name (same as Meta's), living
// under this store's own navs/ folder — the folder is what keeps it out
// of ListMetaProfiles now, not a "--" + "nav_" combined marker.
private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? VtankProfileDirectory.AutoCharacterFileName(_characterName, Server, "af", NavPrefix)
? $"{VtankProfileDirectory.NavFolder}/{VtankProfileDirectory.AutoCharacterFileName(_characterName, Server, "af")}"
: _selected;
private static string ToFileName(string bareName) =>
NavPrefix + bareName + ".af";
$"{VtankProfileDirectory.NavFolder}/{bareName}.af";
private void WriteBinding()
{

View file

@ -36,24 +36,27 @@ internal static class VtankProfileDirectory
internal const string NoneLabel = "[None]";
/// <summary>
/// The <c>nav_</c> marker (round 3 items 3/4) that distinguishes a
/// stand-alone route <c>.af</c> from a Meta profile sharing the same
/// flat <see cref="IPluginHost.VtankProfiles"/> directory and extension
/// — metaf's own observed convention for a nav-only <c>.af</c> (see the
/// committed <c>nav_*.af</c> fixtures). MUST be checked AFTER stripping
/// <see cref="HiddenPrefix"/> when the marker is combined with it (see
/// <see cref="AutoCharacterFileName(string,string,string,string)"/>'s
/// <c>marker</c> parameter): a hidden per-character route file is named
/// <c>--nav_Name_Server.af</c> — hidden prefix FIRST, marker SECOND —
/// never <c>nav_--Name_Server.af</c>, which does not start with
/// <see cref="HiddenPrefix"/> at all and so defeats every
/// <c>StartsWith("--")</c> hidden-file check in this class.
/// Owner decision 2026-09-07 (Campaign VT slice 1c): Meta and Navigation
/// profiles both use metaf's <c>.af</c> grammar and are told apart by
/// living in two separate subfolders of <see cref="IPluginHost.VtankProfiles"/>,
/// with NO file-name marker — <see cref="MetaFolder"/> for
/// <see cref="MossTankMetaProfileStore"/>, <see cref="NavFolder"/> for
/// <see cref="MossTankRouteProfileStore"/>. This replaces the earlier
/// flat-directory <c>nav_</c> marker scheme (round 3 items 3/4), which
/// was only ever how the owner happened to name files in their own metas
/// repo, not a real VTank convention.
/// </summary>
internal const string NavMarker = "nav_";
internal const string MetaFolder = "metas";
/// <summary>See <see cref="MetaFolder"/>.</summary>
internal const string NavFolder = "navs";
/// <summary>
/// VTank's single per-character default filename
/// (<c>uTank2/PluginCore.cs:3863-3865</c>): <c>--Name_Server.ext</c>.
/// Callers that store this file inside a subfolder (Meta/Nav, see
/// <see cref="MetaFolder"/>/<see cref="NavFolder"/>) prepend that folder
/// themselves — this method only ever produces the bare file name.
/// </summary>
public static string AutoCharacterFileName(
string characterName,
@ -61,21 +64,6 @@ internal static class VtankProfileDirectory
string extension) =>
$"{HiddenPrefix}{characterName}_{server}.{extension.TrimStart('.')}";
/// <summary>
/// Overload for a per-character auto file that ALSO carries a kind
/// marker (currently only <see cref="NavMarker"/>, for
/// <see cref="MossTankRouteProfileStore"/>'s route <c>.af</c>): the
/// hidden prefix always comes first so the file stays hidden from every
/// other character's picker exactly like the unmarked overload above —
/// <c>--{marker}Name_Server.ext</c>, e.g. <c>--nav_Name_Server.af</c>.
/// </summary>
public static string AutoCharacterFileName(
string characterName,
string server,
string extension,
string marker) =>
$"{HiddenPrefix}{marker}{characterName}_{server}.{extension.TrimStart('.')}";
/// <summary>
/// The longer, trailing-underscore prefix
/// (<c>uTank2/PluginCore.cs:933,3866</c>, field <c>dw</c>) that marks a
@ -186,10 +174,12 @@ internal static class VtankProfileDirectory
/// VTank's navigation-profile list (<c>l()</c>,
/// <c>uTank2/PluginCore.cs:7156-7185</c>): seeds
/// <see cref="NoneLabel"/>/<see cref="ByCharacterLabel"/>, then every
/// <see cref="NavMarker"/>-marked <c>.af</c> file that starts with
/// neither <c>--</c> nor <c>~~</c>. Round 3 item 4: a Meta profile and a
/// route share this same flat, single-extension directory — without
/// the marker check this returned every Meta <c>.af</c> too.
/// <c>.af</c> file directly under <see cref="NavFolder"/> that starts
/// with neither <c>--</c> nor <c>~~</c>. Owner decision 2026-09-07: the
/// folder itself is what separates route profiles from Meta profiles
/// now — see <see cref="MetaFolder"/>. <see cref="ProfileEntry.FileName"/>
/// carries the folder-relative real storage key (<c>navs/Name.af</c>);
/// <see cref="ProfileEntry.DisplayName"/> is the bare name.
/// </summary>
public static IReadOnlyList<ProfileEntry> ListNavigationProfiles(IPluginStorage storage)
{
@ -198,16 +188,14 @@ internal static class VtankProfileDirectory
new(string.Empty, NoneLabel),
new(string.Empty, ByCharacterLabel),
};
foreach (string fileName in EnumerateFileNames(storage, ".af"))
foreach (string bareName in EnumerateFolderFileNames(storage, NavFolder, ".af"))
{
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)
|| fileName.StartsWith(NavHiddenPrefix, StringComparison.Ordinal))
if (bareName.StartsWith(HiddenPrefix, StringComparison.Ordinal)
|| bareName.StartsWith(NavHiddenPrefix, StringComparison.Ordinal))
{
continue;
}
if (!fileName.StartsWith(NavMarker, StringComparison.Ordinal))
continue;
entries.Add(new ProfileEntry(fileName, fileName));
entries.Add(new ProfileEntry($"{NavFolder}/{bareName}", bareName));
}
return entries;
}
@ -216,12 +204,12 @@ internal static class VtankProfileDirectory
/// VTank's meta-profile list (<c>ac()</c>,
/// <c>uTank2/PluginCore.cs:7187+</c>): seeds
/// <see cref="NoneLabel"/>/<see cref="ByCharacterLabel"/>, then every
/// non-<c>--</c>, non-<see cref="NavMarker"/>-marked file (a meta and a
/// nav profile share the same directory and extension here —
/// <c>.af</c> — so the marker is what VTank's own separate
/// <c>.met</c>/<c>.nav</c> extensions used to provide; round 3 item 4:
/// without excluding <see cref="NavMarker"/> files this returned every
/// route <c>.af</c> too).
/// non-<c>--</c> <c>.af</c> file directly under <see cref="MetaFolder"/>.
/// Owner decision 2026-09-07: the folder itself is what separates Meta
/// profiles from route profiles now — see <see cref="NavFolder"/>.
/// <see cref="ProfileEntry.FileName"/> carries the folder-relative real
/// storage key (<c>metas/Name.af</c>); <see cref="ProfileEntry.DisplayName"/>
/// is the bare name.
/// </summary>
public static IReadOnlyList<ProfileEntry> ListMetaProfiles(IPluginStorage storage)
{
@ -230,13 +218,11 @@ internal static class VtankProfileDirectory
new(string.Empty, NoneLabel),
new(string.Empty, ByCharacterLabel),
};
foreach (string fileName in EnumerateFileNames(storage, ".af"))
foreach (string bareName in EnumerateFolderFileNames(storage, MetaFolder, ".af"))
{
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal))
if (bareName.StartsWith(HiddenPrefix, StringComparison.Ordinal))
continue;
if (fileName.StartsWith(NavMarker, StringComparison.Ordinal))
continue;
entries.Add(new ProfileEntry(fileName, fileName));
entries.Add(new ProfileEntry($"{MetaFolder}/{bareName}", bareName));
}
return entries;
}
@ -302,6 +288,13 @@ internal static class VtankProfileDirectory
/// <see langword="null"/> when the file is short (line 5 is present
/// only when the stream isn't already at EOF, <c>da.cs:125-128</c> —
/// an older <c>.cdf</c> predating meta support has no line 5 at all).
/// This type is a generic pass-through: <see cref="TryReadCharacterBinding"/>/
/// <see cref="WriteCharacterBinding"/> store and return whatever strings
/// the two callers hand them. Since owner decision 2026-09-07,
/// <c>NavFileName</c>/<c>MetaFileName</c> are the folder-relative real
/// storage keys <see cref="MossTankRouteProfileStore"/>/
/// <see cref="MossTankMetaProfileStore"/> write (<c>navs/Name.af</c>,
/// <c>metas/Name.af</c>), not a bare file name.
/// </summary>
public readonly record struct VtankCharacterBinding(
string SettingsFileName,
@ -401,4 +394,33 @@ internal static class VtankProfileDirectory
yield return key;
}
}
/// <summary>
/// Lists BARE file names (folder prefix stripped) directly under
/// <paramref name="folder"/> matching <paramref name="extension"/> —
/// the <see cref="MetaFolder"/>/<see cref="NavFolder"/> counterpart of
/// <see cref="EnumerateFileNames"/> above. A key nested one level
/// deeper than <paramref name="folder"/> (a sub-directory of it) is
/// skipped for the same reason <see cref="EnumerateFileNames"/> skips
/// any key containing '/' — neither Meta nor Nav storage is ever
/// nested past its one folder.
/// </summary>
private static IEnumerable<string> EnumerateFolderFileNames(
IPluginStorage storage,
string folder,
string extension)
{
if (!storage.IsAvailable)
yield break;
string folderPrefix = folder + "/";
foreach (string bareName in storage.List(folder)
.Where(key => key.StartsWith(folderPrefix, StringComparison.Ordinal))
.Select(key => key[folderPrefix.Length..])
.Where(bareName => !bareName.Contains('/', StringComparison.Ordinal))
.Where(bareName => bareName.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
.OrderBy(static bareName => bareName, StringComparer.OrdinalIgnoreCase))
{
yield return bareName;
}
}
}