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...";
}
}