namespace Aeshnidae.SkillMastery;
///
/// One tunable segment of the mastery cost curve.
///
/// A band covers skill points from the previous band's
/// up to and including its own, so bands are written in ascending order and the
/// first one starts at 0.
///
public class CostBand
{
/// Highest skill point total this band applies to.
public int UpToSkillPoints { get; set; }
///
/// What one whole skill point costs in this band, as a multiple of the last
/// retail trained rank, 306,860,483 xp (the specialized table's 350,046,134 only
/// when Settings.PriceByAdvancementClass is on). So 1.0 is "a skill point costs
/// what retail's most expensive rank cost", and 0.5 is half that.
///
/// Priced per skill point rather than per rank on purpose: RanksPerSkillPoint
/// then only controls how finely the purchase is chopped up, never how much it
/// costs in total. Change the divisor from 10 to 2 and the price is identical -
/// you just buy it in five steps instead of fifty.
///
public double CostPerSkillPoint { get; set; } = 1.0;
///
/// Compounding within the band, applied per rank from the band's first rank.
/// 1.0 is flat, which is the point of having bands - put the shape in the
/// multipliers where you can see it rather than in an exponent.
///
public double Growth { get; set; } = 1.0;
}
public class Settings
{
public const string FileName = "Settings.json";
public bool Enabled { get; set; } = true;
///
/// Refuse mastery until the skill is at its retail maximum.
///
/// OFF, because the price already does this job. At CostPerSkillPoint 1.0 a
/// mastery point costs what retail's most expensive rank cost, so early retail
/// ranks - orders of magnitude cheaper - are always the better buy. Nobody takes
/// mastery early by choice, and a hard gate only adds friction.
///
/// The invariant that actually matters is the one to watch when editing bands:
/// while a mastery point costs more than the retail rank it competes with,
/// players raise retail first by choice. Price a band below that and they will
/// skip retail entirely - mastery survives enlightenment and retail does not, so
/// cheap mastery makes enlightenment toothless.
///
public bool RequireRetailCap { get; set; } = false;
///
/// Mastery ranks that make up one skill point. 1: one /raise is one whole point.
///
/// 10 was tried twice (2026-09-12): each raise a tenth of a point at a tenth of the
/// price. It never changed what a point cost, only chopped the purchase up, and
/// buying fractions that do nothing until the tenth lands read as odd. Tom's call:
/// keep raises whole and put the weight in
/// instead. The readouts still cope with any value here.
///
public int RanksPerSkillPoint { get; set; } = 1;
///
/// How much dearer a mastery point is than the retail curve says.
///
/// 10: the first mastery point costs ten times what rank 209 would have (3.31B;
/// 3.78B for a specialized skill only with PriceByAdvancementClass on), and the
/// curve climbs from there at the retail rate.
/// This is where the "1/10th of a point" mechanic's weight went when the fractions
/// were dropped - same power per Radiance as ten tenths at full price each would
/// have been. Baked into the generated band's CostPerSkillPoint; edit the band to
/// change a live server, this only shapes a fresh Settings.json.
///
public double PointPriceMultiplier { get; set; } = 10.0;
///
/// Whether a specialized skill pays the specialized table's rate for mastery.
///
/// OFF: one price for every skill, the trained table continued. Tom's call,
/// 2026-09-14 - a mastery point is the same +1 whatever the skill's class, and
/// pricing it by class meant buying it while trained and specializing afterwards
/// came out ~12% cheaper than the other way round, for the same result. ON restores
/// the per-class rate: 350,046,134 against 306,860,483 for the base rank, x1.14.
///
public bool PriceByAdvancementClass { get; set; } = false;
///
/// Play the retail augmentation burst on every /raise and the fireworks on the
/// first point and every tenth. Purely visual; off means silent purchases.
///
public bool Effects { get; set; } = true;
///
/// Ceiling on mastery skill points per skill.
///
/// Mostly ceremonial under the retail curve - the price does the capping. At 1.0787
/// per point, +50 costs about one full climb to level 275 in total, and +100 costs
/// forty of them. Nobody reaches 200; the number is here so /mastery can state a
/// ceiling that is a number rather than "when the sun burns out".
///
public int MaxSkillPoints { get; set; } = 200;
/// Width of each generated band, in skill points.
public int BandSize { get; set; } = 10;
///
/// The cost curve.
///
/// THE DEFAULT IS THE RETAIL SKILL TABLE, CONTINUED. From rank 150 to 208 the retail
/// trained-skill table is a clean geometric progression - each rank costs 1.0787x
/// the one before, steady to four decimal places (specialized runs a little steeper
/// and noisier, ~1.099; one multiplier is used for both, which under-prices
/// specialized slightly and is the simpler thing to reason about). So the first
/// mastery point is priced as rank 209 would have been, the second as rank 210, and
/// so on, times : one band covering everything,
/// CostPerSkillPoint 10.787 (ten times the step from 208 to 209), Growth 1.0787
/// (each step after). Players already know this curve from raising every skill they
/// have; mastery is that curve not stopping, with a 10x weight on it.
///
/// What it costs, in Radiance (= experience), trained, at the 10x default
/// (a climb from 1 to 275 is about 191B):
/// +1 3.3B +10 6.5B (48B total - a quarter of a climb)
/// +30 29.8B (366B total - about two climbs)
/// +50 135.5B (1.8T total - about nine and a half climbs)
/// +100 6.0T (82T total - over four hundred climbs; nobody gets here)
///
/// Generated on first run and then editable: split it into segments to reshape a
/// stretch without touching the rest. Past the last band,
/// applies flat.
///
public List Bands { get; set; } = new();
/// The retail table's per-rank multiplier at its tail. See .
public const double RetailGrowth = 1.0787;
public double DefaultCostPerSkillPoint { get; set; } = 1.0;
/// Writes the retail-curve band the first time the mod runs. See .
public bool EnsureBands()
{
if (Bands.Count > 0)
return false;
Bands.Add(new CostBand
{
UpToSkillPoints = MaxSkillPoints,
CostPerSkillPoint = Math.Max(0.0001, PointPriceMultiplier) * RetailGrowth,
Growth = RetailGrowth,
});
return true;
}
/// The band a given skill point total sits in.
public CostBand BandFor(int skillPoints)
{
foreach (var band in Bands)
{
if (skillPoints < band.UpToSkillPoints)
return band;
}
return new CostBand { UpToSkillPoints = int.MaxValue, CostPerSkillPoint = DefaultCostPerSkillPoint };
}
/// First skill point covered by a band, for the within-band growth exponent.
public int BandStart(CostBand band)
{
var start = 0;
foreach (var b in Bands)
{
if (ReferenceEquals(b, band))
return start;
start = b.UpToSkillPoints;
}
return start;
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
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();
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not read {FileName}, 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 write {FileName}: {ex.Message}", ModManager.LogLevel.Warn);
}
}
}