namespace Aeshnidae.Bank;
///
/// Settings.json in the deployed mod folder. Edit and /bankreload.
///
public class Settings
{
public const string FileName = "Settings.json";
///
/// Which Legendary Key sizes a withdrawal may hand out, in charges. Payout is
/// greedy largest-first, so this decides what you actually get.
///
/// The full set the world database offers is 25, 10, 5, 4, 3, 2, 1:
/// [25,10,5,4,3,2,1] fewest items - "withdraw 24" gives 10+10+4 (3 keys)
/// [25,1] only the two common ones - "withdraw 3" gives 3 singles
///
/// Deposits always accept every variant regardless of this list.
///
public int[] LegendaryKeyPayout { get; set; } = { 25, 10, 5, 4, 3, 2, 1 };
/// Sturdy Iron sizes: 50 is the Keyring, 1 the plain key.
public int[] SturdyIronKeyPayout { get; set; } = { 50, 1 };
/// Largest single deposit or withdrawal. 0 for no limit.
public long MaxTransaction { get; set; } = 0;
// ---- RADIANCE AND RESONANCE ---------------------------------------------
//
// Two earned currencies with no physical form. They are never carried, only
// banked, which is what makes them account-wide without a rule saying so.
/// Experience from kills and quests pays radiance.
public bool RadianceEnabled { get; set; } = true;
///
/// Radiance per point of experience RECEIVED - after the server rate, the quest
/// modifier, enchantments and any fellowship split.
///
/// 1.0, and it should stay 1.0: Radiance is experience's mirror, and the mastery
/// prices in Aeshnidae.SkillMastery are the retail skill table continued, which only
/// reads correctly if a Radiance is worth exactly an experience point. Change this
/// and every mastery price silently changes with it.
///
/// (Until 2026-09-12 this was RadiancePerXp = 0.001 against a creature's raw
/// XpOverride, kills only. Balances earned under that rule were multiplied by 500
/// when the rule changed, which is the ratio between the two.)
///
public double RadiancePerExperience { get; set; } = 1.0;
/// Quest completions pay resonance.
public bool ResonanceEnabled { get; set; } = true;
/// Resonance for completing a quest, when it has no override below.
public long ResonancePerQuest { get; set; } = 10;
///
/// Per-quest resonance, by quest name, for the ones worth more than the flat rate.
/// Names are the registry names, as /myquests reports them.
///
public Dictionary ResonanceOverrides { get; set; } = new();
///
/// Registry entries that are bookkeeping rather than quests, as wildcard patterns
/// (* matches anything; case-insensitive): pickup and turn-in timers, the wait
/// stamp a kill task sets at hand-in, stipend flags. They pay nothing, whatever the
/// flat rate says. Kill counting itself is handled by the counting guard in
/// Patches (HandleKillTask and Increment), whatever the counter is called - a
/// task pays once, on the kill that completes it - so it needs no pattern here.
///
public string[] ResonanceSkipPatterns { get; set; } = { "*Timer*", "*Wait_*", "*Stipend*", "*Cooldown*" };
private System.Text.RegularExpressions.Regex[]? _resonanceSkip;
/// Does a registry name match ?
public bool IsResonanceSkipped(string questName)
{
_resonanceSkip ??= (ResonanceSkipPatterns ?? Array.Empty())
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => new System.Text.RegularExpressions.Regex(
"^" + System.Text.RegularExpressions.Regex.Escape(p).Replace("\\*", ".*") + "$",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled))
.ToArray();
return !string.IsNullOrEmpty(questName) && _resonanceSkip.Any(r => r.IsMatch(questName));
}
///
/// Pay only the first time a quest is solved, rather than every completion.
///
/// OFF, deliberately. A currency that ignores repeatable content makes repeatable
/// content worthless, and dailies are where a steady economy should come from. Turn
/// it on if a fast-repeating quest turns out to be a printing press.
///
public bool ResonanceFirstSolveOnly { get; set; } = false;
/// Tell players in chat as they earn radiance.
public bool AnnounceRadiance { get; set; } = true;
///
/// Seconds to gather radiance into one chat line.
///
/// 0, the default: every kill reports its own line, so the number is always next to
/// the monster that paid it. That is the point of a per-kill currency - you can see
/// what a thing is worth without doing arithmetic afterwards.
///
/// Set it to something like 15 if the chat volume becomes a problem on a fast
/// farming loop, and a clearing will read as "You gain 340 Radiance." once instead
/// of thirty times.
///
/// Only radiance is affected either way. Resonance is always immediate.
///
public double AnnounceRollupSeconds { get; set; } = 0;
public bool AnnounceResonance { get; set; } = true;
///
/// How often earned currency is written to the database, in seconds.
///
/// Awards happen inside a landblock tick, and a MySQL round trip there would put
/// database latency in the world loop, so they are buffered in memory and written
/// by a background flush. Balances always read as balance + pending, so nothing
/// looks missing in between.
///
/// The cost is that a hard crash loses at most this many seconds of earnings. Set
/// to 0 to write every award synchronously instead - correct, durable, and paid for
/// on the world thread.
///
public double FlushSeconds { get; set; } = 10;
///
/// Characters may opt into having earned luminance go straight to the bank, with
/// /bank autolum on.
///
/// Off for a character until they ask for it, and worth understanding before you
/// leave this enabled: banked luminance is not subject to MaximumLuminance, so a
/// flagged character never loses an award to the cap. That removes a designed
/// constraint - in retail the cap is what forces a choice between hoarding for an
/// aura and spending now. Turn this off server-wide to keep that tension.
///
public bool AllowLuminanceAutoBank { get; set; } = true;
/// Tell a flagged character in chat each time luminance is banked for them.
public bool AnnounceLuminanceBanked { get; set; } = true;
/// Players can send radiance and resonance to each other with /bank pay.
public bool AllowPlayerTransfers { get; set; } = true;
///
/// Fraction of a transfer destroyed in transit, 0 to 1.
///
/// Zero: what you send is what arrives. It was 0.02 until 2026-09-14, as the one sink
/// the mod had; Tom took it out. Radiance already has a sink that matters - skill
/// mastery destroys it - and a fee on handing it to a friend only taxed the social
/// use of the currency. Set it above zero if the economy ever needs draining.
///
public double TransferTax { get; set; } = 0.0;
///
/// Smallest transfer allowed. It kept a tax from being rounded away one unit at a
/// time; with no tax it is simply a floor against spamming /b pay. Radiance is on
/// the experience scale - one kill is thousands - so this sits where it still means
/// something.
///
public long MinimumTransfer { get; set; } = 100_000;
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();
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);
}
}
}