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>
234 lines
8.6 KiB
C#
234 lines
8.6 KiB
C#
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.Plugins.MossTank.Tests;
|
|
|
|
public sealed class ProfileGiveControllerTests
|
|
{
|
|
[Fact]
|
|
public void NamedProfileGivesOnlyKeepMatchesAndWaitsForCompletion()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
ObjectsValue =
|
|
[
|
|
new PluginWorldObject(
|
|
100u, 0u, "Mule", PluginObjectClass.Player,
|
|
0u, 0u, 0u),
|
|
],
|
|
ItemsValue =
|
|
[
|
|
Item(10u, "Trade Pyreal"),
|
|
Item(11u, "Personal Note"),
|
|
],
|
|
};
|
|
var storage = new MemoryStorage();
|
|
var host = new FakeHost(automation, storage);
|
|
var profiles = new MossTankLootProfileStore(host);
|
|
profiles.BindCharacter(automation.Name);
|
|
Assert.True(profiles.Create("Mule Items", false, [], out _));
|
|
profiles.SaveCurrent(
|
|
[
|
|
new LootRule
|
|
{
|
|
Expression = "name ~= trade",
|
|
Action = LootAction.Keep,
|
|
},
|
|
new LootRule
|
|
{
|
|
Expression = "*",
|
|
Action = LootAction.NoLoot,
|
|
},
|
|
]);
|
|
var controller = new ProfileGiveController(host, profiles);
|
|
|
|
Assert.True(controller.TryStart("Mule Items.utl", "Mule"));
|
|
Assert.True(controller.Tick(0.1d, canAct: true));
|
|
Assert.Equal([(10u, 100u, 0u)], automation.Gives);
|
|
|
|
// A second tick cannot submit another command until the server's
|
|
// inventory completion advances for the exact source object.
|
|
Assert.True(controller.Tick(0.1d, canAct: true));
|
|
Assert.Single(automation.Gives);
|
|
automation.Completion = new PluginInventoryCompletion(
|
|
1,
|
|
PluginInventoryCommandKind.Give,
|
|
10u,
|
|
0u);
|
|
Assert.True(controller.Tick(0.1d, canAct: true));
|
|
Assert.False(controller.Tick(0.1d, canAct: true));
|
|
Assert.False(controller.IsRunning);
|
|
Assert.Contains("1 item(s)", controller.Status, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void StartRejectsBusyMissingProfileAndMissingTarget()
|
|
{
|
|
var automation = new FakeAutomation
|
|
{
|
|
ObjectsValue =
|
|
[
|
|
new PluginWorldObject(
|
|
100u, 0u, "Mule", PluginObjectClass.Npc,
|
|
0u, 0u, 0u),
|
|
],
|
|
};
|
|
var storage = new MemoryStorage();
|
|
var host = new FakeHost(automation, storage);
|
|
var profiles = new MossTankLootProfileStore(host);
|
|
profiles.BindCharacter(automation.Name);
|
|
var controller = new ProfileGiveController(host, profiles);
|
|
|
|
Assert.False(controller.TryStart("Missing", "Mule"));
|
|
Assert.True(profiles.Create("Empty", false, [], out _));
|
|
Assert.False(controller.TryStart("Empty", "Missing"));
|
|
Assert.True(controller.TryStart("Empty", "Mule"));
|
|
Assert.False(controller.TryStart("Empty", "Mule"));
|
|
}
|
|
|
|
private static PluginInventoryItem Item(uint id, string name) => new(
|
|
id, 0u, name, 0u, 1u, 0u, 0u, 0u, 0u, 0u, 0u,
|
|
1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0);
|
|
|
|
private sealed class FakeAutomation
|
|
: IAutomationSurface, ICharacterInfo, IItemAutomation,
|
|
IWorldObjectAutomation
|
|
{
|
|
public bool IsAvailable { get; set; } = true;
|
|
public ICharacterInfo Character => this;
|
|
public ISpellCatalog Spells => NoOpAutomationSurface.Instance;
|
|
public IMagicCommands Magic => NoOpAutomationSurface.Instance;
|
|
public IPluginChat Chat => NoOpAutomationSurface.Instance;
|
|
public IItemAutomation Items => this;
|
|
public IWorldObjectAutomation Objects => this;
|
|
public bool IsInWorld => IsAvailable;
|
|
public string Name => "Tester";
|
|
public uint ObjectId => 1u;
|
|
public uint CurrentHealth => 0u;
|
|
public uint MaxHealth => 0u;
|
|
public uint CurrentStamina => 0u;
|
|
public uint MaxStamina => 0u;
|
|
public uint CurrentMana => 0u;
|
|
public uint MaxMana => 0u;
|
|
public IReadOnlyList<PluginSkillInfo> Skills => [];
|
|
public IReadOnlyList<PluginAttributeInfo> Attributes => [];
|
|
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments => [];
|
|
public IReadOnlyList<PluginInventoryItem> ItemsValue { get; set; } = [];
|
|
public IReadOnlyList<PluginWorldObject> ObjectsValue { get; set; } = [];
|
|
public PluginInventoryCompletion Completion { get; set; }
|
|
public List<(uint Item, uint Target, uint Amount)> Gives { get; } = [];
|
|
bool IItemAutomation.IsAvailable => true;
|
|
bool IItemAutomation.IsBusy => false;
|
|
bool IWorldObjectAutomation.IsAvailable => true;
|
|
public PluginInventoryCompletion LastInventoryCompletion => Completion;
|
|
public IReadOnlyList<PluginInventoryItem> CaptureOwnedItems() => ItemsValue;
|
|
public IReadOnlyList<PluginWorldObject> CaptureObjects() => ObjectsValue;
|
|
public bool TryGet(uint objectId, out PluginWorldObject value)
|
|
{
|
|
foreach (PluginWorldObject item in ObjectsValue)
|
|
{
|
|
if (item.ObjectId == objectId)
|
|
{
|
|
value = item;
|
|
return true;
|
|
}
|
|
}
|
|
value = default;
|
|
return false;
|
|
}
|
|
public bool TryCaptureProperties(
|
|
uint objectId,
|
|
out PluginItemProperties properties)
|
|
{
|
|
properties = new PluginItemProperties(
|
|
new Dictionary<uint, int>(),
|
|
new Dictionary<uint, long>(),
|
|
new Dictionary<uint, bool>(),
|
|
new Dictionary<uint, double>(),
|
|
new Dictionary<uint, string>(),
|
|
new Dictionary<uint, uint>(),
|
|
new Dictionary<uint, uint>());
|
|
return true;
|
|
}
|
|
public PluginItemCommandResult Give(
|
|
uint objectId,
|
|
uint targetObjectId,
|
|
uint amount = 0u)
|
|
{
|
|
Gives.Add((objectId, targetObjectId, amount));
|
|
return new PluginItemCommandResult(PluginItemCommandStatus.Started);
|
|
}
|
|
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
|
|
{
|
|
skill = default;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private sealed class FakeHost(
|
|
FakeAutomation automation,
|
|
IPluginStorage storage) : IPluginHost
|
|
{
|
|
public bool HasUi => false;
|
|
public IPluginLogger Log { get; } = new FakeLogger();
|
|
public IGameState State { get; } = new FakeState();
|
|
public IEvents Events { get; } = new FakeEvents();
|
|
public ISelectionService Selection { get; } = new FakeSelection();
|
|
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
|
|
{
|
|
private readonly Dictionary<string, string> _text =
|
|
new(StringComparer.Ordinal);
|
|
public bool IsAvailable => true;
|
|
public string? ReadText(string key) =>
|
|
_text.TryGetValue(key, out string? value) ? value : null;
|
|
public void WriteText(string key, string content) => _text[key] = content;
|
|
public bool Delete(string key) => _text.Remove(key);
|
|
}
|
|
|
|
private sealed class FakeLogger : IPluginLogger
|
|
{
|
|
public void Info(string message) { }
|
|
public void Warn(string message) { }
|
|
public void Error(string message, Exception? exception = null) { }
|
|
}
|
|
|
|
private sealed class FakeState : IGameState
|
|
{
|
|
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
|
|
}
|
|
|
|
private sealed class FakeEvents : IEvents
|
|
{
|
|
public event Action<WorldEntitySnapshot> EntitySpawned
|
|
{
|
|
add { }
|
|
remove { }
|
|
}
|
|
public event Action<double> Tick
|
|
{
|
|
add { }
|
|
remove { }
|
|
}
|
|
}
|
|
|
|
private sealed class FakeSelection : ISelectionService
|
|
{
|
|
public uint? SelectedObjectId => null;
|
|
public uint? PreviousObjectId => null;
|
|
public event Action<SelectionChangedEvent> Changed
|
|
{
|
|
add { }
|
|
remove { }
|
|
}
|
|
public bool Select(uint objectId) => false;
|
|
public bool Clear() => false;
|
|
}
|
|
}
|