namespace Aeshnidae.Junk; /// /// /junk - destroy items in your own packs by name. /// /// ACE's @delete takes the one object you have selected; emptying a pack of /// sixty salvage bags that way is sixty selections. This walks the main pack and /// the side packs, matches on the item name and consumes each match the same way /// the server consumes a spell component - inventory, client and encumbrance all /// updated - so nothing is left on the floor to decay. /// public static class Commands { [CommandHandler("junk", AccessLevel.Developer, CommandHandlerFlag.RequiresWorld, 1, "Destroy every unequipped item in your packs whose name contains the text.", "/junk [count]\n" + "/junk \"Salvage (\" every salvage bag, main pack and side packs\n" + "/junk salvage 10 at most ten of them\n" + "/junk =Pyreal the whole name only: the coins, not the Pyreal Scarabs\n" + "Packs themselves, equipped items and anything you are wielding are never touched.")] public static void HandleJunk(Session session, params string[] parameters) { var player = session?.Player; if (player is null) { ModManager.Log($"[{Mod.Name}] /junk needs a player; from the console use junk-from ."); return; } Junk(session, player, parameters); } /// /// The console form: the same thing done to an online player's packs. For the /// admin gear that should not be in a pack at all - the Sonic Screwdriver that /// unlocks anything (2026-09-22) - without asking the player to do it themselves. /// [CommandHandler("junk-from", AccessLevel.Admin, CommandHandlerFlag.None, 2, "Destroy every unequipped item in an online player's packs whose name contains the text. Console-safe.", "/junk-from [count] same rules as /junk; the player is told what went")] public static void HandleJunkFrom(Session session, params string[] parameters) { var target = PlayerManager.GetOnlinePlayer(parameters[0]); if (target is null) { Reply(session, $"'{parameters[0]}' is not online."); return; } Junk(session, target, parameters.Skip(1).ToArray(), tellPlayer: true); } private static void Junk(Session? session, Player player, string[] parameters, bool tellPlayer = false) { // A trailing number is the count; everything before it is the name text, // so "/junk Salvage (" needs no quoting. var words = parameters.ToList(); var limit = int.MaxValue; if (words.Count > 1 && int.TryParse(words[^1], out var count)) { if (count < 1) { Reply(session, "The count must be at least 1."); return; } limit = count; words.RemoveAt(words.Count - 1); } var text = string.Join(' ', words).Trim().Trim('"'); // A leading '=' asks for the whole name: "=Pyreal" is the coins and // leaves the Pyreal Scarabs, which "Pyreal" would take too. var exact = text.StartsWith('='); if (exact) text = text[1..].Trim(); if (text.Length < Mod.Settings.MinimumTextLength) { Reply(session, $"Usage: /junk [count] - the text must be at least {Mod.Settings.MinimumTextLength} characters, so a slip cannot empty the pack."); return; } var matches = new List(); foreach (var item in player.Inventory.Values) { if (item is Container pack) { // A side pack's contents are fair game; the pack itself never is. foreach (var inner in pack.Inventory.Values) if (Matches(inner, text, exact)) matches.Add(inner); continue; } if (Matches(item, text, exact)) matches.Add(item); } if (matches.Count == 0) { Reply(session, exact ? $"Nothing in your packs is named \"{text}\"." : $"Nothing in your packs is named like \"{text}\"."); return; } var destroyed = 0; var failed = 0; var names = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var item in matches.Take(limit)) { bool gone; try { gone = player.TryConsumeFromInventoryWithNetworking(item); } catch (Exception ex) { gone = false; ModManager.Log($"[{Mod.Name}] /junk could not destroy 0x{item.Guid}:{item.Name}: {ex}", ModManager.LogLevel.Error); } if (!gone) { failed++; continue; } destroyed++; names[item.Name ?? "?"] = names.GetValueOrDefault(item.Name ?? "?") + 1; } var summary = string.Join(", ", names.OrderByDescending(n => n.Value).Select(n => n.Value == 1 ? n.Key : $"{n.Value} x {n.Key}")); var left = matches.Count - destroyed - failed; var report = $"Destroyed {destroyed} item(s): {summary}." + (failed > 0 ? $" {failed} could not be removed (see the server log)." : "") + (left > 0 ? $" {left} more match; raise the count to take them too." : ""); Reply(session, (tellPlayer ? $"{player.Name}: " : "") + report); if (tellPlayer && destroyed > 0) player.SendMessage($"Removed from your packs by the server: {summary}."); // The same trail @delete leaves, so a purge is never a mystery in #audit. if (destroyed > 0) PlayerManager.BroadcastToAuditChannel(player, $"{player.Name} junked {destroyed} item(s) named like \"{text}\": {summary}"); } private static bool Matches(WorldObject item, string text, bool exact) { if (item.Name is null || item is Container) return false; return exact ? item.Name.Equals(text, StringComparison.OrdinalIgnoreCase) : item.Name.Contains(text, StringComparison.OrdinalIgnoreCase); } [CommandHandler("junk-reload", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Restart the Aeshnidae.Junk mod, re-reading Settings.json.", "/junk-reload")] public static void HandleReload(Session session, params string[] parameters) { var container = Mod.Container; if (container is null) { Reply(session, $"{Mod.Name} is not loaded."); return; } container.Restart(); Reply(session, $"{Mod.Name} restarted."); } /// /// The client renders an embedded newline as a music note, so a multi-line /// message has to go out as one SendMessage per line. /// private static void Reply(Session? session, string message) { if (session?.Player is null) { ModManager.Log(message); return; } foreach (var line in (message ?? "").Split('\n')) { var text = line.TrimEnd('\r'); if (!string.IsNullOrWhiteSpace(text)) session.Player.SendMessage(text); } } }