feat(vt): round 3 item 9 — loot cutover to real .utl (consistency with the other three)
MossTankLootProfileStore now reads/writes real .utl files through VtankLootProfileSerializer.TryRead/Write in the VtankProfiles storage, matching the Settings/.usd, Route/.af, and Meta/.af cutovers already landed: directory-backed listing (new VtankProfileDirectory.ListLootProfiles, *.utl, "--" hidden rule), .cdf participation (LootFileName, already present in VtankCharacterBinding but never populated by this store), and a one-time JSON migration (SweepLegacyRosterIfNeeded) that converts BOTH this character's own "By char" document and every other named profile the pre-cutover roster still lists — unlike Meta/Route (whose rosters were already abandoned pre-round-2), loot's roster was still the LIVE mechanism right up to this commit, so there is no separate "selected vs the rest" split the way Settings/Meta/Route each have. WriteLegacyExport is deleted; exports/ has no remaining loot writer. Found and fixed a real representational-loss bug the cutover would otherwise have introduced: VtankLootProfileSerializer.ExportRequirements replaces an empty VtankRequirements list with a "safely disabled" VTClassic placeholder requirement — correct when .utl was only ever a courtesy export mirror alongside the authoritative JSON store, but silently destructive once .utl becomes the SOLE store, since every MossTank-authored rule's Expression text would be permanently discarded on its first save/reload cycle. Added a MossTank-owned length-delimited "MossTankRuleExpressions" block (using the serializer's own existing UnknownBlocks round-trip contract — a real VTClassic reader just ignores it as an unrecognized block, the same as any other extension) that restores each affected rule's exact Expression text on load. Filed AD-123: MossTank's own ByCharacter auto-.utl-file convention for loot (kept for consistency with the other three stores) versus retail's real loot picker, which seeds only [None] and has no per-character auto file at all (docs/research/vtank-kb/01-settings-and-profiles.md section 3). Updated four pre-existing tests for the new storage shape/behavior: ProfileGiveControllerTests' FakeHost now wires VtankProfiles (the loot store no longer uses Storage at all); LootProfilesAreIndependentNamedDocuments and LootCommandsImportAndExportExactVtclassicUtlFiles now assert against the real file/no-exports-mirror shape instead of the deleted hashed-JSON/ exports-mirror one. Added LootRosterSweepConvertsByCharacterAndEveryNamedLegacyProfileOnce pinning the migration + its idempotence. Mutation: reverted MossTankLootProfileStore.cs/VtankProfileDirectory.cs to HEAD (keeping only the new/changed tests) and ran the three cutover-dependent tests — all three failed (no real .utl file, no ListLootProfiles, exports/ mirror still expected) — confirming they exercise the bug/gap before the fix. LootingUsesVtankDefaultsAndPersistsTheOrderedRuleEditor (pre-existing, unchanged) independently caught the representational-loss bug during development before the MossTankRuleExpressions block was added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
2566dad1d3
commit
43482c1380
5 changed files with 511 additions and 203 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
|
@ -6,13 +7,32 @@ using AcDream.Plugin.Abstractions;
|
|||
namespace AcDream.Plugins.MossTank;
|
||||
|
||||
/// <summary>
|
||||
/// Independent VTank loot-profile lifecycle. Macro settings select this
|
||||
/// profile by character, but its ordered rules live in their own document.
|
||||
/// VTank-compatible loot profile lifecycle. <c>.utl</c> (VTClassic's real
|
||||
/// public format, <see cref="VtankLootProfileSerializer"/>) is the ONLY
|
||||
/// storage/authoring format — round 3 item 9 cutover, matching the
|
||||
/// Settings/.usd, Route/.af, and Meta/.af cutovers already landed. Real
|
||||
/// VTank's own loot picker (<c>aa()</c>, <c>uTank2/PluginCore.cs:7127-7154</c>)
|
||||
/// seeds ONLY <c>[None]</c> — there is no per-character auto loot file and
|
||||
/// no "mine only" filter for loot at all
|
||||
/// (<c>docs/research/vtank-kb/01-settings-and-profiles.md</c> section 3).
|
||||
/// MossTank keeps its own established <see cref="ByCharacter"/> convention
|
||||
/// (already load-bearing for the other three stores) as a recorded
|
||||
/// adaptation rather than a retail behavior.
|
||||
/// </summary>
|
||||
internal sealed class MossTankLootProfileStore
|
||||
{
|
||||
public const string ByCharacter = "By char";
|
||||
private const string IndexKey = "profiles/loot/index.json";
|
||||
|
||||
// The pre-cutover roster (round 3 item 9): before this cutover, this
|
||||
// key was the LIVE index (Names + SelectedByCharacter) — unlike Meta/
|
||||
// Route's already-abandoned pre-round-2 rosters, loot's roster was
|
||||
// still actively read/written until this exact commit. Read-only now,
|
||||
// consulted ONLY by the one-time SweepLegacyRosterIfNeeded (which also
|
||||
// converts the SELECTED profile, so there is no separate
|
||||
// MigrateLegacyIfNeeded here the way Settings/Meta/Route each have —
|
||||
// the sweep already covers every named profile, selected or not).
|
||||
private const string LegacyRosterKey = "profiles/loot/index.json";
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
|
|
@ -20,62 +40,89 @@ internal sealed class MossTankLootProfileStore
|
|||
};
|
||||
|
||||
private readonly IPluginHost _host;
|
||||
private IndexDocument _index;
|
||||
private string _characterName = string.Empty;
|
||||
private string _selected = ByCharacter;
|
||||
private bool _rosterSwept;
|
||||
|
||||
public MossTankLootProfileStore(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;
|
||||
/// <summary>
|
||||
/// The bare name a user typed to select/create this profile (the file
|
||||
/// name minus its <c>.utl</c> extension) — internal identity
|
||||
/// (<see cref="_selected"/>) always carries the real, on-disk file
|
||||
/// name; only display strips it, matching
|
||||
/// <see cref="MossTankMetaProfileStore.Selected"/>'s own convention.
|
||||
/// </summary>
|
||||
public string Selected => StripUtl(_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 static string StripUtl(string name) => name.Equals(
|
||||
ByCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
? name
|
||||
: name.EndsWith(".utl", StringComparison.OrdinalIgnoreCase)
|
||||
? name[..^4]
|
||||
: name;
|
||||
|
||||
private string Server => _host.Automation.Character.WorldName;
|
||||
private IPluginStorage VtankStorage => _host.VtankProfiles;
|
||||
private bool CanBindFiles => _characterName.Length > 0 && Server.Length > 0;
|
||||
|
||||
public IReadOnlyList<string> AvailableNames
|
||||
{
|
||||
get
|
||||
{
|
||||
var names = new List<string> { ByCharacter };
|
||||
foreach (VtankProfileDirectory.ProfileEntry entry in
|
||||
VtankProfileDirectory.ListLootProfiles(VtankStorage))
|
||||
{
|
||||
if (entry.FileName.Length == 0)
|
||||
continue; // VTank's own "[None]"/MossTank's "[By char]" sentinels.
|
||||
names.Add(StripUtl(entry.FileName));
|
||||
}
|
||||
return names;
|
||||
}
|
||||
}
|
||||
|
||||
public bool BindCharacter(string? characterName)
|
||||
{
|
||||
string normalized = string.IsNullOrWhiteSpace(characterName)
|
||||
? string.Empty
|
||||
: characterName.Trim();
|
||||
if (string.Equals(
|
||||
normalized,
|
||||
_characterName,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.Equals(normalized, _characterName, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
}
|
||||
_characterName = normalized;
|
||||
_selected = _index.SelectedByCharacter.TryGetValue(
|
||||
SelectionKey(),
|
||||
out string? selected)
|
||||
&& IsKnown(selected)
|
||||
? CanonicalName(selected)
|
||||
: ByCharacter;
|
||||
VtankProfileDirectory.VtankCharacterBinding? binding = CanBindFiles
|
||||
? VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server)
|
||||
: null;
|
||||
_selected = binding is { LootFileName.Length: > 0 } bound
|
||||
? bound.LootFileName
|
||||
: ByCharacter;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Select(string? name)
|
||||
{
|
||||
string normalized = name?.Trim() ?? string.Empty;
|
||||
if (!IsKnown(normalized))
|
||||
if (normalized.Length == 0)
|
||||
return false;
|
||||
_selected = CanonicalName(normalized);
|
||||
_index.SelectedByCharacter[SelectionKey()] = _selected;
|
||||
SaveIndex();
|
||||
return true;
|
||||
if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_selected = ByCharacter;
|
||||
WriteBinding();
|
||||
return true;
|
||||
}
|
||||
|
||||
string candidate = ToFileName(normalized);
|
||||
if (VtankStorage.IsAvailable && VtankStorage.ReadText(candidate) is not null)
|
||||
{
|
||||
_selected = candidate;
|
||||
WriteBinding();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool Create(
|
||||
|
|
@ -97,32 +144,17 @@ internal sealed class MossTankLootProfileStore
|
|||
return false;
|
||||
}
|
||||
|
||||
LootProfileDocument? currentDocument = copyCurrent
|
||||
? Read<LootProfileDocument>(CurrentKey())
|
||||
: null;
|
||||
var document = new LootProfileDocument
|
||||
string fileName = ToFileName(normalized);
|
||||
var profile = new VtankLootProfile
|
||||
{
|
||||
Rules = copyCurrent
|
||||
? current.Select(LootRuleDocument.From).ToArray()
|
||||
: [],
|
||||
Rules = copyCurrent ? current.ToList() : [],
|
||||
SalvageCombine = copyCurrent
|
||||
? (settings?.SalvageCombine.Clone()
|
||||
?? currentDocument?.SalvageCombine?.Clone()
|
||||
?? new VtankSalvageCombineSettings())
|
||||
? settings?.SalvageCombine.Clone() ?? new VtankSalvageCombineSettings()
|
||||
: new VtankSalvageCombineSettings(),
|
||||
UnknownBlocks = copyCurrent
|
||||
? currentDocument?.UnknownBlocks ?? []
|
||||
: [],
|
||||
};
|
||||
Write(ProfileKey(normalized, byCharacter: false), document);
|
||||
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, document);
|
||||
WriteUtl(fileName, profile);
|
||||
_selected = fileName;
|
||||
WriteBinding();
|
||||
notice = copyCurrent
|
||||
? $"Copied loot rules to {_selected}."
|
||||
: $"Created loot profile {_selected}.";
|
||||
|
|
@ -133,18 +165,23 @@ internal sealed class MossTankLootProfileStore
|
|||
public bool LoadCurrent(List<LootRule> target, LootSettings? settings = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
LootProfileDocument? document = Read<LootProfileDocument>(CurrentKey());
|
||||
if (document is null)
|
||||
SweepLegacyRosterIfNeeded();
|
||||
string fileName = CurrentFileName();
|
||||
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
|
||||
if (text is null)
|
||||
return false;
|
||||
target.Clear();
|
||||
foreach (LootRuleDocument rule in document.Rules ?? [])
|
||||
target.Add(rule.ToRule());
|
||||
if (settings is not null)
|
||||
if (!VtankLootProfileSerializer.TryRead(text, out VtankLootProfile profile, out string error))
|
||||
{
|
||||
settings.SalvageCombine =
|
||||
document.SalvageCombine?.Clone()
|
||||
?? new VtankSalvageCombineSettings();
|
||||
RecoveryNotice = MossTankProfileRecovery.Preserve(
|
||||
_host, "loot", fileName, text, new FormatException(error));
|
||||
_host.Log.Warn(RecoveryNotice);
|
||||
return false;
|
||||
}
|
||||
ApplyMossTankExpressions(profile);
|
||||
target.Clear();
|
||||
target.AddRange(profile.Rules);
|
||||
if (settings is not null)
|
||||
settings.SalvageCombine = profile.SalvageCombine.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -160,20 +197,22 @@ internal sealed class MossTankLootProfileStore
|
|||
string normalized = name?.Trim() ?? string.Empty;
|
||||
if (normalized.EndsWith(".utl", StringComparison.OrdinalIgnoreCase))
|
||||
normalized = normalized[..^4];
|
||||
if (!IsKnown(normalized))
|
||||
if (normalized.Length == 0)
|
||||
return false;
|
||||
|
||||
string canonical = CanonicalName(normalized);
|
||||
string key = canonical.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
? ProfileKey(_characterName, byCharacter: true)
|
||||
: ProfileKey(canonical, byCharacter: false);
|
||||
LootProfileDocument? document = Read<LootProfileDocument>(key);
|
||||
if (document is null)
|
||||
string fileName = normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
? CurrentFileName()
|
||||
: ToFileName(normalized);
|
||||
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
|
||||
if (text is null || !VtankLootProfileSerializer.TryRead(
|
||||
text, out VtankLootProfile profile, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ApplyMossTankExpressions(profile);
|
||||
target.Clear();
|
||||
foreach (LootRuleDocument rule in document.Rules ?? [])
|
||||
target.Add(rule.ToRule());
|
||||
target.AddRange(profile.Rules);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -181,17 +220,22 @@ internal sealed class MossTankLootProfileStore
|
|||
IReadOnlyList<LootRule> rules,
|
||||
LootSettings? settings = null)
|
||||
{
|
||||
LootProfileDocument? existing = Read<LootProfileDocument>(CurrentKey());
|
||||
var document = new LootProfileDocument
|
||||
string fileName = CurrentFileName();
|
||||
var profile = new VtankLootProfile
|
||||
{
|
||||
Rules = rules.Select(LootRuleDocument.From).ToArray(),
|
||||
SalvageCombine = settings?.SalvageCombine.Clone()
|
||||
?? existing?.SalvageCombine?.Clone()
|
||||
?? new VtankSalvageCombineSettings(),
|
||||
UnknownBlocks = existing?.UnknownBlocks ?? [],
|
||||
Rules = rules.ToList(),
|
||||
SalvageCombine = settings?.SalvageCombine.Clone() ?? new VtankSalvageCombineSettings(),
|
||||
};
|
||||
Write(CurrentKey(), document);
|
||||
WriteLegacyExport(LegacyProfileName(), document);
|
||||
// Preserve UnknownBlocks/SourceVersion from whatever is already on
|
||||
// disk (a drop-in real .utl may carry blocks MossTank does not
|
||||
// understand yet) rather than discarding them on every save.
|
||||
string? existingText = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
|
||||
if (existingText is not null
|
||||
&& VtankLootProfileSerializer.TryRead(existingText, out VtankLootProfile existing, out _))
|
||||
{
|
||||
profile.UnknownBlocks = existing.UnknownBlocks;
|
||||
}
|
||||
WriteUtl(fileName, profile);
|
||||
}
|
||||
|
||||
public void ClearCurrent(List<LootRule> target, LootSettings? settings = null)
|
||||
|
|
@ -239,47 +283,271 @@ internal sealed class MossTankLootProfileStore
|
|||
return false;
|
||||
}
|
||||
|
||||
var document = new LootProfileDocument
|
||||
{
|
||||
Rules = imported.Rules.Select(LootRuleDocument.From).ToArray(),
|
||||
SalvageCombine = imported.SalvageCombine.Clone(),
|
||||
UnknownBlocks = imported.UnknownBlocks.Select(
|
||||
VtankLootExtraBlockDocument.From).ToArray(),
|
||||
};
|
||||
Write(ProfileKey(normalized, byCharacter: false), document);
|
||||
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();
|
||||
string fileName = ToFileName(normalized);
|
||||
WriteUtl(fileName, imported);
|
||||
_selected = fileName;
|
||||
WriteBinding();
|
||||
target.Clear();
|
||||
target.AddRange(imported.Rules);
|
||||
if (settings is not null)
|
||||
settings.SalvageCombine = imported.SalvageCombine.Clone();
|
||||
WriteLegacyExport(_selected, document);
|
||||
notice = $"Imported VTClassic loot profile {_selected}.";
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsKnown(string? name) => name is not null
|
||||
&& (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
|| _index.Names.Contains(name, StringComparer.OrdinalIgnoreCase));
|
||||
// ------------------------------------------------------------------
|
||||
// Legacy JSON -> .utl migration (round 3 item 9).
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private string CanonicalName(string name) => name.Equals(
|
||||
ByCharacter,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? ByCharacter
|
||||
: _index.Names.First(entry => entry.Equals(
|
||||
name,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
/// <summary>
|
||||
/// Round 3 item 9: a ONE-TIME sweep (guarded by <see cref="_rosterSwept"/>)
|
||||
/// over the pre-cutover roster at <see cref="LegacyRosterKey"/>. Unlike
|
||||
/// Settings/Meta/Route's own per-selection migration (which only ever
|
||||
/// converted whichever ONE profile happened to be selected), this
|
||||
/// single pass converts EVERY named profile the roster lists AND this
|
||||
/// character's own "By char" JSON document, since the roster was still
|
||||
/// the live mechanism right up to this cutover — there is no separate
|
||||
/// "already migrated one, sweep the rest" split here.
|
||||
/// </summary>
|
||||
private void SweepLegacyRosterIfNeeded()
|
||||
{
|
||||
if (_rosterSwept)
|
||||
return;
|
||||
_rosterSwept = true;
|
||||
if (!_host.Storage.IsAvailable)
|
||||
return;
|
||||
|
||||
private string CurrentKey() => _selected.Equals(
|
||||
ByCharacter,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? ProfileKey(_characterName, byCharacter: true)
|
||||
: ProfileKey(_selected, byCharacter: false);
|
||||
// This character's own "By char" JSON document, if not already a
|
||||
// real .utl file.
|
||||
string byCharacterFileName = CurrentFileName();
|
||||
if (VtankStorage.ReadText(byCharacterFileName) is null)
|
||||
{
|
||||
LootProfileDocument? byCharacter = ReadLegacyJson(
|
||||
ProfileKey(_characterName, byCharacter: true));
|
||||
if (byCharacter is not null)
|
||||
{
|
||||
WriteUtl(byCharacterFileName, byCharacter.ToVtankProfile());
|
||||
_host.Storage.Delete(ProfileKey(_characterName, byCharacter: true));
|
||||
}
|
||||
}
|
||||
|
||||
LegacyRosterDocument? roster = ReadLegacyRoster();
|
||||
if (roster?.Names is not { Count: > 0 } names)
|
||||
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 = ProfileKey(name, byCharacter: false);
|
||||
LootProfileDocument? legacy = ReadLegacyJson(legacyKey);
|
||||
if (legacy is null)
|
||||
continue; // already converted (or never existed); drop the row.
|
||||
|
||||
string fileName = ToFileName(name);
|
||||
if (VtankStorage.ReadText(fileName) is null)
|
||||
WriteUtl(fileName, legacy.ToVtankProfile());
|
||||
_host.Storage.Delete(legacyKey);
|
||||
migrated++;
|
||||
}
|
||||
|
||||
if (migrated == 0)
|
||||
return;
|
||||
if (remaining.Count > 0)
|
||||
{
|
||||
_host.Storage.WriteText(
|
||||
LegacyRosterKey,
|
||||
JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, Options));
|
||||
}
|
||||
else
|
||||
{
|
||||
_host.Storage.Delete(LegacyRosterKey);
|
||||
}
|
||||
_host.Log.Warn($"Migrated {migrated} legacy MossTank named loot profile(s) from the old roster.");
|
||||
}
|
||||
|
||||
private LegacyRosterDocument? ReadLegacyRoster()
|
||||
{
|
||||
string? json = null;
|
||||
try
|
||||
{
|
||||
json = _host.Storage.ReadText(LegacyRosterKey);
|
||||
return string.IsNullOrWhiteSpace(json)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<LegacyRosterDocument>(json, Options);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
RecoveryNotice = MossTankProfileRecovery.Preserve(
|
||||
_host, "loot", LegacyRosterKey, json, error);
|
||||
_host.Log.Warn(RecoveryNotice);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private LootProfileDocument? ReadLegacyJson(string key)
|
||||
{
|
||||
if (!_host.Storage.IsAvailable)
|
||||
return null;
|
||||
string? json = null;
|
||||
try
|
||||
{
|
||||
json = _host.Storage.ReadText(key);
|
||||
return string.IsNullOrWhiteSpace(json)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<LootProfileDocument>(json, Options);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "loot", key, json, error);
|
||||
_host.Log.Warn(RecoveryNotice);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// File naming, storage plumbing.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|
||||
? VtankProfileDirectory.AutoCharacterFileName(_characterName, Server, "utl")
|
||||
: _selected;
|
||||
|
||||
private static string ToFileName(string bareName) =>
|
||||
bareName.EndsWith(".utl", StringComparison.OrdinalIgnoreCase)
|
||||
? bareName
|
||||
: bareName + ".utl";
|
||||
|
||||
private void WriteBinding()
|
||||
{
|
||||
if (!CanBindFiles || !VtankStorage.IsAvailable)
|
||||
return;
|
||||
VtankProfileDirectory.VtankCharacterBinding existing =
|
||||
VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server)
|
||||
?? new VtankProfileDirectory.VtankCharacterBinding(
|
||||
string.Empty, CurrentFileName(), string.Empty, null);
|
||||
VtankProfileDirectory.WriteCharacterBinding(
|
||||
VtankStorage,
|
||||
_characterName,
|
||||
Server,
|
||||
existing with { LootFileName = CurrentFileName() });
|
||||
}
|
||||
|
||||
private void WriteUtl(string fileName, VtankLootProfile profile)
|
||||
{
|
||||
if (!VtankStorage.IsAvailable)
|
||||
return;
|
||||
AttachMossTankExpressions(profile);
|
||||
try
|
||||
{
|
||||
VtankStorage.WriteText(fileName, VtankLootProfileSerializer.Write(profile));
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
_host.Log.Warn($"MossTank loot profile could not be saved: {error.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// MossTank rule-expression preservation (round 3 item 9): VTClassic's
|
||||
// real .utl requirement grammar cannot express an arbitrary MossTank
|
||||
// Expression string at all (VtankLootProfileSerializer.ExportRequirements
|
||||
// replaces an empty VtankRequirements list with a "safely disabled"
|
||||
// placeholder requirement for VTClassic compatibility) — fine when
|
||||
// .utl was only ever a courtesy export mirror alongside the
|
||||
// authoritative JSON store, but silently DESTRUCTIVE now that .utl is
|
||||
// the SOLE store: every MossTank-authored rule's Expression would be
|
||||
// permanently lost on its very first save/reload cycle. Preserved
|
||||
// instead in a MossTank-owned length-delimited block
|
||||
// (VtankLootProfileSerializer's own UnknownBlocks mechanism already
|
||||
// round-trips any block type it doesn't recognize as "SalvageCombine"
|
||||
// verbatim — exactly the "a foreign reader keeps what it doesn't
|
||||
// understand" contract this needs) so a real VTClassic reading the
|
||||
// same file simply ignores it as an unknown block, the same as any
|
||||
// other extension block.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private const string MossTankRulesBlockType = "MossTankRuleExpressions";
|
||||
|
||||
private static void AttachMossTankExpressions(VtankLootProfile profile)
|
||||
{
|
||||
profile.UnknownBlocks.RemoveAll(static block =>
|
||||
string.Equals(block.Type, MossTankRulesBlockType, StringComparison.Ordinal));
|
||||
|
||||
var payload = new StringBuilder();
|
||||
payload.Append(profile.Rules.Count.ToString(CultureInfo.InvariantCulture)).Append("\r\n");
|
||||
foreach (LootRule rule in profile.Rules)
|
||||
{
|
||||
// A rule with real VtankRequirements (imported from a genuine
|
||||
// VTClassic file, never touched by MossTank's own editor) has
|
||||
// nothing of ours to preserve — record an empty slot so load
|
||||
// leaves its VtankRequirements-derived state alone.
|
||||
string expression = rule.VtankRequirements.Count > 0 ? string.Empty : rule.Expression;
|
||||
payload.Append(expression.Length.ToString(CultureInfo.InvariantCulture)).Append("\r\n");
|
||||
payload.Append(expression);
|
||||
}
|
||||
profile.UnknownBlocks.Add(new VtankLootExtraBlock
|
||||
{
|
||||
Type = MossTankRulesBlockType,
|
||||
Payload = payload.ToString(),
|
||||
});
|
||||
}
|
||||
|
||||
private static void ApplyMossTankExpressions(VtankLootProfile profile)
|
||||
{
|
||||
VtankLootExtraBlock? block = profile.UnknownBlocks.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.Type, MossTankRulesBlockType, StringComparison.Ordinal));
|
||||
if (block is null)
|
||||
return;
|
||||
profile.UnknownBlocks.Remove(block);
|
||||
|
||||
string payload = block.Payload ?? string.Empty;
|
||||
int position = 0;
|
||||
if (!TryReadPayloadLine(payload, ref position, out string countText)
|
||||
|| !int.TryParse(countText, NumberStyles.Integer, CultureInfo.InvariantCulture, out int count))
|
||||
{
|
||||
return; // Corrupt/foreign block reusing our type name; ignore rather than throw.
|
||||
}
|
||||
int limit = Math.Min(count, profile.Rules.Count);
|
||||
for (int index = 0; index < limit; index++)
|
||||
{
|
||||
if (!TryReadPayloadLine(payload, ref position, out string lengthText)
|
||||
|| !int.TryParse(lengthText, NumberStyles.Integer, CultureInfo.InvariantCulture, out int length)
|
||||
|| length < 0
|
||||
|| length > payload.Length - position)
|
||||
{
|
||||
return; // Truncated/corrupt; stop applying rather than throw.
|
||||
}
|
||||
string expression = payload.Substring(position, length);
|
||||
position += length;
|
||||
if (expression.Length == 0)
|
||||
continue;
|
||||
profile.Rules[index].Expression = expression;
|
||||
profile.Rules[index].VtankRequirements.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadPayloadLine(string text, ref int position, out string line)
|
||||
{
|
||||
if (position > text.Length)
|
||||
{
|
||||
line = string.Empty;
|
||||
return false;
|
||||
}
|
||||
int start = position;
|
||||
while (position < text.Length && text[position] is not ('\r' or '\n'))
|
||||
position++;
|
||||
line = text[start..position];
|
||||
if (position < text.Length && text[position] == '\r')
|
||||
position++;
|
||||
if (position < text.Length && text[position] == '\n')
|
||||
position++;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ProfileKey(string value, bool byCharacter)
|
||||
{
|
||||
|
|
@ -290,90 +558,13 @@ internal sealed class MossTankLootProfileStore
|
|||
return $"profiles/loot/{hash}.json";
|
||||
}
|
||||
|
||||
private string SelectionKey() => string.IsNullOrWhiteSpace(_characterName)
|
||||
? "_default"
|
||||
: _characterName;
|
||||
// ------------------------------------------------------------------
|
||||
// Migration-only shapes: the OLD JSON roster and per-profile document,
|
||||
// kept solely so SweepLegacyRosterIfNeeded can recover them once.
|
||||
// Nothing else in this file writes either shape again.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private T? Read<T>(string key) where T : class
|
||||
{
|
||||
if (!_host.Storage.IsAvailable)
|
||||
return null;
|
||||
string? json = null;
|
||||
try
|
||||
{
|
||||
json = _host.Storage.ReadText(key);
|
||||
return string.IsNullOrWhiteSpace(json)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<T>(json, Options);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
RecoveryNotice = MossTankProfileRecovery.Preserve(
|
||||
_host,
|
||||
"loot",
|
||||
key,
|
||||
json,
|
||||
error);
|
||||
_host.Log.Warn(RecoveryNotice);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void Write<T>(string key, T document)
|
||||
{
|
||||
if (!_host.Storage.IsAvailable)
|
||||
return;
|
||||
try
|
||||
{
|
||||
_host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options));
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
_host.Log.Warn($"MossTank loot profile could not be saved: {error.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveIndex() => Write(IndexKey, _index);
|
||||
|
||||
private void WriteLegacyExport(string name, LootProfileDocument document)
|
||||
{
|
||||
if (!_host.Storage.IsAvailable)
|
||||
return;
|
||||
try
|
||||
{
|
||||
_host.Storage.WriteText(
|
||||
$"exports/{LegacyFileName(name)}.utl",
|
||||
VtankLootProfileSerializer.Write(document.ToVtankProfile()));
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
_host.Log.Warn(
|
||||
$"MossTank VTClassic loot export could not be saved: {error.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private string LegacyProfileName() => _selected.Equals(
|
||||
ByCharacter,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? string.IsNullOrWhiteSpace(_characterName)
|
||||
? ByCharacter
|
||||
: _characterName
|
||||
: _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 ? "Loot" : result.ToString();
|
||||
}
|
||||
|
||||
private sealed class IndexDocument
|
||||
private sealed class LegacyRosterDocument
|
||||
{
|
||||
public int Version { get; set; } = 1;
|
||||
public List<string> Names { get; set; } = [];
|
||||
|
|
|
|||
|
|
@ -241,6 +241,37 @@ internal static class VtankProfileDirectory
|
|||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VTank's real loot-profile list (<c>aa()</c>,
|
||||
/// <c>uTank2/PluginCore.cs:7127-7154</c>) seeds ONLY
|
||||
/// <see cref="NoneLabel"/> — retail has no per-character auto loot
|
||||
/// file and no "mine only" filter for loot at all (docs/research/vtank-kb/01-settings-and-profiles.md
|
||||
/// section 3: "the loot default has no equivalent auto-name; loot
|
||||
/// profiles default to none") — then every non-<c>--</c> file across
|
||||
/// whatever loot-profile extension family that build understands.
|
||||
/// Round 3 item 9: MossTank keeps its own established
|
||||
/// <see cref="ByCharacterLabel"/> convention (already load-bearing for
|
||||
/// Settings/Nav/Meta) as a SECOND seed entry for internal consistency
|
||||
/// across all four stores — a recorded adaptation (register), not a
|
||||
/// retail behavior — while still seeding retail's own
|
||||
/// <see cref="NoneLabel"/> first.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ProfileEntry> ListLootProfiles(IPluginStorage storage)
|
||||
{
|
||||
var entries = new List<ProfileEntry>
|
||||
{
|
||||
new(string.Empty, NoneLabel),
|
||||
new(string.Empty, ByCharacterLabel),
|
||||
};
|
||||
foreach (string fileName in EnumerateFileNames(storage, ".utl"))
|
||||
{
|
||||
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal))
|
||||
continue;
|
||||
entries.Add(new ProfileEntry(fileName, fileName));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The per-character spell-tracking cache filename (<c>dm</c> class,
|
||||
/// <c>refs/vtank/decompiled/dm.cs:391</c>): always
|
||||
|
|
|
|||
|
|
@ -850,6 +850,84 @@ public sealed class MossTankPanelTests
|
|||
Assert.Equal("Corpse range 2m", second.LootRangeText);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reproduces MossTankLootProfileStore's pre-cutover hashed JSON key
|
||||
/// (its own private <c>ProfileKey</c> is 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 LegacyLootProfileKey(string value, bool byCharacter)
|
||||
{
|
||||
string identity = (byCharacter ? "char:" : "named:") + value.Trim().ToUpperInvariant();
|
||||
string hash = Convert.ToHexString(
|
||||
System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(identity)));
|
||||
return $"profiles/loot/{hash}.json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round 3 item 9: unlike Meta/Route (whose pre-cutover roster was
|
||||
/// already abandoned before their own cutovers), loot's
|
||||
/// "profiles/loot/index.json" was still the LIVE mechanism right up to
|
||||
/// this exact commit, so this single one-time sweep must convert BOTH
|
||||
/// this character's own "By char" JSON document AND every other named
|
||||
/// profile the roster still lists — not just whichever one happened to
|
||||
/// be selected.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LootRosterSweepConvertsByCharacterAndEveryNamedLegacyProfileOnce()
|
||||
{
|
||||
var storage = new MemoryStorage();
|
||||
var automation = new FakeAutomation { Name = "Barris" };
|
||||
storage.Text[LegacyLootProfileKey("Barris", byCharacter: true)] = """
|
||||
{
|
||||
"Rules": [
|
||||
{ "Name": "Coins", "Expression": "name ~= coin", "Action": 0, "Priority": 3 }
|
||||
]
|
||||
}
|
||||
""";
|
||||
storage.Text["profiles/loot/index.json"] = """
|
||||
{
|
||||
"Version": 1,
|
||||
"Names": ["Farming"],
|
||||
"SelectedByCharacter": {}
|
||||
}
|
||||
""";
|
||||
storage.Text[LegacyLootProfileKey("Farming", byCharacter: false)] = """
|
||||
{
|
||||
"Rules": [
|
||||
{ "Name": "Salvage", "Expression": "name ~= salvage", "Action": 0, "Priority": 1 }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var panel = new MossTankPanel(new FakeHost(automation, storage));
|
||||
|
||||
string byCharacterFile = VtankProfileDirectory.AutoCharacterFileName(
|
||||
"Barris", string.Empty, "utl");
|
||||
Assert.True(storage.Text.ContainsKey(byCharacterFile));
|
||||
Assert.True(storage.Text.ContainsKey("Farming.utl"));
|
||||
Assert.False(storage.Text.ContainsKey(LegacyLootProfileKey("Barris", byCharacter: true)));
|
||||
Assert.False(storage.Text.ContainsKey(LegacyLootProfileKey("Farming", byCharacter: false)));
|
||||
Assert.False(storage.Text.ContainsKey("profiles/loot/index.json"));
|
||||
|
||||
// The "By char" profile is already loaded (panel construction binds
|
||||
// and loads it); the raw .utl round-trips VTClassic-side, but the
|
||||
// human expression text is only visible through the store's own
|
||||
// load path (MossTankLootProfileStore.ApplyMossTankExpressions),
|
||||
// which the panel's rule rows expose.
|
||||
Assert.Contains("name ~= coin", Assert.Single(panel.LootRuleRows), StringComparison.Ordinal);
|
||||
|
||||
panel.SelectLootProfile("Farming");
|
||||
Assert.Contains("name ~= salvage", Assert.Single(panel.LootRuleRows), StringComparison.Ordinal);
|
||||
|
||||
// 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.Contains("name ~= coin", Assert.Single(reloaded.LootRuleRows), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LootProfilesAreIndependentNamedDocuments()
|
||||
{
|
||||
|
|
@ -870,9 +948,9 @@ public sealed class MossTankPanelTests
|
|||
Assert.Single(panel.LootRuleRows);
|
||||
panel.SelectLootProfile("Currency");
|
||||
Assert.Equal(2, panel.LootRuleRows.Count);
|
||||
Assert.Contains(
|
||||
storage.Text.Keys,
|
||||
key => key.StartsWith("profiles/loot/", StringComparison.Ordinal));
|
||||
// Round 3 item 9: a named loot profile is now a real .utl file, not
|
||||
// a hashed JSON document under profiles/loot/.
|
||||
Assert.True(storage.Text.ContainsKey("Currency.utl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -988,7 +1066,10 @@ public sealed class MossTankPanelTests
|
|||
Assert.Equal("Legacy", panel.LootProfileName);
|
||||
Assert.Single(panel.LootRuleRows);
|
||||
Assert.Contains("KeepUpTo", panel.LootRuleRows[0], StringComparison.Ordinal);
|
||||
string exported = storage.Text["exports/Legacy.utl"];
|
||||
// Round 3 item 9: the imported profile is now written directly as
|
||||
// the real "Legacy.utl" file — there is no separate exports/
|
||||
// mirror any more (WriteLegacyExport is deleted).
|
||||
string exported = storage.Text["Legacy.utl"];
|
||||
Assert.True(VtankLootProfileSerializer.TryRead(
|
||||
exported,
|
||||
out VtankLootProfile roundTrip,
|
||||
|
|
|
|||
|
|
@ -176,6 +176,10 @@ public sealed class ProfileGiveControllerTests
|
|||
public IUiRegistry Ui => NoOpUiRegistry.Instance;
|
||||
public IPluginStorage Storage => storage;
|
||||
public IAutomationSurface Automation => automation;
|
||||
// Round 3 item 9: MossTankLootProfileStore now reads/writes real
|
||||
// .utl files through VtankProfiles, not the JSON-only Storage —
|
||||
// same backing store here (the key namespaces never collide).
|
||||
public IPluginStorage VtankProfiles => storage;
|
||||
}
|
||||
|
||||
private sealed class MemoryStorage : IPluginStorage
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue