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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue