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