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:
parent
479495ecc6
commit
a5d52bfb07
5 changed files with 700 additions and 468 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue