namespace Aeshnidae.DevKit; /// /// The content developer's loop, as chat commands: /// /// /winfo what does this weenie do (any server) /// /wfind which wcid is that (any server) /// /wexport give me its SQL (any server, to Discord) /// /wimport put my edited SQL in and make it live (staging only) /// /wdiff what did I change (staging) /// /lbdiff what did I place or move (staging) /// /submit package it for the change ledger (staging, to Discord) /// /// Every command works from the console too (CommandHandlerFlag.None), so the whole /// loop can be driven and tested through Aeshnidae.RemoteConsole without a client. /// The slow parts - the SQL writer's first build, database reads, Discord - run off /// the world thread and reply through the player's action queue. /// public static class Commands { // ---- inspect -------------------------------------------------------------------- [CommandHandler("winfo", AccessLevel.Developer, CommandHandlerFlag.None, 1, "Describe a weenie: what it gives, awards, sells, spawns and casts, then its properties.", "/winfo [full]\n" + "e.g. /winfo 22642 Brighteyes the Tailor, emotes decoded\n" + " /winfo 22642 full also palettes, textures and anim parts")] public static void HandleInfo(Session session, params string[] parameters) { var target = parameters[0].Trim(); var full = parameters.Length > 1 && parameters[1].Equals("full", StringComparison.OrdinalIgnoreCase); Background(session, () => { var weenie = Exporter.Find(target); if (weenie is null) return new[] { $"No weenie '{target}' on {Mod.Settings.Label}. Try /wfind ." }; return WeenieInfo.Render(weenie, full); }); } [CommandHandler("wfind", AccessLevel.Developer, CommandHandlerFlag.None, 1, "Find weenies by name. Every word given must appear in the name.", "/wfind [word ...]\n" + "e.g. /wfind tusker tusk")] public static void HandleFind(Session session, params string[] parameters) { var words = parameters.Select(p => p.Trim()).Where(p => p.Length > 0).ToArray(); if (words.Length == 0) { Reply(session, "Usage: /wfind [word ...]"); return; } Background(session, () => { var max = Mod.Settings.MaxFindResults; var hits = Names.Weenies .Where(kv => words.All(w => kv.Value.Contains(w, StringComparison.OrdinalIgnoreCase))) .OrderBy(kv => kv.Key) .ToList(); if (hits.Count == 0) return new[] { $"No weenie name contains {string.Join(" + ", words.Select(w => $"'{w}'"))}." }; var lines = new List { $"{hits.Count} match(es) for {string.Join(" + ", words)}:" }; lines.AddRange(hits.Take(max).Select(kv => $" {kv.Key,6} {kv.Value}")); if (hits.Count > max) lines.Add($" ... {hits.Count - max} more - add a word to narrow it."); return lines; }); } // ---- export / import ------------------------------------------------------------------ [CommandHandler("wexport", AccessLevel.Developer, CommandHandlerFlag.None, 1, "Export a weenie's SQL to the developer Discord channel as a file.", "/wexport \n" + "e.g. /wexport 22642 or /wexport ace22642-brighteyesthetailor")] public static void HandleExport(Session session, params string[] parameters) { var settings = Mod.Settings; if (!settings.HasWebhook) { Reply(session, $"{Mod.Name}: no Discord webhook configured - set WebhookUrl in Settings.json and /devkit-reload."); return; } var target = parameters[0].Trim(); var who = Who(session); Reply(session, $"Exporting {target} to Discord…"); Background(session, () => { var export = Exporter.Export(target); if (export is null) return new[] { $"Couldn't find weenie '{target}'." }; if (export.Sql.Length > settings.MaxAttachmentBytes) return new[] { $"{export.FileName} is {export.Sql.Length / 1024} KB, over the {settings.MaxAttachmentBytes / 1024} KB attachment limit." }; var w = export.Weenie; var name = w.WeeniePropertiesString?.FirstOrDefault(s => s.Type == (ushort)PropertyString.Name)?.Value ?? w.ClassName; var message = $":package: **{name}** `wcid {w.ClassId}` · {(WeenieType)w.Type} · {export.Lines} lines · from **{settings.Label}**\n" + $"exported by {who}"; var sent = DiscordUpload.SendFileAsync(settings.WebhookUrl, settings.Username, message, export.FileName, export.Sql).GetAwaiter().GetResult(); ModManager.Log($"[{Mod.Name}] {who} exported {w.ClassId} {name} -> Discord: {(sent.Ok ? "ok" : sent.Detail)}"); return new[] { sent.Ok ? $"Sent {export.FileName} ({export.Sql.Length / 1024} KB) to Discord. Edit it, upload it to sql/weenies/ and /wimport {w.ClassId}." : $"Discord refused {export.FileName}: {sent.Detail}", }; }); } [CommandHandler("lbexport", AccessLevel.Developer, CommandHandlerFlag.None, 0, "Export a landblock's placed instances as SQL to the developer Discord channel.", "/lbexport [landblock]\n" + "e.g. /lbexport A9B4 no argument = the landblock you are standing in")] public static void HandleLandblockExport(Session session, params string[] parameters) { var settings = Mod.Settings; if (!settings.HasWebhook) { Reply(session, $"{Mod.Name}: no Discord webhook configured."); return; } if (!TryLandblock(session, parameters.Length > 0 ? parameters[0] : "here", out var lb, out var error)) { Reply(session, error); return; } var who = Who(session); Background(session, () => { var export = Exporter.ExportLandblock(lb); if (export is null) return new[] { $"Landblock {lb:X4} has no placed instances." }; var message = $":round_pushpin: **landblock {lb:X4}** {export.Instances} instance(s) · from **{settings.Label}**\nexported by {who}"; var sent = DiscordUpload.SendFileAsync(settings.WebhookUrl, settings.Username, message, export.FileName, export.Sql).GetAwaiter().GetResult(); return new[] { sent.Ok ? $"Sent {export.FileName} ({export.Instances} instances) to Discord." : $"Discord refused: {sent.Detail}" }; }); } [CommandHandler("wimport", AccessLevel.Developer, CommandHandlerFlag.None, 1, "Import an edited weenie .sql from the content folder's sql/weenies/ and make it live at once.", "/wimport \n" + "Reads /sql/weenies/ *.sql (as /import-sql does), then reloads the\n" + "weenie and re-points its live instances (as /clearweenie does), then shows what changed\n" + "against live. Staging only - refused where AllowImport is off.")] public static void HandleImport(Session session, params string[] parameters) { var settings = Mod.Settings; if (!settings.AllowImport) { Reply(session, $"Imports are OFF on {settings.Label}. Changes to the live world go through the change ledger: /submit from staging."); return; } var target = parameters[0].Trim(); var who = Who(session); Background(session, () => { var folder = WeenieSqlFolder(); if (!Directory.Exists(folder)) return new[] { $"No {folder} - is content_folder set? (/modifystring content_folder )" }; var matches = FindImportFiles(folder, target); if (matches.Count == 0) return new[] { $"Nothing in {folder} matches '{target}'. Files are named ' .sql' - upload yours there first." }; if (matches.Count > 1) return new[] { $"'{target}' matches {matches.Count} files - say which:" }.Concat(matches.Select(f => " " + Path.GetFileName(f))).ToArray(); var file = matches[0]; var m = Regex.Match(Path.GetFileName(file), @"\d+"); if (!m.Success || !uint.TryParse(m.Value, out var wcid)) return new[] { $"Can't tell the wcid from '{Path.GetFileName(file)}' - name it ' .sql'." }; // ACE's own importer: runs the file against the world db as one batch. try { ACE.Server.Command.Handlers.Processors.DeveloperContentCommands.ImportSQL(file); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] /wimport {file} by {who} failed: {ex}", ModManager.LogLevel.Error); return new[] { $"Import FAILED, nothing changed: {Root(ex).Message}" }; } ModManager.Log($"[{Mod.Name}] {who} imported {Path.GetFileName(file)} on {settings.Label}"); var lines = new List { $"Imported {Path.GetFileName(file)}." }; // Make it live: evict from ACE's cache, then let Aeshnidae.ClearWeenie re-point // the instances already standing in the world. Dispatched by name so this mod // does not depend on that one's types; if it is not loaded, the eviction alone // means the next spawn is right, and we say so. DatabaseManager.World.ClearCachedWeenie(wcid); lines.Add(RunOnWorld(session, $"clearweenie {wcid}") ? $"Reloaded {wcid} and refreshed its live instances." : $"Reloaded {wcid}. (Aeshnidae.ClearWeenie is not loaded, so instances already in the world keep the old definition until they respawn.)"); if (settings.HasBase) { var diff = Diff.Weenie(wcid); lines.Add(diff.Changed ? $"Against live, {diff.Subject}:" : $"{diff.Subject} is now identical to live."); lines.AddRange(Cap(diff.Lines, settings.MaxDiffLines)); } return lines; }); } // ---- diff ----------------------------------------------------------------------------- [CommandHandler("wdiff", AccessLevel.Developer, CommandHandlerFlag.None, 1, "Show how a weenie on this server differs from live.", "/wdiff [wcid ...]\n" + "~ changed - removed + added. Compares against the base copy of live this server was cloned from.")] public static void HandleDiff(Session session, params string[] parameters) { if (!RequireBase(session)) return; var wcids = ParseWcids(parameters, out var bad); if (bad.Count > 0) Reply(session, $"Not a wcid: {string.Join(", ", bad)}"); if (wcids.Count == 0) return; Background(session, () => { var lines = new List(); foreach (var wcid in wcids) { var diff = Diff.Weenie(wcid); if (!diff.InBase && !diff.InWorld) lines.Add($"{wcid}: no such weenie on either side."); else if (!diff.Changed) lines.Add($"{diff.Subject}: identical to live."); else { lines.Add($"{diff.Subject}: {diff.Lines.Count} change(s) against live"); lines.AddRange(Cap(diff.Lines, Mod.Settings.MaxDiffLines)); } } return lines; }); } [CommandHandler("lbdiff", AccessLevel.Developer, CommandHandlerFlag.None, 0, "Show how a landblock's placed instances differ from live.", "/lbdiff [landblock]\n" + "e.g. /lbdiff A9B4 no argument = the landblock you are standing in")] public static void HandleLandblockDiff(Session session, params string[] parameters) { if (!RequireBase(session)) return; if (!TryLandblock(session, parameters.Length > 0 ? parameters[0] : "here", out var lb, out var error)) { Reply(session, error); return; } Background(session, () => { var diff = Diff.Landblock(lb); if (!diff.InBase && !diff.InWorld) return new[] { $"Landblock {lb:X4} has no placed instances on either side." }; if (!diff.Changed) return new[] { $"Landblock {lb:X4}: identical to live." }; var lines = new List { $"Landblock {lb:X4}: {diff.Lines.Count} change(s) against live" }; lines.AddRange(Cap(diff.Lines, Mod.Settings.MaxDiffLines)); return lines; }); } // ---- submit --------------------------------------------------------------------------- [CommandHandler("submit", AccessLevel.Developer, CommandHandlerFlag.None, 2, "Package changed weenies and landblocks as a change file + patch note for the ledger, and post them to Discord.", "/submit [...] [-- why it changed]\n" + "e.g. /submit tusk-xp-halved 22419 22420 -- 95M for one turn-in outpaced every other reward\n" + " /submit holtburg-font-portal 90301 lb:A9B4 -- a second door to the Font, beside the buff bot\n" + "The slug is lower-case words joined by dashes; it becomes the file name and the title.")] public static void HandleSubmit(Session session, params string[] parameters) { var settings = Mod.Settings; var slug = parameters[0].Trim().ToLowerInvariant(); if (!Submit.ValidSlug(slug)) { Reply(session, $"'{slug}' is not a slug: lower-case words joined by dashes, e.g. tusk-xp-halved."); return; } var wcids = new List(); var landblocks = new List(); var why = ""; for (var i = 1; i < parameters.Length; i++) { var p = parameters[i].Trim(); if (p == "--") { why = string.Join(' ', parameters.Skip(i + 1)).Trim(); break; } if (p.StartsWith("lb:", StringComparison.OrdinalIgnoreCase)) { if (!TryLandblock(session, p[3..], out var lb, out var error)) { Reply(session, error); return; } if (!landblocks.Contains(lb)) landblocks.Add(lb); continue; } if (uint.TryParse(p, out var wcid) && wcid > 0) { if (!wcids.Contains(wcid)) wcids.Add(wcid); continue; } Reply(session, $"'{p}' is neither a wcid nor lb:XXXX. Put the reason after ' -- '."); return; } if (wcids.Count == 0 && landblocks.Count == 0) { Reply(session, "Nothing to submit: list at least one wcid or lb:XXXX."); return; } var author = Who(session); Reply(session, $"Building {slug}: {wcids.Count} weenie(s), {landblocks.Count} landblock(s)…"); Background(session, () => { var output = Submit.Build(new Submit.Request(slug, wcids, landblocks, why, author)); var (sqlPath, notePath) = Submit.Save(output); var lines = new List(); lines.AddRange(output.Summary.Select(s => " " + s)); lines.AddRange(output.Warnings.Select(w => " ! " + w)); lines.Add($"Saved {Path.GetFileName(sqlPath)} and {Path.GetFileName(notePath)} in {Path.GetDirectoryName(sqlPath)}."); ModManager.Log($"[{Mod.Name}] {author} submitted {slug} ({string.Join(",", wcids)}; {string.Join(",", landblocks.Select(l => l.ToString("X4")))}) -> {sqlPath}"); if (!settings.HasWebhook) { lines.Add("No Discord webhook configured - the files are on disk only."); return lines; } var message = new StringBuilder() .AppendLine($":inbox_tray: **Submission: {slug}** from **{settings.Label}** by {author}") .AppendLine(string.Join("\n", output.Summary.Select(s => "• " + s))); if (output.Warnings.Count > 0) message.AppendLine(string.Join("\n", output.Warnings.Select(w => ":warning: " + w))); if (!string.IsNullOrWhiteSpace(why)) message.AppendLine($"**Why:** {why}"); message.Append("To apply: fill in the patch note, rename `NNN` to the day's next number, drop both in `Mods/Content/sql/changes/`, `push-content.sh`."); var files = new[] { new DiscordUpload.Attachment(Path.GetFileName(sqlPath), output.ChangeSql), new DiscordUpload.Attachment(Path.GetFileName(notePath), output.PatchNote), }; var total = output.ChangeSql.Length + output.PatchNote.Length; if (total > settings.MaxAttachmentBytes) { lines.Add($"Too big for Discord ({total / 1024} KB) - files are on disk only."); return lines; } var sent = DiscordUpload.SendFilesAsync(settings.WebhookUrl, settings.Username, message.ToString(), files).GetAwaiter().GetResult(); lines.Add(sent.Ok ? "Posted to Discord for review." : $"Discord refused the post: {sent.Detail} - the files are still on disk."); return lines; }); } // ---- status ------------------------------------------------------------------------- [CommandHandler("devkit", AccessLevel.Developer, CommandHandlerFlag.None, 0, "Show Aeshnidae.DevKit status and its commands.", "/devkit")] public static void HandleStatus(Session session, params string[] parameters) { var container = Mod.Container; var s = Mod.Settings; Reply(session, new StringBuilder() .AppendLine($"{Mod.Name} v{container?.Meta.Version ?? "?"} - {container?.Status.ToString() ?? "not loaded"} - this is **{s.Label}**") .AppendLine($"Imports: {(s.AllowImport ? "ALLOWED" : "off")} Base schema: {(s.HasBase ? s.BaseSchema : "(none - no diff/submit)")} Db: {(Db.Ready ? Db.WorldSchema : "not connected")}") .AppendLine($"Webhook: {s.MaskedWebhook} Max attachment: {s.MaxAttachmentBytes / 1024} KB Submissions: {(s.HasBase || s.AllowImport ? Submit.Directory() : "n/a")}") .AppendLine("Inspect: /winfo [full] /wfind ") .AppendLine("Export: /wexport /lbexport [landblock]") .AppendLine("Change: /wimport /wdiff /lbdiff [landblock]") .AppendLine("Ship: /submit -- why") .ToString()); } [CommandHandler("devkit-reload", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Restart Aeshnidae.DevKit, re-reading Settings.json.", "/devkit-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."); } // ---- helpers --------------------------------------------------------------------------- private static bool RequireBase(Session session) { if (!Db.Ready) { Reply(session, $"{Mod.Name}: no world database connection (see the log)."); return false; } if (!Mod.Settings.HasBase) { Reply(session, $"No base schema on {Mod.Settings.Label} - there is nothing to diff against here. Diff and submit from staging."); return false; } if (!Db.SchemaExists(Mod.Settings.BaseSchema)) { Reply(session, $"Base schema '{Mod.Settings.BaseSchema}' does not exist - has refresh-staging.sh been run?"); return false; } return true; } private static List ParseWcids(string[] parameters, out List bad) { var wcids = new List(); bad = new List(); foreach (var raw in parameters) foreach (var token in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { if (uint.TryParse(token, out var wcid) && wcid > 0) { if (!wcids.Contains(wcid)) wcids.Add(wcid); } else bad.Add(token); } return wcids; } /// "A9B4", "0xA9B4", "0xA9B40019" (a cell id) or "here" (the player's landblock). private static bool TryLandblock(Session? session, string text, out ushort landblock, out string error) { landblock = 0; error = ""; text = text.Trim(); if (text.Equals("here", StringComparison.OrdinalIgnoreCase)) { var lb = session?.Player?.CurrentLandblock; if (lb is null) { error = "'here' needs a player in the world - give the landblock, e.g. A9B4."; return false; } landblock = lb.Id.Landblock; return true; } if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) text = text[2..]; if (!uint.TryParse(text, System.Globalization.NumberStyles.HexNumber, null, out var value) || value == 0) { error = $"'{text}' is not a landblock. Use four hex digits, e.g. A9B4, or 'here'."; return false; } landblock = value > 0xFFFF ? (ushort)(value >> 16) : (ushort)value; return true; } private static string WeenieSqlFolder() { var content = PropertyManager.GetString("content_folder").Item ?? "Content"; if (content.StartsWith('.')) content = Path.Combine(Environment.CurrentDirectory, content); return Path.Combine(content, "sql", "weenies"); } private static List FindImportFiles(string folder, string target) { var files = Directory.GetFiles(folder, "*.sql"); if (uint.TryParse(target, out var wcid)) { // ' name.sql', with or without ACE's zero padding, is the file for this wcid. var rx = new Regex($@"^0*{wcid}(?!\d)", RegexOptions.IgnoreCase); return files.Where(f => rx.IsMatch(Path.GetFileName(f))).OrderBy(f => f).ToList(); } var name = target.EndsWith(".sql", StringComparison.OrdinalIgnoreCase) ? target : target + ".sql"; return files.Where(f => Path.GetFileName(f).Equals(name, StringComparison.OrdinalIgnoreCase)).ToList(); } /// /// Runs a console-style command line on the world thread when there is a player to /// hang it off, or inline otherwise. True if the command exists and did not throw. /// private static bool RunOnWorld(Session? session, string commandLine) { try { CommandManager.ParseCommand(commandLine, out var command, out var parameters); var response = CommandManager.GetCommandHandler(null, command, parameters, out var info); if (response != CommandHandlerResponse.Ok || info is null) return false; var handler = (CommandHandler)info.Handler; var player = session?.Player; if (player is null) { handler.Invoke(null, parameters); return true; } var done = new ManualResetEventSlim(false); var ok = false; var chain = new ActionChain(); chain.AddAction(player, () => { try { handler.Invoke(null, parameters); ok = true; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] '{commandLine}' threw: {ex}", ModManager.LogLevel.Error); } finally { done.Set(); } }); chain.EnqueueChain(); done.Wait(TimeSpan.FromSeconds(30)); return ok; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] '{commandLine}' failed: {ex}", ModManager.LogLevel.Error); return false; } } private static IEnumerable Cap(List lines, int max) { if (lines.Count <= max) return lines.Select(l => " " + l); return lines.Take(max).Select(l => " " + l).Append($" ... {lines.Count - max} more line(s); /submit includes them all."); } private static Exception Root(Exception ex) { while (ex.InnerException is not null) ex = ex.InnerException; return ex; } private static string Who(Session? session) => session?.Player?.Name ?? session?.Account ?? "console"; /// /// Run work off the world thread and deliver its lines afterwards - through the /// player's action queue when there is a player, to the log for the console. /// A throw becomes one line rather than a dead command. /// private static void Background(Session? session, Func> work) { _ = Task.Run(() => { IEnumerable lines; try { lines = work(); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] command failed: {ex}", ModManager.LogLevel.Error); lines = new[] { $"Failed: {Root(ex).Message}" }; } var text = string.Join("\n", lines); var player = session?.Player; if (player is null) { Reply(session, text); return; } var chain = new ActionChain(); chain.AddAction(player, () => Reply(session, text)); chain.EnqueueChain(); }); } /// The client renders an embedded newline as a music note, so one send per line. private static void Reply(Session? session, string message) { foreach (var line in (message ?? "").Split('\n')) { var text = line.TrimEnd('\r'); if (string.IsNullOrWhiteSpace(text)) continue; if (session?.Player is null) ModManager.Log($"[{Mod.Name}] {text}"); else session.Player.SendMessage(text); } } }