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