namespace Aeshnidae.Challenge; /// What one run drew: the band it was drawn from, the cast, and the boss. public class DrawnRoster { public double Target { get; set; } public double Low { get; set; } public double High { get; set; } /// How many species were in the score band before shape matching, and after. public int BandSize { get; set; } public int ShapedSize { get; set; } /// The medians the shape filter was anchored on - for the log and the console readout. public double MedianDpm { get; set; } public double MedianTtk { get; set; } public List Species { get; set; } = new(); public IndexedCreature? Boss { get; set; } /// The mean score of the cast actually drawn, boss included. What the reward is paid on. public double MeanScore => Species.Count == 0 && Boss is null ? 0 : Species.Concat(Boss is null ? Enumerable.Empty() : new[] { Boss }) .Average(c => c.Score ?? 0); public string Describe() => $"band {Low:0.###}-{High:0.###} (target {Target:0.###}), {BandSize} in band, {ShapedSize} after shape; " + $"cast {string.Join(", ", Species.Select(s => $"{s.Name} {s.Score:0.###}"))}; " + $"boss {Boss?.Name ?? "none"} {Boss?.Score:0.###}; mean {MeanScore:0.###}"; } /// /// Drawing a difficulty-matched cast. /// /// The whole algorithm, in order: /// /// 1. TARGET. target = BaseScore x ScorePerResolve ^ Resolve. Resolve is PropertyInt /// 9053 on the character, read and never written (Aeshnidae.Awakens owns it). /// /// 2. BAND. Every scored creature whose score is in [target / (1+w), target x (1+w)], /// filtered to placed and mobile creatures by the settings. This is the "difficulty /// matched" part and it is the only thing Resolve moves. /// /// 3. SHAPE. The band's median damage-per-minute and median time-to-kill, then keep only /// the creatures within ShapeFactor of BOTH. The index publishes those two numbers /// precisely because a score is a product of them and so cannot tell a sponge from a /// glass cannon; a run made of one extreme would not feel like its band. Anchored on /// the band's own median rather than on a written-down shape, so it follows the data /// up the scale instead of needing a table per Resolve. /// /// 4. BOSS. Drawn at random from the strongest BossTopFraction of the shaped pool - the /// top of the band, differently each run. /// /// 5. CAST. RosterSpecies species drawn without replacement from the shaped pool, boss /// excluded so the boss is not also a minion. /// /// Every step is logged when LogRosters is on, because the only way to know a band is /// right is to read what it drew. /// public static class Roster { /// The target score for a Resolve level. public static double TargetFor(ChallengeShell shell, int resolve) { var step = shell.ScorePerResolve <= 0 ? 1.0 : shell.ScorePerResolve; return shell.BaseScore * Math.Pow(step, Math.Max(0, resolve)); } /// The creatures in the score band for a target, after the placed/mobile filters. public static List Band(ChallengeShell shell, double target) { var width = Math.Max(0, shell.BandWidth); var low = target / (1 + width); var high = target * (1 + width); return Index.All .Where(c => c.Score is { } s && s >= low && s <= high) .Where(c => !shell.PlacedOnly || c.Placed) .Where(c => !shell.ExcludeImmobile || !c.Immobile) .ToList(); } /// /// The shape filter. Returns the kept creatures and the medians it used. /// /// A ShapeFactor at or below 1 would keep only the median itself, so it is treated as /// "off" - the alternative is a silently empty pool. /// public static (List Kept, double MedianDpm, double MedianTtk) Shape(ChallengeShell shell, List band) { if (band.Count == 0) return (band, 0, 0); var medianDpm = Median(band.Select(c => c.Dpm)); var medianTtk = Median(band.Select(c => c.Ttk)); var factor = shell.ShapeFactor; if (factor <= 1 || medianDpm <= 0 || medianTtk <= 0) return (band, medianDpm, medianTtk); var kept = band .Where(c => c.Dpm >= medianDpm / factor && c.Dpm <= medianDpm * factor) .Where(c => c.Ttk >= medianTtk / factor && c.Ttk <= medianTtk * factor) .ToList(); // A shape filter that emptied the pool is worse than no shape filter. return (kept.Count >= 2 ? kept : band, medianDpm, medianTtk); } /// Draws a roster for a Resolve level. Returns null when the index has nothing in band. public static DrawnRoster? Draw(ChallengeShell shell, int resolve) { var target = TargetFor(shell, resolve); var width = Math.Max(0, shell.BandWidth); var band = Band(shell, target); if (band.Count == 0) { ModManager.Log($"[{Mod.Name}] nothing in the index between {target / (1 + width):0.###} and {target * (1 + width):0.###} " + $"(Resolve {resolve} on {shell.Name}); no roster drawn", ModManager.LogLevel.Warn); return null; } var (pool, medianDpm, medianTtk) = Shape(shell, band); var drawn = new DrawnRoster { Target = target, Low = target / (1 + width), High = target * (1 + width), BandSize = band.Count, ShapedSize = pool.Count, MedianDpm = medianDpm, MedianTtk = medianTtk, }; // The boss: the top of the band. BossScoreFactor 1.0 means the roster's own band; // anything else draws the boss from its own band a notch up. var bossPool = Math.Abs(shell.BossScoreFactor - 1.0) < 0.0001 ? pool : Shape(shell, Band(shell, target * shell.BossScoreFactor)).Kept; if (bossPool.Count == 0) bossPool = pool; var topCount = Math.Max(1, (int)Math.Ceiling(bossPool.Count * Math.Clamp(shell.BossTopFraction, 0.0001, 1.0))); var top = bossPool.OrderByDescending(c => c.Score ?? 0).Take(topCount).ToList(); drawn.Boss = top[ThreadSafeRandom.Next(0, top.Count - 1)]; // The cast: species drawn without replacement, the boss left out so it is the one // thing in the dungeon that is not also standing in a corridor. var candidates = pool.Where(c => c.Wcid != drawn.Boss.Wcid).ToList(); // Same wcid can appear twice in the index only if the builder emitted it twice; be // defensive, because drawing the same species twice would silently shrink the cast. candidates = candidates.GroupBy(c => c.Wcid).Select(g => g.First()).ToList(); var want = Math.Max(1, shell.RosterSpecies); for (var i = 0; i < want && candidates.Count > 0; i++) { var pick = ThreadSafeRandom.Next(0, candidates.Count - 1); drawn.Species.Add(candidates[pick]); candidates.RemoveAt(pick); } if (Mod.Settings.LogRosters) ModManager.Log($"[{Mod.Name}] {shell.Name}, Resolve {resolve}: {drawn.Describe()}"); return drawn; } private static double Median(IEnumerable values) { var sorted = values.OrderBy(v => v).ToList(); if (sorted.Count == 0) return 0; return sorted.Count % 2 == 1 ? sorted[sorted.Count / 2] : (sorted[sorted.Count / 2 - 1] + sorted[sorted.Count / 2]) / 2.0; } }