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