// The Weenie extension methods (GetName, GetValue, GetProperty) and PropertiesCreateList are // in ACE.Entity.Models, not members of the type. Not in GlobalUsings because that namespace // also defines a Biota, a Weenie and a Spell that collide with the aliases there; the aliases // win inside a file that opens the namespace, which is what Scroll.cs already relies on. using ACE.Entity.Models; namespace Aeshnidae.Awakens; public static class Commands { [CommandHandler("awaken", AccessLevel.Player, CommandHandlerFlag.RequiresWorld, -1, "Awakening: back to level 1 with everything else kept; each one makes your dungeon copies harder and richer.", "/awaken what it is, whether you may, and how many times you have\n" + "/awaken confirm do it (within a minute of /awaken)")] public static void HandleAwaken(Session session, params string[] parameters) { var player = session.Player; if (player is null) return; var s = Mod.Settings; if (parameters.Length > 0 && parameters[0].Equals("confirm", StringComparison.OrdinalIgnoreCase)) { if (!Awakening.IsArmed(player)) { Reply(session, "Type /awaken first, then /awaken confirm within a minute."); return; } Reply(session, Awakening.Do(player)); return; } var count = Awakening.CountOf(player); var refusal = Awakening.Refusal(player); var sb = new StringBuilder(); sb.AppendLine(s.ResetSkillsAndAttributes ? "Awakening - back to level 1 with your skills, attributes, skill credits and unassigned experience RESET, as in enlightenment. You keep your society, luminance, auras, titles, Radiance and every raise you have bought." : "Awakening - back to level 1, keeping every skill, attribute, credit, point of unassigned experience, your society, luminance, auras and gear."); sb.AppendLine($" each awakening : your personal dungeon copies +5% harder and +5% richer (enlightenment does not count); your level cap +{s.LevelsPerAwakening}"); sb.AppendLine($" requires : your level cap - {LevelCap.RetailMaxLevel} plus {s.LevelsPerAwakening} per awakening{(s.MaxAwakenings > 0 ? $"; at most {s.MaxAwakenings} times" : "")}"); sb.AppendLine($" you : level {player.Level ?? 1} of {LevelCap.Of(player)}, awakened {count} time{(count == 1 ? "" : "s")}, enlightened {player.Enlightenment} time{(player.Enlightenment == 1 ? "" : "s")}"); if (refusal is not null) { sb.Append($" not now : {refusal}"); Reply(session, sb.ToString()); return; } Awakening.Arm(player); sb.AppendLine(s.ResetSkillsAndAttributes ? " EVERYTHING you wear goes to your pack, and your skills go back to untrained - you will re-train and re-raise them on the climb. Your raises past the cap (Radiance) are kept." : " Items with a level requirement go to your pack; you will not be able to wear them until you are that level again."); sb.Append($" /awaken confirm within {s.ConfirmSeconds:N0} seconds to do it. There is no undoing it."); Reply(session, sb.ToString()); } [CommandHandler("awaken-set", AccessLevel.Admin, CommandHandlerFlag.None, 2, "Set a player's awakening count without awakening them. Testing.", "/awaken-set ")] public static void HandleSet(Session session, params string[] parameters) { if (parameters.Length < 2 || !int.TryParse(parameters[^1], out var count) || count < 0) { Reply(session, "/awaken-set "); return; } var name = string.Join(" ", parameters[..^1]); var target = PlayerManager.GetOnlinePlayer(name) ?? PlayerManager.GetOnlinePlayer("+" + name); if (target is null) { Reply(session, "That player is not online."); return; } var before = Awakening.CountOf(target); if (count == 0) target.RemoveProperty(Awakening.Property); else target.SetProperty(Awakening.Property, count); target.SaveBiotaToDatabase(); Reply(session, $"{target.Name}: awakenings {before} -> {count}."); ModManager.Log($"[{Mod.Name}] {session?.Player?.Name ?? "console"} set {target.Name}'s awakenings {before} -> {count}"); target.SendMessage($"Your awakening count was set to {count} by staff; your level cap is now {LevelCap.Of(target)}."); } [CommandHandler("awaken-reload", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Restart the Aeshnidae.Awakens mod, re-reading Settings.json.", "/awaken-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."); } // ------------------------------------------------------------------ Resolve [CommandHandler("resolve", AccessLevel.Player, CommandHandlerFlag.RequiresWorld, -1, "Resolve: the danger you set your own challenge runs to. Buy seals from the Keeper, press one onto your scroll.", "/resolve what is applied, what the next step takes in seals, and what you are carrying\n" + "/resolve scroll ask the Keeper you are standing at for the Scroll of Resolve (free, replaceable)")] public static void HandleResolve(Session session, params string[] parameters) { var player = session.Player; if (player is null) return; var verb = parameters.Length > 0 ? parameters[0].ToLowerInvariant() : ""; switch (verb) { case "": foreach (var line in Resolve.Readout(player, player)) Reply(session, line); Reply(session, $" {Mod.Settings.ResolveSellerDescription} sells the seals. Use her and her shop window opens; " + "/resolve scroll for the parchment."); return; case "scroll": Reply(session, Scroll.Hand(player)); return; // `buy` and `confirm` were the old purchase, retired on 2026-10-13 when the Keeper // became a vendor. They are still ANSWERED rather than dropped, because a player // who learned them in the morning deserves to be told where the shop went instead // of getting "unknown command" - and because the lines above still mention seals. case "buy": case "confirm": Reply(session, $"There is nothing to type any more: {Mod.Settings.ResolveSellerDescription} is a shopkeeper now. " + "Use her and a shop window opens, the same as any vendor."); Reply(session, $"Seals are {Seals.PriceLine(Resolve.Kind?.SealWcid ?? 0, Resolve.Kind?.SealPyreals ?? 0)} each, flat, " + "however many you take. What rises with each step is how many seals the step needs - /resolve says what yours takes."); return; default: Reply(session, "/resolve for the reading, /resolve scroll for the parchment. The seals are bought from the Keeper's shop window."); return; } } [CommandHandler("resolve-set", AccessLevel.Admin, CommandHandlerFlag.None, 2, "Set a character's Resolve without charging for it. Testing. Console-safe; works on an offline character.", "resolve-set set the Resolve in force to n\n" + "resolve-set seal|eraser take one step up or down, as using a seal on the scroll does, with nothing consumed")] public static void HandleResolveSet(Session session, params string[] parameters) { var kind = Resolve.Kind; if (kind is null) { Reply(session, "There is no seal of any kind on this server."); return; } var last = parameters[^1].ToLowerInvariant(); // The old `resolve-set paid ` wrote PropertyInt 9054, which is retired - // see Resolve.RetiredPaidProperty. Answered rather than dropped, so a stale note or an // old script gets a reason instead of setting a Resolve of 0. if (parameters.Length >= 3 && parameters[^2].Equals("paid", StringComparison.OrdinalIgnoreCase)) { Reply(session, "The seals-paid-for count (9054) is retired: the seal price is flat now, so nothing prices off it. " + "resolve-set sets the Resolve in force."); return; } // Applying one by hand: the same property write the real use makes, without a token to // consume. The point of a harness is that it is not a second implementation, so this // asks the real guard and writes the one real property. if (last is "seal" or "eraser") { var who = string.Join(" ", parameters[..^1]).Trim(); var target = Find(who); if (target is null) { Reply(session, $"No character called {who}."); return; } var eraser = last == "eraser"; var before = Seals.AppliedOn(target, kind); // Seals carried is handed in as "enough", because this harness grants the step // without taking anything - every OTHER refusal (the dial off, the maximum, a // saturated step) is the real one. var (needed, refusal) = eraser ? Seals.NextErase(before, kind) : Seals.NextStep(before, kind, Seals.Uncountable - 1); if (refusal is not null) { Reply(session, $"{target.Name}: {refusal}"); return; } var now = eraser ? Math.Max(0, before - 1) : before + 1; if (now <= 0) target.RemoveProperty((PropertyInt)kind.PropertyId); else target.SetProperty((PropertyInt)kind.PropertyId, now); Resolve.RetirePaidCount(target); target.SaveBiotaToDatabase(); Reply(session, $"{target.Name}: {(eraser ? "eraser" : "seal")} applied - {kind.Label} {before} -> {Seals.AppliedOn(target, kind)}" + $" (a real {(eraser ? "erase" : "step")} would have taken {needed:N0} {(eraser ? "eraser" : "seal")}(s))."); ModManager.Log($"[{Mod.Name}] {session?.Player?.Name ?? "console"} applied a {(eraser ? "eraser" : "seal")} to {target.Name}"); return; } if (!int.TryParse(last, out var level) || level < 0) { Reply(session, "resolve-set (n is 0 or more)"); return; } var name = string.Join(" ", parameters[..^1]).Trim(); // Offline characters count: the whole point of the harness is to set a value, read it // back out of the database and prove it survived a save. var who2 = Find(name); if (who2 is null) { Reply(session, $"No character called {name}."); return; } var beforeLevel = Seals.AppliedOn(who2, kind); if (level == 0) who2.RemoveProperty((PropertyInt)kind.PropertyId); else who2.SetProperty((PropertyInt)kind.PropertyId, level); Resolve.RetirePaidCount(who2); who2.SaveBiotaToDatabase(); Reply(session, $"{who2.Name}: {kind.Label} {beforeLevel} -> {level}. " + $"The next step takes {Money(Seals.SealsForStep(kind, level + 1), Seals.Uncountable)} seal(s)."); ModManager.Log($"[{Mod.Name}] {session?.Player?.Name ?? "console"} set {who2.Name}'s {kind.Label} {beforeLevel} -> {level}"); if (PlayerManager.GetOnlinePlayer(who2.Guid) is { } online) online.SendMessage($"Your {kind.Label} was set to {level} by staff."); } /// A character by name, online or offline - the harness works on both. private static IPlayer? Find(string name) => string.IsNullOrWhiteSpace(name) ? null : PlayerManager.FindByName(name, out _) ?? PlayerManager.FindByName("+" + name, out _); [CommandHandler("resolve-status", AccessLevel.Admin, CommandHandlerFlag.None, 0, "The Resolve terms in force, the seals-per-step curve, the shop, and the guard that refuses a step. Console-safe.", "resolve-status the settings in force, the hooks, the shop and where the curve saturates\n" + "resolve-status steps [n] the first n steps (default 20): seals, running total, pyreals\n" + "resolve-status apply [eraser] what the apply guard answers\n" + "resolve-status vendor the seller and her shop, read out of the world database\n" + "resolve-status scroll mint a scroll for that character, print the pages it would carry, destroy it")] public static void HandleResolveStatus(Session session, params string[] parameters) { var s = Mod.Settings; var verb = parameters.Length > 0 ? parameters[0].ToLowerInvariant() : ""; var kind = Resolve.Kind; if (kind is null) { Reply(session, "There is no seal of any kind on this server."); return; } if (verb is "steps" or "prices") { var count = parameters.Length > 1 && int.TryParse(parameters[1], out var n) && n > 0 ? Math.Min(n, 200) : 20; Reply(session, $"{kind.Label}: step 1 takes {Math.Max(1, kind.SealsForFirstStep)} seal(s), " + $"x{kind.SealsPerStepGrowth} a step, at {kind.SealPyreals:N0} pyreals a seal:"); Reply(session, " step | seals | seals in all | pyreals in all | stacks of 250"); for (var step = 1; step <= count; step++) { var seals = Seals.SealsForStep(kind, step); var cumulative = Seals.SealsToReach(kind, step); var stacks = seals >= Seals.Uncountable ? "-" : ((seals + 249) / 250).ToString("N0"); Reply(session, $" {step,5} | {Money(seals, Seals.Uncountable),9} | {Money(cumulative, Seals.Uncountable),12} " + $"| {Money(Seals.PyrealsToReach(kind, step), Resolve.Unaffordable),18} | {stacks,5}"); } var saturates = Seals.SaturatesAt(kind); Reply(session, saturates > 0 ? $" first saturated step: {saturates} (held at the sentinel {Seals.Uncountable:N0} and refused by name)" : " no step within 4,096 saturates"); Reply(session, $" step 0 and below: {Money(Seals.SealsForStep(kind, 0), Seals.Uncountable)} / " + $"{Money(Seals.SealsForStep(kind, -5), Seals.Uncountable)} seals (both refused)"); return; } if (verb == "apply") { if (parameters.Length < 3 || !int.TryParse(parameters[1], out var carried) || !int.TryParse(parameters[2], out var applied)) { Reply(session, "resolve-status apply [eraser]"); return; } var eraser = parameters.Length > 3 && parameters[3].StartsWith("eraser", StringComparison.OrdinalIgnoreCase); var (needed, refusal) = eraser ? Seals.NextErase(applied, kind) : Seals.NextStep(applied, kind, carried); Reply(session, $"a character with {kind.Label} {applied} carrying {carried:N0} " + $"{(eraser ? "eraser" : "seal")}(s) presses {(eraser ? "an eraser" : "a seal")} onto the scroll:"); Reply(session, $" would take: {Money(needed, Seals.Uncountable)} {(eraser ? "eraser" : "seal")}(s)"); Reply(session, refusal is null ? $" would go ahead: {kind.Label} {applied} -> {(eraser ? applied - 1 : applied + 1)}" : $" REFUSED: {refusal}"); return; } if (verb == "vendor") { // Reads the shop out of the world database and asserts every requirement ACE's // Vendor.ValidateVendorRequirements checks, because a vendor that fails one of them // is silently switched off (OpenForBusiness false) and just does nothing when used. var seller = Seals.SellerWeenie(); if (seller is null) { Reply(session, $"no seller weenie in the world cache for [{string.Join(", ", s.ResolveSellerWcids ?? new List())}] " + "- has the ledger been applied and clearweenie run?"); return; } Reply(session, $"{seller.GetName()} (wcid {seller.WeenieClassId}) is a {seller.WeenieType}" + $"{(seller.WeenieType == WeenieType.Vendor ? "" : " - NOT a Vendor, so no shop window opens")}"); var merchandise = seller.GetProperty(PropertyInt.MerchandiseItemTypes); var minValue = seller.GetProperty(PropertyInt.MerchandiseMinValue); var maxValue = seller.GetProperty(PropertyInt.MerchandiseMaxValue); var buy = seller.GetProperty(PropertyFloat.BuyPrice); var sell = seller.GetProperty(PropertyFloat.SellPrice); var alt = seller.GetProperty(PropertyDataId.AlternateCurrency); Reply(session, $" ACE requires all five: MerchandiseItemTypes {Req(merchandise)}, MerchandiseMinValue {Req(minValue)}, " + $"MerchandiseMaxValue {Req(maxValue)}, BuyPrice {Req(buy)}, SellPrice {Req(sell)}"); Reply(session, $" buys from players : {(merchandise is null or 0 ? "NOTHING - MerchandiseItemTypes is " + (merchandise is null ? "missing" : "0") : "item types " + (ItemType)merchandise.Value)}" + $"; AllowGive {seller.GetProperty(PropertyBool.AllowGive) ?? false}"); Reply(session, $" trade currency : {(alt is null or 0 ? "pyreal coin (no AlternateCurrency)" : $"wcid {alt} " + (DatabaseManager.World.GetCachedWeenie(alt.Value)?.GetName() ?? "NOT IN THE WORLD CACHE"))}"); Reply(session, $" PlayerKillerStatus : {(PlayerKillerStatus)(seller.GetProperty(PropertyInt.PlayerKillerStatus) ?? 0)}; " + $"ItemUseable {(Usable)(seller.GetProperty(PropertyInt.ItemUseable) ?? 0)}; UseRadius {seller.GetProperty(PropertyFloat.UseRadius) ?? 0}"); var shop = (seller.PropertiesCreateList ?? Array.Empty()) .Where(c => c.DestinationType == DestinationType.Shop).ToList(); Reply(session, $" shop list : {shop.Count} entr{(shop.Count == 1 ? "y" : "ies")}"); foreach (var row in shop) { var item = DatabaseManager.World.GetCachedWeenie(row.WeenieClassId); var price = Seals.PriceAt(row.WeenieClassId); Reply(session, $" {row.WeenieClassId} {item?.GetName() ?? "NOT IN THE WORLD CACHE"}" + $" - stack {(row.StackSize == -1 ? "unlimited" : row.StackSize.ToString())}" + $", MaxStackSize {item?.GetProperty(PropertyInt.MaxStackSize)?.ToString() ?? "-"}" + $", Value {item?.GetValue()?.ToString("N0") ?? "-"}" + $", IsSellable {item?.GetProperty(PropertyBool.IsSellable)?.ToString() ?? "(default true)"}" + $" -> {(price.Found ? $"{price.Units:N0} {price.Currency} = {price.Pyreals:N0} pyreals" : "PRICE UNREADABLE: " + price.Note)}"); } // The settings copy against the shop's real price. A readout that quotes a price // the shop does not charge is the drift this check exists to catch. foreach (var (what, wcid, setting) in new[] { ("seal", kind.SealWcid, kind.SealPyreals), ("eraser", kind.EraserWcid, kind.EraserPyreals), }) { var price = Seals.PriceAt(wcid); var onShelf = shop.Any(c => c.WeenieClassId == wcid); Reply(session, $" {what,-7} : settings say {setting:N0} pyreals; " + $"the shop says {(price.Found ? price.Pyreals.ToString("N0") + " pyreals" : "unreadable (" + price.Note + ")")}" + $"{(price.Found && price.Pyreals != setting ? " <-- THEY DISAGREE, fix one of them" : "")}" + $"{(onShelf ? "" : " <-- NOT ON HER SHOP LIST")}"); } // And then the real thing: build the vendor ACE would build and ask HER, not the // rows. This is the only way to see OpenForBusiness and the stocked shop on a // server nobody can log in to - the weenie rows can all look right while ACE still // switches a vendor off, because ValidateVendorRequirements logs its complaint and // then ActOnUse simply returns and the player sees nothing at all. // // On the WORLD thread: minting a WorldObject takes a dynamic guid, and the remote // console runs on a thread of its own. Destroyed again at the end; she is never in // a landblock and nobody can see her. WorldManager.EnqueueAction(new ActionEventDelegate(() => { WorldObject? probe = null; try { probe = WorldObjectFactory.CreateNewWorldObject(seller.WeenieClassId); if (probe is not Vendor vendor) { Reply(session, $" live check : wcid {seller.WeenieClassId} came back as " + $"{probe?.GetType().Name ?? "nothing"}, NOT a Vendor - has clearweenie run?"); return; } Reply(session, $" live check : built a {nameof(Vendor)}; OpenForBusiness {vendor.OpenForBusiness}" + $"{(vendor.OpenForBusiness ? "" : " <-- ACE HAS SWITCHED HER OFF, see the [VENDOR] error in the journal")}"); // LoadInventory is private and is what ActOnUse calls before it sends the // window; there is no public way in without a player. AccessTools.Method(typeof(Vendor), "LoadInventory")?.Invoke(vendor, null); Reply(session, $" stocked : {vendor.DefaultItemsForSale.Count} item(s) in the window"); foreach (var item in vendor.DefaultItemsForSale.Values.ToList()) Reply(session, $" {item.WeenieClassId} {item.Name} - stack {(item.VendorShopCreateListStackSize == -1 ? "unlimited" : item.VendorShopCreateListStackSize?.ToString() ?? "null")}" + $", MaxStackSize {item.MaxStackSize?.ToString() ?? "-"}, Value {item.Value?.ToString("N0") ?? "-"}" + $", attuned {item.Attuned?.ToString() ?? "-"}" + $" -> ACE charges {vendor.GetSellCost(item):N0} " + $"{(vendor.AlternateCurrency is null ? "pyreals" : "unit(s) of wcid " + vendor.AlternateCurrency)}"); foreach (var item in vendor.DefaultItemsForSale.Values.ToList()) item.Destroy(); vendor.DefaultItemsForSale.Clear(); // A stack's price has to scale, or buying 250 would cost one seal's worth. // SetStackSize recomputes Value from StackUnitValue, which is why the ledger // sets both; this mints the stack ACE's own ItemProfileToWorldObjects would // mint and asks the vendor what it would charge for it. foreach (var wcid in new[] { kind.SealWcid, kind.EraserWcid }) { if (wcid == 0) continue; var stack = WorldObjectFactory.CreateNewWorldObject(wcid); if (stack is null) continue; var max = Math.Max(1, (int)(stack.MaxStackSize ?? 1)); stack.SetStackSize(max); Reply(session, $" a full stack of {stack.Name}: {stack.StackSize} x, Value {stack.Value:N0}, " + $"burden {stack.EncumbranceVal:N0} -> ACE charges {vendor.GetSellCost(stack):N0} " + $"{(vendor.AlternateCurrency is null ? "pyreals" : "unit(s) of wcid " + vendor.AlternateCurrency)}"); stack.Destroy(); } } catch (Exception ex) { Reply(session, $" live check threw: {ex}"); } finally { try { probe?.Destroy(); } catch { } Reply(session, " (test vendor destroyed)"); } })); return; } if (verb == "scroll") { // Mints a scroll for a named character, prints the pages it would carry, then // destroys it. The only way to see the readout and the pagination on a server // nobody can log in to - and it exercises the real weenie, the real stamps and // the real Scroll.Rewrite, not a copy of them. // // On the WORLD thread: minting a WorldObject takes a dynamic guid, and the // remote console runs on a thread of its own. var target = parameters.Length > 1 ? Find(string.Join(" ", parameters[1..]).Trim()) : null; if (target is null) { Reply(session, "resolve-status scroll "); return; } WorldManager.EnqueueAction(new ActionEventDelegate(() => { try { if (WorldObjectFactory.CreateNewWorldObject(s.ResolveScrollWcid) is not Book scroll) { Reply(session, $"wcid {s.ResolveScrollWcid} did not come back as a Book - is the weenie applied and clearweenie run?"); return; } Scroll.Stamp(scroll, "the Keeper"); Scroll.Rewrite(target, scroll); var pages = scroll.Biota.PropertiesBookPageData; Reply(session, $"{scroll.Name} (wcid {scroll.WeenieClassId}) for {target.Name}: " + $"attuned {scroll.Attuned}, bonded {scroll.Bonded}, retained {scroll.GetProperty(PropertyBool.Retained)}, " + $"{pages.Count} page(s) of max {scroll.Biota.PropertiesBook?.MaxNumPages} x {scroll.Biota.PropertiesBook?.MaxNumCharsPerPage} chars"); for (var i = 0; i < pages.Count; i++) { Reply(session, $"--- page {i + 1} ({pages[i].PageText.Length} chars) ---"); foreach (var line in pages[i].PageText.Split('\n')) Reply(session, " " + line); } scroll.Destroy(); Reply(session, "(test scroll destroyed)"); } catch (Exception ex) { Reply(session, $"scroll test threw: {ex}"); } })); return; } Reply(session, $"Resolve {(s.Enabled && s.ResolveEnabled ? "ON" : "OFF")} " + $"(mod {(s.Enabled ? "on" : "off")}, dial {(s.ResolveEnabled ? "on" : "off")}); {Seals.Kinds.Count} seal kind(s)"); foreach (var k in Seals.Kinds) { Reply(session, $" {k.Key}: {k.Label}, one step a use, max {(k.Max > 0 ? k.Max.ToString() : "none")}; " + $"property {k.PropertyId}; seal wcid {k.SealWcid}, eraser wcid {k.EraserWcid}"); Reply(session, $" step 1 takes {Math.Max(1, k.SealsForFirstStep)} seal(s) x{k.SealsPerStepGrowth} a step " + $"(saturates at step {Seals.SaturatesAt(k)}, sentinel {Seals.Uncountable:N0}); " + $"seal {k.SealPyreals:N0} / eraser {k.EraserPyreals:N0} pyreals in settings"); Reply(session, $" the shop says: seal {Seals.PriceLine(k.SealWcid, k.SealPyreals)}, " + $"eraser {Seals.PriceLine(k.EraserWcid, k.EraserPyreals)}"); } Reply(session, $" scroll wcid {s.ResolveScrollWcid}; Keeper required for a scroll {s.ResolveSellerRequired}, " + $"seller wcids [{string.Join(", ", s.ResolveSellerWcids ?? new List())}] \"{s.ResolveSellerDescription}\", " + $"range {s.ResolveSellerRange}m"); Reply(session, " resolve-status vendor for the shop itself; PropertyInt 9054 (seals paid for) is RETIRED"); // Whether the two hooks are really on. This repo has been bitten twice by a patch // that registered, logged nothing and never fired (Healing's prefix on // UpdateVitalDelta, CombatWindow's on CalculateDamage - both inlined away), so they // are asserted rather than assumed. Reply(session, $" read hook : Book.ActOnUse prefix {Hooked(AccessTools.Method(typeof(Book), nameof(Book.ActOnUse)))}"); Reply(session, $" seal hook : RecipeManager.UseObjectOnTarget prefix " + $"{Hooked(AccessTools.Method(typeof(RecipeManager), nameof(RecipeManager.UseObjectOnTarget)))}"); var online = PlayerManager.GetAllOnline().Where(p => Resolve.Of(p) > 0).ToList(); Reply(session, online.Count == 0 ? " nobody online has a Resolve above 0" : " online with Resolve: " + string.Join(", ", online.Select(p => $"{p.Name} {Resolve.Of(p)}"))); var stale = PlayerManager.GetAllOnline().Where(p => p.GetProperty(Resolve.RetiredPaidProperty) is not null).ToList(); if (stale.Count > 0) Reply(session, " still carrying the retired 9054 (cleared the next time they use a seal): " + string.Join(", ", stale.Select(p => p.Name))); } /// A count or a price, or the word for "it saturated". private static string Money(long amount, long sentinel) => amount >= sentinel ? "SATURATED" : amount.ToString("N0"); /// Whether a property ACE's vendor validation insists on is present. private static string Req(object? value) => value is null ? "MISSING (vendor disabled!)" : value.ToString()!; private static string Hooked(MethodBase? method) { try { if (method is null) return "METHOD NOT FOUND"; var prefixes = Harmony.GetPatchInfo(method)?.Prefixes; return prefixes?.Any(p => p.owner == Mod.HarmonyId) == true ? "APPLIED" : "NOT APPLIED"; } catch (Exception ex) { return $"could not be read ({ex.GetType().Name})"; } } [CommandHandler("resolve-reload", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Restart the Aeshnidae.Awakens mod, re-reading Settings.json - the same restart as awaken-reload.", "/resolve-reload")] public static void HandleResolveReload(Session session, params string[] parameters) => HandleReload(session, parameters); 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.Network.EnqueueSend(new GameMessageSystemChat(text, ChatMessageType.Broadcast)); } } }