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