using ACE.Entity.Models; namespace Aeshnidae.Codex; /// /// The book itself: one per character, found or made on demand, and its pages /// rebuilt from the providers every time it is opened. /// /// Why a real book and not a bare BookDataResponse: the 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. /codex asks ACE to send the /// same answer for the same book (Player.ReadBook), which is the one thing here that /// has not been proven in a client; if it turns out not to open the window, the book /// in the pack still does, and the command is a shortcut rather than the door. /// /// Why rebuild rather than update: the pages are a rendering of live state (balances, /// ranks, requirements) and cost nothing to regenerate; keeping them current would /// mean every provider notifying this mod of every change. Pages are written with /// IgnoreAuthor false and an author id that is nobody's, so the client's writable-book /// editing declines for players (Book.ModifyPage / DeletePage) - staff can scribble, /// and the next opening erases it. /// internal static class Codex { /// An author id that is no character's: the book is written by the server. private const uint ServerAuthor = 0xFFFFFFFE; public static bool IsCodex(WorldObject? item) => item is Book && item.WeenieClassId == Mod.Settings.BookWcid && string.Equals(item.Name, Mod.Settings.Name, StringComparison.Ordinal); public static Book? Find(Player player) { try { return player.GetInventoryItemsOfWCID(Mod.Settings.BookWcid).FirstOrDefault(IsCodex) as Book; } catch { return null; } } /// The character's Codex, made if they have none. Null if it could not be made. public static Book? Ensure(Player player, bool announce) { var existing = Find(player); if (existing is not null) return existing; if (!Mod.Settings.GrantOnLogin && !announce) return null; try { if (WorldObjectFactory.CreateNewWorldObject(Mod.Settings.BookWcid) is not Book book) { ModManager.Log($"[{Mod.Name}] wcid {Mod.Settings.BookWcid} is not a book; no Codex for {player.Name}", ModManager.LogLevel.Warn); return null; } book.Name = Mod.Settings.Name; book.ScribeName = "Aelrynth"; book.ScribeIID = ServerAuthor; book.IgnoreAuthor = false; book.Inscription = Mod.Settings.Inscription; // Cannot be dropped, traded, sold or lost on death. It is a readout, not an item. book.Attuned = AttunedStatus.Attuned; book.Bonded = BondedStatus.Bonded; book.SetProperty(PropertyBool.Retained, true); if (Mod.Settings.IconOverride > 0) book.IconId = Mod.Settings.IconOverride; if (!player.TryCreateInInventoryWithNetworking(book)) { book.Destroy(); if (announce) player.SendMessage("Your pack is full; the Codex needs one slot."); return null; } if (announce) player.SendMessage($"{Mod.Settings.Name} added to your pack. Open it, or type /codex."); return book; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not give {player.Name} a Codex: {ex.Message}", ModManager.LogLevel.Error); return null; } } /// /// Replaces every page with a fresh rendering. names the /// section to put first (matched on any prefix of its title); the rest follow in /// settings order. Page one is always the contents. /// public static void Rebuild(Player player, Book book, string? first = null) { var sections = Providers.Render(player); if (!string.IsNullOrWhiteSpace(first)) { var index = sections.FindIndex(s => s.Title.StartsWith(first, StringComparison.OrdinalIgnoreCase)); if (index > 0) { var chosen = sections[index]; sections.RemoveAt(index); sections.Insert(0, chosen); } } var pages = new List(); var maxPages = Math.Max(2, book.Biota.PropertiesBook.MaxNumPages); var maxChars = Math.Max(200, Math.Min(Mod.Settings.MaxCharsPerPage, book.Biota.PropertiesBook.MaxNumCharsPerPage)); // Contents first - its page numbers depend on the sections that follow, so it // is laid out after them and inserted at the front. var toc = new StringBuilder(); toc.AppendLine(Mod.Settings.Name.ToUpperInvariant()); toc.AppendLine(); toc.AppendLine($"For {player.Name}. Rebuilt each time it is opened."); toc.AppendLine(); var pageNumber = 2; foreach (var section in sections) { toc.AppendLine($"{pageNumber}. {section.Title}"); foreach (var page in section.Pages) { pages.Add(Fit(page, maxChars)); pageNumber++; if (pages.Count >= maxPages - 1) break; } if (pages.Count >= maxPages - 1) break; } if (sections.Count == 0) toc.AppendLine("Nothing to show - no section is loaded."); toc.AppendLine(); toc.AppendLine("/codex
opens at that section."); pages.Insert(0, Fit(toc.ToString().TrimEnd(), maxChars)); try { var data = book.Biota.PropertiesBookPageData; while (data.GetPageCount(book.BiotaDatabaseLock) > 0) data.RemovePage(0, book.BiotaDatabaseLock); foreach (var text in pages) { data.AddPage(new ACE.Entity.Models.PropertiesBookPageData { AuthorId = ServerAuthor, AuthorName = "Aelrynth", AuthorAccount = "", IgnoreAuthor = false, PageText = text, }, out _, book.BiotaDatabaseLock); } book.SetProperty(PropertyInt.AppraisalPages, pages.Count); book.ChangesDetected = true; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not rebuild {player.Name}'s Codex: {ex.Message}", ModManager.LogLevel.Error); } } /// Opens the book for the player: rebuild, then ask ACE to send it. public static void Open(Player player, string? section) { var book = Ensure(player, announce: true); if (book is null) return; Rebuild(player, book, section); player.ReadBook(book.Guid.Full); } private static string Fit(string text, int maxChars) { text = (text ?? "").Replace("\r\n", "\n").TrimEnd(); if (text.Length <= maxChars) return text; return text[..(maxChars - 4)].TrimEnd() + "\n..."; } }