acdream/tests/AcDream.Plugins.MossTank.Tests/ProfileGiveControllerTests.cs
Erik 43482c1380 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>
2026-09-07 01:51:34 +02:00

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;
}
}