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:
Erik 2026-09-07 01:51:34 +02:00
parent 2566dad1d3
commit 43482c1380
5 changed files with 511 additions and 203 deletions

File diff suppressed because one or more lines are too long

View file

@ -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; } = [];

View file

@ -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

View file

@ -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,

View file

@ -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