namespace Aeshnidae.BuffBot; /// /// Chat / console commands added by this mod. /// /// ModContainer scans the assembly for [CommandHandler] methods and registers them /// when the mod is enabled (Meta.json "RegisterCommands": true), then removes them /// again on Disable. Handlers must be public static and match ACE's CommandHandler /// delegate exactly: (Session session, params string[] parameters). /// /// Putting a [CommandCategory("...")] attribute on this class would exclude it from /// automatic registration, leaving it for ModContainer.RegisterCommandCategory(). /// public static class Commands { [CommandHandler("buffbot", AccessLevel.Player, CommandHandlerFlag.None, 0, "Show Aeshnidae mod status.", "/buffbot")] public static void HandleStatus(Session session, params string[] parameters) { var container = Mod.Container; var text = new StringBuilder() .AppendLine($"{Mod.Name} v{container?.Meta.Version ?? "?"} - {container?.Status.ToString() ?? "not loaded"}") .AppendLine($"Buff bots whose banes reach every armour piece: {string.Join(", ", Mod.Settings.Bots)}") .AppendLine($"Town wardens: {string.Join(", ", Mod.Settings.Wardens ?? new List())} - /warden for the town you are standing in") .AppendLine($"Town tinkers: {string.Join(", ", Mod.Settings.Tinkers ?? new List())} - /tinker for the town you are standing in") .ToString(); Reply(session, text); } [CommandHandler("buffbot-check", AccessLevel.Admin, CommandHandlerFlag.None, 1, "What each bot spell becomes for a character of the given level. Console-safe.", "/buffbot-check [bot wcid]")] public static void HandleCheck(Session session, params string[] parameters) { if (!int.TryParse(parameters[0], out var level)) { Reply(session, "Give a character level."); return; } var wcid = parameters.Length > 1 && uint.TryParse(parameters[1], out var w) ? w : Mod.Settings.Bots.FirstOrDefault(); var weenie = DatabaseManager.World.GetCachedWeenie(wcid); if (weenie is null) { Reply(session, $"No weenie {wcid}."); return; } uint wanted = 0; foreach (var band in Mod.Settings.LevelBands.OrderBy(b => b.UpToLevel)) if (wanted == 0 && level <= band.UpToLevel) wanted = band.SpellLevel; if (wanted == 0) wanted = Mod.Settings.SpellLevelAbove; var sb = new StringBuilder($"{weenie.ClassName} ({wcid}) on a level {level} character - spell level {wanted}:\n"); var unchanged = 0; foreach (var emote in weenie.PropertiesEmote ?? Array.Empty()) { foreach (var action in emote.PropertiesEmoteAction) { if (action.SpellId is null || (action.Type != (uint)EmoteType.CastSpell && action.Type != (uint)EmoteType.CastSpellInstant)) continue; var spell = new Spell((uint)action.SpellId.Value); var stepped = Bands.At(spell, wanted); if (stepped.Id == spell.Id) { unchanged++; sb.AppendLine($" {spell.Name} (L{spell.Level}) - as written"); } else sb.AppendLine($" {spell.Name} (L{spell.Level}) -> {stepped.Name} (L{stepped.Level})"); } } sb.AppendLine($"{unchanged} cast as written."); Reply(session, sb.ToString().TrimEnd()); } // ---------------------------------------------------------------- town wardens [CommandHandler("warden", AccessLevel.Player, CommandHandlerFlag.RequiresWorld, 0, "The Town Warden beside you: this town's tier, what it gives, what the next tier needs, and who maintains it.", "/warden\n" + "/warden pay radiance from your bank\n" + "/warden pay pyreals from your pack")] public static void HandleWarden(Session session, params string[] parameters) { var player = session?.Player; if (player is null) { Reply(session, "/warden needs a player. Use warden-status from the console."); return; } if (parameters.Length > 0 && parameters[0].Equals("pay", StringComparison.OrdinalIgnoreCase)) { if (parameters.Length < 3) { Reply(session, "/warden pay "); return; } foreach (var line in Wardens.Pay(player, parameters[1], parameters[2])) player.SendMessage(line); return; } var warden = Wardens.Nearest(player); if (warden?.Location is null) { Reply(session, "There is no Town Warden near you. They stand in the main towns."); return; } foreach (var line in Wardens.Report(player, warden.Location.Landblock)) player.SendMessage(line); } [CommandHandler("warden-status", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Every town warden's tier and pot. Console-safe.", "warden-status")] public static void HandleWardenStatus(Session session, params string[] parameters) { var sb = new StringBuilder(); sb.AppendLine($"{Mod.Name} wardens: wcid(s) {string.Join(", ", Mod.Settings.Wardens ?? new List())}, " + $"ledger {(WardenDb.Ready ? "ready" : "UNAVAILABLE: " + WardenDb.LastError)}, " + $"ForcedTier {Mod.Settings.ForcedTier}{(Wardens.Forced ? " (every town is held at this tier; nothing can be paid)" : " (off)")}"); sb.AppendLine($"Tier ladder: {string.Join("; ", (Mod.Settings.Tiers ?? new List()).OrderBy(t => t.Tier).Select(t => $"{t.Tier}={Tiers.Describe(t.Tier)}"))}"); for (var tier = 1; tier <= Mod.Settings.MaxTier; tier++) { var (r, p) = Tiers.Price(tier); sb.AppendLine($" tier {tier,2} costs {r,15:N0} Radiance + {p,15:N0} pyreals"); } foreach (var town in (Mod.Settings.Towns ?? new List())) { if (!uint.TryParse(town.Landblock?.Replace("0x", ""), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var lb)) continue; var row = Wardens.RowFor(lb); var (r, p) = Tiers.Price(row.Tier + 1); sb.AppendLine($" 0x{lb:X4} {town.Name,-13} tier {row.Tier,2} (in force {Wardens.TierOf(lb),2}) " + $"pot {row.RadiancePaid,12:N0}/{r,12:N0} R {row.PyrealsPaid,12:N0}/{p,12:N0} p " + $"{(string.IsNullOrEmpty(row.OwnerAllegianceName) ? "unmaintained" : row.OwnerAllegianceName)}" + $"{(string.IsNullOrEmpty(row.LastPatronName) ? "" : $" (last: {row.LastPatronName})")}"); } Reply(session, sb.ToString().TrimEnd()); } [CommandHandler("warden-set", AccessLevel.Admin, CommandHandlerFlag.None, 2, "Write a town warden's tier straight in, for testing. The landblock is hex, as the ledger writes it.", "warden-set ")] public static void HandleWardenSet(Session session, params string[] parameters) { if (!uint.TryParse(parameters[0].Replace("0x", ""), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var landblock)) { Reply(session, "Give the landblock in hex, e.g. A9B4."); return; } if (!int.TryParse(parameters[1], out var tier) || tier < 0 || tier > Mod.Settings.MaxTier) { Reply(session, $"Give a tier between 0 and {Mod.Settings.MaxTier}."); return; } if (!WardenDb.SetTier(landblock, tier)) { Reply(session, "The wardens' ledger would not take that."); return; } Wardens.Reload(); Reply(session, $"0x{landblock:X4} ({Wardens.TownName(landblock)}) is tier {tier}: {Tiers.Describe(tier)}. " + $"In force: {Wardens.TierOf(landblock)}{(Wardens.Forced ? " - ForcedTier is on, so the stored tier is not what the warden is using" : "")}."); } [CommandHandler("warden-reload", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Re-read the town wardens' tiers from the shard and forget the cached spell families. Console-safe.", "warden-reload")] public static void HandleWardenReload(Session session, params string[] parameters) { WardenDb.Initialize(); Wardens.Reload(); Reply(session, $"{Mod.Name}: {Wardens.Rows.Count} warden row(s) re-read, " + $"ledger {(WardenDb.Ready ? "ready" : "UNAVAILABLE: " + WardenDb.LastError)}, ForcedTier {Mod.Settings.ForcedTier}."); } // ---------------------------------------------------------------- town tinkers [CommandHandler("tinker", AccessLevel.Player, CommandHandlerFlag.RequiresWorld, 0, "The Town Tinker beside you: his skill, what the next step costs, the pot so far, who maintains him, and your own four tinkering skills for comparison.", "/tinker\n" + "/tinker pay radiance from your bank\n" + "/tinker pay pyreals from your pack")] public static void HandleTinker(Session session, params string[] parameters) { var player = session?.Player; if (player is null) { Reply(session, "/tinker needs a player. Use tinker-status from the console."); return; } if (parameters.Length > 0 && parameters[0].Equals("pay", StringComparison.OrdinalIgnoreCase)) { if (parameters.Length < 3) { Reply(session, "/tinker pay "); return; } foreach (var line in Tinkers.Pay(player, parameters[1], parameters[2])) player.SendMessage(line); return; } var tinker = Tinkers.Nearest(player); if (tinker?.Location is null) { Reply(session, "There is no Town Tinker near you. They stand in the main towns, beside the wardens."); return; } foreach (var line in Tinkers.Report(player, tinker.Location.Landblock)) player.SendMessage(line); } [CommandHandler("tinker-status", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Every town tinker's skill and pot, and the state of the formula guard. Console-safe.", "tinker-status")] public static void HandleTinkerStatus(Session session, params string[] parameters) { var sb = new StringBuilder(); sb.AppendLine($"{Mod.Name} tinkers: wcid(s) {string.Join(", ", Mod.Settings.Tinkers ?? new List())}, " + $"ledger {(TinkerDb.Ready ? "ready" : "UNAVAILABLE: " + TinkerDb.LastError)}, " + $"range {Mod.Settings.TinkerRange:N0} m, +{Tinkers.SkillPerStep} skill a step, no cap, " + $"ForcedTinkerSkill {Mod.Settings.ForcedTinkerSkill}{(Tinkers.Forced ? " (every tinker is held at this skill; nothing can be paid)" : " (off)")}"); sb.AppendLine($"Formula guard: {(Formula.Ok ? "OK" : "OFF - every tink is stock ACE's")} - {Formula.Status}; " + $"stock floor {(Formula.StockAvailable ? "available" : "UNAVAILABLE")}"); foreach (var step in new[] { 1, 2, 5, 10, 20, 40, 100 }) { var (r, p) = Tinkers.Price(step); sb.AppendLine($" step {step,3} (skill {step * Tinkers.SkillPerStep,5}) costs {Tinkers.Money(r),22} Radiance + {Tinkers.Money(p),22} pyreals"); } foreach (var town in (Mod.Settings.Towns ?? new List())) { if (!uint.TryParse(town.Landblock?.Replace("0x", ""), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var lb)) continue; var row = Tinkers.RowFor(lb); var (r, p) = Tinkers.PriceFromSkill(row.Skill); sb.AppendLine($" 0x{lb:X4} {town.Name,-13} skill {row.Skill,5} (in force {Tinkers.SkillOf(lb),5}) " + $"pot {row.RadiancePaid,14:N0}/{Tinkers.Money(r),14} R {row.PyrealsPaid,14:N0}/{Tinkers.Money(p),14} p " + $"{(string.IsNullOrEmpty(row.OwnerAllegianceName) ? "unmaintained" : row.OwnerAllegianceName)}" + $"{(string.IsNullOrEmpty(row.LastPatronName) ? "" : $" (last: {row.LastPatronName})")}"); } // Which tinkers are actually standing in the world right now, and under what name - // the per-town rename happens on the instance as a landblock loads, so this is the // only way to see from the console that it took. try { var standing = 0; foreach (var block in LandblockManager.GetLoadedLandblocks()) foreach (var wo in block.GetAllWorldObjectsForDiagnostics()) { if (!Tinkers.IsTinker(wo.WeenieClassId) || wo.IsDestroyed || wo.Location is null) continue; standing++; sb.AppendLine($" standing: 0x{wo.Location.Landblock:X4} \"{wo.Name}\" at " + $"{wo.Location.PositionX:F2} {wo.Location.PositionY:F2} {wo.Location.PositionZ:F2} (guid 0x{wo.Guid.Full:X8})"); } sb.AppendLine($" {standing} tinker(s) loaded in the world now. A town's landblock unloads when nobody is near it; " + "inst-reload brings it back without standing there."); } catch (Exception ex) { sb.AppendLine($" could not walk the loaded landblocks: {ex.Message}"); } Reply(session, sb.ToString().TrimEnd()); } [CommandHandler("tinker-set", AccessLevel.Admin, CommandHandlerFlag.None, 2, "Write a town tinker's skill straight in, for testing. The landblock is hex, as the ledger writes it.", "tinker-set ")] public static void HandleTinkerSet(Session session, params string[] parameters) { if (!uint.TryParse(parameters[0].Replace("0x", ""), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var landblock)) { Reply(session, "Give the landblock in hex, e.g. A9B4."); return; } if (!int.TryParse(parameters[1], out var skill) || skill < 0) { Reply(session, "Give a skill of 0 or more. There is no cap, but a negative skill is not a thing."); return; } if (!TinkerDb.SetSkill(landblock, skill)) { Reply(session, "The tinkers' ledger would not take that."); return; } Tinkers.Reload(); Reply(session, $"0x{landblock:X4} ({Wardens.TownName(landblock)}) tinker is skill {skill}. " + $"In force: {Tinkers.SkillOf(landblock)}{(Tinkers.Forced ? " - ForcedTinkerSkill is on, so the stored skill is not what the tinker is using" : "")}."); } [CommandHandler("tinker-reload", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Re-read the town tinkers' skills from the shard. Console-safe.", "tinker-reload")] public static void HandleTinkerReload(Session session, params string[] parameters) { TinkerDb.Initialize(); Tinkers.Reload(); Reply(session, $"{Mod.Name}: {Tinkers.Rows.Count} tinker row(s) re-read, " + $"ledger {(TinkerDb.Ready ? "ready" : "UNAVAILABLE: " + TinkerDb.LastError)}, " + $"ForcedTinkerSkill {Mod.Settings.ForcedTinkerSkill}, formula guard {(Formula.Ok ? "OK" : "OFF")}."); } [CommandHandler("tinker-formula", AccessLevel.Admin, CommandHandlerFlag.None, 0, "The opcode sha256 of the ACE tinkering method this mod copied, and whether it is pinned. Console-safe.", "tinker-formula")] public static void HandleTinkerFormula(Session session, params string[] parameters) { var sb = new StringBuilder() .AppendLine($"RecipeManager.GetTinkerChance opcode sha256: {(string.IsNullOrEmpty(Formula.Fingerprint) ? "UNREADABLE" : Formula.Fingerprint)}") .AppendLine($"Settings.TinkerFormulaOpcodeSha256: {(string.IsNullOrWhiteSpace(Mod.Settings.TinkerFormulaOpcodeSha256) ? "(not pinned)" : Mod.Settings.TinkerFormulaOpcodeSha256)}") .AppendLine($"Guard: {(Formula.Ok ? "OK" : "OFF - every tink is stock ACE's")} - {Formula.Status}") .AppendLine($"Stock floor (reverse patch): {(Formula.StockAvailable ? "available" : "UNAVAILABLE")}"); Reply(session, sb.ToString().TrimEnd()); } [CommandHandler("buffbot-reload", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Restart the Aeshnidae.BuffBot mod, re-reading Settings.json.", "/buffbot-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); } } }