namespace Aeshnidae.Mule;
///
/// /mule - the only way in. There is no client window: the retail client cannot be given
/// one (direction 1 - the retail client is the target), and a container the client can see
/// is a container the client can be told to open, which is exactly what a vault with no
/// landblock must never be asked to do.
///
/// The item name can be several words ("nether longbow"), so parsing works from both ends,
/// as /bank's does: the first argument is the verb, a trailing number is a page or a
/// which-one, and everything between is joined back into a name. That makes
/// /mule withdraw nether longbow
/// /mule withdraw nether longbow 2
/// two spellings of the same thing without needing quotes.
///
public static class Commands
{
[CommandHandler("mule", AccessLevel.Player, CommandHandlerFlag.RequiresWorld, -1,
"Your account's item vault. Shared by every character on your account.",
"/mule what is in it, and how to use it\n" +
"/mule deposit store the item you have selected\n" +
"/mule deposit pack 2 store everything in that pack (not the pack)\n" +
"/mule withdraw take one back out by name\n" +
"/mule list [name] a page of what is in there, newest first\n" +
"/mule log the last movements\n" +
"/mule help the long version")]
public static void HandleMule(Session session, params string[] parameters)
{
var player = session?.Player;
if (player is null)
return;
if (!Mod.Settings.Enabled)
{
Send(player, "The vault is closed.");
return;
}
if (!MuleDb.Ready)
{
Send(player, "The vault is not open - its storage failed to initialise. Check the server log.");
return;
}
try
{
Run(player, parameters ?? Array.Empty());
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] /mule failed for {player.Name}: {ex}", ModManager.LogLevel.Error);
Send(player, "Something went wrong at the vault. Nothing was changed.");
}
}
private static void Run(Player player, string[] args)
{
if (args.Length == 0)
{
Send(player, Summary(player));
return;
}
switch (args[0].ToLowerInvariant())
{
case "help" or "h" or "?":
Send(player, Help());
return;
case "deposit" or "dep" or "d" or "store" or "in":
Deposit(player, args.Skip(1).ToArray());
return;
case "withdraw" or "wd" or "w" or "take" or "out" or "get":
Withdraw(player, args.Skip(1).ToArray());
return;
case "list" or "l" or "ls":
Send(player, List(player, args.Skip(1).ToArray()));
return;
case "log" or "history" or "ledger":
Send(player, Log(player));
return;
default:
Send(player, $"'{args[0]}' is not a vault verb. /mule help lists them.");
return;
}
}
// ---- /mule ---------------------------------------------------------------
private static string Summary(Player player)
{
var accountId = player.Account?.AccountId ?? 0;
var handle = Vault.Get(accountId, out var error);
if (handle is null)
return error;
var text = new StringBuilder();
text.AppendLine($"Your vault holds {handle.Count:N0} of {Mod.Settings.MaxItems:N0} items. It belongs to your account, so every character on it reaches the same vault, from anywhere.");
if (handle.Count > 0)
{
// A summary rather than a list: a thousand lines of chat is not a summary.
var groups = handle.Items
.GroupBy(i => i.Name ?? "?")
.OrderByDescending(g => g.Count())
.ThenBy(g => g.Key)
.Take(8)
.ToList();
text.AppendLine("Most of it: " + string.Join(", ", groups.Select(g => g.Count() > 1 ? $"{g.Key} x{g.Count()}" : g.Key)));
var kinds = handle.Items.Select(i => i.Name).Distinct().Count();
text.AppendLine($"{kinds:N0} different thing(s). /mule list shows them a page at a time, newest first; /mule list filters.");
}
text.Append("Select an item and /mule deposit to store it. /mule withdraw brings it back. Take gear off before storing it.");
return text.ToString();
}
private static string Help() =>
"The vault is account-wide item storage. There is no window to open: items go in and out by command, from anywhere.\n" +
"/mule what is in it\n" +
"/mule deposit store the item you have selected (click it first)\n" +
"/mule deposit pack 2 store everything in your second pack - the pack itself stays\n" +
"/mule withdraw take one back; if several match you are asked which\n" +
"/mule withdraw 0x... take one back by its object id, as /mule list prints it\n" +
"/mule list [name] [n] page n of what is in there, newest first, filtered by name\n" +
"/mule log your last movements, as the vault recorded them\n" +
$"It holds {Mod.Settings.MaxItems:N0} items. What it will not take: anything you are wearing or wielding (take it off first), " +
"a pack with things in it (store what is inside instead), packs and foci themselves, and anything in a trade window. " +
"Items keep everything - spells, enchantments, tinks, workmanship, item experience, stack size. " +
"What is in your vault is also listed on the website under My Characters.";
// ---- /mule deposit -------------------------------------------------------
private static void Deposit(Player player, string[] args)
{
if (args.Length > 0 && args[0].ToLowerInvariant() is "pack" or "p")
{
DepositPack(player, args.Skip(1).ToArray());
return;
}
if (args.Length > 0)
{
Send(player, "Select the item first, then /mule deposit. (Or /mule deposit pack for a whole pack.)");
return;
}
var selected = player.RequestedAppraisalTarget ?? player.CurrentAppraisalTarget;
if (selected is null or 0)
{
Send(player, "Nothing is selected. Click the item you want to store, then /mule deposit.");
return;
}
var item = player.FindObject(selected.Value, Player.SearchLocations.MyInventory, out _, out _, out _);
if (item is null)
{
// Deliberately not SearchLocations.Everywhere: the vault only ever takes
// something out of the player's own packs.
Send(player, "That is not in your pack. Select something you are carrying.");
return;
}
var result = Movement.Deposit(player, item);
if (!result.Ok)
Send(player, result.Message);
}
private static void DepositPack(Player player, string[] args)
{
var packs = SidePacks(player);
if (args.Length == 0)
{
if (packs.Count == 0)
{
Send(player, "You have no packs. /mule deposit stores the item you have selected.");
return;
}
var lines = new StringBuilder("Which pack? ");
for (var i = 0; i < packs.Count; i++)
lines.Append($"{i + 1} = {packs[i].Name} ({packs[i].Inventory.Count} item(s)){(i < packs.Count - 1 ? ", " : "")}");
Send(player, lines.ToString());
return;
}
if (!int.TryParse(args[0], out var which) || which < 1 || which > packs.Count)
{
Send(player, packs.Count == 0
? "You have no packs."
: $"Pick a pack between 1 and {packs.Count}. /mule deposit pack lists them.");
return;
}
var result = Movement.DepositAll(player, packs[which - 1]);
if (!result.Ok)
Send(player, result.Message);
}
/// The player's side packs, in the order the client shows them.
private static List SidePacks(Player player) =>
player.Inventory.Values
.OfType()
.Where(c => c.UseBackpackSlot)
.OrderBy(c => c.PlacementPosition ?? 0)
.ToList();
// ---- /mule withdraw ------------------------------------------------------
private static void Withdraw(Player player, string[] args)
{
var accountId = player.Account?.AccountId ?? 0;
var handle = Vault.Get(accountId, out var error);
if (handle is null)
{
Send(player, error);
return;
}
if (args.Length == 0)
{
Send(player, "Which one? /mule withdraw , or /mule list to see what is in there.");
return;
}
// An object id takes precedence over a name: it is unambiguous, and /mule list
// prints it beside every line for exactly this.
if (args.Length == 1 && TryParseGuid(args[0], out var guid))
{
var exact = handle.Items.FirstOrDefault(i => i.Guid.Full == guid);
if (exact is null)
{
Send(player, "Nothing in your vault has that object id. /mule list shows them.");
return;
}
var one = Movement.Withdraw(player, exact);
if (!one.Ok)
Send(player, one.Message);
return;
}
// A trailing number is "which of the ones that matched", not part of the name.
var pick = 0;
var nameParts = args.ToList();
if (nameParts.Count > 1 && int.TryParse(nameParts[^1], out var n) && n > 0)
{
pick = n;
nameParts.RemoveAt(nameParts.Count - 1);
}
var name = string.Join(' ', nameParts).Trim();
if (name.Length == 0)
{
Send(player, "Which one? /mule withdraw .");
return;
}
var matches = handle.Items
.Where(i => (i.Name ?? "").Contains(name, StringComparison.OrdinalIgnoreCase))
.OrderBy(i => i.Name)
.ToList();
if (matches.Count == 0)
{
Send(player, $"Nothing in your vault matches '{name}'. /mule list searches it.");
return;
}
// An exact name match wins outright - "pyreal" should not be ambiguous because
// "pyreal note" exists.
var exacts = matches.Where(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)).ToList();
if (exacts.Count > 0 && exacts.Count < matches.Count)
matches = exacts;
if (matches.Count > 1 && pick == 0)
{
var lines = new StringBuilder($"{matches.Count} match '{name}'. /mule withdraw {name} , or use the object id:\n");
for (var i = 0; i < Math.Min(matches.Count, Mod.Settings.PageSize); i++)
lines.AppendLine($" {i + 1}. {Line(matches[i])}");
if (matches.Count > Mod.Settings.PageSize)
lines.AppendLine($" ... and {matches.Count - Mod.Settings.PageSize} more.");
Send(player, lines.ToString().TrimEnd());
return;
}
if (pick > matches.Count)
{
Send(player, $"Only {matches.Count} match '{name}'.");
return;
}
var chosen = matches[pick > 0 ? pick - 1 : 0];
var result = Movement.Withdraw(player, chosen);
if (!result.Ok)
Send(player, result.Message);
}
// ---- /mule list ----------------------------------------------------------
private static string List(Player player, string[] args)
{
var accountId = player.Account?.AccountId ?? 0;
var handle = Vault.Get(accountId, out var error);
if (handle is null)
return error;
var page = 1;
var parts = args.ToList();
if (parts.Count > 0 && int.TryParse(parts[^1], out var p) && p > 0)
{
page = p;
parts.RemoveAt(parts.Count - 1);
}
var filter = string.Join(' ', parts).Trim();
// Newest first. PlacementPosition is the order items went in - a deposit appends
// at the end of the link, so descending is newest first - and the links
// themselves fill in order, so the last link's items are the newest of all.
var all = handle.Links
.Select((link, seq) => (link, seq))
.SelectMany(x => x.link.Inventory.Values.Select(i => (item: i, x.seq, pos: i.PlacementPosition ?? 0)))
.OrderByDescending(x => x.seq)
.ThenByDescending(x => x.pos)
.Select(x => x.item)
.ToList();
if (filter.Length > 0)
all = all.Where(i => (i.Name ?? "").Contains(filter, StringComparison.OrdinalIgnoreCase)).ToList();
if (all.Count == 0)
return filter.Length > 0
? $"Nothing in your vault matches '{filter}'. It holds {handle.Count:N0} item(s)."
: "Your vault is empty. Select an item and /mule deposit to store it.";
var pages = (all.Count + Mod.Settings.PageSize - 1) / Mod.Settings.PageSize;
page = Math.Clamp(page, 1, pages);
var text = new StringBuilder();
text.AppendLine(filter.Length > 0
? $"Vault, '{filter}': {all.Count:N0} match - page {page} of {pages}"
: $"Vault: {handle.Count:N0} of {Mod.Settings.MaxItems:N0} items - page {page} of {pages}, newest first");
foreach (var item in all.Skip((page - 1) * Mod.Settings.PageSize).Take(Mod.Settings.PageSize))
text.AppendLine(" " + Line(item));
if (pages > 1)
text.AppendLine(filter.Length > 0
? $"/mule list {filter} {Math.Min(page + 1, pages)} for the next page."
: $"/mule list {Math.Min(page + 1, pages)} for the next page.");
text.Append("The website's My Characters page shows the whole vault with full appraisals.");
return text.ToString();
}
private static string Line(WorldObject item)
{
var bits = new List();
// Material first: a vault full of "Salvage (100)" rows is unreadable without it,
// and the material is the only thing that tells two bags apart (2026-10-09).
// Shown for anything that carries one - a Silver Tachi is worth knowing too -
// and left out when the name already says it, so nothing reads "Iron Iron Key".
if (item.MaterialType is { } material && material != ACE.Entity.Enum.MaterialType.Unknown)
{
var name = material.ToString();
if (item.Name?.Contains(name, StringComparison.OrdinalIgnoreCase) != true)
bits.Add(name);
}
if (item.ItemWorkmanship is not null && item.Workmanship is > 0)
bits.Add($"wk {item.Workmanship:0.#}");
if (item.NumTimesTinkered > 0)
bits.Add($"{item.NumTimesTinkered} tink");
if (item.ItemTotalXp is > 0)
bits.Add("has xp");
var tail = bits.Count > 0 ? $" [{string.Join(", ", bits)}]" : "";
return $"{Movement.Describe(item)}{tail} (0x{item.Guid.Full:X8})";
}
// ---- /mule log -----------------------------------------------------------
private static string Log(Player player)
{
var accountId = player.Account?.AccountId ?? 0;
if (accountId == 0)
return "Your account could not be read.";
var lines = MuleDb.Recent(accountId, Mod.Settings.LogLines);
if (lines.Count == 0)
return "Nothing has gone into or out of your vault yet.";
var text = new StringBuilder($"The last {lines.Count} movement(s) at your vault, newest first:\n");
foreach (var line in lines)
{
var what = line.StackSize > 1 ? $"{line.StackSize:N0} x {line.ItemName}" : line.ItemName;
var arrow = line.Direction == Movement.In ? "in " : "out";
var verdict = line.Result == "moved" ? "" : $" - {line.Result}{(line.Detail.Length > 0 ? $": {line.Detail}" : "")}";
text.AppendLine($" {line.At:yyyy-MM-dd HH:mm} {arrow} {what} ({line.Character}){verdict}");
}
text.Append("Times are the server's. The same list, with more of it, is on the website.");
return text.ToString();
}
// ---- helpers -------------------------------------------------------------
private static bool TryParseGuid(string s, out uint guid)
{
guid = 0;
if (string.IsNullOrWhiteSpace(s))
return false;
s = s.Trim();
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
return uint.TryParse(s[2..], System.Globalization.NumberStyles.HexNumber, null, out guid);
// A bare decimal is only a guid if it is in the dynamic range; otherwise it is a
// "which one" number and the caller handles it.
return uint.TryParse(s, out guid) && guid >= 0x80000000;
}
private static void Send(Player player, string message)
{
foreach (var line in (message ?? "").Split('\n'))
{
var text = line.TrimEnd('\r');
if (!string.IsNullOrWhiteSpace(text))
player.SendMessage(text);
}
}
}