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:
parent
d631cc4fe6
commit
479495ecc6
6 changed files with 912 additions and 509 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue