namespace Aeshnidae.QuestBonus; /// /// Read from Settings.json in the deployed mod folder /// (C:\ACEPublic\Mods\Aeshnidae.QuestBonus\Settings.json), written with defaults /// on first run. /// /// To retune live: edit the file, then "/mod restart Aeshnidae.QuestBonus" or /// "/qb reload". Both re-read this and resync every online character. /// public class Settings { public const string FileName = "Settings.json"; /// /// XP multiplier gained per quest point: 0.001 = +0.1% per point. /// With at 1, that is +0.1% per solved quest, /// so 50 quests give +5% and 200 give +20%. /// public double BonusConversion { get; set; } = 0.001; /// Points for a solved quest with no explicit entry in . public double DefaultPoints { get; set; } = 1.0; /// /// Ceiling on the final multiplier, so a very long-lived character cannot run /// away with it. 2.0 = at most double XP. Set to 0 to disable the cap. /// public double MaxMultiplier { get; set; } = 2.0; /// Master switch for the on-quest messages below. public bool NotifyQuest { get; set; } = true; /// /// Shown when a quest first starts counting toward the bonus. /// /// Placeholders: /// {quest} quest name, e.g. ChasingOswaldDone /// {points} points gained or lost /// {bonus} new total bonus, e.g. +4.2% /// {qp} new total quest points /// /// Set to "" to stay silent on gains. /// public string QuestGainedMessage { get; set; } = "Way to go ya filthy animal.. You've gained QB by completing {quest}!"; /// Shown when a quest stops counting (removed, erased or decremented to zero). public string QuestLostMessage { get; set; } = "You've lost {points} QB from {quest}. Now at {bonus} XP."; /// Tell the player how much every single XP award was boosted. Very chatty. public bool NotifyExp { get; set; } = false; /// /// Per-quest overrides, by quest name. Anything not listed is worth /// . Set a quest to 0 to exclude it. /// /// Quests.txt in the source folder lists all 4,180 known quest names in this /// exact format, ready to paste. The defaults below exclude the recurring /// stipend timers, which are bookkeeping flags rather than real quests. /// public Dictionary QuestWeights { get; set; } = new(StringComparer.OrdinalIgnoreCase) { ["StipendsCollectedInAMonth"] = 0, ["StipendTimer_08"] = 0, ["StipendTimer_Monthly"] = 0, }; /// /// Weights by wildcard pattern, for the registry entries that are bookkeeping rather /// than quests: pickup and turn-in timers, kill-task counters and the wait stamp a /// hand-in sets, stipend flags. Checked after (an exact /// name wins) and before ; the first pattern that matches /// decides. * matches anything; names are matched case-insensitively. /// /// Before 2026-09-14 every one of these counted a full point the first time it was /// set, so the bonus was as much a count of portals walked through and kill tasks /// picked up as of quests solved. The shard has ~4,200 registry names and ~570 of /// them are timers, kill counters or portal stamps. /// public Dictionary QuestWeightPatterns { get; set; } = new(StringComparer.OrdinalIgnoreCase) { ["*Timer*"] = 0, ["*Wait_*"] = 0, ["*Stipend*"] = 0, ["*Cooldown*"] = 0, ["*KillTask*"] = 0, ["*Killed*"] = 0, }; private List<(System.Text.RegularExpressions.Regex Pattern, double Weight)>? _patterns; /// The first pattern weight that matches, or null when none does. public double? PatternWeightOf(string questName) { _patterns ??= (QuestWeightPatterns ?? new Dictionary()) .Where(kv => !string.IsNullOrWhiteSpace(kv.Key)) .Select(kv => (new System.Text.RegularExpressions.Regex( "^" + System.Text.RegularExpressions.Regex.Escape(kv.Key).Replace("\\*", ".*") + "$", System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled), kv.Value)) .ToList(); if (string.IsNullOrEmpty(questName)) return null; foreach (var (pattern, weight) in _patterns) { if (pattern.IsMatch(questName)) return weight; } return null; } private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, // Without this the default HTML-safe encoder writes apostrophes as ', // which makes the message templates unpleasant to hand-edit. Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; public static Settings Load(string modPath) { var path = Path.Combine(modPath, FileName); try { if (File.Exists(path)) { var loaded = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) ?? new Settings(); loaded.Normalize(); return loaded; } 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(); } } /// /// ACE matches quest names case-insensitively (QuestManager.GetQuest uses /// OrdinalIgnoreCase), but System.Text.Json deserializes into a plain /// case-sensitive dictionary. Rebuild it so "pathwardencomplete" in /// Settings.json still matches "PathwardenComplete" in the registry. /// private void Normalize() => QuestWeights = new Dictionary( QuestWeights ?? new Dictionary(), StringComparer.OrdinalIgnoreCase); 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); } } }