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