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);
}
}
}