feat(vt): metas and routes cut over to real .af files (round 2 steps 2-3)

MossTankMetaProfileStore and MossTankRouteProfileStore now store their
profiles as real .af files through IPluginHost.VtankProfiles, named and
listed by VtankProfileDirectory's rules, instead of a hashed JSON
document plus a stale "exports/" mirror (WriteLegacyExport deleted from
both). Named Meta profiles are plain shared files (matching VTank's own
ac() picker, which has no per-character sub-profile carve-out); named
route profiles carry a "nav_" prefix (metaf's own observed convention
for a stand-alone nav .af, confirmed against the committed nav_*.af
fixtures) so a route and a Meta profile sharing a user-typed name never
collide in the shared VtankProfiles directory. Both stores' Selected/
AvailableNames strip the file extension (and the route store's "nav_"
prefix) for display, matching the bare names users type at /vt meta|nav.

The route store now persists only the fields metaf's NAV: grammar
actually carries (Mode, Waypoints, FollowTarget) — Enabled/Priority/
MinimumDistanceMeters/FollowAroundCorners/OpenDoors/Door* are real VTank
Settings-table rows already owned end-to-end by MossTankProfileStore's
.usd profile (round 2 step 1), matching real VTank's own split between
global nav prefs and the per-route file; ClearCurrent and LoadCurrent
were narrowed to match. LoadCurrent gained an ISpellCatalog parameter
(TryLoadNav's own requirement); both call sites now pass
host.Automation.Spells.

Both stores gained the same one-time legacy-JSON migration as the
Settings store: first load converts a not-yet-migrated JSON profile to
.af and deletes the JSON key, leaving an existing .af counterpart (and
its stale JSON) untouched. The Meta store's SaveCurrent/Create now
refuse (return false, set SaveNotice, leave the prior .af content in
place) rather than silently drop a disabled rule that metaf/.af cannot
represent — MossTankPanel's four rule-editing call sites were updated to
prefer that refusal notice over their own generic success message.

Mutations shown to fail: MossTankMetaProfileStore.MigrateLegacyIfNeeded
stubbed to a no-op made MetaStoreMigratesLegacyJsonProfileToAfAndDeletesTheJsonKey
fail (legacy key was not deleted); SaveCurrent's SaveMeta call switched to
dropDisabledRules:true made MetaStoreRefusesToSaveADisabledRuleAndKeepsThePriorAfContent
fail (the disabled rule was silently written). Both restored and green.

NavigationTests.RouteProfilesRoundTripEveryWaypointField (asserting the
pre-cutover JSON-carries-everything behavior) was split into
RouteProfilesRoundTripWaypointFieldsButLeaveSettingsOwnedFieldsAlone and
FollowModeRouteRoundTripsTheFollowTargetThroughAf, and now also
documents two pre-existing, already-recorded metaf representational
gaps discovered by routing this path through .af for the first time:
JumpDirection has no .af representation at all (MetafSerializer.cs:924)
and a "jmp" node carries no cell id (six bare fields, no hex component).
MossTankPanelTests' nav/meta export-path tests were updated from the
retired "exports/meta|nav/" mirror to the real file locations.

591 MossTank tests passing (was 588 after step 1's commit, +3 new tests
net of the two renamed/retired ones).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 00:27:37 +02:00
parent 479495ecc6
commit a5d52bfb07
5 changed files with 700 additions and 468 deletions

View file

@ -5,47 +5,66 @@ using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>Independent VTank-style By-char/named Meta profile lifetime.</summary>
/// <summary>
/// VTank-compatible Meta profile lifecycle. <c>.af</c> (metaf's human-
/// readable grammar, see <see cref="MetafSerializer"/>) is the ONLY storage
/// and authoring format — real VTank's own <c>ac()</c> meta-profile picker
/// (<c>docs/research/vtank-kb/01-settings-and-profiles.md</c> section 3)
/// seeds <c>[None]</c>/<c>[By char]</c> then every non-<c>--</c> file: named
/// 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.
/// </summary>
internal sealed class MossTankMetaProfileStore
{
public const string ByCharacter = "By char";
private const string IndexKey = "profiles/meta/index.json";
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
private readonly IPluginHost _host;
private IndexDocument _index;
private string _character = string.Empty;
private string _selected = ByCharacter;
private string? _pendingLegacyBareName;
public MossTankMetaProfileStore(IPluginHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_index = Read<IndexDocument>(IndexKey) ?? new IndexDocument();
_index.Names ??= [];
_index.SelectedByCharacter = new Dictionary<string, string>(
_index.SelectedByCharacter ?? [],
StringComparer.OrdinalIgnoreCase);
}
private List<string> Names => _index.Names ??= [];
private Dictionary<string, string> SelectedByCharacter =>
_index.SelectedByCharacter ??= new Dictionary<string, string>(
StringComparer.OrdinalIgnoreCase);
public string Selected => _selected;
/// <summary>
/// The bare name a user typed to select/create this profile (the file
/// name minus its <c>.af</c> extension) — internal identity (<see cref="_selected"/>)
/// always carries the real, on-disk file name; only display strips it.
/// </summary>
public string Selected => StripAf(_selected);
public string? RecoveryNotice { get; private set; }
public IReadOnlyList<string> AvailableNames => new[] { ByCharacter }
.Concat(Names)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static name => name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? 0 : 1)
.ThenBy(static name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
public string? SaveNotice { get; private set; }
private string Server => _host.Automation.Character.WorldName;
private IPluginStorage VtankStorage => _host.VtankProfiles;
private bool CanBindFiles => _character.Length > 0 && Server.Length > 0;
private static string StripAf(string name) => name.Equals(
ByCharacter, StringComparison.OrdinalIgnoreCase)
? name
: name.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? name[..^3]
: name;
public IReadOnlyList<string> AvailableNames
{
get
{
var names = new List<string> { ByCharacter };
foreach (VtankProfileDirectory.ProfileEntry entry in
VtankProfileDirectory.ListMetaProfiles(VtankStorage))
{
if (entry.FileName.Length == 0)
continue; // VTank's own "[None]"/"[By char]" sentinels.
names.Add(StripAf(entry.FileName));
}
return names;
}
}
public bool BindCharacter(string? characterName)
{
@ -55,33 +74,105 @@ internal sealed class MossTankMetaProfileStore
if (normalized.Equals(_character, StringComparison.OrdinalIgnoreCase))
return false;
_character = normalized;
_selected = SelectedByCharacter.TryGetValue(
CharacterKey(),
out string? selected)
&& IsKnown(selected)
? Canonical(selected)
: ByCharacter;
_pendingLegacyBareName = null;
VtankProfileDirectory.VtankCharacterBinding? binding = CanBindFiles
? VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _character, Server)
: null;
_selected = binding is { MetaFileName.Length: > 0 } bound
? bound.MetaFileName
: ByCharacter;
return true;
}
public MetaProfile LoadCurrent() =>
Read<MetaProfile>(CurrentKey()) ?? new MetaProfile();
public void SaveCurrent(MetaProfile profile)
public MetaProfile LoadCurrent()
{
Write(CurrentKey(), profile);
WriteLegacyExport(LegacyProfileName(), profile);
MigrateLegacyIfNeeded();
string fileName = CurrentFileName();
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
if (text is null)
return new MetaProfile();
if (!MetafSerializer.TryLoadMeta(text, _host.Automation.Spells, out MetaProfile profile, out string error))
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host, "meta", fileName, text, new FormatException(error));
_host.Log.Warn(RecoveryNotice);
return new MetaProfile();
}
return profile;
}
/// <summary>
/// Saves <paramref name="profile"/> as this selection's <c>.af</c> file.
/// Real VTank/metaf has no way to represent <see cref="MetaRule.Enabled"/>
/// <see langword="false"/> (a MossTank-only extension) — rather than
/// silently dropping a disabled rule, the save is refused entirely (the
/// file on disk keeps its last good content) and <see cref="SaveNotice"/>
/// carries a user-visible reason. Returns <see langword="true"/> only
/// when the file was actually written.
/// </summary>
public bool SaveCurrent(MetaProfile profile)
{
string fileName = CurrentFileName();
string text;
try
{
text = MetafSerializer.SaveMeta(profile);
}
catch (InvalidOperationException error)
{
SaveNotice = $"Meta profile '{fileName}' was NOT saved: {error.Message}";
_host.Log.Warn(SaveNotice);
return false;
}
if (!VtankStorage.IsAvailable)
return false;
try
{
VtankStorage.WriteText(fileName, text);
}
catch (Exception error)
{
SaveNotice = $"Meta profile '{fileName}' could not be saved: {error.Message}";
_host.Log.Warn(SaveNotice);
return false;
}
SaveNotice = null;
return true;
}
public bool Select(string? name)
{
string normalized = Normalize(name);
if (!IsKnown(normalized))
if (normalized.Length == 0)
return false;
_selected = Canonical(normalized);
SelectedByCharacter[CharacterKey()] = _selected;
SaveIndex();
return true;
if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
_selected = ByCharacter;
_pendingLegacyBareName = null;
WriteBinding();
return true;
}
string plain = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? normalized
: normalized + ".af";
if (VtankStorage.IsAvailable && VtankStorage.ReadText(plain) is not null)
{
_selected = plain;
_pendingLegacyBareName = null;
WriteBinding();
return true;
}
if (_host.Storage.IsAvailable
&& _host.Storage.ReadText(LegacyNamedKey(normalized)) is not null)
{
_selected = plain;
_pendingLegacyBareName = normalized;
WriteBinding();
return true;
}
return false;
}
public bool Create(
@ -97,19 +188,18 @@ internal sealed class MossTankMetaProfileStore
notice = "Enter a unique Meta profile name (1-64 characters).";
return false;
}
MetaProfile document = copyCurrent
? Clone(current)
: new MetaProfile();
Write(NamedKey(normalized), document);
if (!Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
Names.Add(normalized);
_selected = normalized;
SelectedByCharacter[CharacterKey()] = normalized;
SaveIndex();
WriteLegacyExport(normalized, document);
string fileName = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? normalized
: normalized + ".af";
MetaProfile document = copyCurrent ? Clone(current) : new MetaProfile();
if (!SaveTo(fileName, document, out notice))
return false;
_selected = fileName;
_pendingLegacyBareName = null;
WriteBinding();
notice = copyCurrent
? $"Copied Meta profile to {normalized}."
: $"Created Meta profile {normalized}.";
? $"Copied Meta profile to {fileName}."
: $"Created Meta profile {fileName}.";
return true;
}
@ -145,15 +235,18 @@ internal sealed class MossTankMetaProfileStore
notice = $"Could not import {Path.GetFileName(key)}: {error}";
return false;
}
if (!Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
Names.Add(normalized);
_selected = Names.First(existing => existing.Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
SelectedByCharacter[CharacterKey()] = _selected;
SaveIndex();
SaveCurrent(profile);
notice = $"Imported VTank Meta profile {_selected}.";
string fileName = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? normalized
: normalized + ".af";
if (!SaveTo(fileName, profile, out string saveNotice))
{
notice = saveNotice;
return false;
}
_selected = fileName;
_pendingLegacyBareName = null;
WriteBinding();
notice = $"Imported VTank Meta profile {fileName}.";
return true;
}
@ -164,142 +257,142 @@ internal sealed class MossTankMetaProfileStore
return empty;
}
private string CurrentKey() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? $"profiles/meta/by-character/{Hash(_character)}.json"
: NamedKey(_selected);
// ------------------------------------------------------------------
// Legacy JSON -> .af migration.
// ------------------------------------------------------------------
private static string NamedKey(string name) =>
$"profiles/meta/named/{Hash(name)}.json";
private string CharacterKey() =>
string.IsNullOrWhiteSpace(_character) ? "anonymous" : _character;
private bool IsKnown(string name) => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
|| Names.Contains(name, StringComparer.OrdinalIgnoreCase);
private string Canonical(string name) => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ByCharacter
: Names.First(existing => existing.Equals(
name,
StringComparison.OrdinalIgnoreCase));
private void SaveIndex() => Write(IndexKey, _index);
/// <summary>
/// <c>.af</c> is the only VTank-compatible export format now (Campaign
/// VT slice 1 Part A — MossTank no longer authors the binary
/// <c>.met</c> format at all, matching <see cref="VtankMetaProfileSerializer"/>'s
/// demotion to a one-shot import). Exports live under
/// <c>exports/meta/</c> — a distinct subdirectory from
/// <see cref="MossTankRouteProfileStore"/>'s <c>exports/nav/</c> — so a
/// Meta profile and a route (Navigation) profile sharing the same name
/// cannot silently overwrite each other's <c>.af</c> file (both stores
/// used to write into the same flat <c>exports/</c> root).
/// </summary>
private void WriteLegacyExport(string name, MetaProfile profile)
private void MigrateLegacyIfNeeded()
{
if (!_host.Storage.IsAvailable)
string fileName = CurrentFileName();
if (!VtankStorage.IsAvailable || VtankStorage.ReadText(fileName) is not null)
{
_pendingLegacyBareName = null;
return;
try
{
// MetafSerializer.SaveMeta(profile) (item H, slice-1 fix round)
// throws when the profile has any disabled rule — real VTank/
// metaf has no marker for MossTank's own "disabled" extension, so
// this .af mirror is deliberately left stale (rather than
// silently dropping the rule) until the user re-enables or
// deletes it. The authoritative JSON save above always has full
// fidelity regardless.
_host.Storage.WriteText(
$"exports/meta/{LegacyFileName(name)}.af",
MetafSerializer.SaveMeta(profile));
}
catch (Exception error)
string legacyKey = _pendingLegacyBareName is { Length: > 0 } bareName
? LegacyNamedKey(bareName)
: LegacyByCharacterKey();
MetaProfile? legacy = ReadLegacyJson(legacyKey);
if (legacy is null)
{
_host.Log.Warn(
$"MossTank VTank Meta export could not be saved: {error.Message}");
_pendingLegacyBareName = null;
return;
}
if (!SaveTo(fileName, legacy, out string notice))
{
// Representational loss (a disabled rule) blocks the migration
// outright rather than silently dropping it: the legacy JSON
// stays put (still fully readable) until the user resolves it.
_host.Log.Warn($"MossTank could not migrate legacy Meta profile: {notice}");
_pendingLegacyBareName = null;
return;
}
if (_host.Storage.IsAvailable)
_host.Storage.Delete(legacyKey);
_pendingLegacyBareName = null;
_host.Log.Warn($"Migrated legacy MossTank Meta profile '{legacyKey}' to '{fileName}'.");
}
private string LegacyProfileName() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? string.IsNullOrWhiteSpace(_character) ? ByCharacter : _character
: _selected;
private static string LegacyFileName(string name)
{
char[] invalid = Path.GetInvalidFileNameChars();
var result = new StringBuilder(name.Length);
foreach (char value in name.Trim())
{
result.Append(value is '/' or '\\' || invalid.Contains(value)
? '_'
: value);
}
return result.Length == 0 ? "Meta" : result.ToString();
}
private T? Read<T>(string key)
private MetaProfile? ReadLegacyJson(string key)
{
if (!_host.Storage.IsAvailable)
return default;
return null;
string? json = null;
try
{
json = _host.Storage.ReadText(key);
return string.IsNullOrWhiteSpace(json)
? default
: JsonSerializer.Deserialize<T>(json, Options);
? null
: JsonSerializer.Deserialize<MetaProfile>(json, JsonOptions);
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host,
"meta",
key,
json,
error);
RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "meta", key, json, error);
_host.Log.Error(RecoveryNotice, error);
return default;
return null;
}
}
private void Write<T>(string key, T value)
private bool SaveTo(string fileName, MetaProfile profile, out string notice)
{
if (!_host.Storage.IsAvailable)
return;
string text;
try
{
_host.Storage.WriteText(key, JsonSerializer.Serialize(value, Options));
text = MetafSerializer.SaveMeta(profile);
}
catch (InvalidOperationException error)
{
notice = $"Meta profile '{fileName}' was NOT saved: {error.Message}";
SaveNotice = notice;
_host.Log.Warn(notice);
return false;
}
if (!VtankStorage.IsAvailable)
{
notice = "VTank Meta storage is unavailable.";
return false;
}
try
{
VtankStorage.WriteText(fileName, text);
}
catch (Exception error)
{
_host.Log.Error($"Unable to save MossTank Meta profile '{key}'.", error);
notice = $"Meta profile '{fileName}' could not be saved: {error.Message}";
SaveNotice = notice;
_host.Log.Warn(notice);
return false;
}
SaveNotice = null;
notice = string.Empty;
return true;
}
// ------------------------------------------------------------------
// File naming, storage plumbing.
// ------------------------------------------------------------------
private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? VtankProfileDirectory.AutoCharacterFileName(_character, Server, "af")
: _selected;
private void WriteBinding()
{
if (!CanBindFiles || !VtankStorage.IsAvailable)
return;
VtankProfileDirectory.VtankCharacterBinding existing =
VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _character, Server)
?? new VtankProfileDirectory.VtankCharacterBinding(
string.Empty, string.Empty, string.Empty, CurrentFileName());
VtankProfileDirectory.WriteCharacterBinding(
VtankStorage,
_character,
Server,
existing with { MetaFileName = CurrentFileName() });
}
private static MetaProfile Clone(MetaProfile profile) =>
JsonSerializer.Deserialize<MetaProfile>(
JsonSerializer.Serialize(profile, Options),
Options) ?? new MetaProfile();
JsonSerializer.Serialize(profile, JsonOptions),
JsonOptions) ?? new MetaProfile();
private static string Normalize(string? name) => name?.Trim() ?? string.Empty;
private string LegacyByCharacterKey() =>
$"profiles/meta/by-character/{Hash(_character)}.json";
private static string LegacyNamedKey(string name) =>
$"profiles/meta/named/{Hash(name)}.json";
private static string Hash(string value)
{
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(value.ToLowerInvariant()));
return Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant();
}
private sealed class IndexDocument
private static readonly JsonSerializerOptions JsonOptions = new()
{
public List<string>? Names { get; set; } = [];
public Dictionary<string, string>? SelectedByCharacter { get; set; } = [];
}
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
}

View file

@ -213,7 +213,7 @@ internal sealed partial class MossTankPanel
}
_routeProfiles = new MossTankRouteProfileStore(host);
_routeProfiles.BindCharacter(host.Automation.Character.Name);
if (!_routeProfiles.LoadCurrent(_navigationSettings))
if (!_routeProfiles.LoadCurrent(_navigationSettings, host.Automation.Spells))
_routeProfiles.SaveCurrent(_navigationSettings);
_combat = new CombatController(host, _combatSettings, _vitalSettings);
_buffCasterPreparer = new BuffCasterPreparer(
@ -1785,7 +1785,7 @@ internal sealed partial class MossTankPanel
private void LoadRouteProfile()
{
if (!_routeProfiles.LoadCurrent(_navigationSettings))
if (!_routeProfiles.LoadCurrent(_navigationSettings, _host.Automation.Spells))
_routeProfiles.SaveCurrent(_navigationSettings);
if (_initialized)
ApplyPersistedOptionOverrides();
@ -2244,9 +2244,9 @@ internal sealed partial class MossTankPanel
}
_metaProfile.Rules.Add(rule);
_selectedMetaRule = _metaProfile.Rules.Count - 1;
SaveMetaProfile();
RefreshMetaEditor();
_metaNotice = $"Added rule in {rule.State}.";
if (SaveMetaProfile())
_metaNotice = $"Added rule in {rule.State}.";
}
private void ApplyMetaRuleCore()
@ -2263,9 +2263,9 @@ internal sealed partial class MossTankPanel
}
replacement.Id = current.Id;
_metaProfile.Rules[_selectedMetaRule] = replacement;
SaveMetaProfile();
RefreshMetaEditor();
_metaNotice = $"Updated rule in {replacement.State}.";
if (SaveMetaProfile())
_metaNotice = $"Updated rule in {replacement.State}.";
}
private bool TryBuildMetaRule(out MetaRule rule, out string error)
@ -2320,9 +2320,9 @@ internal sealed partial class MossTankPanel
_selectedMetaRule = Math.Min(
_selectedMetaRule,
Math.Max(0, _metaProfile.Rules.Count - 1));
SaveMetaProfile();
RefreshMetaEditor();
_metaNotice = $"Removed rule from {selected.State}.";
if (SaveMetaProfile())
_metaNotice = $"Removed rule from {selected.State}.";
}
private void MoveMetaRule(int direction)
@ -2337,9 +2337,9 @@ internal sealed partial class MossTankPanel
_metaProfile.Rules.RemoveAt(_selectedMetaRule);
_metaProfile.Rules.Insert(destination, rule);
_selectedMetaRule = destination;
SaveMetaProfile();
RefreshMetaEditor();
_metaNotice = $"Moved {rule.State} rule.";
if (SaveMetaProfile())
_metaNotice = $"Moved {rule.State} rule.";
}
private void SelectMetaProfileCore(string name)
@ -2389,7 +2389,22 @@ internal sealed partial class MossTankPanel
RefreshMetaEditor();
}
private void SaveMetaProfile() => _metaProfiles.SaveCurrent(_metaProfile);
/// <summary>
/// Saves the current Meta profile as <c>.af</c>. Returns
/// <see langword="false"/> when the save was refused (a disabled rule
/// has no metaf-compatible representation) — callers must not overwrite
/// <see cref="_metaNotice"/> with a generic success message in that
/// case, since <see cref="MossTankMetaProfileStore.SaveNotice"/> already
/// carries the user-visible reason.
/// </summary>
private bool SaveMetaProfile()
{
if (_metaProfiles.SaveCurrent(_metaProfile))
return true;
if (_metaProfiles.SaveNotice is { } notice)
_metaNotice = notice;
return false;
}
private static string DescribeMetaCondition(MetaCondition condition) =>
condition.Kind switch

View file

@ -6,45 +6,77 @@ using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Independent VTank navigation-profile lifecycle. The selected route is
/// remembered per character; "By char" is a private route document and named
/// profiles are reusable copies.
/// VTank-compatible navigation-route lifecycle. <c>.af</c> is the ONLY
/// storage/authoring format — but only for the fields metaf's own
/// <c>NAV:</c> grammar actually represents (<see cref="NavigationSettings.Mode"/>,
/// <see cref="NavigationSettings.Waypoints"/>,
/// <see cref="NavigationSettings.FollowTargetObjectId"/>/
/// <see cref="NavigationSettings.FollowTargetName"/> — see
/// <see cref="MetafSerializer.SaveNav"/>/<see cref="MetafSerializer.TryLoadNav"/>).
/// Every other <see cref="NavigationSettings"/> field
/// (Enabled/Priority/MinimumDistanceMeters/FollowAroundCorners/OpenDoors/
/// Door*) is a real VTank Settings-table row with its own catalog name
/// (<c>EnableNav</c>, <c>NavPriorityBoost</c>, <c>NavCloseStopRange</c>,
/// …) already owned end-to-end by <see cref="MossTankProfileStore"/>'s
/// <c>.usd</c> profile — this store never touches them, matching real
/// VTank's own split between the Settings-scoped nav prefs and the
/// per-route file.
///
/// Named route files carry a <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.
/// </summary>
internal sealed class MossTankRouteProfileStore
{
public const string ByCharacter = "By char";
private const string IndexKey = "profiles/route/index.json";
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
private const string NavPrefix = "nav_";
private readonly IPluginHost _host;
private IndexDocument _index;
private string _characterName = string.Empty;
private string _selected = ByCharacter;
private string? _pendingLegacyBareName;
public MossTankRouteProfileStore(IPluginHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_index = Read<IndexDocument>(IndexKey) ?? new IndexDocument();
_index.Names ??= [];
_index.SelectedByCharacter = new Dictionary<string, string>(
_index.SelectedByCharacter ?? new Dictionary<string, string>(),
StringComparer.OrdinalIgnoreCase);
}
public string Selected => _selected;
public string Selected => Strip(_selected);
public string? RecoveryNotice { get; private set; }
public IReadOnlyList<string> AvailableNames => new[] { ByCharacter }
.Concat(_index.Names)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(static name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
private string Server => _host.Automation.Character.WorldName;
private IPluginStorage VtankStorage => _host.VtankProfiles;
private bool CanBindFiles => _characterName.Length > 0 && Server.Length > 0;
private static string Strip(string fileName)
{
if (fileName.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
return fileName;
string value = fileName;
if (value.StartsWith(NavPrefix, StringComparison.Ordinal))
value = value[NavPrefix.Length..];
if (value.EndsWith(".af", StringComparison.OrdinalIgnoreCase))
value = value[..^3];
return value;
}
public IReadOnlyList<string> AvailableNames
{
get
{
var names = new List<string> { ByCharacter };
foreach (VtankProfileDirectory.ProfileEntry entry in
VtankProfileDirectory.ListNavigationProfiles(VtankStorage))
{
if (entry.FileName.Length == 0)
continue; // VTank's own "[None]"/"[By char]" sentinels.
names.Add(Strip(entry.FileName));
}
return names;
}
}
public bool BindCharacter(string? characterName)
{
@ -54,24 +86,46 @@ internal sealed class MossTankRouteProfileStore
if (string.Equals(normalized, _characterName, StringComparison.OrdinalIgnoreCase))
return false;
_characterName = normalized;
_selected = _index.SelectedByCharacter.TryGetValue(
SelectionKey(),
out string? selected)
&& IsKnown(selected)
? CanonicalName(selected)
: ByCharacter;
_pendingLegacyBareName = null;
VtankProfileDirectory.VtankCharacterBinding? binding = CanBindFiles
? VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server)
: null;
_selected = binding is { NavFileName.Length: > 0 } bound
? bound.NavFileName
: ByCharacter;
return true;
}
public bool Select(string? name)
{
string normalized = name?.Trim() ?? string.Empty;
if (!IsKnown(normalized))
string normalized = Normalize(name);
if (normalized.Length == 0)
return false;
_selected = CanonicalName(normalized);
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
return true;
if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
_selected = ByCharacter;
_pendingLegacyBareName = null;
WriteBinding();
return true;
}
string candidate = ToFileName(normalized);
if (VtankStorage.IsAvailable && VtankStorage.ReadText(candidate) is not null)
{
_selected = candidate;
_pendingLegacyBareName = null;
WriteBinding();
return true;
}
if (_host.Storage.IsAvailable
&& _host.Storage.ReadText(LegacyProfileKey(normalized, byCharacter: false)) is not null)
{
_selected = candidate;
_pendingLegacyBareName = normalized;
WriteBinding();
return true;
}
return false;
}
public bool Create(
@ -80,7 +134,7 @@ internal sealed class MossTankRouteProfileStore
NavigationSettings current,
out string notice)
{
string normalized = name?.Trim() ?? string.Empty;
string normalized = Normalize(name);
if (normalized.Length is < 1 or > 64)
{
notice = "Enter a route profile name (1-64 characters).";
@ -91,46 +145,39 @@ internal sealed class MossTankRouteProfileStore
notice = "'By char' is the built-in route profile.";
return false;
}
Write(
ProfileKey(normalized, byCharacter: false),
copyCurrent
? RouteDocument.Capture(current)
: new RouteDocument());
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
_index.Names.Add(normalized);
_selected = _index.Names.First(entry => entry.Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
WriteLegacyExport(_selected, copyCurrent ? current : new NavigationSettings());
string fileName = ToFileName(normalized);
NavigationSettings source = copyCurrent ? current : new NavigationSettings();
WriteAf(fileName, MetafSerializer.SaveNav(source));
_selected = fileName;
_pendingLegacyBareName = null;
WriteBinding();
notice = copyCurrent
? $"Copied route to {_selected}."
: $"Created route profile {_selected}.";
? $"Copied route to {Strip(fileName)}."
: $"Created route profile {Strip(fileName)}.";
return true;
}
public bool LoadCurrent(NavigationSettings target)
public bool LoadCurrent(NavigationSettings target, ISpellCatalog spells)
{
ArgumentNullException.ThrowIfNull(target);
RouteDocument? document = Read<RouteDocument>(CurrentKey());
if (document is null)
ArgumentNullException.ThrowIfNull(spells);
MigrateLegacyIfNeeded(target, spells);
string fileName = CurrentFileName();
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
if (text is null)
return false;
document.Apply(target);
if (!MetafSerializer.TryLoadNav(text, target, spells, out string error))
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host, "route", fileName, text, new FormatException(error));
_host.Log.Warn(RecoveryNotice);
return false;
}
return true;
}
public void SaveCurrent(NavigationSettings settings)
{
Write(CurrentKey(), RouteDocument.Capture(settings));
WriteLegacyExport(
_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? string.IsNullOrWhiteSpace(_characterName)
? ByCharacter
: _characterName
: _selected,
settings);
}
public void SaveCurrent(NavigationSettings settings) =>
WriteAf(CurrentFileName(), MetafSerializer.SaveNav(settings));
public bool TryImportLegacy(
string? name,
@ -138,7 +185,7 @@ internal sealed class MossTankRouteProfileStore
ISpellCatalog spells,
out string notice)
{
string normalized = name?.Trim() ?? string.Empty;
string normalized = Normalize(name);
if (!_host.Storage.IsAvailable || normalized.Length == 0)
{
notice = "Legacy navigation storage is unavailable.";
@ -162,64 +209,59 @@ internal sealed class MossTankRouteProfileStore
notice = $"Could not import {Path.GetFileName(key)}: {error}";
return false;
}
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
_index.Names.Add(normalized);
_selected = _index.Names.First(entry => entry.Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
SaveCurrent(target);
notice = $"Imported VTank navigation profile {_selected}.";
string fileName = ToFileName(normalized);
WriteAf(fileName, MetafSerializer.SaveNav(target));
_selected = fileName;
_pendingLegacyBareName = null;
WriteBinding();
notice = $"Imported VTank navigation profile {Strip(fileName)}.";
return true;
}
/// <summary>
/// Resets only the fields this store owns (Mode/Waypoints/FollowTarget)
/// — Enabled/Priority/MinimumDistanceMeters/FollowAroundCorners/
/// OpenDoors/Door* belong to the Settings profile and are left alone.
/// </summary>
public void ClearCurrent(NavigationSettings target)
{
target.Enabled = false;
target.Priority = false;
target.Mode = RouteMode.Circular;
target.MinimumDistanceMeters = 2d;
target.FollowTargetObjectId = 0u;
target.FollowTargetName = string.Empty;
target.FollowAroundCorners = true;
target.OpenDoors = false;
target.Waypoints.Clear();
SaveCurrent(target);
}
private bool IsKnown(string? name) => name is not null
&& (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|| _index.Names.Contains(name, StringComparer.OrdinalIgnoreCase));
// ------------------------------------------------------------------
// Legacy JSON -> .af migration.
// ------------------------------------------------------------------
private string CanonicalName(string name) => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ByCharacter
: _index.Names.First(entry => entry.Equals(
name,
StringComparison.OrdinalIgnoreCase));
private string CurrentKey() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ProfileKey(_characterName, byCharacter: true)
: ProfileKey(_selected, byCharacter: false);
private static string ProfileKey(string value, bool byCharacter)
private void MigrateLegacyIfNeeded(NavigationSettings target, ISpellCatalog spells)
{
string identity = (byCharacter ? "char:" : "named:")
+ value.Trim().ToUpperInvariant();
string hash = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
return $"profiles/route/{hash}.json";
string fileName = CurrentFileName();
if (!VtankStorage.IsAvailable || VtankStorage.ReadText(fileName) is not null)
{
_pendingLegacyBareName = null;
return;
}
string legacyKey = _pendingLegacyBareName is { Length: > 0 } bareName
? LegacyProfileKey(bareName, byCharacter: false)
: LegacyProfileKey(_characterName, byCharacter: true);
LegacyRouteDocument? legacy = ReadLegacyJson(legacyKey);
if (legacy is null)
{
_pendingLegacyBareName = null;
return;
}
legacy.ApplyRouteOnly(target);
WriteAf(fileName, MetafSerializer.SaveNav(target));
if (_host.Storage.IsAvailable)
_host.Storage.Delete(legacyKey);
_pendingLegacyBareName = null;
_host.Log.Warn($"Migrated legacy MossTank route profile '{legacyKey}' to '{fileName}'.");
}
private string SelectionKey() => string.IsNullOrWhiteSpace(_characterName)
? "_default"
: _characterName;
private T? Read<T>(string key) where T : class
private LegacyRouteDocument? ReadLegacyJson(string key)
{
if (!_host.Storage.IsAvailable)
return null;
@ -229,28 +271,49 @@ internal sealed class MossTankRouteProfileStore
json = _host.Storage.ReadText(key);
return string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<T>(json, Options);
: JsonSerializer.Deserialize<LegacyRouteDocument>(json, JsonOptions);
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host,
"route",
key,
json,
error);
RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "route", key, json, error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
private void Write<T>(string key, T document)
// ------------------------------------------------------------------
// File naming, storage plumbing.
// ------------------------------------------------------------------
private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? NavPrefix + VtankProfileDirectory.AutoCharacterFileName(_characterName, Server, "af")
: _selected;
private static string ToFileName(string bareName) =>
NavPrefix + bareName + ".af";
private void WriteBinding()
{
if (!_host.Storage.IsAvailable)
if (!CanBindFiles || !VtankStorage.IsAvailable)
return;
VtankProfileDirectory.VtankCharacterBinding existing =
VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server)
?? new VtankProfileDirectory.VtankCharacterBinding(
string.Empty, string.Empty, CurrentFileName(), null);
VtankProfileDirectory.WriteCharacterBinding(
VtankStorage,
_characterName,
Server,
existing with { NavFileName = CurrentFileName() });
}
private void WriteAf(string fileName, string text)
{
if (!VtankStorage.IsAvailable)
return;
try
{
_host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options));
VtankStorage.WriteText(fileName, text);
}
catch (Exception error)
{
@ -258,58 +321,28 @@ internal sealed class MossTankRouteProfileStore
}
}
private void SaveIndex() => Write(IndexKey, _index);
private static string Normalize(string? name) => name?.Trim() ?? string.Empty;
/// <summary>
/// <c>.af</c> is the only VTank-compatible export format now (Campaign
/// VT slice 1 Part A — MossTank no longer authors the binary
/// <c>.nav</c> format at all, matching <see cref="VtankNavRouteSerializer"/>'s
/// demotion to a one-shot import). Exports live under
/// <c>exports/nav/</c> — a distinct subdirectory from
/// <see cref="MossTankMetaProfileStore"/>'s <c>exports/meta/</c> — so a
/// route (Navigation) profile and a Meta profile sharing the same name
/// cannot silently overwrite each other's <c>.af</c> file (both stores
/// used to write into the same flat <c>exports/</c> root).
/// </summary>
private void WriteLegacyExport(string name, NavigationSettings settings)
private static string LegacyProfileKey(string value, bool byCharacter)
{
if (!_host.Storage.IsAvailable)
return;
try
{
_host.Storage.WriteText(
$"exports/nav/{LegacyFileName(name)}.af",
MetafSerializer.SaveNav(settings));
}
catch (Exception error)
{
_host.Log.Warn(
$"MossTank VTank navigation export could not be saved: {error.Message}");
}
string identity = (byCharacter ? "char:" : "named:") + value.Trim().ToUpperInvariant();
string hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
return $"profiles/route/{hash}.json";
}
private static string LegacyFileName(string name)
private static readonly JsonSerializerOptions JsonOptions = new()
{
char[] invalid = Path.GetInvalidFileNameChars();
var result = new StringBuilder(name.Length);
foreach (char value in name.Trim())
{
result.Append(value is '/' or '\\' || invalid.Contains(value)
? '_'
: value);
}
return result.Length == 0 ? "Route" : result.ToString();
}
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
private sealed class IndexDocument
{
public int Version { get; set; } = 1;
public List<string> Names { get; set; } = [];
public Dictionary<string, string> SelectedByCharacter { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
}
private sealed class RouteDocument
// ------------------------------------------------------------------
// Migration-only: the OLD full route document shape, kept solely so a
// not-yet-migrated JSON profile can still be read once and converted.
// Only the fields this store still owns (Mode/Waypoints/FollowTarget)
// are applied — the rest belonged to the Settings profile even before
// the cutover and are left to whatever that profile already holds.
private sealed class LegacyRouteDocument
{
public int Version { get; set; } = 1;
public bool Enabled { get; set; }
@ -323,50 +356,20 @@ internal sealed class MossTankRouteProfileStore
public double DoorIdentifyRangeMeters { get; set; } = 20d;
public double DoorOpenRangeMeters { get; set; } = 4d;
public int DoorLockpickExcessThreshold { get; set; } = -50;
public WaypointDocument[] Waypoints { get; set; } = [];
public LegacyWaypointDocument[] Waypoints { get; set; } = [];
public static RouteDocument Capture(NavigationSettings value) => new()
public void ApplyRouteOnly(NavigationSettings value)
{
Enabled = value.Enabled,
Priority = value.Priority,
Mode = value.Mode,
MinimumDistanceMeters = value.MinimumDistanceMeters,
FollowTargetObjectId = value.FollowTargetObjectId,
FollowTargetName = value.FollowTargetName,
FollowAroundCorners = value.FollowAroundCorners,
OpenDoors = value.OpenDoors,
DoorIdentifyRangeMeters = value.DoorIdentifyRangeMeters,
DoorOpenRangeMeters = value.DoorOpenRangeMeters,
DoorLockpickExcessThreshold = value.DoorLockpickExcessThreshold,
Waypoints = value.Waypoints.Select(WaypointDocument.From).ToArray(),
};
public void Apply(NavigationSettings value)
{
value.Enabled = Enabled;
value.Priority = Priority;
value.Mode = Enum.IsDefined(Mode) ? Mode : RouteMode.Circular;
value.MinimumDistanceMeters = Math.Clamp(
MinimumDistanceMeters,
0.5d,
50d);
value.FollowTargetObjectId = FollowTargetObjectId;
value.FollowTargetName = FollowTargetName ?? string.Empty;
value.FollowAroundCorners = FollowAroundCorners;
value.OpenDoors = OpenDoors;
value.DoorIdentifyRangeMeters = Math.Clamp(
DoorIdentifyRangeMeters, 1d, 100d);
value.DoorOpenRangeMeters = Math.Clamp(
DoorOpenRangeMeters, 0.5d, value.DoorIdentifyRangeMeters);
value.DoorLockpickExcessThreshold = Math.Clamp(
DoorLockpickExcessThreshold, -500, 500);
value.Waypoints.Clear();
foreach (WaypointDocument waypoint in Waypoints ?? [])
foreach (LegacyWaypointDocument waypoint in Waypoints ?? [])
value.Waypoints.Add(waypoint.ToWaypoint());
}
}
private sealed class WaypointDocument
private sealed class LegacyWaypointDocument
{
public RouteWaypointType Type { get; set; }
public uint CellId { get; set; }
@ -375,12 +378,6 @@ internal sealed class MossTankRouteProfileStore
public double Elevation { get; set; }
public float HeadingDegrees { get; set; }
public bool IsOutdoor { get; set; }
/// <summary>
/// The second coordinate triple Portal2/UseNPC waypoints carry
/// (VTank's embedded "d"-record / metaf's "tgtxyz" — see
/// <see cref="RouteWaypoint.ReferencePosition"/>). Unused for every
/// other waypoint type.
/// </summary>
public uint ReferenceCellId { get; set; }
public double ReferenceEastWest { get; set; }
public double ReferenceNorthSouth { get; set; }
@ -401,46 +398,11 @@ internal sealed class MossTankRouteProfileStore
public int JumpChargeMilliseconds { get; set; } = 1000;
public RouteJumpDirection JumpDirection { get; set; }
public static WaypointDocument From(RouteWaypoint value) => new()
{
Type = value.Type,
CellId = value.Position.CellId,
EastWest = value.Position.EastWest,
NorthSouth = value.Position.NorthSouth,
Elevation = value.Position.Elevation,
HeadingDegrees = value.Position.HeadingDegrees,
IsOutdoor = value.Position.IsOutdoor,
ReferenceCellId = value.ReferencePosition.CellId,
ReferenceEastWest = value.ReferencePosition.EastWest,
ReferenceNorthSouth = value.ReferencePosition.NorthSouth,
ReferenceElevation = value.ReferencePosition.Elevation,
ReferenceHeadingDegrees = value.ReferencePosition.HeadingDegrees,
ReferenceIsOutdoor = value.ReferencePosition.IsOutdoor,
ObjectId = value.ObjectId,
ObjectName = value.ObjectName,
LegacyObjectClass = value.LegacyObjectClass,
LegacyReferenceValid = value.LegacyReferenceValid,
Text = value.Text,
DurationMilliseconds = value.DurationMilliseconds,
Recall = value.Recall,
RecallSpellId = value.RecallSpellId,
RecallSpellName = value.RecallSpellName,
JumpHeadingDegrees = value.JumpHeadingDegrees,
JumpRun = value.JumpRun,
JumpChargeMilliseconds = value.JumpChargeMilliseconds,
JumpDirection = value.JumpDirection,
};
public RouteWaypoint ToWaypoint() => new()
{
Type = Enum.IsDefined(Type) ? Type : RouteWaypointType.Point,
Position = new PluginNavigationPosition(
CellId,
EastWest,
NorthSouth,
Elevation,
HeadingDegrees,
IsOutdoor),
CellId, EastWest, NorthSouth, Elevation, HeadingDegrees, IsOutdoor),
ReferencePosition = new PluginNavigationPosition(
ReferenceCellId,
ReferenceEastWest,
@ -457,17 +419,10 @@ internal sealed class MossTankRouteProfileStore
Recall = Enum.IsDefined(Recall) ? Recall : RouteRecallKind.Lifestone,
RecallSpellId = RecallSpellId,
RecallSpellName = RecallSpellName ?? string.Empty,
JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees)
? JumpHeadingDegrees
: 0f,
JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees) ? JumpHeadingDegrees : 0f,
JumpRun = JumpRun,
JumpChargeMilliseconds = Math.Clamp(
JumpChargeMilliseconds,
0,
10_000),
JumpDirection = Enum.IsDefined(JumpDirection)
? JumpDirection
: RouteJumpDirection.Forward,
JumpChargeMilliseconds = Math.Clamp(JumpChargeMilliseconds, 0, 10_000),
JumpDirection = Enum.IsDefined(JumpDirection) ? JumpDirection : RouteJumpDirection.Forward,
};
}
}

View file

@ -102,6 +102,117 @@ public sealed class MossTankPanelTests
Assert.Equal(0.5d, panel.EvaluateExpression("uboptget['AttackDistance']").AsNumber(), precision: 7);
}
/// <summary>
/// Reproduces MossTankMetaProfileStore's pre-cutover by-character JSON
/// hash key (its own <c>Hash(string)</c> — 12-byte truncated SHA256,
/// lowercase hex — is private; the format is the migration contract
/// itself, reproduced verbatim here).
/// </summary>
private static string LegacyMetaByCharacterKey(string characterName)
{
byte[] hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(characterName.ToLowerInvariant()));
return $"profiles/meta/by-character/{Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant()}.json";
}
[Fact]
public void MetaStoreMigratesLegacyJsonProfileToAfAndDeletesTheJsonKey()
{
var storage = new MemoryStorage();
string legacyKey = LegacyMetaByCharacterKey("Barris");
var legacyProfile = new MetaProfile
{
Rules =
[
new MetaRule
{
State = "Default",
Condition = MetaCondition.Always(),
Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say hi" },
Enabled = true,
},
],
};
storage.Text[legacyKey] = System.Text.Json.JsonSerializer.Serialize(
legacyProfile,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
var store = new MossTankMetaProfileStore(
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
store.BindCharacter("Barris");
MetaProfile loaded = store.LoadCurrent();
Assert.False(storage.Text.ContainsKey(legacyKey));
MetaRule rule = Assert.Single(loaded.Rules);
Assert.Equal("/say hi", rule.Action.Text);
// Idempotent second run: nothing left to migrate, loads straight
// from the now-real .af file.
var reopened = new MossTankMetaProfileStore(
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
reopened.BindCharacter("Barris");
MetaProfile reloaded = reopened.LoadCurrent();
Assert.Single(reloaded.Rules);
}
[Fact]
public void MetaStoreLeavesLegacyJsonUntouchedWhenAfCounterpartExists()
{
var storage = new MemoryStorage();
string legacyKey = LegacyMetaByCharacterKey("Barris");
storage.Text[legacyKey] = System.Text.Json.JsonSerializer.Serialize(new MetaProfile
{
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say stale" } }],
});
string realKey = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "af");
storage.Text[realKey] = MetafSerializer.SaveMeta(new MetaProfile
{
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say real" } }],
});
var store = new MossTankMetaProfileStore(
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
store.BindCharacter("Barris");
MetaProfile loaded = store.LoadCurrent();
Assert.True(storage.Text.ContainsKey(legacyKey));
Assert.Equal("/say real", Assert.Single(loaded.Rules).Action.Text);
}
[Fact]
public void MetaStoreRefusesToSaveADisabledRuleAndKeepsThePriorAfContent()
{
var storage = new MemoryStorage();
var store = new MossTankMetaProfileStore(
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
store.BindCharacter("Barris");
var enabledOnly = new MetaProfile
{
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say good" } }],
};
Assert.True(store.SaveCurrent(enabledOnly));
string key = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "af");
string goodContent = storage.Text[key];
var withDisabledRule = new MetaProfile
{
Rules =
[
new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say good" } },
new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say off" }, Enabled = false },
],
};
bool saved = store.SaveCurrent(withDisabledRule);
Assert.False(saved);
Assert.NotNull(store.SaveNotice);
Assert.Contains("disabled", store.SaveNotice, StringComparison.OrdinalIgnoreCase);
// The refusal must not silently drop the disabled rule: the file on
// disk keeps its last good, fully-representable content.
Assert.Equal(goodContent, storage.Text[key]);
}
[Fact]
public void FirstRunGuidanceExplainsProfilesImportsAndPersistentShelfOnce()
{
@ -764,22 +875,24 @@ public sealed class MossTankPanelTests
Assert.Single(panel.RouteRows);
Assert.Contains("12.5", panel.RouteRows[0], StringComparison.Ordinal);
// .af is the only VTank-compatible export format now (Campaign VT
// slice 1 Part A — the .nav writer was deleted, one-shot import
// only).
// .af is the ONLY storage/authoring format now (Campaign VT slice 1
// Part A round 2 — the old "exports/nav/" side-car mirror is gone;
// this is the real route file, named with the "nav_" prefix so it
// never collides with a Meta profile of the same user-typed name in
// the shared VtankProfiles directory).
Command(panel, "nav save Exported.nav");
// Item E (slice-1 fix round): the writer now prepends metaf's own
// navHeader block, so the file no longer STARTS with "NAV: " —
// Contains proves the body is still there.
Assert.Contains(
"NAV: ",
storage.Text["exports/nav/Exported.af"],
storage.Text["nav_Exported.af"],
StringComparison.Ordinal);
}
// Item J (slice-1 fix round): "/vt nav save Foo.af" used to keep the
// ".af" suffix (only ".nav" was stripped), producing a doubled
// "exports/nav/Foo.af.af" export instead of "exports/nav/Foo.af".
// "nav_Foo.af.af" export instead of "nav_Foo.af".
[Fact]
public void NavSaveAcceptsAnAfSuffixedNameWithoutDoublingIt()
{
@ -788,17 +901,21 @@ public sealed class MossTankPanelTests
Command(panel, "nav save Foo.af");
Assert.True(storage.Text.ContainsKey("exports/nav/Foo.af"));
Assert.False(storage.Text.ContainsKey("exports/nav/Foo.af.af"));
Assert.True(storage.Text.ContainsKey("nav_Foo.af"));
Assert.False(storage.Text.ContainsKey("nav_Foo.af.af"));
}
// Item C (Campaign VT slice-1 fix round): a Meta profile and a route
// (Navigation) profile named identically used to write into the SAME
// flat "exports/" directory — "Same.af" from one silently clobbered
// "Same.af" from the other. exports/meta/ and exports/nav/ are
// separate subdirectories so both coexist.
// "Same.af" from the other. Campaign VT slice-1 round 2 replaced the
// separate exports/meta/ + exports/nav/ side-car mirrors with the real
// storage cutover: both stores now write directly into the shared
// VtankProfiles directory, so the same collision risk exists there
// instead — resolved by the route store's "nav_" file-name prefix
// (metaf's own observed convention for a stand-alone nav .af).
[Fact]
public void MetaAndRouteExportsWithTheSameNameDoNotCollide()
public void MetaAndRouteProfilesWithTheSameNameDoNotCollide()
{
var storage = new MemoryStorage();
storage.Text["imports/Same.met"] =
@ -812,15 +929,15 @@ public sealed class MossTankPanelTests
Command(panel, "meta save Same.met");
Command(panel, "nav save Same.nav");
Assert.True(storage.Text.ContainsKey("exports/meta/Same.af"));
Assert.True(storage.Text.ContainsKey("exports/nav/Same.af"));
Assert.True(storage.Text.ContainsKey("Same.af"));
Assert.True(storage.Text.ContainsKey("nav_Same.af"));
Assert.Contains(
"STATE: ",
storage.Text["exports/meta/Same.af"],
storage.Text["Same.af"],
StringComparison.Ordinal);
Assert.Contains(
"NAV: ",
storage.Text["exports/nav/Same.af"],
storage.Text["nav_Same.af"],
StringComparison.Ordinal);
}
@ -851,16 +968,17 @@ public sealed class MossTankPanelTests
Assert.Single(panel.MetaRows);
Assert.Contains("/say imported", panel.MetaRows[0], StringComparison.Ordinal);
// .af is the only VTank-compatible export format now (Campaign VT
// slice 1 Part A — the .met writer was deleted, one-shot import
// only).
// .af is the ONLY storage/authoring format now (Campaign VT slice 1
// Part A round 2 — the .met writer was deleted, one-shot import
// only, and the old "exports/meta/" side-car mirror is gone: this
// IS the real profile file).
Command(panel, "meta save Exported.met");
Assert.Contains(
"STATE: ",
storage.Text["exports/meta/Exported.af"],
storage.Text["Exported.af"],
StringComparison.Ordinal);
Assert.True(MetafSerializer.TryLoadMeta(
storage.Text["exports/meta/Exported.af"],
storage.Text["Exported.af"],
NoOpSpellCatalogForExport.Instance,
out MetaProfile exported,
out string error), error);
@ -877,8 +995,8 @@ public sealed class MossTankPanelTests
Command(panel, "meta save Foo.af");
Assert.True(storage.Text.ContainsKey("exports/meta/Foo.af"));
Assert.False(storage.Text.ContainsKey("exports/meta/Foo.af.af"));
Assert.True(storage.Text.ContainsKey("Foo.af"));
Assert.False(storage.Text.ContainsKey("Foo.af.af"));
}
private sealed class NoOpSpellCatalogForExport : ISpellCatalog

View file

@ -379,24 +379,23 @@ public sealed class NavigationTests
Assert.False(controller.Tick(0.01d, canAct: true));
}
// Campaign VT slice 1 Part A round 2: the route (.af) file now carries
// ONLY Mode/Waypoints/FollowTarget — Enabled/Priority/
// MinimumDistanceMeters/FollowAroundCorners/OpenDoors/Door* are real
// VTank Settings-table rows (EnableNav, NavPriorityBoost,
// NavCloseStopRange, …) owned end-to-end by MossTankProfileStore's
// .usd profile now, matching real VTank's own split between global nav
// prefs and the per-route file. Renamed from
// RouteProfilesRoundTripEveryWaypointField, which asserted the pre-
// cutover behavior where the route JSON carried everything.
[Fact]
public void RouteProfilesRoundTripEveryWaypointField()
public void RouteProfilesRoundTripWaypointFieldsButLeaveSettingsOwnedFieldsAlone()
{
var storage = new MemoryStorage();
var host = new FakeHost(new FakeAutomation(), storage);
var source = new NavigationSettings
{
Enabled = true,
Priority = true,
Mode = RouteMode.Linear,
MinimumDistanceMeters = 4.5d,
FollowTargetObjectId = 99u,
FollowTargetName = "Leader",
FollowAroundCorners = false,
OpenDoors = true,
DoorIdentifyRangeMeters = 35d,
DoorOpenRangeMeters = 3.5d,
DoorLockpickExcessThreshold = 17,
};
source.Waypoints.Add(new RouteWaypoint
{
@ -416,28 +415,72 @@ public sealed class NavigationTests
Assert.True(first.BindCharacter("Test Character"));
first.SaveCurrent(source);
var target = new NavigationSettings();
// Pre-seed values a Settings-profile load would already have set —
// loading the route must leave every one of them untouched.
var target = new NavigationSettings
{
Enabled = false,
Priority = false,
MinimumDistanceMeters = 9d,
FollowAroundCorners = true,
OpenDoors = false,
DoorIdentifyRangeMeters = 11d,
DoorOpenRangeMeters = 1d,
DoorLockpickExcessThreshold = -3,
};
var second = new MossTankRouteProfileStore(host);
Assert.True(second.BindCharacter("Test Character"));
Assert.True(second.LoadCurrent(target));
Assert.True(second.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance));
Assert.True(target.Enabled);
Assert.True(target.Priority);
Assert.Equal(RouteMode.Linear, target.Mode);
Assert.Equal(4.5d, target.MinimumDistanceMeters);
Assert.Equal(99u, target.FollowTargetObjectId);
Assert.False(target.FollowAroundCorners);
Assert.True(target.OpenDoors);
Assert.Equal(35d, target.DoorIdentifyRangeMeters);
Assert.Equal(3.5d, target.DoorOpenRangeMeters);
Assert.Equal(17, target.DoorLockpickExcessThreshold);
RouteWaypoint waypoint = Assert.Single(target.Waypoints);
Assert.Equal(RouteWaypointType.Jump, waypoint.Type);
Assert.Equal(271.5f, waypoint.JumpHeadingDegrees);
Assert.True(waypoint.JumpRun);
Assert.Equal(875, waypoint.JumpChargeMilliseconds);
Assert.Equal(RouteJumpDirection.StrafeRight, waypoint.JumpDirection);
Assert.Equal(0x7F7F0001u, waypoint.Position.CellId);
// .af cannot represent JumpDirection at all (MetafSerializer.cs:924
// — a pre-existing, recorded divergence-register gap, not
// something this store cutover changes): it always comes back at
// the model's own default, Forward, regardless of what was saved.
Assert.Equal(RouteJumpDirection.Forward, waypoint.JumpDirection);
// metaf's "jmp" node format carries no cell id either (FORMAT: jmp
// x y z heading run chargems — six fields, no hex cell component;
// MetafSerializer.cs:915-935), so Position.CellId is not asserted
// for a Jump waypoint specifically.
Assert.False(target.Enabled);
Assert.False(target.Priority);
Assert.Equal(9d, target.MinimumDistanceMeters);
Assert.True(target.FollowAroundCorners);
Assert.False(target.OpenDoors);
Assert.Equal(11d, target.DoorIdentifyRangeMeters);
Assert.Equal(1d, target.DoorOpenRangeMeters);
Assert.Equal(-3, target.DoorLockpickExcessThreshold);
}
[Fact]
public void FollowModeRouteRoundTripsTheFollowTargetThroughAf()
{
var storage = new MemoryStorage();
var host = new FakeHost(new FakeAutomation(), storage);
var source = new NavigationSettings
{
Mode = RouteMode.Target,
FollowTargetObjectId = 99u,
FollowTargetName = "Leader",
};
var first = new MossTankRouteProfileStore(host);
Assert.True(first.BindCharacter("Test Character"));
first.SaveCurrent(source);
var target = new NavigationSettings();
var second = new MossTankRouteProfileStore(host);
Assert.True(second.BindCharacter("Test Character"));
Assert.True(second.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance));
Assert.Equal(RouteMode.Target, target.Mode);
Assert.Equal(99u, target.FollowTargetObjectId);
Assert.Equal("Leader", target.FollowTargetName);
}
private static NavigationController Controller(
@ -496,6 +539,9 @@ public sealed class NavigationTests
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IPluginStorage Storage { get; } = storage ?? NoOpPluginStorage.Instance;
public IAutomationSurface Automation { get; } = automation;
// Real VTank .usd/.af/.cdf storage, same backing store as the
// plugin's own JSON storage (key namespaces never collide).
public IPluginStorage VtankProfiles { get; } = storage ?? NoOpPluginStorage.Instance;
}
private sealed class FakeAutomation
@ -576,6 +622,11 @@ public sealed class NavigationTests
public bool IsAvailable => true;
public string? ReadText(string key) =>
_text.TryGetValue(key, out string? value) ? value : null;
public IReadOnlyList<string> List(string prefix) => _text.Keys
.Where(key => prefix.Length == 0
|| key.StartsWith(prefix + "/", StringComparison.Ordinal))
.OrderBy(static key => key, StringComparer.Ordinal)
.ToArray();
public void WriteText(string key, string content) => _text[key] = content;
public bool Delete(string key) => _text.Remove(key);
}