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