namespace Aeshnidae.CombatWindow; /// /// The evade and resist window (Tom, 2026-09-19: "An extra 10 skill you go from /// resisting nothing to everything. It makes content after retail either you survive /// it or you don't."). /// /// ACE decides every evade and every resist with one curve, SkillCheck.GetSkillChance: /// chance to land = 1 / (1 + e^(-slope x (attack - defense))) /// with the slope 0.03. That number IS the window: 73 skill points under and you land /// one hit in ten, 73 over and nine in ten, and ten points around even swings the /// outcome by 7.5%. Against monsters scaled by tier the gap opens fast, and past it /// nothing lands or nothing misses. Two knobs widen it: /// /// - a gentler slope: 0.02 makes the 10%-90% span 110 points instead of 73, 0.015 /// makes it 146; /// - a floor and a ceiling: however wide the gap, a hit still lands (and still /// misses) some of the time, so no fight is decided before the first swing. /// /// Combat only - melee and missile evasion (DamageEvent.GetEvadeChance) and spell /// resistance (WorldObject.TryResistSpell). Lockpicking, healing, mana conversion and /// the rest keep retail's curve. Players and monsters alike: the curve has no side. /// public class Settings { public const string FileName = "Settings.json"; public bool Enabled { get; set; } = true; // ---- THE NUMBERS --------------------------------------------------------- /// Slope of the curve for melee and missile evasion. Retail 0.03. public double EvadeSlope { get; set; } = 0.02; /// Slope of the curve for spell resistance. Retail 0.03. public double ResistSlope { get; set; } = 0.02; /// /// The least chance an attack has to land, and the least chance a target has to /// evade - whatever the gap. 0.05 is one in twenty. 0 is retail (no floor). /// public double MinLandChance { get; set; } = 0.05; /// The most an attack can have to land; 1 - this is the least a target can evade. 0.95. public double MaxLandChance { get; set; } = 0.95; /// The least chance a spell has to land, and the least a target has to resist. public double MinSpellLandChance { get; set; } = 0.05; public double MaxSpellLandChance { get; set; } = 0.95; /// The chance to land at a given skill gap, on these terms. public double LandChance(int attack, int defense, bool spell) { var slope = spell ? ResistSlope : EvadeSlope; var chance = 1.0 - 1.0 / (1.0 + Math.Exp(slope * (attack - defense))); var min = spell ? MinSpellLandChance : MinLandChance; var max = spell ? MaxSpellLandChance : MaxLandChance; return Math.Clamp(chance, Math.Clamp(min, 0, 1), Math.Clamp(max, 0, 1)); } 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); } } }