fix(vt): round 3 item 2 — sweep the whole legacy profile roster, not just the selected one
The pre-cutover roster documents (profiles/{index,meta/index,route/index}.json)
carried EVERY named profile a character had, but each store's cutover only
ever converted the ONE currently-selected profile (MigrateLegacyIfNeeded) —
every other named profile was silently orphaned: never converted to the new
.usd/.af format, never listed again. Worse, MossTankProfileStore actively
reused "profiles/index.json" for a MineOnly-only shape, so the very next
SetMineOnly() save would have permanently discarded the roster.
Added a one-time SweepLegacyRosterIfNeeded() to all three stores
(settings/meta/route), guarded by a per-instance flag so repeat LoadCurrent
calls are a no-op:
- Settings: entries owned by the currently-bound character convert to their
real per-(character,server) sub-profile .usd + side-car; entries owned by
a different character are written back to the roster for that character's
own future session. MineOnly now lives at a NEW dedicated key
(profiles/macro/preferences.json) so the old roster key is never again
flattened/overwritten.
- Meta/route: named profiles were never owner-scoped (one shared, globally-
hashed key per name), so every roster entry converts unconditionally.
Mutation: reverted all three store .cs files to HEAD (keeping only the new
tests) and ran SettingsRosterSweepConvertsEveryNamedLegacyProfileOnce,
MetaRosterSweepConvertsEveryNamedLegacyProfileOnce, and
RouteRosterSweepConvertsEveryNamedLegacyProfileOnce — all three failed
(missing converted .usd/.af files) — confirming the tests exercise the bug
before the fix.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
b6d642c9b7
commit
145bd62503
5 changed files with 534 additions and 6 deletions
|
|
@ -20,10 +20,20 @@ internal sealed class MossTankMetaProfileStore
|
|||
{
|
||||
public const string ByCharacter = "By char";
|
||||
|
||||
// The pre-cutover roster (round 3 item 2): before the .af cutover this
|
||||
// store kept a flat Names list here (unlike the settings roster, named
|
||||
// Meta profiles were never owner-scoped — one shared, globally-hashed
|
||||
// key per name). The cutover stopped reading this key at all, orphaning
|
||||
// every named profile except whichever one a character had selected
|
||||
// (that one alone still converts, via MigrateLegacyIfNeeded). Read-only:
|
||||
// consulted ONLY by the one-time SweepLegacyRosterIfNeeded.
|
||||
private const string LegacyRosterKey = "profiles/meta/index.json";
|
||||
|
||||
private readonly IPluginHost _host;
|
||||
private string _character = string.Empty;
|
||||
private string _selected = ByCharacter;
|
||||
private string? _pendingLegacyBareName;
|
||||
private bool _rosterSwept;
|
||||
|
||||
public MossTankMetaProfileStore(IPluginHost host)
|
||||
{
|
||||
|
|
@ -86,6 +96,7 @@ internal sealed class MossTankMetaProfileStore
|
|||
|
||||
public MetaProfile LoadCurrent()
|
||||
{
|
||||
SweepLegacyRosterIfNeeded();
|
||||
MigrateLegacyIfNeeded();
|
||||
string fileName = CurrentFileName();
|
||||
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
|
||||
|
|
@ -261,6 +272,94 @@ internal sealed class MossTankMetaProfileStore
|
|||
// Legacy JSON -> .af migration.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Round 3 item 2: a ONE-TIME sweep (guarded by <see cref="_rosterSwept"/>)
|
||||
/// over the pre-cutover roster at <see cref="LegacyRosterKey"/>. Unlike
|
||||
/// <see cref="MigrateLegacyIfNeeded"/> — which only ever converts the
|
||||
/// ONE currently-selected profile — this converts every OTHER named
|
||||
/// Meta profile the old roster still lists. Named Meta profiles were
|
||||
/// never owner-scoped (one shared, globally-hashed key per name), so —
|
||||
/// unlike the settings roster — every entry converts regardless of
|
||||
/// which character is currently bound.
|
||||
/// </summary>
|
||||
private void SweepLegacyRosterIfNeeded()
|
||||
{
|
||||
if (_rosterSwept)
|
||||
return;
|
||||
_rosterSwept = true;
|
||||
if (!_host.Storage.IsAvailable)
|
||||
return;
|
||||
List<string>? names = ReadRosterNames();
|
||||
if (names is not { Count: > 0 })
|
||||
return;
|
||||
|
||||
var remaining = new List<string>();
|
||||
int migrated = 0;
|
||||
foreach (string rawName in names)
|
||||
{
|
||||
string name = (rawName ?? string.Empty).Trim();
|
||||
if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
|
||||
continue; // stale/invalid row; drop it rather than loop on it forever.
|
||||
|
||||
string legacyKey = LegacyNamedKey(name);
|
||||
MetaProfile? legacy = ReadLegacyJson(legacyKey);
|
||||
if (legacy is null)
|
||||
continue; // already converted (or never existed); drop the row.
|
||||
|
||||
string fileName = name.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
|
||||
? name
|
||||
: name + ".af";
|
||||
if (VtankStorage.IsAvailable && VtankStorage.ReadText(fileName) is null)
|
||||
{
|
||||
if (!SaveTo(fileName, legacy, out string notice))
|
||||
{
|
||||
// Representational loss (a disabled rule) — same gate as
|
||||
// MigrateLegacyIfNeeded: keep the row so this can be
|
||||
// retried once the user resolves it.
|
||||
_host.Log.Warn($"MossTank could not sweep legacy Meta profile '{name}': {notice}");
|
||||
remaining.Add(name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_host.Storage.Delete(legacyKey);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
if (migrated == 0)
|
||||
return;
|
||||
|
||||
if (remaining.Count > 0)
|
||||
{
|
||||
_host.Storage.WriteText(
|
||||
LegacyRosterKey,
|
||||
JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, JsonOptions));
|
||||
}
|
||||
else
|
||||
{
|
||||
_host.Storage.Delete(LegacyRosterKey);
|
||||
}
|
||||
_host.Log.Warn($"Migrated {migrated} legacy MossTank named Meta profile(s) from the old roster.");
|
||||
}
|
||||
|
||||
private List<string>? ReadRosterNames()
|
||||
{
|
||||
string? json = null;
|
||||
try
|
||||
{
|
||||
json = _host.Storage.ReadText(LegacyRosterKey);
|
||||
return string.IsNullOrWhiteSpace(json)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<LegacyRosterDocument>(json, JsonOptions)?.Names;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
RecoveryNotice = MossTankProfileRecovery.Preserve(
|
||||
_host, "meta", LegacyRosterKey, json, error);
|
||||
_host.Log.Warn(RecoveryNotice);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void MigrateLegacyIfNeeded()
|
||||
{
|
||||
string fileName = CurrentFileName();
|
||||
|
|
@ -395,4 +494,11 @@ internal sealed class MossTankMetaProfileStore
|
|||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
// Read-only: the pre-cutover roster shape at LegacyRosterKey, kept
|
||||
// solely so SweepLegacyRosterIfNeeded can recover it once.
|
||||
private sealed class LegacyRosterDocument
|
||||
{
|
||||
public List<string> Names { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,11 +23,18 @@ internal sealed class MossTankProfileStore
|
|||
// Ancient pre-hash single-profile fallback (the very first shape this
|
||||
// store ever wrote). Read-only: migration consults it, nothing else does.
|
||||
private const string OldSingleProfileKey = "profile.json";
|
||||
// Reuses the pre-cutover index key deliberately: this store never reads
|
||||
// its old "Profiles"/"SelectedByCharacter" shape back (both concepts are
|
||||
// now resolved from real files + the .cdf binding), so the collision is
|
||||
// harmless and existing corrupt-recovery behavior keeps its original key.
|
||||
private const string PreferencesKey = "profiles/index.json";
|
||||
// The pre-cutover full index document (round 3 item 2): its OLD shape
|
||||
// carried the entire named-profile roster (Profiles: [{Name, Owner}])
|
||||
// plus MineOnly/SelectedByCharacter. The settings cutover (round 2 step
|
||||
// 1) started reusing this exact key for a MineOnly-only shape, which
|
||||
// silently discarded any roster a pre-cutover install still had on disk
|
||||
// the next time SetMineOnly() saved — read-only here now, and consulted
|
||||
// ONLY for the one-time roster sweep (SweepLegacyRosterIfNeeded);
|
||||
// MineOnly itself lives at the NEW PreferencesKey below from now on so
|
||||
// this key is never again written in the flattened shape that caused
|
||||
// the loss.
|
||||
private const string LegacyRosterKey = "profiles/index.json";
|
||||
private const string PreferencesKey = "profiles/macro/preferences.json";
|
||||
private const string SideCarDirectory = "profiles/macro/sidecar/";
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
|
|
@ -43,11 +50,14 @@ internal sealed class MossTankProfileStore
|
|||
private string? _pendingLegacyBareName;
|
||||
private VtankDatabase? _currentDatabase;
|
||||
private string? _currentDatabaseFileName;
|
||||
private bool _rosterSwept;
|
||||
|
||||
public MossTankProfileStore(IPluginHost host)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
_preferences = ReadJson<PreferencesDocument>(PreferencesKey) ?? new PreferencesDocument();
|
||||
_preferences = ReadJson<PreferencesDocument>(PreferencesKey)
|
||||
?? ReadJson<PreferencesDocument>(LegacyRosterKey)
|
||||
?? new PreferencesDocument();
|
||||
}
|
||||
|
||||
public string Selected => _selected;
|
||||
|
|
@ -248,6 +258,7 @@ internal sealed class MossTankProfileStore
|
|||
VtankSettingsProfileSerializer.AllSettings settings,
|
||||
ISet<string> noBuffItemNames)
|
||||
{
|
||||
SweepLegacyRosterIfNeeded();
|
||||
MigrateLegacyIfNeeded(settings, noBuffItemNames);
|
||||
string fileName = CurrentFileName();
|
||||
string? text = ReadUsdText(fileName);
|
||||
|
|
@ -405,6 +416,96 @@ internal sealed class MossTankProfileStore
|
|||
// Legacy JSON -> .usd/.json side-car migration.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Round 3 item 2: a ONE-TIME sweep (guarded by <see cref="_rosterSwept"/>,
|
||||
/// so repeat calls from <see cref="LoadCurrent"/> are a no-op) over the
|
||||
/// pre-cutover roster document at <see cref="LegacyRosterKey"/>. Unlike
|
||||
/// <see cref="MigrateLegacyIfNeeded"/> — which only ever converts the
|
||||
/// ONE currently-selected profile — this converts every OTHER named
|
||||
/// profile the old roster still lists, so a character's whole macro
|
||||
/// library survives the cutover rather than only whichever profile
|
||||
/// happened to be selected at the time.
|
||||
///
|
||||
/// A roster entry's real legacy document has no server dimension at all
|
||||
/// (pre-cutover named profiles were one flat, globally-hashed key), but
|
||||
/// its NEW <c>.usd</c> home is a per-(character,server) sub-profile
|
||||
/// (<see cref="VtankProfileDirectory.SubProfilePrefix"/>) — the same
|
||||
/// target <see cref="MigrateLegacyIfNeeded"/> already uses for the
|
||||
/// selected profile. That requires a server, which is only known for
|
||||
/// the CURRENTLY bound character, so an entry owned by a different
|
||||
/// character is left in the roster (rewritten back minus whatever this
|
||||
/// pass did convert) for that character's own future session to finish.
|
||||
/// </summary>
|
||||
private void SweepLegacyRosterIfNeeded()
|
||||
{
|
||||
if (_rosterSwept)
|
||||
return;
|
||||
_rosterSwept = true;
|
||||
LegacyRosterDocument? roster = ReadJson<LegacyRosterDocument>(LegacyRosterKey);
|
||||
if (roster?.Profiles is not { Count: > 0 } profiles)
|
||||
return;
|
||||
|
||||
var remaining = new List<LegacyRosterEntry>();
|
||||
int migrated = 0;
|
||||
foreach (LegacyRosterEntry entry in profiles)
|
||||
{
|
||||
string name = (entry.Name ?? string.Empty).Trim();
|
||||
if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
|
||||
continue; // stale/invalid row; drop it rather than loop on it forever.
|
||||
|
||||
bool ownedByCurrentCharacter = string.IsNullOrWhiteSpace(entry.Owner)
|
||||
|| entry.Owner.Trim().Equals(_characterName, StringComparison.OrdinalIgnoreCase);
|
||||
if (!ownedByCurrentCharacter)
|
||||
{
|
||||
remaining.Add(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
string legacyKey = LegacyProfileKey(name, byCharacter: false);
|
||||
LegacyProfileDocument? legacy = ReadJson<LegacyProfileDocument>(legacyKey);
|
||||
if (legacy is null)
|
||||
continue; // already converted (or never existed); drop the row.
|
||||
|
||||
string fileName = VtankProfileDirectory.SubProfilePrefix(_characterName, Server)
|
||||
+ name + ".usd";
|
||||
if (ReadUsdText(fileName) is null)
|
||||
{
|
||||
var settings = new VtankSettingsProfileSerializer.AllSettings
|
||||
{
|
||||
Combat = new CombatSettings(),
|
||||
Buffs = new BuffSettings(),
|
||||
Vitals = new VitalSettings(),
|
||||
Inventory = new InventorySettings(),
|
||||
Navigation = new NavigationSettings(),
|
||||
};
|
||||
var noBuffItemNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
legacy.Apply(
|
||||
settings.Combat, settings.Buffs, settings.Vitals, settings.Inventory, noBuffItemNames);
|
||||
VtankDatabase database = VtankSettingsProfileSerializer.CreateNew(settings);
|
||||
WriteUsdText(fileName, database.Render());
|
||||
WriteJson(SideCarKey(fileName), SideCarDocument.Capture(settings, noBuffItemNames));
|
||||
}
|
||||
if (_host.Storage.IsAvailable)
|
||||
_host.Storage.Delete(legacyKey);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
if (migrated == 0)
|
||||
return;
|
||||
|
||||
if (remaining.Count > 0)
|
||||
WriteJson(LegacyRosterKey, new LegacyRosterDocument { Profiles = remaining });
|
||||
else if (_host.Storage.IsAvailable)
|
||||
_host.Storage.Delete(LegacyRosterKey);
|
||||
// MineOnly's own migration to the new PreferencesKey is otherwise
|
||||
// lazy (only SetMineOnly() writes it) — make it durable the moment
|
||||
// this sweep runs so a user who never toggles the checkbox still
|
||||
// stops depending on the old, now-legacy-only key.
|
||||
WriteJson(PreferencesKey, _preferences);
|
||||
_host.Log.Warn(
|
||||
$"Migrated {migrated} legacy MossTank named settings profile(s) from the old roster.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs once per store (per selection) when the resolved <c>.usd</c>
|
||||
/// file does not exist yet but the equivalent old JSON profile document
|
||||
|
|
@ -594,6 +695,24 @@ internal sealed class MossTankProfileStore
|
|||
public bool MineOnly { get; set; } = true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Read-only: the pre-cutover full roster shape at LegacyRosterKey, kept
|
||||
// solely so SweepLegacyRosterIfNeeded can recover it once. Nothing here
|
||||
// writes the OLD "Version"/"SelectedByCharacter" fields back — only a
|
||||
// residual Profiles list (entries this session couldn't yet convert).
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private sealed class LegacyRosterDocument
|
||||
{
|
||||
public List<LegacyRosterEntry> Profiles { get; set; } = [];
|
||||
}
|
||||
|
||||
private sealed class LegacyRosterEntry
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Owner { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// MossTank-only residual state: everything with no VTank setting name
|
||||
// (item/consumable name lists, the monster-rule table, and a handful of
|
||||
|
|
|
|||
|
|
@ -32,11 +32,20 @@ internal sealed class MossTankRouteProfileStore
|
|||
{
|
||||
public const string ByCharacter = "By char";
|
||||
private const string NavPrefix = "nav_";
|
||||
// The pre-cutover roster (round 3 item 2): before the .af cutover this
|
||||
// store kept a flat Names list here (named routes were never
|
||||
// owner-scoped — one shared, globally-hashed key per name). The
|
||||
// cutover stopped reading this key at all, orphaning every named route
|
||||
// except whichever one a character had selected (that one alone still
|
||||
// converts, via MigrateLegacyIfNeeded). Read-only: consulted ONLY by
|
||||
// the one-time SweepLegacyRosterIfNeeded.
|
||||
private const string LegacyRosterKey = "profiles/route/index.json";
|
||||
|
||||
private readonly IPluginHost _host;
|
||||
private string _characterName = string.Empty;
|
||||
private string _selected = ByCharacter;
|
||||
private string? _pendingLegacyBareName;
|
||||
private bool _rosterSwept;
|
||||
|
||||
public MossTankRouteProfileStore(IPluginHost host)
|
||||
{
|
||||
|
|
@ -161,6 +170,7 @@ internal sealed class MossTankRouteProfileStore
|
|||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
ArgumentNullException.ThrowIfNull(spells);
|
||||
SweepLegacyRosterIfNeeded();
|
||||
MigrateLegacyIfNeeded(target, spells);
|
||||
string fileName = CurrentFileName();
|
||||
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
|
||||
|
|
@ -236,6 +246,86 @@ internal sealed class MossTankRouteProfileStore
|
|||
// Legacy JSON -> .af migration.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Round 3 item 2: a ONE-TIME sweep (guarded by <see cref="_rosterSwept"/>)
|
||||
/// over the pre-cutover roster at <see cref="LegacyRosterKey"/>. Unlike
|
||||
/// <see cref="MigrateLegacyIfNeeded"/> — which only ever converts the
|
||||
/// ONE currently-selected route — this converts every OTHER named route
|
||||
/// the old roster still lists, using a fresh <see cref="NavigationSettings"/>
|
||||
/// per entry so the live/selected route is never touched. Named routes
|
||||
/// were never owner-scoped (one shared, globally-hashed key per name),
|
||||
/// so every entry converts regardless of which character is currently
|
||||
/// bound.
|
||||
/// </summary>
|
||||
private void SweepLegacyRosterIfNeeded()
|
||||
{
|
||||
if (_rosterSwept)
|
||||
return;
|
||||
_rosterSwept = true;
|
||||
if (!_host.Storage.IsAvailable)
|
||||
return;
|
||||
List<string>? names = ReadRosterNames();
|
||||
if (names is not { Count: > 0 })
|
||||
return;
|
||||
|
||||
var remaining = new List<string>();
|
||||
int migrated = 0;
|
||||
foreach (string rawName in names)
|
||||
{
|
||||
string name = (rawName ?? string.Empty).Trim();
|
||||
if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
|
||||
continue; // stale/invalid row; drop it rather than loop on it forever.
|
||||
|
||||
string legacyKey = LegacyProfileKey(name, byCharacter: false);
|
||||
LegacyRouteDocument? legacy = ReadLegacyJson(legacyKey);
|
||||
if (legacy is null)
|
||||
continue; // already converted (or never existed); drop the row.
|
||||
|
||||
string fileName = ToFileName(name);
|
||||
if (VtankStorage.IsAvailable && VtankStorage.ReadText(fileName) is null)
|
||||
{
|
||||
var scratch = new NavigationSettings();
|
||||
legacy.ApplyRouteOnly(scratch);
|
||||
WriteAf(fileName, MetafSerializer.SaveNav(scratch));
|
||||
}
|
||||
_host.Storage.Delete(legacyKey);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
if (migrated == 0)
|
||||
return;
|
||||
|
||||
if (remaining.Count > 0)
|
||||
{
|
||||
_host.Storage.WriteText(
|
||||
LegacyRosterKey,
|
||||
JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, JsonOptions));
|
||||
}
|
||||
else
|
||||
{
|
||||
_host.Storage.Delete(LegacyRosterKey);
|
||||
}
|
||||
_host.Log.Warn($"Migrated {migrated} legacy MossTank named route profile(s) from the old roster.");
|
||||
}
|
||||
|
||||
private List<string>? ReadRosterNames()
|
||||
{
|
||||
string? json = null;
|
||||
try
|
||||
{
|
||||
json = _host.Storage.ReadText(LegacyRosterKey);
|
||||
return string.IsNullOrWhiteSpace(json)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<LegacyRosterDocument>(json, JsonOptions)?.Names;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "route", LegacyRosterKey, json, error);
|
||||
_host.Log.Warn(RecoveryNotice);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void MigrateLegacyIfNeeded(NavigationSettings target, ISpellCatalog spells)
|
||||
{
|
||||
string fileName = CurrentFileName();
|
||||
|
|
@ -336,6 +426,13 @@ internal sealed class MossTankRouteProfileStore
|
|||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
// Read-only: the pre-cutover roster shape at LegacyRosterKey, kept
|
||||
// solely so SweepLegacyRosterIfNeeded can recover it once.
|
||||
private sealed class LegacyRosterDocument
|
||||
{
|
||||
public List<string> Names { get; set; } = [];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Migration-only: the OLD full route document shape, kept solely so a
|
||||
// not-yet-migrated JSON profile can still be read once and converted.
|
||||
|
|
|
|||
|
|
@ -136,6 +136,90 @@ public sealed class MossTankPanelTests
|
|||
Assert.True(panel.LootEnabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round 3 item 2: the pre-cutover "profiles/index.json" roster carried
|
||||
/// EVERY named profile a character had (<c>Profiles: [{Name, Owner}]</c>),
|
||||
/// not just whichever one happened to be selected. Before the fix, the
|
||||
/// settings cutover only ever converted the currently-selected profile
|
||||
/// (via <c>MigrateLegacyIfNeeded</c>) — every OTHER named profile in the
|
||||
/// roster was silently orphaned: never converted, never listed, and (once
|
||||
/// <c>SetMineOnly</c> saved) permanently unreachable once the shared key
|
||||
/// got flattened to a bare <c>{MineOnly}</c> document.
|
||||
/// </summary>
|
||||
private static string LegacyNamedProfileKey(string name)
|
||||
{
|
||||
string identity = "named:" + name.Trim().ToUpperInvariant();
|
||||
string hash = Convert.ToHexString(
|
||||
System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(identity)));
|
||||
return $"profiles/macro/{hash}.json";
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettingsRosterSweepConvertsEveryNamedLegacyProfileOnce()
|
||||
{
|
||||
var storage = new MemoryStorage();
|
||||
var automation = new FakeAutomation { Name = "Barris" };
|
||||
storage.Text["profiles/index.json"] = """
|
||||
{
|
||||
"Version": 1,
|
||||
"MineOnly": false,
|
||||
"Profiles": [
|
||||
{ "Name": "Farming", "Owner": "Barris" },
|
||||
{ "Name": "Buffing", "Owner": "Barris" }
|
||||
],
|
||||
"SelectedByCharacter": {}
|
||||
}
|
||||
""";
|
||||
storage.Text[LegacyNamedProfileKey("Farming")] =
|
||||
"""{ "Combat": { "MaximumRange": 42.0 } }""";
|
||||
storage.Text[LegacyNamedProfileKey("Buffing")] =
|
||||
"""{ "Combat": { "MaximumRange": 24.0 } }""";
|
||||
|
||||
var panel = new MossTankPanel(new FakeHost(automation, storage));
|
||||
|
||||
// MineOnly recovered from the old roster's own shape...
|
||||
Assert.False(panel.MineOnlyEnabled);
|
||||
// ...and both named profiles converted to real per-character
|
||||
// sub-profile .usd files, each keeping its own distinct value.
|
||||
string farmingUsd = VtankProfileDirectory.SubProfilePrefix("Barris", string.Empty)
|
||||
+ "Farming.usd";
|
||||
string buffingUsd = VtankProfileDirectory.SubProfilePrefix("Barris", string.Empty)
|
||||
+ "Buffing.usd";
|
||||
Assert.Equal(42d, RangeOf(storage, farmingUsd));
|
||||
Assert.Equal(24d, RangeOf(storage, buffingUsd));
|
||||
|
||||
// Both legacy JSON keys and the whole (now fully-swept) roster key
|
||||
// are gone; MineOnly now lives at its own dedicated key.
|
||||
Assert.False(storage.Text.ContainsKey(LegacyNamedProfileKey("Farming")));
|
||||
Assert.False(storage.Text.ContainsKey(LegacyNamedProfileKey("Buffing")));
|
||||
Assert.False(storage.Text.ContainsKey("profiles/index.json"));
|
||||
Assert.True(storage.Text.ContainsKey("profiles/macro/preferences.json"));
|
||||
|
||||
// Idempotent: a fresh panel against the same storage sweeps nothing
|
||||
// more (there is no roster key left to read) and keeps both values.
|
||||
var reloaded = new MossTankPanel(new FakeHost(
|
||||
new FakeAutomation { Name = "Barris" }, storage));
|
||||
Assert.False(reloaded.MineOnlyEnabled);
|
||||
Assert.Equal(42d, RangeOf(storage, farmingUsd));
|
||||
Assert.Equal(24d, RangeOf(storage, buffingUsd));
|
||||
}
|
||||
|
||||
private static double RangeOf(MemoryStorage storage, string usdKey)
|
||||
{
|
||||
string text = Assert.Contains(usdKey, (IDictionary<string, string>)storage.Text);
|
||||
var settings = new VtankSettingsProfileSerializer.AllSettings
|
||||
{
|
||||
Combat = new CombatSettings(),
|
||||
Buffs = new BuffSettings(),
|
||||
Vitals = new VitalSettings(),
|
||||
Inventory = new InventorySettings(),
|
||||
Navigation = new NavigationSettings(),
|
||||
};
|
||||
VtankSettingsProfileSerializer.Load(text, settings);
|
||||
return settings.Combat.MaximumRange;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reproduces MossTankMetaProfileStore's pre-cutover by-character JSON
|
||||
/// hash key (its own <c>Hash(string)</c> — 12-byte truncated SHA256,
|
||||
|
|
@ -213,6 +297,67 @@ public sealed class MossTankPanelTests
|
|||
Assert.Equal("/say real", Assert.Single(loaded.Rules).Action.Text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reproduces MossTankMetaProfileStore's pre-cutover named-profile JSON
|
||||
/// hash key (its own <c>LegacyNamedKey</c>/<c>Hash(string)</c> are
|
||||
/// private; the format is the migration contract itself, reproduced
|
||||
/// verbatim here).
|
||||
/// </summary>
|
||||
private static string LegacyMetaNamedKey(string name)
|
||||
{
|
||||
byte[] hash = System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(name.ToLowerInvariant()));
|
||||
return $"profiles/meta/named/{Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant()}.json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round 3 item 2: like the settings roster, MossTankMetaProfileStore's
|
||||
/// pre-cutover "profiles/meta/index.json" carried EVERY named Meta
|
||||
/// profile's name (never owner-scoped — one shared, globally-hashed key
|
||||
/// per name), and the cutover stopped reading it entirely — orphaning
|
||||
/// every named profile except whichever one happened to be selected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MetaRosterSweepConvertsEveryNamedLegacyProfileOnce()
|
||||
{
|
||||
var storage = new MemoryStorage();
|
||||
storage.Text["profiles/meta/index.json"] = """{ "Names": ["Farming", "Buffing"] }""";
|
||||
storage.Text[LegacyMetaNamedKey("Farming")] = System.Text.Json.JsonSerializer.Serialize(
|
||||
new MetaProfile
|
||||
{
|
||||
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say farming" } }],
|
||||
});
|
||||
storage.Text[LegacyMetaNamedKey("Buffing")] = System.Text.Json.JsonSerializer.Serialize(
|
||||
new MetaProfile
|
||||
{
|
||||
Rules = [new MetaRule { Action = new MetaAction { Kind = MetaActionKind.ChatCommand, Text = "/say buffing" } }],
|
||||
});
|
||||
|
||||
var store = new MossTankMetaProfileStore(
|
||||
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
|
||||
store.BindCharacter("Barris");
|
||||
store.LoadCurrent();
|
||||
|
||||
Assert.False(storage.Text.ContainsKey(LegacyMetaNamedKey("Farming")));
|
||||
Assert.False(storage.Text.ContainsKey(LegacyMetaNamedKey("Buffing")));
|
||||
Assert.False(storage.Text.ContainsKey("profiles/meta/index.json"));
|
||||
Assert.True(MetafSerializer.TryLoadMeta(
|
||||
storage.Text["Farming.af"], NoOpSpellCatalogForExport.Instance, out MetaProfile farming, out _));
|
||||
Assert.Equal("/say farming", Assert.Single(farming.Rules).Action.Text);
|
||||
Assert.True(MetafSerializer.TryLoadMeta(
|
||||
storage.Text["Buffing.af"], NoOpSpellCatalogForExport.Instance, out MetaProfile buffing, out _));
|
||||
Assert.Equal("/say buffing", Assert.Single(buffing.Rules).Action.Text);
|
||||
|
||||
// Idempotent: a fresh store against the same storage sweeps nothing
|
||||
// more (there is no roster key left to read).
|
||||
var reopened = new MossTankMetaProfileStore(
|
||||
new FakeHost(new FakeAutomation { Name = "Barris" }, storage));
|
||||
reopened.BindCharacter("Barris");
|
||||
reopened.LoadCurrent();
|
||||
Assert.True(storage.Text.ContainsKey("Farming.af"));
|
||||
Assert.True(storage.Text.ContainsKey("Buffing.af"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MetaStoreRefusesToSaveADisabledRuleAndKeepsThePriorAfContent()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -534,6 +534,67 @@ public sealed class NavigationTests
|
|||
Assert.Single(target.Waypoints);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reproduces MossTankRouteProfileStore's pre-cutover named-profile JSON
|
||||
/// hash key (its own <c>LegacyProfileKey</c> is private; the format is
|
||||
/// the migration contract itself, reproduced verbatim here).
|
||||
/// </summary>
|
||||
private static string LegacyRouteNamedKey(string name)
|
||||
{
|
||||
string identity = "named:" + name.Trim().ToUpperInvariant();
|
||||
string hash = Convert.ToHexString(
|
||||
System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(identity)));
|
||||
return $"profiles/route/{hash}.json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round 3 item 2: like the settings and Meta rosters,
|
||||
/// MossTankRouteProfileStore's pre-cutover "profiles/route/index.json"
|
||||
/// carried EVERY named route's name (never owner-scoped — one shared,
|
||||
/// globally-hashed key per name), and the cutover stopped reading it
|
||||
/// entirely — orphaning every named route except whichever one happened
|
||||
/// to be selected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RouteRosterSweepConvertsEveryNamedLegacyProfileOnce()
|
||||
{
|
||||
var storage = new MemoryStorage();
|
||||
storage.Text["profiles/route/index.json"] = """{ "Names": ["Farming", "Buffing"] }""";
|
||||
storage.Text[LegacyRouteNamedKey("Farming")] = """
|
||||
{ "Mode": 1, "Waypoints": [ { "Type": 0, "EastWest": 1.0, "NorthSouth": 2.0 } ] }
|
||||
""";
|
||||
storage.Text[LegacyRouteNamedKey("Buffing")] = """
|
||||
{ "Mode": 1, "Waypoints": [ { "Type": 0, "EastWest": 3.0, "NorthSouth": 4.0 } ] }
|
||||
""";
|
||||
|
||||
var store = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
|
||||
Assert.True(store.BindCharacter("Barris"));
|
||||
var target = new NavigationSettings();
|
||||
store.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance);
|
||||
|
||||
Assert.False(storage.Text.ContainsKey(LegacyRouteNamedKey("Farming")));
|
||||
Assert.False(storage.Text.ContainsKey(LegacyRouteNamedKey("Buffing")));
|
||||
Assert.False(storage.Text.ContainsKey("profiles/route/index.json"));
|
||||
var farming = new NavigationSettings();
|
||||
Assert.True(MetafSerializer.TryLoadNav(
|
||||
storage.Text["nav_Farming.af"], farming, MetafSerializer.NoOpSpells.Instance, out _));
|
||||
Assert.Equal(1.0d, Assert.Single(farming.Waypoints).Position.EastWest, precision: 3);
|
||||
var buffing = new NavigationSettings();
|
||||
Assert.True(MetafSerializer.TryLoadNav(
|
||||
storage.Text["nav_Buffing.af"], buffing, MetafSerializer.NoOpSpells.Instance, out _));
|
||||
Assert.Equal(3.0d, Assert.Single(buffing.Waypoints).Position.EastWest, precision: 3);
|
||||
|
||||
// Idempotent: a fresh store against the same storage sweeps nothing
|
||||
// more (there is no roster key left to read).
|
||||
var reopened = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
|
||||
Assert.True(reopened.BindCharacter("Barris"));
|
||||
var reloadTarget = new NavigationSettings();
|
||||
reopened.LoadCurrent(reloadTarget, MetafSerializer.NoOpSpells.Instance);
|
||||
Assert.True(storage.Text.ContainsKey("nav_Farming.af"));
|
||||
Assert.True(storage.Text.ContainsKey("nav_Buffing.af"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FollowModeRouteRoundTripsTheFollowTargetThroughAf()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue