namespace Aeshnidae.Challenge; /// /// Every number a challenge run turns on. /// /// THE TWO THAT MATTER, and why they are the ones: /// /// BaseScore the creature power index score a Resolve-0 run is drawn around. /// 0.40 is the middle of the range the index's own validation table /// calls a level-15 dungeon: Lugian Thrall 0.32, Drudge Slave 0.34, /// Lugian Overseer 0.48, Mudlurk Mosswart 0.51. A level 1-10 /// character fights those, and the first challenge run is meant to /// be easy. /// /// ScorePerResolve what one point of Resolve does to that target. 1.6 per point, so /// Resolve 5 draws around 0.40 x 1.6^5 = 4.19 and Resolve 10 around /// 44. Multiplicative rather than additive because the index is a /// ratio scale - "twice the fight" is meaningful, "0.4 more fight" /// is not - and because the pool stays well populated the whole way /// up (46, 80, 111, 112, 129, 106, 115, 93, 133, 155, 123 species in /// band at Resolve 0..10, measured 2026-10-13 against the index of /// the same day). /// /// Resolve NEVER multiplies a creature. It moves the band the roster is drawn from. The /// reason is the one the index exists for (ops/build-creature-index.md): a retail /// creature's skills, health and damage are written in by hand, and only 13 creatures in /// the database carry our attribute-only marker, so scaling an arbitrary one multiplies /// numbers nobody chose. Drawing a harder cast is the same difficulty curve without that /// problem - and the appraisal window tells the truth about every monster in the room, /// because nothing was touched. /// public class Settings { public const string FileName = "Settings.json"; /// /// Off by default. Off means the Filling hook is never installed, so InstancesNoDat /// behaves exactly as it does today and a copy of a challenge shell opens stock. /// public bool Enabled { get; set; } = false; /// /// The creature power index, relative to ACE's own content_folder /// (/modifystring content_folder). Built by C:\Aeshnidae\ops\build-creature-index.py; /// read once at load and on challenge-admin reload, never written. /// /// An absolute path is used as given, for a staging box whose content folder is not /// writable. /// public string IndexFile { get; set; } = "data/creature-index.json"; /// Log every roster as it is drawn: the band, the shaped pool, and each species with its score. public bool LogRosters { get; set; } = true; /// The shells that hand out challenge runs. One for now (the Old Mine). public List Shells { get; set; } = new() { ChallengeShell.OldMine() }; public ChallengeShell? ShellFor(ushort landblock) => Shells?.FirstOrDefault(s => s.TryParse(out var lb) && lb == landblock); 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); } } } /// /// One challenge shell: a retail dungeon landblock whose personal copies are filled with a /// drawn roster instead of their own monsters. /// /// The walls, the cells and the spawn spots are retail's. Everything standing in the copy /// is ours, and the real dungeon is untouched - a copy has always been a separate set of /// objects in the same geometry (InstancesNoDat/Scaling.cs says why that is safe). /// public class ChallengeShell { /// What players are told it is called. public string Name { get; set; } = ""; /// Four-digit hex landblock id, as in the cell dat and in InstancesNoDat's own entry. public string Landblock { get; set; } = ""; /// /// The portal that opens a run. It must also be the landblock's OnlyVia in /// InstancesNoDat's Settings.json, or the retail dungeon stops being retail - see the /// Readme. Recorded here so /challenge and the console readout can name it; the /// placement itself lives in InstancesNoDat's hub variant, which is what makes it /// movable without touching this mod. /// public uint EntryPortalWcid { get; set; } /// The chest that appears when the boss dies. public uint RewardChestWcid { get; set; } // ---- THE BAND ----------------------------------------------------------- /// Index score a Resolve-0 roster is drawn around. See the class comment on Settings. public double BaseScore { get; set; } = 0.40; /// Factor on the target score per point of Resolve (PropertyInt 9053). public double ScorePerResolve { get; set; } = 1.6; /// /// Half-width of the band, as a fraction: the band is [target / (1 + w), target x (1 + w)]. /// /// 0.35 rather than the index README's example 10%: at the bottom of the scale the /// index is sparse (46 species in a +-35% band at 0.40, and a 10% band there holds /// about a dozen), and a run that draws 5 species out of 12 is the same run every /// time. +-35% is a factor of 1.82 from floor to ceiling, which at these scores is /// the difference between a Lugian Thrall and a Mudlurk Mosswart - a different fight, /// not a different league. /// public double BandWidth { get; set; } = 0.35; /// /// SHAPE. Two creatures with the same score can be a sponge and a glass cannon (the /// index publishes dpmVsPlayer and ttkSeconds for exactly this reason), and a roster /// that happened to be all one or all the other would not feel like the band it came /// from. So the band is narrowed to the creatures whose damage per minute AND whose /// time to kill are both within this factor of the MEDIAN of the band: the extremes /// are out of the pool before anything is drawn, rather than mixed in and hoped about. /// /// 2.5 keeps nearly all of a healthy band (42 of 46 at Resolve 0) and cuts the tails. /// 1.0 would be "every fight identical"; a large number switches shape matching off. /// public double ShapeFactor { get; set; } = 2.5; /// /// How many species one run draws. Five is enough that two runs are visibly different /// casts (5 of 42 is one chance in 850,000 of the same five) and few enough that each /// one shows up often enough to be learned. /// public int RosterSpecies { get; set; } = 5; /// /// How many of the shell's spawn spots are filled. The Old Mine has 69; 18 of them is /// a ten to fifteen minute run at level 1-10 rather than an hour, which is the whole /// brief for a first challenge run. Raise it and the run gets longer, not harder. /// public int Spawns { get; set; } = 18; /// /// Spots nearest the entry that are left empty, so arriving is not being jumped. The /// portal puts a level-1 character on the floor with no chance to look around first. /// public int EntryClearSpots { get; set; } = 2; // ---- THE BOSS ----------------------------------------------------------- /// /// The boss is the top of the band: drawn at random from the strongest this fraction /// of the shaped pool. 0.15 of 42 is the top six, so the boss differs between runs and /// is always the hardest thing the band could have offered. /// public double BossTopFraction { get; set; } = 0.15; /// /// Multiplier on the target score for the boss's own band, if the top of the roster's /// band is ever judged too soft to read as a boss. 1.0 - the boss comes out of the same /// band as its minions, which is what makes "Resolve raises the band" the only /// difficulty dial. 2.0 would draw the boss a notch above its room; it is here so that /// is a settings change rather than a code change. /// public double BossScoreFactor { get; set; } = 1.0; // ---- THE PAYOUT --------------------------------------------------------- /// /// Experience for the boss, before the server's half rate, for a run drawn at exactly /// BaseScore. Paid once per run to everyone who was in the copy when the boss died. /// /// 4,000 raw is 2,000 received at xp_modifier 0.5 - about a fifth of the way to level /// 10 from level 1, for a run of ten to fifteen minutes. Deliberately not generous /// enough to be the fastest route to level 10; the point of the first challenge run is /// to prove the mechanic, and an experience rate is the easiest thing to raise later /// and the hardest to lower. /// public long BossXp { get; set; } = 4000; /// /// REWARD FOLLOWS THE DRAW, NOT RESOLVE. The payout is multiplied by /// (mean score of the roster actually drawn / BaseScore) raised to this power, so an /// unlucky easy draw inside a high band pays less than a lucky hard one - which is the /// point of paying for the band rather than for the dial. 1.0 is linear in the index /// score; the index score is already a square root of the damage a player absorbs, so /// linear here is the square root of the real work, which is the conservative end. /// public double RewardScorePower { get; set; } = 1.0; /// Cap on that multiplier, so a freak draw cannot pay a thousand times. 0 = uncapped. public double MaxRewardMultiplier { get; set; } = 50.0; /// /// Only draw creatures the world database places somewhere (the index's `placed` /// flag): a challenge run should be made of things players have met or could meet, /// not of unused test weenies. /// public bool PlacedOnly { get; set; } = true; /// /// Leave out creatures the index flags immobile. A stationary monster at a spawn spot /// in a corridor is a wall, not a fight. /// public bool ExcludeImmobile { get; set; } = true; /// /// Placed wcids this shell's copies never spawn, on top of the ones worked out /// automatically (every creature, every chest, every locked door, every generator - /// see Shell.cs). For anything the automatic rule misses. /// public List AlsoRemove { get; set; } = new(); public bool TryParse(out ushort landblock) { var text = (Landblock ?? "").Trim().Replace("0x", "", StringComparison.OrdinalIgnoreCase); return ushort.TryParse(text, System.Globalization.NumberStyles.HexNumber, null, out landblock); } /// /// The first challenge run: the Old Mine, landblock 0190. /// /// Chosen because it is the shortest of the shells that were on the table - 289 cells /// in the cell dat against Tumerok Cavern's 300, the Citadel Below's 386, Carved Cave's /// 399, Mossy Cave's 444 and Folthid Cellar's 592 - and because its 69 creature spots /// spread along one broad descent from the entry to a far room, which gives a natural /// place to put a boss without authoring a single position by hand. It has its own exit /// portal already placed, so leaving is retail's job. Nothing in it is quest-bearing. /// public static ChallengeShell OldMine() => new() { Name = "the Old Mine", Landblock = "0190", EntryPortalWcid = 3000140, RewardChestWcid = 3000141, }; }