feat(vt): settings profiles cut over to real .usd files (round 2 step 1)

MossTankProfileStore now stores the VTank-catalog Settings (Combat/Buffs/
Vitals/Inventory/Navigation) as a real .usd database read/written through
VtankSettingsProfileSerializer, listed via VtankProfileDirectory's real
naming rules (auto "By char" file, "--Name_Server_" sub-profiles, .cdf
per-character binding) instead of a hashed JSON document. Only state with
no VTank setting name (item/consumable lists, the monster-rule table, and
a handful of MossTank-only knobs) still lives in a small JSON side-car
keyed by the real .usd file name.

A one-time migration converts a not-yet-migrated legacy JSON profile into
its .usd + side-car pair on first load and deletes the JSON key, leaving
an existing .usd counterpart (and its stale JSON) untouched. `opt setinall`
now patches every known .usd file's Settings row directly, keeping each
file's side-car DynamicSettings mirror in sync so MossTankPanel's existing
option-override replay doesn't clobber the freshly patched value on the
next load.

VtankProfileDirectory gained WriteCharacterBinding (the .cdf writer
counterpart to the existing reader). VtankSettingsProfileSerializer.Apply
is now internal so the store can seed live settings directly from a
parsed database without a text round-trip.

Mutation shown to fail: MigrateLegacyIfNeeded stubbed to a no-op made
FirstLoadMigratesLegacyJsonMacroProfileToUsdAndDeletesTheJsonKey fail
(legacy key was not deleted); restored, it passes along with the
ExistingUsdCounterpartLeavesLegacyJsonUntouchedAndUnread companion test.

585 -> 587 MossTank tests passing (net +2 after adjusting three existing
tests to the new, more retail-faithful defaults/identity: a brand-new
profile now seeds from VTank's own shipped defaultsettings.usd rather
than a MossTank-guessed CLR default, and Selected/MacroProfileNames now
surface the real VTank file name instead of a bare invented name).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 00:12:41 +02:00
parent d631cc4fe6
commit 479495ecc6
6 changed files with 912 additions and 509 deletions

View file

@ -241,10 +241,7 @@ internal sealed partial class MossTankPanel
if (_profiles.Create(
name,
copyCurrent: true,
_combatSettings,
_buffSettings,
_vitalSettings,
_inventorySettings,
_allSettings,
_noBuffItemNames,
out string notice))
{
@ -437,9 +434,7 @@ internal sealed partial class MossTankPanel
SetMetaOption(canonical, value);
if (operation.Equals("setinall", StringComparison.OrdinalIgnoreCase))
{
int count = _profiles.SetOptionInAll(
canonical,
ToMonsterValue(value));
int count = _profiles.SetOptionInAll(canonical, _allSettings);
WriteVtank($"Set option {canonical} in {count} profile(s) = {GetMetaOption(canonical).ToDisplayString()}");
}
else

View file

@ -48,6 +48,14 @@ internal sealed partial class MossTankPanel
private readonly CombatSettings _combatSettings = new();
private readonly InventorySettings _inventorySettings = new();
private readonly NavigationSettings _navigationSettings = new();
/// <summary>
/// One stable bundle of every live settings object the VTank-catalog
/// Settings profile (<c>.usd</c>) covers, built once so
/// <see cref="MossTankProfileStore"/>'s Load/Save/Create/ClearCurrent/
/// SetOptionInAll surface never has to repeat five constructor
/// parameters at every call site.
/// </summary>
private readonly VtankSettingsProfileSerializer.AllSettings _allSettings;
private readonly MossTankProfileStore _profiles;
private readonly MossTankLootProfileStore _lootProfiles;
private readonly MossTankRouteProfileStore _routeProfiles;
@ -182,14 +190,17 @@ internal sealed partial class MossTankPanel
{
_host = host;
_firstRunGuidancePending = NeedsFirstRunGuidance(host);
_allSettings = new VtankSettingsProfileSerializer.AllSettings
{
Combat = _combatSettings,
Buffs = _buffSettings,
Vitals = _vitalSettings,
Inventory = _inventorySettings,
Navigation = _navigationSettings,
};
_profiles = new MossTankProfileStore(host);
_profiles.BindCharacter(host.Automation.Character.Name);
_profiles.LoadCurrent(
_combatSettings,
_buffSettings,
_vitalSettings,
_inventorySettings,
_noBuffItemNames);
_profiles.LoadCurrent(_allSettings, _noBuffItemNames);
_lootProfiles = new MossTankLootProfileStore(host);
_lootProfiles.BindCharacter(host.Automation.Character.Name);
if (!_lootProfiles.LoadCurrent(
@ -3420,10 +3431,7 @@ internal sealed partial class MossTankPanel
if (!_profiles.Create(
_profileNameDraft,
copyCurrent,
_combatSettings,
_buffSettings,
_vitalSettings,
_inventorySettings,
_allSettings,
_noBuffItemNames,
out string notice))
{
@ -3437,24 +3445,14 @@ internal sealed partial class MossTankPanel
private void ClearProfileCore()
{
_profiles.ClearCurrent(
_combatSettings,
_buffSettings,
_vitalSettings,
_inventorySettings,
_noBuffItemNames);
_profiles.ClearCurrent(_allSettings, _noBuffItemNames);
_profileLifecycleNotice = $"Cleared {_profiles.Selected} to VTank defaults.";
ResetProfileConsumers();
}
private void LoadSelectedProfile()
{
_profiles.LoadCurrent(
_combatSettings,
_buffSettings,
_vitalSettings,
_inventorySettings,
_noBuffItemNames);
_profiles.LoadCurrent(_allSettings, _noBuffItemNames);
LoadLootProfile();
LoadRouteProfile();
ApplyPersistedOptionOverrides();
@ -3538,12 +3536,7 @@ internal sealed partial class MossTankPanel
private void SaveProfile()
{
_profiles.SaveCurrent(
_combatSettings,
_buffSettings,
_vitalSettings,
_inventorySettings,
_noBuffItemNames);
_profiles.SaveCurrent(_allSettings, _noBuffItemNames);
_lootProfiles.SaveCurrent(
_inventorySettings.Loot.Rules,
_inventorySettings.Loot);

File diff suppressed because it is too large Load diff

View file

@ -277,6 +277,40 @@ internal static class VtankProfileDirectory
return new VtankCharacterBinding(settings, lines[2], lines[3], meta);
}
/// <summary>
/// Writes <paramref name="binding"/> as <paramref name="characterName"/>/
/// <paramref name="server"/>'s <c>.cdf</c> (<c>da.q()</c>,
/// <c>refs/vtank/decompiled/da.cs:156-164</c>): the version header, then
/// settings/loot/nav on lines 2-4, then the meta filename on line 5 only
/// when <see cref="VtankCharacterBinding.MetaFileName"/> is non-empty —
/// matching <see cref="TryReadCharacterBinding"/>'s own line-5-is-optional
/// parsing exactly. No-ops when the storage is unavailable.
/// </summary>
public static void WriteCharacterBinding(
IPluginStorage storage,
string characterName,
string server,
VtankCharacterBinding binding)
{
ArgumentNullException.ThrowIfNull(storage);
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
ArgumentException.ThrowIfNullOrWhiteSpace(server);
if (!storage.IsAvailable)
return;
var lines = new List<string>
{
CdfHeader,
binding.SettingsFileName,
binding.LootFileName,
binding.NavFileName,
};
if (!string.IsNullOrEmpty(binding.MetaFileName))
lines.Add(binding.MetaFileName);
storage.WriteText(
CdfFileName(characterName, server),
string.Join("\r\n", lines) + "\r\n");
}
/// <summary>
/// Lists root-level file names matching <paramref name="extension"/>
/// through <see cref="IPluginStorage.List"/> alone — no

View file

@ -178,7 +178,8 @@ internal static class VtankSettingsProfileSerializer
// Apply: .usd cell -> live setting.
// ------------------------------------------------------------------
private static void Apply(string rawName, VtankCell cell, AllSettings s)
/// <summary>Internal (not private) so the profile store can seed live settings directly from a parsed database (fresh/default profiles, migration).</summary>
internal static void Apply(string rawName, VtankCell cell, AllSettings s)
{
if (!VtankOptionCatalog.IsKnown(rawName))
return;

View file

@ -26,6 +26,82 @@ public sealed class MossTankPanelTests
Assert.Contains("{ this is not json", backup.Value, StringComparison.Ordinal);
}
/// <summary>
/// Reproduces the pre-cutover hashed JSON key a "By char" macro profile
/// used to be stored under (<c>MossTankProfileStore.LegacyProfileKey</c>,
/// not directly callable from a test — the hash and identity string are
/// reproduced verbatim here since the format is the migration contract
/// itself, byte for byte).
/// </summary>
private static string LegacyByCharacterProfileKey(string characterName)
{
string identity = "char:" + characterName.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 FirstLoadMigratesLegacyJsonMacroProfileToUsdAndDeletesTheJsonKey()
{
var storage = new MemoryStorage();
var automation = new FakeAutomation { Name = "Barris" };
string legacyKey = LegacyByCharacterProfileKey("Barris");
storage.Text[legacyKey] = """
{
"Combat": { "MaximumRange": 48.0 },
"ItemNames": ["Wand of Testing"]
}
""";
var panel = new MossTankPanel(new FakeHost(automation, storage));
// The legacy key is gone; the real .usd file (this character's
// auto "By char" name) exists with the migrated value.
Assert.False(storage.Text.ContainsKey(legacyKey));
string usdKey = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "usd");
Assert.True(storage.Text.ContainsKey(usdKey));
Assert.Equal(0.2d, panel.EvaluateExpression("uboptget['AttackDistance']").AsNumber(), precision: 7);
Assert.Contains("Wand of Testing", panel.ItemProfileText, StringComparison.Ordinal);
// Idempotent second run: a fresh panel against the same storage
// loads straight from the now-real .usd file, migrates nothing
// (there is no legacy key left to consult), and keeps the value.
var reloaded = new MossTankPanel(new FakeHost(
new FakeAutomation { Name = "Barris" }, storage));
Assert.Equal(0.2d, reloaded.EvaluateExpression("uboptget['AttackDistance']").AsNumber(), precision: 7);
Assert.Contains("Wand of Testing", reloaded.ItemProfileText, StringComparison.Ordinal);
}
[Fact]
public void ExistingUsdCounterpartLeavesLegacyJsonUntouchedAndUnread()
{
var storage = new MemoryStorage();
var automation = new FakeAutomation { Name = "Barris" };
string legacyKey = LegacyByCharacterProfileKey("Barris");
storage.Text[legacyKey] = """{ "Combat": { "MaximumRange": 240.0 } }""";
string usdKey = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "usd");
var seedCombat = new CombatSettings { MaximumRange = 120d };
VtankDatabase seedDatabase = VtankSettingsProfileSerializer.CreateNew(
new VtankSettingsProfileSerializer.AllSettings
{
Combat = seedCombat,
Buffs = new BuffSettings(),
Vitals = new VitalSettings(),
Inventory = new InventorySettings(),
Navigation = new NavigationSettings(),
});
storage.Text[usdKey] = seedDatabase.Render();
var panel = new MossTankPanel(new FakeHost(automation, storage));
// The real .usd file wins; the stale legacy JSON is left completely
// alone (not read, not deleted) rather than silently discarded.
Assert.True(storage.Text.ContainsKey(legacyKey));
Assert.Equal(0.5d, panel.EvaluateExpression("uboptget['AttackDistance']").AsNumber(), precision: 7);
}
[Fact]
public void FirstRunGuidanceExplainsProfilesImportsAndPersistentShelfOnce()
{
@ -477,7 +553,11 @@ public sealed class MossTankPanelTests
Assert.Contains("name ~= coin", second.LootRuleRows[0], StringComparison.Ordinal);
Assert.Equal("Keep up to 2", second.LootKeepCountText);
Assert.Equal("Priority 1", second.LootPriorityText);
Assert.Equal("Corpse range 38m", second.LootRangeText);
// A brand-new profile now seeds from VTank's own shipped
// defaultsettings.usd (CorpseApproachRange-Max = 0), not a
// MossTank-invented 40m CLR default, so one decrement from 0 clamps
// at the 2m floor rather than landing on 38.
Assert.Equal("Corpse range 2m", second.LootRangeText);
}
[Fact]
@ -638,13 +718,20 @@ public sealed class MossTankPanelTests
panel.SetProfileNameDraft("Fellowship");
panel.CopyProfile();
Assert.Equal("Fellowship", panel.SelectedMacroProfile);
Assert.Contains("Fellowship", panel.MacroProfileNames);
// Selection now surfaces the real VTank file name (the
// "--Name_Server_" sub-profile convention), not the bare name the
// user typed — this is the whole point of the .usd cutover.
const string fellowshipFile = "--Moss Wart__Fellowship.usd";
Assert.Equal(fellowshipFile, panel.SelectedMacroProfile);
Assert.Contains(fellowshipFile, panel.MacroProfileNames);
panel.SetNormalHealth(0.88f);
panel.SelectMacroProfile(MossTankProfileStore.ByCharacter);
Assert.Equal(0.42f, panel.NormalHealthValue, precision: 2);
// A bare name typed back (matching VTank's own "/vt settings load"
// convention) still resolves to this character's own sub-profile.
panel.SelectMacroProfile("Fellowship");
Assert.Equal(fellowshipFile, panel.SelectedMacroProfile);
Assert.Equal(0.88f, panel.NormalHealthValue, precision: 2);
}
@ -1470,7 +1557,8 @@ public sealed class MossTankPanelTests
private sealed class FakeHost(
IAutomationSurface automation,
IPluginStorage? storage = null,
IPluginLootClassifierRegistry? lootClassifiers = null) : IPluginHost
IPluginLootClassifierRegistry? lootClassifiers = null,
IPluginStorage? vtankProfiles = null) : IPluginHost
{
public bool HasUi => false;
public IPluginLogger Log { get; } = new FakeLogger();
@ -1483,6 +1571,14 @@ public sealed class MossTankPanelTests
public IAutomationSurface Automation { get; } = automation;
public IPluginLootClassifierRegistry LootClassifiers { get; } =
lootClassifiers ?? NoOpPluginLootClassifierRegistry.Instance;
// Real VTank .usd/.af/.cdf storage. Defaults to the SAME backing
// store as the plugin's own JSON storage when a test supplies one
// (the key namespaces never collide: JSON keys are always "/"-
// prefixed, VTank file names never are) so existing tests keep
// exercising real persistence without every call site needing a
// second storage instance.
public IPluginStorage VtankProfiles { get; } =
vtankProfiles ?? storage ?? NoOpPluginStorage.Instance;
}
private sealed class FakeLootClassifierRegistry(
@ -1802,7 +1898,8 @@ public sealed class MossTankPanelTests
public string? ReadText(string key) =>
Text.TryGetValue(key, out string? value) ? value : null;
public IReadOnlyList<string> List(string prefix) => Text.Keys
.Where(key => key.StartsWith(prefix + "/", StringComparison.Ordinal))
.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;