// The page helpers (GetPageCount/AddPage/RemovePage, each taking the biota's lock) are // extension methods in ACE.Entity.Models, not members of the list. Not in GlobalUsings // because that namespace also defines a Biota, a Weenie and a Spell that collide with the // aliases there; this file needs none of those. using ACE.Entity.Models; namespace Aeshnidae.Awakens; /// /// The Resolve scroll: a readout, and the parchment a seal is pressed onto. /// /// Using it prints what is applied and what it is paying - the Resolve in force, what that /// sets, the price of the next seal and of an eraser - and rewrites its own page with the /// same lines, so a player who keeps the window open is reading the truth of the moment /// they opened it. Using a SEAL on it is how a step is bought into the character; that is /// . /// /// THE SCROLL IS NOT THE SOURCE OF TRUTH. The truth is PropertyInt 9053 on the character /// (). The scroll holds no state of its own, nothing reads it, /// and losing it costs nothing but a walk back to a seller - the seals you already applied /// are on the character, and the ones you have not applied still work on the new scroll. It /// is attuned and bonded all the same: an item that reports your progression should not be /// droppable on death or sellable to a vendor by accident. /// /// Why a book and not a bare chat response: the retail client opens its book window when it /// uses a book it holds and the server answers - a path retail exercised on every lore tome, /// so it needs no unverified assumption about the client. The same lines go to chat in the /// same breath, so the feature works even if the window does not (direction 1: everything a /// player can do must work on the retail client, and every panel has a command underneath). /// /// The handout is the KEEPER's job (/resolve scroll, standing at her). The SELLING is /// not this mod's job at all any more: since the evening of 2026-10-13 Ismene Vale is a plain /// ACE vendor, so seals and erasers are bought through her shop window at a flat price set in /// the world database, and /resolve buy only points at her. The seller is a setting, and /// it has already moved once: the Town Wardens sold seals for the first few hours of /// 2026-10-13, as an interim, because they already stood in twenty towns and already took /// pyreals; since the Challenge Hall was built the same day it is Ismene Vale, the Keeper of /// Seals, standing where the challenge runs start. The wcids come from settings rather than /// from Aeshnidae.BuffBot, because mods share numbers and never types, and because moving the /// scroll writer costs one setting and a reload - which is exactly what that move cost. /// public static class Scroll { /// An author id that is no character's: the scroll is written by the server. private const uint ServerAuthor = 0xFFFFFFFE; public static bool IsScroll(WorldObject? item) => item is not null && Mod.Settings.ResolveScrollWcid != 0 && item.WeenieClassId == Mod.Settings.ResolveScrollWcid; public static Book? Find(Player player) { try { return player.GetInventoryItemsOfWCID(Mod.Settings.ResolveScrollWcid).FirstOrDefault() as Book; } catch { return null; } } /// /// The seller nearest the player, within ResolveSellerRange, or null. /// /// Lifted from Aeshnidae.BuffBot's Wardens.Nearest - the same walk over the landblock's /// objects and the same straight-line distance. Copied rather than called for the usual /// reason (separate assembly contexts). /// public static WorldObject? NearestSeller(Player player) { var block = player.CurrentLandblock; if (block is null || player.Location is null) return null; var wcids = Mod.Settings.ResolveSellerWcids ?? new List(); if (wcids.Count == 0) return null; WorldObject? best = null; var bestDistance = Math.Max(0.5, Mod.Settings.ResolveSellerRange); foreach (var wo in block.GetAllWorldObjectsForDiagnostics()) { if (wo.IsDestroyed || wo.Location is null || !wcids.Contains(wo.WeenieClassId)) continue; var distance = Math.Sqrt(Math.Pow(wo.Location.PositionX - player.Location.PositionX, 2) + Math.Pow(wo.Location.PositionY - player.Location.PositionY, 2) + Math.Pow(wo.Location.PositionZ - player.Location.PositionZ, 2)); if (distance <= bestDistance) { bestDistance = distance; best = wo; } } return best; } /// The Keeper a player is standing at, or the refusal line. Only /resolve scroll /// asks now - buying is her own vendor window and ACE's UseRadius decides that. public static (WorldObject? Seller, string? Refusal) SellerFor(Player player) { if (!Mod.Settings.ResolveSellerRequired) return (null, null); var seller = NearestSeller(player); return seller is null ? (null, $"{Mod.Settings.ResolveSellerDescription} handles this. Stand at the seller and ask again.") : (seller, null); } /// /// A seller hands the player a scroll, refusing nothing but a full pack and a second /// copy. Returns the line the player should see. /// public static string Hand(Player player) { var settings = Mod.Settings; if (!settings.Enabled || !settings.ResolveEnabled) return "Resolve is turned off on this server."; if (settings.ResolveScrollWcid == 0) return "There is no scroll on this server yet."; if (Find(player) is not null) return "You already carry one. Use it to read it, or /resolve for the same lines in chat."; var (seller, refusal) = SellerFor(player); if (refusal is not null) return refusal; try { if (WorldObjectFactory.CreateNewWorldObject(settings.ResolveScrollWcid) is not Book scroll) { ModManager.Log($"[{Mod.Name}] wcid {settings.ResolveScrollWcid} is not a book; no Resolve scroll for {player.Name}", ModManager.LogLevel.Warn); return "The scroll could not be written. Tell a staff member."; } Stamp(scroll, seller?.Name ?? "the seller"); Rewrite(player, scroll); if (!player.TryCreateInInventoryWithNetworking(scroll)) { scroll.Destroy(); return "Your pack is full; the scroll needs one slot."; } ModManager.Log($"[{Mod.Name}] {seller?.Name ?? "a seller"} gave {player.Name} a Resolve scroll (Resolve {Resolve.Of(player)})"); return $"{seller?.Name ?? "The Keeper"} writes out your Resolve and hands you the scroll. Use it to read it, " + "and press your seals onto it. It is a readout only, and another is free if you lose it."; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not give {player.Name} a Resolve scroll: {ex.Message}", ModManager.LogLevel.Error); return "The scroll could not be written. Tell a staff member."; } } /// /// The marks a minted scroll carries. Set here as well as in the weenie so a scroll is /// correct even if the row is ever edited. /// public static void Stamp(Book scroll, string scribe) { scroll.Attuned = AttunedStatus.Attuned; scroll.Bonded = BondedStatus.Bonded; scroll.SetProperty(PropertyBool.Retained, true); scroll.ScribeName = scribe; scroll.ScribeIID = ServerAuthor; scroll.IgnoreAuthor = false; // Pre-stamped with Aeshnidae.AwakenedRewards' own attuned-item marker (9042), so that // mod's first-ownership scaling returns early rather than renaming the scroll // " (Awakened N)" for an awakened character. A number shared between mods, never a type. scroll.SetProperty((PropertyInt)9042, 0); } /// /// Replaces the scroll's pages with a fresh reading. Pages are written with an author /// id that is nobody's and IgnoreAuthor false, so the client's writable-book editing /// declines for players - and the next reading erases anything staff scribbled. /// public static void Rewrite(IPlayer player, Book scroll) { try { var maxPages = Math.Max(1, scroll.Biota.PropertiesBook?.MaxNumPages ?? 1); var maxChars = Math.Max(200, scroll.Biota.PropertiesBook?.MaxNumCharsPerPage ?? 1000); var pages = Paginate(Resolve.Readout(player, player as Player), maxPages, maxChars); var data = scroll.Biota.PropertiesBookPageData; while (data.GetPageCount(scroll.BiotaDatabaseLock) > 0) data.RemovePage(0, scroll.BiotaDatabaseLock); foreach (var text in pages) data.AddPage(new PropertiesBookPageData { AuthorId = ServerAuthor, AuthorName = "Aelrynth", AuthorAccount = "", IgnoreAuthor = false, PageText = text, }, out _, scroll.BiotaDatabaseLock); scroll.SetProperty(PropertyInt.AppraisalPages, pages.Count); scroll.ChangesDetected = true; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not rewrite {player?.Name}'s Resolve scroll: {ex.Message}", ModManager.LogLevel.Warn); } } /// /// The readout laid out into whole lines per page, never splitting a line and never /// exceeding the page's own character limit (retail's parchment is 1,000, and the client's /// book window was built for that number - nothing here asks it for more). Lines that do /// not fit in the pages there are are dropped rather than wrapped off the end; the chat /// copy always has all of them. /// private static List Paginate(List lines, int maxPages, int maxChars) { var pages = new List(); var page = new StringBuilder(); foreach (var line in lines) { var piece = line.Length > maxChars ? line[..(maxChars - 3)] + "..." : line; if (page.Length > 0 && page.Length + piece.Length + 1 > maxChars) { pages.Add(page.ToString()); page.Clear(); if (pages.Count >= maxPages) return pages; } if (page.Length > 0) page.Append('\n'); page.Append(piece); } if (page.Length > 0 && pages.Count < maxPages) pages.Add(page.ToString()); if (pages.Count == 0) pages.Add("Nothing to report."); return pages; } } /// /// Two patches: reading the scroll, and pressing something onto it. /// /// Both are PREFIXES on methods with real bodies that the JIT cannot inline away - the trap /// Mods\README.md and CLAUDE.md both record, which killed Healing's prefix on /// UpdateVitalDelta and CombatWindow's on CalculateDamage. /// /// Book.ActOnUse - the hook Aeshnidae.Codex takes, reached only by books. The pages are /// rewritten and the lines printed, then ACE sends the book exactly as it sends any other, /// so if this mod is ever unloaded the scroll is simply a blank parchment. /// /// RecipeManager.UseObjectOnTarget - where ACE's whole use-item-on-item grammar ends up /// (Player.HandleActionUseWithTarget -> WorldObject.HandleActionUseOnTarget -> here). The /// client has already done its own TargetType compatibility check and any walk-to, so by /// this point the player is at the target holding the source. Returning false stops ACE /// looking the pair up in cook_book and telling the player it "cannot be used on" it. /// Seals.cs explains at length why this is a prefix and not a real recipe. /// [HarmonyPatch] public static class ScrollPatches { [HarmonyPrefix] [HarmonyPatch(typeof(Book), nameof(Book.ActOnUse))] public static void PreActOnUse(Book __instance, WorldObject worldObject) { try { if (worldObject is not Player player || !Scroll.IsScroll(__instance)) return; Scroll.Rewrite(player, __instance); foreach (var line in Resolve.Readout(player, player)) player.Session.Network.EnqueueSend(new GameMessageSystemChat(line, ChatMessageType.Broadcast)); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] Resolve scroll read failed: {ex.Message}", ModManager.LogLevel.Warn); } } /// /// A seal or an eraser used on the Resolve scroll. Anything else falls through to ACE /// untouched, including a seal used on the wrong thing - which ACE refuses with its own /// "cannot be used on" line, and that is the right answer. /// [HarmonyPrefix] [HarmonyPatch(typeof(RecipeManager), nameof(RecipeManager.UseObjectOnTarget))] public static bool PreUseObjectOnTarget(Player player, WorldObject source, WorldObject target, bool confirmed) { try { if (player is null || source is null || target is null) return true; var kind = Seals.KindOfSeal(source.WeenieClassId); var eraser = false; if (kind is null) { kind = Seals.KindOfEraser(source.WeenieClassId); eraser = kind is not null; } if (kind is null) return true; // not ours; ACE's recipes carry on // Ours, so from here every exit is ours too: the player gets a reason and the // use-done event, and ACE never looks the pair up in cook_book. if (!Scroll.IsScroll(target)) { player.Session.Network.EnqueueSend(new GameMessageSystemChat( $"The {source.Name} only works on a Scroll of Resolve. {Mod.Settings.ResolveSellerDescription} will write you one, free - /resolve scroll, standing at her.", ChatMessageType.Craft)); player.SendUseDoneEvent(); return false; } var line = Seals.Use(player, source, kind, eraser); player.Session.Network.EnqueueSend(new GameMessageSystemChat(line, ChatMessageType.Craft)); // The parchment now says something different, so rewrite it while it is in hand. if (target is Book scroll) Scroll.Rewrite(player, scroll); player.SendUseDoneEvent(); return false; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] seal use failed for {player?.Name}: {ex.Message}", ModManager.LogLevel.Error); try { player?.Session?.Network?.EnqueueSend(new GameMessageSystemChat( "Something went wrong with that seal. Nothing was changed; tell a staff member.", ChatMessageType.Craft)); player?.SendUseDoneEvent(); } catch { } return false; } } }