namespace Aeshnidae.Challenge;
///
/// One player command and one console command.
///
/// `challenge-admin` is the whole test harness, and it exists because the thing that most
/// needs proving - that two runs draw different but equivalent casts - cannot be seen from
/// inside the game without running the dungeon twenty times. Every subcommand is
/// console-safe (CommandHandlerFlag.None), so it works through
/// Aeshnidae.RemoteConsole with nobody logged in.
///
public static class Commands
{
[CommandHandler("challenge", AccessLevel.Player, CommandHandlerFlag.RequiresWorld, 0,
"What this challenge run drew, and what to look for.",
"/challenge")]
public static void HandleChallenge(Session session, params string[] parameters)
{
var player = session?.Player;
if (player?.CurrentLandblock is not { } block)
return;
var landblock = (ushort)block.Id.Landblock;
var copy = Instances.CopyOf(block);
var run = Runs.RunFor(landblock, copy);
if (run is null)
{
Reply(session, "You are not in a challenge run.");
Reply(session, Mod.Settings.Shells is { Count: > 0 }
? $"Challenge runs: {string.Join(", ", Mod.Settings.Shells.Select(s => s.Name))}."
: "No challenge runs are configured.");
return;
}
Reply(session, $"{run.Shell.Name}, Resolve {run.Resolve}.");
Reply(session, $"Drawn from this run: {string.Join(", ", run.Roster.Species.Select(s => s.Name))}.");
Reply(session, $"At the bottom: {run.Roster.Boss?.Name ?? "unknown"}.");
Reply(session, run.BossDead
? "Cleared. The chest is at the bottom."
: "Nothing here leaves a corpse. The chest appears when the one at the bottom is dead.");
Reply(session, $"Reward for the band you drew: x{run.RewardMultiplier:0.##}.");
}
[CommandHandler("challenge-admin", AccessLevel.Admin, CommandHandlerFlag.None, 0,
"Challenge runs: status, reload, and the console tests.",
"/challenge-admin\n" +
" status the mod, the index, the shells, the binding\n" +
" shell the shell's spawn spots, removals and boss spot\n" +
" draw [times] draw rosters without opening anything\n" +
" bands [maxResolve] the band and pool size at each Resolve\n" +
" variant the JSON a copy would be filled with\n" +
" open REALLY open that copy with a drawn filling\n" +
" look what is standing in an open copy\n" +
" kill-boss [wcid] kill the boss of an open copy, or one creature of that wcid\n" +
" chest what the reward chest holds\n" +
" tier InstancesNoDat's scaling answers for ANY open copy\n" +
" close forget the run record\n" +
" runs every run on record\n" +
" reload re-read settings, the index and the shells")]
public static void HandleAdmin(Session session, params string[] parameters)
{
try
{
var what = parameters.Length == 0 ? "status" : parameters[0].ToLowerInvariant();
var rest = parameters.Skip(1).ToArray();
switch (what)
{
case "status": Status(session); return;
case "shell": ShellReadout(session, rest); return;
case "draw": Draw(session, rest); return;
case "bands": Bands(session, rest); return;
case "variant": Variant(session, rest); return;
case "open": Open(session, rest); return;
case "look": Look(session, rest); return;
case "kill-boss": KillBoss(session, rest); return;
case "chest": Chest(session, rest); return;
case "tier": Tier(session, rest); return;
case "close": Close(session, rest); return;
case "runs": RunsReadout(session); return;
case "reload": Reply(session, $"{Mod.Name}: {Mod.Reload()}"); return;
default: Reply(session, $"Unknown subcommand '{what}'. Try /challenge-admin status."); return;
}
}
catch (Exception ex)
{
Reply(session, $"{ex.GetType().Name}: {ex.Message}");
ModManager.Log($"[{Mod.Name}] challenge-admin failed: {ex}", ModManager.LogLevel.Warn);
}
}
// ------------------------------------------------------------ subcommands
private static void Status(Session? session)
{
Reply(session, $"{Mod.Name} - {(Mod.Settings.Enabled ? "ENABLED" : "disabled")}");
Reply(session, $" index: {Index.Status}");
Reply(session, $" index generated: {(string.IsNullOrWhiteSpace(Index.Generated) ? "unknown" : Index.Generated)}");
Reply(session, $" InstancesNoDat: {Instances.Status}");
Reply(session, $" runs on record: {Runs.Open.Count}");
foreach (var shell in Mod.Settings.Shells ?? new List())
{
var plan = Shell.PlanFor(shell);
Reply(session, $" {shell.Name} ({shell.Landblock}): portal {shell.EntryPortalWcid}, chest {shell.RewardChestWcid}, " +
$"base {shell.BaseScore:0.###} x{shell.ScorePerResolve:0.##}/Resolve, band +-{shell.BandWidth * 100:0}%, " +
$"shape x{shell.ShapeFactor:0.#}, {shell.RosterSpecies} species over {shell.Spawns} spots");
Reply(session, $" {(plan is null ? "NOT LOADED - no spawn spots" : plan.Describe())}");
}
}
private static void ShellReadout(Session? session, string[] args)
{
if (!Resolve(session, args, 1, out var shell, out var plan))
return;
Reply(session, $"{shell!.Name} {plan!.Describe()}");
Reply(session, $" removes: {string.Join(", ", plan.Removes)}");
Reply(session, $" spots, nearest the entry first (retail wcid in brackets):");
foreach (var spot in plan.Spots)
Reply(session, $" {spot.FromEntry,6:0.0} m {spot.CellHex} {spot.X,7:0.0} {spot.Y,8:0.0} {spot.Z,7:0.0} ({spot.RetailWcid})");
}
///
/// Draw a roster, or several, and print each with its score - the test for "runs differ
/// but stay inside the band". Nothing is opened and no run is recorded.
///
private static void Draw(Session? session, string[] args)
{
if (!Resolve(session, args, 2, out var shell, out _))
return;
var resolve = Int(args, 1, 0);
var times = Math.Clamp(Int(args, 2, 1), 1, 40);
var target = Roster.TargetFor(shell!, resolve);
var band = Roster.Band(shell!, target);
var (pool, medianDpm, medianTtk) = Roster.Shape(shell!, band);
Reply(session, $"{shell!.Name}, Resolve {resolve}: target {target:0.####}, " +
$"band {target / (1 + shell.BandWidth):0.####}-{target * (1 + shell.BandWidth):0.####}, " +
$"{band.Count} in band, {pool.Count} after shape (median dpm {medianDpm:0.#}, ttk {medianTtk:0.#} s)");
for (var i = 1; i <= times; i++)
{
var drawn = Roster.Draw(shell!, resolve);
if (drawn is null)
{
Reply(session, $" run {i}: nothing could be drawn");
continue;
}
Reply(session, $" run {i,2}: boss {drawn.Boss!.Name} {drawn.Boss.Score:0.###} | " +
string.Join(" | ", drawn.Species.Select(s => $"{s.Name} {s.Score:0.###}")) +
$" | mean {drawn.MeanScore:0.###}");
}
}
/// The band at every Resolve - the sanity check that the dial has somewhere to go.
private static void Bands(Session? session, string[] args)
{
if (!Resolve(session, args, 1, out var shell, out _))
return;
var max = Math.Clamp(Int(args, 1, 10), 0, 40);
Reply(session, $"{shell!.Name}: base {shell.BaseScore:0.###}, x{shell.ScorePerResolve:0.##} per Resolve, " +
$"band +-{shell.BandWidth * 100:0}%, shape x{shell.ShapeFactor:0.#}");
for (var r = 0; r <= max; r++)
{
var target = Roster.TargetFor(shell, r);
var band = Roster.Band(shell, target);
var (pool, dpm, ttk) = Roster.Shape(shell, band);
var levels = pool.Count == 0 ? "-" : $"{pool.Min(c => c.Level)}-{pool.Max(c => c.Level)}";
Reply(session, $" Resolve {r,2}: target {target,10:0.###} band {band.Count,4} shaped {pool.Count,4} " +
$"dpm {dpm,8:0.#} ttk {ttk,8:0.#}s retail levels {levels}");
}
}
private static void Variant(Session? session, string[] args)
{
if (!Resolve(session, args, 2, out var shell, out _))
return;
var resolve = Int(args, 1, 0);
// Copy 0, so the same code that fills a real copy runs but nothing is recorded.
var json = Runs.FillingForResolve(shell!, 0, resolve);
if (json is null)
{
Reply(session, "No filling was produced - is the mod enabled and the index loaded?");
return;
}
// Chunked: the console takes long lines but a chat window does not.
foreach (var chunk in Chunks(json, 400))
Reply(session, chunk);
}
///
/// Opens a copy for real, with a drawn filling, and nobody logged in.
///
/// Supply the variant to InstancesNoDat (Variants.Supply), then ask it for the copy
/// (InstanceWorld.GetOrCreate). That is the same order the player path uses - the
/// variant has to be on record before the first row is read, because the spawn happens
/// on a background task - and it is the only way to exercise the real spawn without a
/// client.
///
private static void Open(Session? session, string[] args)
{
if (args.Length < 2)
{
Reply(session, "Needs a landblock and a copy number.");
return;
}
var text = args[0].Trim().Replace("0x", "", StringComparison.OrdinalIgnoreCase);
if (!ushort.TryParse(text, System.Globalization.NumberStyles.HexNumber, null, out var landblock))
{
Reply(session, $"'{args[0]}' is not a hex landblock id. Try 0190.");
return;
}
var copy = Int(args, 1, 1);
var resolve = Int(args, 2, 0);
// A landblock that is NOT a challenge shell opens with whatever filling
// InstancesNoDat's own settings give it. Worth having: without a player nothing else
// on this server can open a copy at all, so this is the only way to look inside the
// Hub of Awakening's shared copy and check a new door is standing in it.
var shell = Mod.Settings.ShellFor(landblock);
if (copy <= 0)
{
Reply(session, "Copy must be 1 or more.");
return;
}
if (Instances.Find(landblock, copy) is not null)
{
Reply(session, $"Copy {copy} of {landblock:X4} is already open. Use another copy number, or let it close.");
return;
}
if (shell is null)
{
// Not a challenge shell: use the filling InstancesNoDat's own Settings.json
// gives this landblock. Its Variants.Choose is only reached through
// Scaling.Remember, which needs a Player, so a console open would otherwise get
// the stock retail landblock - which is exactly what happened the first time
// this was pointed at the Hub of Awakening (2026-10-13): all 24 retail portals,
// none of ours. The file is read directly rather than through the sibling mod,
// so no InstancesNoDat type crosses a load context for a test command.
var named = args.Length > 2 ? string.Join(" ", args.Skip(2)) : null;
var json = Variants.FromSettingsFile(landblock, named, out var why);
if (json is null)
{
Reply(session, $"{landblock:X4} is not a challenge shell, and {why}");
return;
}
if (!Instances.Supply(landblock, copy, json, $"challenge-admin open ({landblock:X4}'s own variant)"))
{
Reply(session, "InstancesNoDat refused that variant - see the log.");
return;
}
Reply(session, $"{landblock:X4} is not a challenge shell - opening it with its own configured filling.");
}
else if (Shell.PlanFor(shell) is null)
{
Reply(session, $"{shell.Name} has no spawn spots loaded - challenge-admin reload, and check the log.");
return;
}
else
{
var json = Runs.FillingForResolve(shell, copy, resolve);
if (json is null)
{
Reply(session, "No filling could be drawn.");
return;
}
if (!Instances.Supply(landblock, copy, json, $"challenge-admin open (Resolve {resolve})"))
{
Reply(session, "InstancesNoDat refused the filling - see the log.");
return;
}
}
var block = Instances.GetOrCreate(landblock, copy);
if (block is null)
{
Reply(session, $"InstancesNoDat could not create copy {copy} of {landblock:X4}.");
return;
}
Reply(session, $"Copy {copy} of {landblock:X4} is open. Spawning runs on a background task; " +
$"give it a second and then /challenge-admin look {landblock:X4} {copy}.");
}
private static void Look(Session? session, string[] args)
{
if (args.Length < 2)
{
Reply(session, "Needs a landblock and a copy number.");
return;
}
var text = args[0].Trim().Replace("0x", "", StringComparison.OrdinalIgnoreCase);
if (!ushort.TryParse(text, System.Globalization.NumberStyles.HexNumber, null, out var landblock))
{
Reply(session, $"'{args[0]}' is not a hex landblock id.");
return;
}
var copy = Int(args, 1, 1);
var block = Instances.Find(landblock, copy);
if (block is null)
{
Reply(session, $"Copy {copy} of {landblock:X4} is not open.");
return;
}
var run = Runs.RunFor(landblock, copy);
if (run is not null)
Reply(session, $"{run.Shell.Name} copy {copy}, Resolve {run.Resolve}, boss {run.Roster.Boss?.Name} ({run.BossWcid}), " +
$"{(run.BossDead ? "CLEARED" : "not cleared")}, reward x{run.RewardMultiplier:0.##}");
var objects = block.GetAllWorldObjectsForDiagnostics().ToList();
var creatures = objects.OfType().Where(c => c is not Player).ToList();
Reply(session, $"{objects.Count} object(s) in the copy; {creatures.Count} creature(s):");
foreach (var group in creatures.GroupBy(c => c.WeenieClassId).OrderByDescending(g => g.Count()))
{
var first = group.First();
var indexed = Index.Find(group.Key);
Reply(session, $" {group.Count(),3} x {first.Name,-32} wcid {group.Key,-7} " +
$"score {(indexed?.Score is { } s ? s.ToString("0.###") : "-"),-8} " +
$"NoCorpse {first.NoCorpse,-5} deathTreasure {(first.DeathTreasureType?.ToString() ?? "none"),-6} " +
$"hp {first.Health.MaxValue,-6} str {first.Strength.Current}");
}
foreach (var other in objects.Where(o => o is not Creature).GroupBy(o => o.WeenieClassId))
Reply(session, $" {other.Count(),3} x {other.First().Name} (wcid {other.Key}, {other.First().WeenieType})");
}
private static void KillBoss(Session? session, string[] args)
{
if (!Resolve(session, args, 2, out var shell, out _))
return;
var landblock = ShellLandblock(shell!);
var copy = Int(args, 1, 1);
var block = Instances.Find(landblock, copy);
var run = Runs.RunFor(landblock, copy);
if (block is null || run is null)
{
Reply(session, $"Copy {copy} of {landblock:X4} is not an open challenge run.");
return;
}
// A wcid instead of the boss, so one MINION can be killed too. Worth having its own
// test: a minion that wields a weapon is the case the NoCorpse work is least sure of
// (ACE drops a creature's wielded treasure loose when it has no corpse, and
// InstancesNoDat's answer is Bonded = Destroy on the kit at spawn).
var wanted = args.Length > 2 && uint.TryParse(args[2], out var w) ? w : run.BossWcid;
var boss = block.GetAllWorldObjectsForDiagnostics().OfType()
.FirstOrDefault(c => c is not Player && c.WeenieClassId == wanted);
if (boss is null)
{
Reply(session, $"Nothing of wcid {wanted} is standing in copy {copy}.");
return;
}
Reply(session, $"Killing {boss.Name} (wcid {wanted}) at {boss.Location?.Cell:X8}, " +
$"holding {boss.EquippedObjects.Count} equipped + {boss.Inventory.Count} carried...");
// On the copy's own thread, so the death - and the chest that follows it - happen
// where every other object in the copy happens. Running it from the console thread
// is what the CLAUDE.md note about LandblockManager lookups warns against.
block.EnqueueAction(new ActionEventDelegate(() =>
{
try { boss.Die(); }
catch (Exception ex) { ModManager.Log($"[{Mod.Name}] console kill failed: {ex}", ModManager.LogLevel.Warn); }
}));
Reply(session, $"Queued. Then: /challenge-admin chest {shell!.Landblock} {copy}");
}
private static void Chest(Session? session, string[] args)
{
if (!Resolve(session, args, 2, out var shell, out _))
return;
var landblock = ShellLandblock(shell!);
var copy = Int(args, 1, 1);
var block = Instances.Find(landblock, copy);
if (block is null)
{
Reply(session, $"Copy {copy} of {landblock:X4} is not open.");
return;
}
var chests = block.GetAllWorldObjectsForDiagnostics()
.Where(o => o.WeenieClassId == shell!.RewardChestWcid)
.OfType()
.ToList();
if (chests.Count == 0)
{
Reply(session, $"No chest (wcid {shell!.RewardChestWcid}) in copy {copy}.");
return;
}
foreach (var chest in chests)
{
Reply(session, $"{chest.Name} at {chest.Location?.Cell:X8} {chest.Location?.PositionX:0.#} {chest.Location?.PositionY:0.#}, " +
$"locked {chest.IsLocked}, {chest.Inventory.Count} item(s):");
foreach (var item in chest.Inventory.Values)
Reply(session, $" {item.Name,-44} wcid {item.WeenieClassId,-7} " +
$"9043 {(item.GetProperty((PropertyInt)9043)?.ToString() ?? "-"),-5} " +
$"9042 {(item.GetProperty((PropertyInt)9042)?.ToString() ?? "-"),-5} " +
$"value {item.Value,-7} workmanship {item.Workmanship?.ToString("0.#") ?? "-"}");
}
}
///
/// What InstancesNoDat says about a copy's scaling. Takes ANY landblock, challenge shell
/// or not, because the test that matters is the comparison: a challenge copy answers
/// IsScaled false and a plain personal copy of another dungeon answers true, under
/// identical conditions and with nobody logged in.
///
private static void Tier(Session? session, string[] args)
{
if (args.Length < 2)
{
Reply(session, "Needs a landblock and a copy number.");
return;
}
var text = args[0].Trim().Replace("0x", "", StringComparison.OrdinalIgnoreCase);
if (!ushort.TryParse(text, System.Globalization.NumberStyles.HexNumber, null, out var landblock))
{
Reply(session, $"'{args[0]}' is not a hex landblock id.");
return;
}
var copy = Int(args, 1, 1);
var (scaled, counted, tier) = Instances.Scaling(landblock, copy);
Reply(session, $"{landblock:X4} copy {copy}: open {Instances.Find(landblock, copy) is not null}, " +
$"challenge shell {Mod.Settings.ShellFor(landblock) is not null}, " +
$"run on record {Runs.RunFor(landblock, copy) is not null}");
Reply(session, $" InstancesNoDat Scaling.IsScaled = {scaled?.ToString() ?? "unavailable"} " +
$"(false means no creature is tier-scaled and AwakenedRewards sees a stock copy)");
Reply(session, $" InstancesNoDat Scaling.Counted = {counted?.ToString() ?? "unavailable"} (the opener's awakenings, capped)");
Reply(session, $" InstancesNoDat Scaling.TierFor = {tier?.ToString() ?? "unavailable"} (what Aeshnidae.AwakenedRewards asks)");
}
private static void Close(Session? session, string[] args)
{
if (!Resolve(session, args, 2, out var shell, out _))
return;
var landblock = ShellLandblock(shell!);
var copy = Int(args, 1, 1);
Reply(session, Runs.Forget(landblock, copy)
? $"Forgot the run record for copy {copy} of {landblock:X4}. (The copy itself is InstancesNoDat's: inst-close.)"
: $"No run on record for copy {copy} of {landblock:X4}.");
}
private static void RunsReadout(Session? session)
{
var runs = Runs.Open;
if (runs.Count == 0)
{
Reply(session, "No challenge runs on record.");
return;
}
foreach (var run in runs.OrderBy(r => r.Opened))
Reply(session, $"{run.Shell.Name} copy {run.Copy} - {run.OwnerName}, Resolve {run.Resolve}, " +
$"open {(DateTime.UtcNow - run.Opened).TotalMinutes:0.#} min, " +
$"boss {run.Roster.Boss?.Name} {(run.BossDead ? "DEAD" : "alive")}, " +
$"mean {run.Roster.MeanScore:0.###}, reward x{run.RewardMultiplier:0.##}");
}
// ------------------------------------------------------------ helpers
private static ushort ShellLandblock(ChallengeShell shell)
{
shell.TryParse(out var landblock);
return landblock;
}
/// Reads args[0] as a shell landblock and checks there are enough arguments.
private static bool Resolve(Session? session, string[] args, int needed, out ChallengeShell? shell, out ShellPlan? plan)
{
shell = null;
plan = null;
if (args.Length < needed)
{
Reply(session, $"Needs {needed} argument(s). /challenge-admin for the list.");
return false;
}
var text = args[0].Trim().Replace("0x", "", StringComparison.OrdinalIgnoreCase);
if (!ushort.TryParse(text, System.Globalization.NumberStyles.HexNumber, null, out var landblock))
{
Reply(session, $"'{args[0]}' is not a hex landblock id. Try 0190.");
return false;
}
shell = Mod.Settings.ShellFor(landblock);
if (shell is null)
{
Reply(session, $"{landblock:X4} is not a challenge shell. Configured: " +
$"{string.Join(", ", (Mod.Settings.Shells ?? new List()).Select(s => s.Landblock))}");
return false;
}
plan = Shell.PlanFor(shell);
if (plan is null)
{
Reply(session, $"{shell.Name} has no spawn spots loaded - /challenge-admin reload, and check the log.");
return false;
}
return true;
}
private static int Int(string[] args, int at, int fallback) =>
args.Length > at && int.TryParse(args[at], out var value) ? value : fallback;
private static IEnumerable Chunks(string text, int size)
{
for (var i = 0; i < text.Length; i += size)
yield return text.Substring(i, Math.Min(size, text.Length - i));
}
private static void Reply(Session? session, string message)
{
if (session?.Player is null)
{
ModManager.Log(message);
return;
}
foreach (var line in (message ?? "").Split('\n'))
{
var text = line.TrimEnd('\r');
if (!string.IsNullOrWhiteSpace(text))
session.Player.SendMessage(text);
}
}
}