namespace Aeshnidae.Awakens;
///
/// Every term of an awakening, in one file.
///
/// An awakening is the soft enlightenment (Tom, 2026-09-19): the character goes
/// back to level 1 and keeps everything else - skills, attributes, credits,
/// unassigned experience, society, luminance, auras, gear. Retail enlightenment is
/// untouched and runs beside it. What an awakening buys is a harder, richer copy of
/// every instanced dungeon: Aeshnidae.InstancesNoDat counts awakenings into its
/// tier, +5% difficulty and +5% rewards each - and one more level of cap, so the
/// climb back is a level longer each time and a character must top it to awaken again.
///
/// Since 2026-10-13 this mod also owns RESOLVE, the dial a character buys with pyreals
/// (PropertyInt 9053, Resolve.cs): the danger their own challenge runs are set to, bought as
/// seals and taken back by erasers. It is a separate ladder from the awakening count and does
/// not touch the awakened dungeons; it lives here because this mod already owns the
/// character's progression property. Its settings are in their own block at the bottom.
///
/// Later the same day the SELLING moved out of this mod entirely: Ismene Vale is a plain ACE
/// vendor now, so the shop window and the money are ACE's, the seals' price is a row in the
/// world database, and the escalation lives in how many seals a step takes. Nothing here
/// prices anything any more - see the Resolve block for which numbers are the truth and which
/// are this mod's copy of them.
///
public class Settings
{
public const string FileName = "Settings.json";
public bool Enabled { get; set; } = true;
///
/// Levels the character's cap rises by with each awakening. The cap starts at
/// retail (275): 276 after the first awakening, 277 after the second. A character
/// must be at its own cap to awaken.
///
public int LevelsPerAwakening { get; set; } = 1;
///
/// How many levels past retail the in-memory XP table is grown at startup - the
/// room for awakenings. A character whose cap would pass the table's end stops
/// at the table's end. 100 is a hundred awakenings at one level each.
///
public int LevelCapHeadroom { get; set; } = 100;
/// How many times a character may awaken. 0 is uncapped.
public int MaxAwakenings { get; set; } = 0;
///
/// The awakening also resets skills and attributes, exactly as retail enlightenment
/// does (Tom, 2026-09-19, evening): every skill untrained with its credits refunded,
/// skill credits back to the heritage base plus the quest credits, attribute and
/// vital ranks to zero, unassigned experience to zero, everything worn to the pack.
/// Kept: luminance, auras, society, titles, Radiance and mastery raises. Without
/// this the climb back produces experience with nothing retail to buy; with it the
/// climb is real, and the character goes through the tiers again to earn the next
/// one. Off is the original soft awakening: level to 1, everything else kept.
///
public bool ResetSkillsAndAttributes { get; set; } = true;
///
/// Items worn at the moment of awakening whose wield requirement is a LEVEL go to
/// the pack, because a level-1 character cannot wear them; everything else stays
/// on. Off keeps even those on, which the client will show but the server will
/// refuse to re-equip.
///
public bool DequipLevelGatedItems { get; set; } = true;
///
/// The climb back would hand out skill credits at every credit level a second
/// time, and the character kept every credit it had. On: an awakened character
/// earns no level credits. Off: they stack, which is not the design.
/// Only meaningful when ResetSkillsAndAttributes is off - the reset takes the
/// level credits away, so the climb has to pay them again and this is ignored.
///
public bool NoLevelCreditsAfterAwakening { get; set; } = true;
/// Seconds a /awaken confirm is good for after /awaken.
public double ConfirmSeconds { get; set; } = 60;
/// The burst and fanfare at the moment of awakening.
public bool Effects { get; set; } = true;
/// Tell the whole server who awakened, and for the how-manyeth time.
public bool BroadcastToServer { get; set; } = true;
// ------------------------------------------------------------------ Resolve and seals
//
// Resolve (2026-10-13) is the OTHER dial this mod owns: the danger a character sets
// their own challenge runs to. It is bought as SEALS - you buy Seals of Resolve from
// Ismene Vale, press one onto your Scroll of Resolve, and the seals the next step needs
// are consumed as the character's Resolve goes up by one. An ERASER, bought and used the
// same way, takes one step back and gives nothing back. Both take effect the instant they
// are used. Seal and eraser are transient purchase tokens; the progression is the
// character property and nothing else.
//
// WHERE THE PRICE LIVES, AND WHERE THE ESCALATION LIVES (changed 2026-10-13, evening)
// -----------------------------------------------------------------------------------
// Ismene is a REGULAR VENDOR now: you use her, her shop window opens, and you buy with
// money as you would from any vendor. There is no command, no quote and no confirmation.
// That moved two things:
//
// * The PRICE of one seal is FLAT, and it is a row in the WORLD DATABASE - the seal
// weenie's Value, read through the vendor's own AlternateCurrency and SellPrice. A
// vendor row cannot express a curve, so the price stopped being one.
// * The ESCALATION became HOW MANY SEALS A STEP TAKES. Raising Resolve from N-1 to N
// consumes SealsForStep(N) = SealsForFirstStep x SealsPerStepGrowth ^ (N-1), rounded
// to a whole seal. At 1 x 1.5 that is the SAME geometric curve the pyreal price used
// to be, only denominated in seals - so the total cost of reaching any Resolve is
// what it always was, to within rounding. Seals.cs has the table.
//
// SealPyreals and EraserPyreals below are this mod's COPY of the vendor's price, used
// only to write the readout. The world database is the truth. `resolve-status vendor`
// reads both and says so when they disagree, because a readout quoting a price the vendor
// does not charge is exactly the kind of drift this repo has been bitten by before.
//
// Every term of a seal lives in the Seals list below, so a SECOND KIND of seal is a
// settings entry and two weenies, not new code. Resolve.cs has the whole design.
///
/// The dial at all. Off: /resolve says so, no seal can be applied, no scroll is written,
/// and any Resolve already on a character sits inert in the biota - nothing is lost.
/// (It does NOT close the shop: Ismene is a world-database vendor and keeps selling until
/// her stock list is emptied by a ledger. Applying a seal is what this switch stops.)
///
public bool ResolveEnabled { get; set; } = true;
///
/// One kind of seal: what it raises, how many seals each step of it takes, and the two
/// weenies that carry it. A new kind (a denser roster, no recall, no vendor healing) is
/// one more entry here plus its two weenies in a ledger - see Resolve.cs.
///
public class SealKind
{
/// What a player and a command call it. One kind today, so nothing has to name it.
public string Key { get; set; } = "resolve";
/// The word the readout uses: "Resolve 7".
public string Label { get; set; } = "Resolve";
/// One line saying what this kind sets, for the readout.
public string What { get; set; } = "the danger your challenge runs are set to";
///
/// The PropertyInt on the character this kind counts into - the progression itself.
/// 9053 for Resolve, and it MUST STAY 9053: that id is the contract the
/// challenge-run system reads (Resolve.Property). A new kind takes the next free id
/// from the repo's allocation, never an invented one.
///
public int PropertyId { get; set; } = 9053;
/// The seal's weenie: 3000138 `aelrynthsealofresolve` (ledger 2026-10-13_001).
public uint SealWcid { get; set; } = 3000138;
///
/// The eraser's weenie: 3000139 `aelrynthresolveeraser`. One eraser kind PER SEAL
/// kind rather than one generic eraser, so using it needs no question and no dialog -
/// an Eraser of Resolve can only mean the one thing.
///
public uint EraserWcid { get; set; } = 3000139;
///
/// The highest this kind may reach. 0 is NO MAXIMUM, and that is the design: the
/// seals-per-step curve is the only wall. Set a number only to put a ceiling on a
/// kind while it is young.
///
public int Max { get; set; } = 0;
///
/// How many seals the FIRST step takes - Resolve 0 to Resolve 1. One: the first step
/// is one seal, so a player's first purchase is immediately a step and the grammar
/// teaches itself. 0 or less is read as 1.
///
public int SealsForFirstStep { get; set; } = 1;
///
/// What each step multiplies the last step's seal count by. 1.5.
///
/// This is the escalation, and it is the ONLY wall there is - seals stack without
/// limit and their price is flat. It is deliberately the same 1.5 the pyreal price
/// used to compound by, so the total cost of reaching a given Resolve is unchanged:
/// the k-th step used to cost 1.5^(k-1) times the base PRICE and now takes 1.5^(k-1)
/// times the base NUMBER OF SEALS, which is the same money.
///
/// Rounded to whole seals, so the ladder reads: step 1 = 1 seal, 2 = 2, 3 = 2, 4 = 3,
/// 5 = 5, 6 = 8, 7 = 11, 8 = 17, 9 = 26, 10 = 38, 11 = 58, 15 = 292, 20 = 2,217.
/// `resolve-status steps` prints it from this setting rather than from that list.
///
/// Values below 1.0 are read as 1.0 - a flat curve - rather than honoured, so a typo
/// cannot make a deep step cheaper than a shallow one. A step whose seal count passes
/// Seals.Uncountable saturates and is REFUSED by name rather than wrapping negative
/// (Aeshnidae.SkillMastery, 2026-09-22, learned that the hard way).
///
public double SealsPerStepGrowth { get; set; } = 1.5;
///
/// What Ismene charges for one seal, in pyreals. DISPLAY ONLY - see the block comment
/// above. The truth is the seal weenie's Value and her AlternateCurrency, both in the
/// world database (ledger 2026-10-13_005): 8 Trade Notes of 250,000 = 2,000,000.
///
/// Two million was the first seal's price under the old escalating purchase and it is
/// unchanged here: a number a character who farms can reach in an evening but not in
/// an hour, so the first seal is a decision rather than a formality.
///
public long SealPyreals { get; set; } = 2_000_000;
///
/// What Ismene charges for one eraser, in pyreals. DISPLAY ONLY, as above: the truth
/// is 4 Trade Notes of 250,000 = 1,000,000 in the world database.
///
/// Half a seal, flat, and it RETURNS NOTHING. Going back is always cheaper than going
/// forward, and at deep Resolve it is very much cheaper than the step it undoes -
/// which is the right asymmetry for a difficulty you chose on purpose: if it turns out
/// to be too much, getting out again should not be a second grind. It cannot be a
/// money pump in either direction, because nothing comes back.
///
public long EraserPyreals { get; set; } = 1_000_000;
}
///
/// Every kind of seal on this server. One today: Resolve, one step a use, no maximum.
///
public List Seals { get; set; } = new() { new SealKind() };
///
/// The scroll's weenie: 3000137 `aelrynthresolvescroll`, a parchment shaped on retail's
/// own 365, attuned and bonded, two pages (ledger 2026-10-13_001). It is the READOUT, and
/// the thing a seal is used ON - the Resolve itself lives on the character, so replacing
/// a destroyed one costs nothing but a walk to the Keeper. 0 means no scroll on this
/// server and /resolve scroll says so.
///
public uint ResolveScrollWcid { get; set; } = 3000137;
///
/// Only the Keeper writes a scroll, and only for someone standing at her. Off lets
/// `/resolve scroll` be typed anywhere, which is for testing and for a server with no
/// Keeper placed. It has nothing to do with BUYING any more: buying is her vendor window,
/// and ACE's own UseRadius decides how close you have to be for that.
///
public bool ResolveSellerRequired { get; set; } = true;
///
/// Which weenies write scrolls - and, for `resolve-status vendor`, which weenie to read
/// the shop out of.
///
/// The seller is **Ismene Vale, Keeper of Seals** (3000143), who stands at the arrival spot
/// of the **Challenge Hall** - the shared instanced copy of the retail Town Network, landblock
/// 0007, which is the home of the challenge-run system (ledger 2026-10-13_003,
/// `Mods/Content/tools/challenge_hall_settings.py`). Resolve is the challenge-run difficulty
/// dial and nothing else, so it is bought where the runs start.
///
/// It moved there on 2026-10-13, the day it shipped. For the first few hours of that day the
/// sellers were the **Town Wardens** (3000134 `aelrynth-npc-townwarden`, the number
/// Aeshnidae.BuffBot's own `Wardens` setting names) because the hall did not exist yet and the
/// wardens already stood in twenty towns and already took pyreals. That was always meant to be
/// an interim, and the move cost exactly what this comment said it would: one setting and one
/// `resolve-reload`, no rebuild and no deploy. Put another wcid here and the scroll writer
/// moves again - but the SHOP is now in her weenie, so a new seller also needs the vendor
/// rows of ledger 2026-10-13_005 copied onto it.
///
public List ResolveSellerWcids { get; set; } = new() { 3000143 };
/// Metres from the Keeper a scroll may be asked for. The buff bot's own range is 10.
public double ResolveSellerRange { get; set; } = 10.0;
///
/// How the seller is named to a player who is not standing at her - the refusal line, the
/// `/resolve` hint, the line an eraser gives when it is used on the wrong thing.
///
/// It is a SETTING for the same reason is: the seller moves,
/// and when it does, every line that tells a player where to go has to move with it or start
/// lying. It already happened once - "A Town Warden handles this, until the hall of runs is
/// built" was in three messages on the morning of 2026-10-13 and was wrong by the afternoon.
/// A sentence begins with it, so capitalise it.
///
public string ResolveSellerDescription { get; set; } = "The Keeper of Seals in the Challenge Hall";
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
public static Settings Load(string modPath)
{
var path = Path.Combine(modPath, FileName);
try
{
if (File.Exists(path))
return JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) ?? new Settings();
var defaults = new Settings();
defaults.Save(modPath);
ModManager.Log($"[{Mod.Name}] wrote default settings to {path}");
return defaults;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not read {path}, using defaults: {ex.Message}", ModManager.LogLevel.Warn);
return new Settings();
}
}
public void Save(string modPath)
{
try
{
File.WriteAllText(Path.Combine(modPath, FileName), JsonSerializer.Serialize(this, JsonOptions));
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not save settings: {ex.Message}", ModManager.LogLevel.Error);
}
}
}