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