// The Weenie extension methods (GetValue, GetProperty, GetPluralName) are extension methods // 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; /// /// Seals and erasers: how a step of Resolve is bought, applied and taken back. /// /// The grammar, end to end: /// /// 1. **Buy** Seals of Resolve from Ismene Vale, Keeper of Seals, who is a plain ACE /// vendor: use her, her shop window opens, you buy as many as you like at a FLAT /// price. No command, no quote, no confirmation - the money and the window are ACE's. /// 2. **Press one onto the scroll** - ACE's own use-item-on-item action, the one the client /// already has. The seals THE NEXT STEP NEEDS burn away together and the character's /// Resolve goes up by one, at once. A short stack is refused by name and nothing is /// taken. /// 3. **An eraser** is bought and used the same way, takes one step back at once, and /// returns NOTHING - see . /// /// A seal is a TRANSIENT PURCHASE TOKEN and never the progression. Lose unused seals and you /// have lost a purchase, never a step: the step only ever lives in the character property. /// Lose the scroll and you have lost nothing at all - the Keeper writes another, free, and the /// seals still work on it. /// /// WHERE THE ESCALATION WENT (2026-10-13, evening) /// ----------------------------------------------- /// It used to be the PRICE: seal k cost 2,000,000 x 1.5^(k-1), quoted by a command, and /// PropertyInt 9054 counted how many had been paid for so the quote could not be dodged by /// buying ten before applying any. /// /// Ismene is a vendor now and a vendor's price is one fixed number per item, so the /// escalation moved to HOW MANY SEALS A STEP TAKES. It is deliberately the SAME CURVE: /// /// old: step k costs 2,000,000 x 1.5^(k-1) pyreals for one seal /// new: step k takes 1 x 1.5^(k-1) seals at 2,000,000 pyreals each /// /// which is the same money, because the base price did not change. The total to reach any /// Resolve is therefore what it always was, to within the rounding of a seal count to a whole /// seal and of the old price to 100,000: /// /// Resolve | seals (cum.) | new total | old total | difference /// 1 | 1 | 2,000,000 | 2,000,000 | 0.00% /// 5 | 13 | 26,000,000 | 26,400,000 | -1.52% /// 10 | 113 | 226,000,000 | 226,800,000 | -0.35% /// 20 | 6,649 | 13,298,000,000 | 13,297,200,000 | +0.01% /// /// And 9054 lost its only job: a flat price cannot be dodged by buying early, because buying /// early costs exactly the same. It is retired - see . /// /// Two walls now instead of one, and that is a feature worth knowing: the money /// (step 20 is 13 billion pyreals of seals in all) and the PACK, because the seals a deep /// step needs have to be carried. A stack is 250, so step 15's 292 seals are two slots and /// step 20's 2,217 are nine; somewhere past step 27 no pack can hold the step at all. The /// arithmetic saturates and refuses long before it could wrap - . /// /// WHY THE SEALS ARE ATTUNED /// ------------------------- /// An untradeable seal cannot be bought cheaply on a fresh character and handed to one deep up /// the ladder. That mattered more when the price escalated; it still matters, because the /// ladder is per character and a shared pool of seals would let two characters fund one /// climb. They are stackable, so a purchase costs one pack slot per 250, and a stack is also /// what keeps Aeshnidae.AwakenedRewards' attuned-item scaling off them (it skips anything /// whose MaxStackSize is above 1). They are also IsSellable FALSE in the weenie, so no vendor /// anywhere buys a 2,000,000-value token back off a player. /// /// WHY A HARMONY PREFIX AND NOT A RECIPE (decided 2026-10-13) /// ---------------------------------------------------------- /// ACE's source-on-target grammar ends in `RecipeManager.UseObjectOnTarget`, which looks the /// pair up in the `cook_book` table and runs a `recipe`. A real recipe/cook_book pair was /// the obvious route and was rejected: /// /// - A recipe's mods operate on the SOURCE and TARGET ITEMS. What has to change here is a /// mod-owned PropertyInt on the PLAYER, with our own refusals (at the maximum, nothing /// to erase, a short stack, the wrong scroll), our own log line and our own message. That /// is not expressible in recipe rows - and a recipe certainly cannot take a VARIABLE /// number of sources off a stack, which is now the whole mechanism. /// - The recipe engine brings a skill check, a difficulty roll, success and failure /// messages and crafting motions. A seal is not a craft; it either applies or it is /// refused, and a roll that could FAIL would destroy a purchase. /// - A cook_book row would hard-code the seal wcid against the scroll wcid in the world /// database, so the pairing would exist in two places - the row and the settings - and /// this repo has been bitten by exactly that kind of drift. /// - The house rule is that every customisation is a Harmony mod (CLAUDE.md, "the one /// rule"), and `UseObjectOnTarget` is a large method with a real body that the JIT /// cannot inline away - the trap that killed Healing's and CombatWindow's patches. /// /// So the client still does all of ACE's work - the double-click-on-target action, the /// TargetType compatibility check, the walk-to - and the prefix replaces only what happens /// at the end, before ACE can say "cannot be used on". /// public static class Seals { /// /// A seal count no pack could hold, used as the saturating value for "beyond arithmetic". /// Not int.MaxValue, so nothing built out of it (a cumulative total, say) can wrap either. /// /// A compounding curve leaves the range of an int quickly, and an unchecked cast of a /// double that large wraps NEGATIVE - a negative seal count would pass every "do you have /// enough" check and then consume nothing. Aeshnidae.SkillMastery minted 7.19e18 Radiance /// that way on 2026-09-22. Here the saturated step is simply refused by name: nobody has /// to be able to take the next step, so the curve is allowed to end. /// public const int Uncountable = int.MaxValue / 4; /// Every kind configured, never null. public static List Kinds => Mod.Settings.Seals ?? new List(); /// The kind a player means by name, or the only one when they name nothing. public static Settings.SealKind? KindNamed(string? key) { var kinds = Kinds; if (string.IsNullOrWhiteSpace(key)) return kinds.Count == 1 ? kinds[0] : null; return kinds.FirstOrDefault(k => k.Key.Equals(key, StringComparison.OrdinalIgnoreCase)) ?? kinds.FirstOrDefault(k => k.Label.Equals(key, StringComparison.OrdinalIgnoreCase)); } /// The kind whose seal this wcid is, or null. public static Settings.SealKind? KindOfSeal(uint wcid) => wcid == 0 ? null : Kinds.FirstOrDefault(k => k.SealWcid == wcid); /// The kind whose eraser this wcid is, or null. public static Settings.SealKind? KindOfEraser(uint wcid) => wcid == 0 ? null : Kinds.FirstOrDefault(k => k.EraserWcid == wcid); // ---------------------------------------------------------------- the count /// Steps of this kind applied to the character - the progression itself. public static int AppliedOn(IPlayer? player, Settings.SealKind kind) => Math.Max(0, player?.GetProperty((PropertyInt)kind.PropertyId) ?? 0); private static void Write(IPlayer player, int propertyId, int value) { if (value <= 0) player.RemoveProperty((PropertyInt)propertyId); else player.SetProperty((PropertyInt)propertyId, value); } /// Seals of this kind in the pack, counted across every stack. public static int SealsCarried(Player player, Settings.SealKind kind) => kind.SealWcid == 0 ? 0 : player.GetNumInventoryItemsOfWCID(kind.SealWcid); /// Erasers of this kind in the pack, counted across every stack. public static int ErasersCarried(Player player, Settings.SealKind kind) => kind.EraserWcid == 0 ? 0 : player.GetNumInventoryItemsOfWCID(kind.EraserWcid); // ---------------------------------------------------------------- the curve /// /// How many seals the -th step of this kind takes - the step that /// raises Resolve from step-1 to step. The first step's count compounded by the growth /// factor once per step already taken, rounded to a whole seal and never below one. /// /// Saturates at rather than wrapping, and answers Uncountable /// for step 0 and below, which is not a step anyone can take - that keeps every caller on /// the one refusal path. /// public static int SealsForStep(Settings.SealKind kind, int step) { if (step <= 0) return Uncountable; var first = Math.Max(1, kind.SealsForFirstStep); var growth = kind.SealsPerStepGrowth; // Below 1.0 the curve would get CHEAPER with every step. Held at 1.0 (flat) rather // than honoured, so a typo in the settings cannot make a deep ladder free. if (double.IsNaN(growth) || growth < 1.0) growth = 1.0; var seals = first * Math.Pow(growth, step - 1); if (double.IsNaN(seals) || double.IsInfinity(seals) || seals >= Uncountable) return Uncountable; var rounded = (long)Math.Round(seals); return rounded < 1 ? 1 : rounded >= Uncountable ? Uncountable : (int)rounded; } /// /// The first step of this kind whose seal count has saturated - where the curve stops /// being a number. Walked rather than solved, so it is always the answer the settings in /// force actually give. 0 means no step within saturates. /// public static int SaturatesAt(Settings.SealKind kind, int searchTo = 4096) { for (var step = 1; step <= searchTo; step++) if (SealsForStep(kind, step) >= Uncountable) return step; return 0; } /// /// What the -th step costs in pyreals: its seals at the flat seal /// price. Informational - the vendor charges the price in the world database, and this is /// the mod's copy of it (see ). Saturates at /// rather than wrapping. /// public static long PyrealsForStep(Settings.SealKind kind, int step) { var seals = SealsForStep(kind, step); var each = kind.SealPyreals; if (seals >= Uncountable || each <= 0 || seals > Resolve.Unaffordable / each) return Resolve.Unaffordable; return seals * each; } /// /// What reaching from zero costs in pyreals, every step added /// up. Saturating, because the total has exactly the same overflow problem as the term. /// public static long PyrealsToReach(Settings.SealKind kind, int resolve) { long total = 0; for (var step = 1; step <= resolve; step++) { var each = PyrealsForStep(kind, step); if (each >= Resolve.Unaffordable || total > Resolve.Unaffordable - each) return Resolve.Unaffordable; total += each; } return total; } /// /// How many seals reaching from zero takes in all. Saturating. /// public static long SealsToReach(Settings.SealKind kind, int resolve) { long total = 0; for (var step = 1; step <= resolve; step++) { var each = SealsForStep(kind, step); if (each >= Uncountable || total > Uncountable - each) return Uncountable; total += each; } return total; } // ---------------------------------------------------------------- the apply guard /// /// What the next step would take, and why it would be refused. Refusal null = go ahead. /// /// Takes the applied count and the seals carried as NUMBERS rather than a character, /// because that is all the guard is: pure arithmetic. It can therefore be asked about any /// combination from the console (`resolve-status apply`) without inventing a player, which /// is the only way the refusals can be tested on a server nobody can log in to. /// public static (int Needed, string? Refusal) NextStep(int applied, Settings.SealKind kind, int carried) { var settings = Mod.Settings; applied = Math.Max(0, applied); carried = Math.Max(0, carried); if (!settings.Enabled || !settings.ResolveEnabled) return (0, "Resolve is turned off on this server."); if (kind.SealWcid == 0) return (0, $"There is no seal for {kind.Label} on this server."); if (kind.Max > 0 && applied >= kind.Max) return (0, $"Your {kind.Label} is {applied}, which is the highest this server allows. The seal is untouched."); var step = applied + 1; var needed = SealsForStep(kind, step); if (needed >= Uncountable) return (0, $"{kind.Label} {step} would take more seals than can be counted. " + $"{applied} is as far as the ladder goes. The seal is untouched."); if (carried < needed) return (needed, $"{kind.Label} {step} needs {needed:N0} seal{(needed == 1 ? "" : "s")}; you have {carried:N0}. " + $"Buy {needed - carried:N0} more from {Lower(settings.ResolveSellerDescription)} and press one onto the scroll again. " + "Nothing was taken."); return (needed, null); } /// /// Whether an eraser would take a step back, and why it would be refused. One eraser is /// one step; there is no curve on the eraser side and nothing comes back. /// public static (int Needed, string? Refusal) NextErase(int applied, Settings.SealKind kind) { var settings = Mod.Settings; applied = Math.Max(0, applied); if (!settings.Enabled || !settings.ResolveEnabled) return (0, "Resolve is turned off on this server."); if (kind.EraserWcid == 0) return (0, $"There is no eraser for {kind.Label} on this server."); if (applied <= 0) return (0, $"Your {kind.Label} is 0. There is nothing to erase, and your eraser is untouched."); return (1, null); } // ---------------------------------------------------------------- using one on the scroll /// /// A seal used on the scroll: the seals the next step needs come off the stacks and the /// character's count goes up by one. An eraser: one comes off and the count goes down by /// one, with nothing returned. Both at once, both final. /// /// Returns the line the player should see. The seals are counted BEFORE anything is /// consumed and the step is refused outright if the stack is short, so a part-charge is /// not reachable; the consume is then checked by re-counting, because ACE's consume-by-wcid /// answers true even when it runs out of stacks to take from. /// public static string Use(Player player, WorldObject token, Settings.SealKind kind, bool eraser) { var applied = AppliedOn(player, kind); var (needed, refusal) = eraser ? NextErase(applied, kind) : NextStep(applied, kind, SealsCarried(player, kind)); if (refusal is not null) return refusal; var wcid = eraser ? kind.EraserWcid : kind.SealWcid; var before = eraser ? ErasersCarried(player, kind) : SealsCarried(player, kind); if (!player.TryConsumeFromInventoryWithNetworking(wcid, needed)) return $"Your {token.Name} would not come off the stack. Nothing happened."; var after = eraser ? ErasersCarried(player, kind) : SealsCarried(player, kind); var taken = before - after; if (taken < needed) { // Cannot happen: the count above and the consume below walk the same inventory. // If it ever does, the step is NOT granted and the player is told plainly, because // granting it would be worse than refusing it. ModManager.Log($"[{Mod.Name}] {player.Name}: wanted {needed} of wcid {wcid} for {kind.Label} {applied + 1}, " + $"only {taken} came off ({before} -> {after}); step NOT applied", ModManager.LogLevel.Error); return $"Only {taken:N0} of the {needed:N0} {(eraser ? "erasers" : "seals")} came off the stack, so nothing was changed. " + "Tell a staff member."; } var now = eraser ? Math.Max(0, applied - 1) : applied + 1; Write(player, kind.PropertyId, now); Resolve.RetirePaidCount(player); player.SavePlayerToDatabase(); ModManager.Log($"[{Mod.Name}] {player.Name} used {(eraser ? "an Eraser" : $"{needed} Seal(s)")} of {kind.Label}: " + $"{applied} -> {now} ({after:N0} {(eraser ? "eraser" : "seal")}(s) left)"); if (eraser) return $"The {token.Name} lifts a seal off the parchment. Your {kind.Label} is {now}. " + "Nothing was returned: the eraser's price was the cost of changing your mind, not a refund." + (now > 0 ? $" Another eraser takes you to {now - 1}." : " There is nothing left to erase."); var next = SealsForStep(kind, now + 1); return $"{needed:N0} Seal{(needed == 1 ? "" : "s")} of {kind.Label} burn{(needed == 1 ? "s" : "")} away into the parchment. " + $"Your {kind.Label} is {now}." + (kind.Max > 0 && now >= kind.Max ? $" {kind.Max} is the highest this server allows." : next >= Uncountable ? " There is no step after this one: the next would take more seals than can be counted." : $" {kind.Label} {now + 1} needs {next:N0} seal{(next == 1 ? "" : "s")}; you are carrying {after:N0}."); } // ---------------------------------------------------------------- what the shop charges /// /// What a vendor actually charges for a wcid, read out of the world database: the number /// of currency units, what that currency is called, and what it comes to in pyreals. /// /// false means the question could not be answered from the /// database - the seller is not a vendor, the weenie is not cached, or there is no seller /// configured - and the caller should fall back to the settings copy of the price. /// public record ShelfPrice(bool Found, int Units, string Currency, long Pyreals, string? Note); /// The seller's weenie, if it is one and it is in the world cache. public static Weenie? SellerWeenie() { foreach (var wcid in Mod.Settings.ResolveSellerWcids ?? new List()) { try { var weenie = DatabaseManager.World.GetCachedWeenie(wcid); if (weenie is not null) return weenie; } catch { } } return null; } /// /// What the seller charges for , by ACE's own arithmetic: /// Vendor.GetSellCost is max(1, ceil(SellPrice x Value - 0.1)), in units of /// AlternateCurrency when the vendor has one and pyreal coin when it does not. /// /// This exists so the readout can quote the price the shop will really charge rather than /// a number in a settings file that nobody updated. `resolve-status vendor` prints both. /// public static ShelfPrice PriceAt(uint itemWcid) { try { if (itemWcid == 0) return new ShelfPrice(false, 0, "", 0, "no wcid"); var seller = SellerWeenie(); if (seller is null) return new ShelfPrice(false, 0, "", 0, "no seller weenie in the world cache"); if (seller.WeenieType != WeenieType.Vendor) return new ShelfPrice(false, 0, "", 0, $"{seller.GetName()} is a {seller.WeenieType}, not a Vendor"); var item = DatabaseManager.World.GetCachedWeenie(itemWcid); if (item is null) return new ShelfPrice(false, 0, "", 0, $"wcid {itemWcid} is not in the world cache"); var sellRate = seller.GetProperty(PropertyFloat.SellPrice) ?? 1.0; var value = item.GetValue() ?? 0; var units = (int)Math.Max(1, (uint)Math.Ceiling(sellRate * value - 0.1)); var altWcid = seller.GetProperty(PropertyDataId.AlternateCurrency); if (altWcid is null or 0) return new ShelfPrice(true, units, "pyreals", units, null); var currency = DatabaseManager.World.GetCachedWeenie(altWcid.Value); if (currency is null) return new ShelfPrice(false, units, "", 0, $"alternate currency wcid {altWcid} is not in the world cache"); var unit = Math.Max(1, currency.GetValue() ?? 1); var name = units == 1 ? currency.GetName() : currency.GetPluralName(); return new ShelfPrice(true, units, name, (long)units * unit, null); } catch (Exception ex) { return new ShelfPrice(false, 0, "", 0, $"{ex.GetType().Name}: {ex.Message}"); } } /// /// One phrase for what a seal (or an eraser) costs, for the readout. The world database /// first; the settings copy only when the database cannot be read, and then it says so. /// public static string PriceLine(uint itemWcid, long settingsPyreals) { var shelf = PriceAt(itemWcid); if (!shelf.Found) return $"{settingsPyreals:N0} pyreals (the shop's own price could not be read)"; return shelf.Currency == "pyreals" ? $"{shelf.Pyreals:N0} pyreals" : $"{shelf.Units:N0} {shelf.Currency} - {shelf.Pyreals:N0} pyreals"; } // ---------------------------------------------------------------- the readout /// The lines for one kind, for /resolve and for the scroll. public static IEnumerable Lines(Player? player, IPlayer reader, Settings.SealKind kind) { var settings = Mod.Settings; var applied = AppliedOn(reader, kind); var carried = player is null ? -1 : SealsCarried(player, kind); var erasers = player is null ? -1 : ErasersCarried(player, kind); yield return $"{kind.Label.ToUpperInvariant()} - {kind.What}. Yours is {applied}."; if (!settings.ResolveEnabled) yield return " Resolve is switched off on this server; nothing below is in force."; yield return $" applied now : {kind.Label} {applied}. Every challenge run you take is set to that danger, and pays accordingly."; yield return " in force : the challenge runs are not open yet. Until they are, this changes nothing you can walk into."; yield return $" your dungeons: unchanged by this. Your own copies of the awakened dungeons are tier {Resolve.AwakeningsOf(reader)}" + " - your awakenings, as they always were. Seals do not touch them."; var step = applied + 1; var needed = SealsForStep(kind, step); if (kind.Max > 0 && applied >= kind.Max) yield return $" next step : none - {kind.Max} is the highest {kind.Label} this server allows."; else if (needed >= Uncountable) yield return " next step : none - it would take more seals than can be counted."; else yield return $" next step : {kind.Label} {step} takes {needed:N0} Seal{(needed == 1 ? "" : "s")} of {kind.Label}" + $" - {PyrealsForStep(kind, step):N0} pyreals of seals in all." + (needed > 1 ? " Press one onto this scroll and the whole lot burns away together." : ""); if (carried >= 0 && !(kind.Max > 0 && applied >= kind.Max) && needed < Uncountable) yield return $" you carry : {carried:N0} seal{(carried == 1 ? "" : "s")}" + (carried >= needed ? " - enough. Press one onto this scroll." : $" - {needed - carried:N0} short of the next step."); yield return $" a seal costs : {PriceLine(kind.SealWcid, kind.SealPyreals)}, flat, from {Lower(settings.ResolveSellerDescription)}. " + "Use her and her shop window opens; buy as many as you like."; if (kind.EraserWcid != 0) yield return $" going back : an Eraser of {kind.Label}, {PriceLine(kind.EraserWcid, kind.EraserPyreals)}, bought from the same shop and " + "used on this scroll the same way. One eraser takes one step off at once, and RETURNS NO SEALS - " + "the eraser's price is the cost of changing your mind, not a refund for the step." + (erasers > 0 ? $" You carry {erasers:N0}." : ""); } /// The seller description reads as a sentence on its own; lower-case it mid-sentence. private static string Lower(string? description) { var text = (description ?? "the seller").Trim(); return text.Length > 1 && char.IsUpper(text[0]) && !char.IsUpper(text[1]) ? char.ToLowerInvariant(text[0]) + text[1..] : text; } }