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