namespace Aeshnidae.Mule; /// /// `mule-admin` (operations) and `mule-test` (the staging harness). /// /// Both are CommandHandlerFlag.None so they run from the console through /// Aeshnidae.RemoteConsole as well as in game. That matters for more than convenience: /// RemoteConsole invokes a handler on its own watcher thread, NOT the world thread, which /// is the thread arrangement the account lock exists for - and the one the race test uses. /// public static class ConsoleCommands { [CommandHandler("mule-admin", AccessLevel.Admin, CommandHandlerFlag.None, -1, "The vault, from the operator's side.", "mule-admin status\n" + "mule-admin reload re-read Settings.json\n" + "mule-admin verify [account] memory against the shard, for one account or all\n" + "mule-admin chain the account's container chain and what is in it\n" + "mule-admin where where the shard says one object is\n" + "mule-admin log [n] the ledger for an account\n" + "mule-admin tally ledger totals by direction and result\n" + "mule-admin orphans vault containers with no row, and rows with no container\n" + "mule-admin release save and let go of a loaded vault\n" + "mule-admin save save every loaded vault now")] public static void HandleAdmin(Session session, params string[] parameters) { try { Say(session, Admin(parameters ?? Array.Empty())); } catch (Exception ex) { Say(session, $"mule-admin threw: {ex.Message}"); ModManager.Log($"[{Mod.Name}] mule-admin threw: {ex}", ModManager.LogLevel.Error); } } private static string Admin(string[] args) { if (args.Length == 0) return Status(); switch (args[0].ToLowerInvariant()) { case "reload": Mod.ReloadSettings(); return $"settings re-read: enabled {Mod.Settings.Enabled}, limit {Mod.Settings.MaxItems} in {Mod.Settings.ChainLength} link(s) of {Mod.Settings.SlotsPerContainer}, attuned {Mod.Settings.AllowAttuned}, packs {Mod.Settings.AllowPacksAndFoci}, harness {Mod.Settings.AllowTestHarness}"; case "verify": return args.Length > 1 && uint.TryParse(args[1], out var va) ? Verify(va) : VerifyAll(); case "chain": return args.Length > 1 && uint.TryParse(args[1], out var ca) ? Chain(ca) : "mule-admin chain "; case "where": return args.Length > 1 ? Where(args[1]) : "mule-admin where "; case "log": if (args.Length < 2 || !uint.TryParse(args[1], out var la)) return "mule-admin log [n]"; var n = args.Length > 2 && int.TryParse(args[2], out var nn) ? nn : 25; return Log(la, n); case "tally": return Tally(); case "orphans": return Orphans(); case "release": if (args.Length < 2 || !uint.TryParse(args[1], out var ra)) return "mule-admin release "; return Vault.Release(ra) ? $"account {ra}'s vault saved and released" : $"account {ra} has no vault loaded"; case "save": return $"{Vault.SaveAll("mule-admin save")} row(s) written"; default: return $"'{args[0]}' is not a mule-admin verb"; } } private static string Status() { var text = new StringBuilder(); text.AppendLine($"Aeshnidae.Mule: {(Mod.Settings.Enabled ? "on" : "OFF")}, storage {(MuleDb.Ready ? "ready" : $"UNAVAILABLE ({MuleDb.LastError})")}"); text.AppendLine($" limit {Mod.Settings.MaxItems:N0} items, {Mod.Settings.ChainLength} link(s) of {Mod.Settings.SlotsPerContainer}; weenie {Mod.Settings.VaultWcid}"); text.AppendLine($" attuned allowed {Mod.Settings.AllowAttuned}, packs and foci allowed {Mod.Settings.AllowPacksAndFoci}, test harness {Mod.Settings.AllowTestHarness}"); text.AppendLine($" {Vault.LoadedCount} vault(s) loaded; loads started {Vault.LoadsStarted}, finished {Vault.LoadsFinished}, failed {Vault.LoadsFailed}"); text.AppendLine($" account lock contended {Vault.LockWaits} time(s); sweep has written {Vault.SweepSaves} row(s)"); text.AppendLine($" {MuleDb.ProtectedCount} container(s) protected from ACE's orphaned-biota purge; {Patches.PurgesRefused} purge(s) refused so far"); foreach (var handle in Vault.Loaded.OrderBy(h => h.AccountId)) text.AppendLine($" account {handle.AccountId}: {handle.Count} item(s), {handle.Links.Count} link(s), " + $"{(handle.Fault.Length > 0 ? "FAULT: " + handle.Fault : handle.Ready ? "ready" : "loading")}, " + $"idle {(DateTime.UtcNow - handle.LastTouched).TotalSeconds:0}s"); return text.ToString().TrimEnd(); } private static string Verify(uint accountId) { var rows = MuleDb.Chain(accountId); if (rows.Count == 0) return $"account {accountId} has no vault"; var onDisk = MuleDb.CountInContainers(rows.Select(r => r.ContainerId)); var loaded = Vault.Loaded.FirstOrDefault(h => h.AccountId == accountId); var ledger = MuleDb.Recent(accountId, 1); var line = $"account {accountId}: {rows.Count} link(s), shard holds {onDisk} item(s)"; if (loaded is null) return line + " (not loaded, so nothing to compare)"; return line + $", memory holds {loaded.Count} - " + (loaded.Count == onDisk ? "AGREE" : "DISAGREE") + (ledger.Count > 0 ? $"; last movement {ledger[0].At:yyyy-MM-dd HH:mm:ss} {ledger[0].Direction} {ledger[0].Result}" : ""); } private static string VerifyAll() { var accounts = MuleDb.AllLinks().Select(r => r.AccountId).Distinct().OrderBy(a => a).ToList(); if (accounts.Count == 0) return "no account has a vault yet"; var text = new StringBuilder($"{accounts.Count} account(s) with a vault:\n"); foreach (var account in accounts) text.AppendLine(" " + Verify(account)); return text.ToString().TrimEnd(); } private static string Chain(uint accountId) { var rows = MuleDb.Chain(accountId); if (rows.Count == 0) return $"account {accountId} has no vault"; var text = new StringBuilder($"account {accountId}:\n"); foreach (var row in rows) { var count = MuleDb.CountInContainers(new[] { row.ContainerId }); var biota = DatabaseManager.Shard.BaseDatabase.GetBiota(row.ContainerId); text.AppendLine($" link {row.Seq}: 0x{row.ContainerId:X8}, {count} item(s) on the shard, biota {(biota is null ? "MISSING" : $"wcid {biota.WeenieClassId}")}"); } return text.ToString().TrimEnd(); } private static string Where(string raw) { if (!TryGuid(raw, out var guid)) return $"'{raw}' is not an object id"; var w = MuleDb.WhereIs(guid); if (!w.Exists) return $"0x{guid:X8}: no biota row - it does not exist"; var owner = w.Container == 0 ? 0u : MuleDb.OwnerOf(w.Container); return $"0x{guid:X8}: container 0x{w.Container:X8}{(owner != 0 ? $" (vault of account {owner})" : "")}, " + $"wielder 0x{w.Wielder:X8}, location {(w.HasLocation ? "yes" : "no")}"; } private static string Log(uint accountId, int n) { var lines = MuleDb.Recent(accountId, n); if (lines.Count == 0) return $"account {accountId} has no ledger lines"; var text = new StringBuilder($"account {accountId}, last {lines.Count} line(s):\n"); foreach (var line in lines) text.AppendLine($" #{line.Id} {line.At:yyyy-MM-dd HH:mm:ss.fff} {line.Direction,-8} {line.Result,-22} 0x{line.ObjectId:X8} {line.ItemName} ({line.Character}){(line.Detail.Length > 0 ? " - " + line.Detail : "")}"); return text.ToString().TrimEnd(); } private static string Tally() { var rows = MuleDb.Tally(); if (rows.Count == 0) return "the ledger is empty"; var text = new StringBuilder("ledger totals:\n"); foreach (var (direction, result, count) in rows) text.AppendLine($" {direction,-8} {result,-24} {count,8:N0}"); return text.ToString().TrimEnd(); } /// /// The two ways the chain can be inconsistent: a row naming a container that is not in /// the biota table (items pointing at it are unreachable), and a container of our /// weenie that no row claims (left behind by a crash between the save and the row - see /// MuleDb.AddLink). /// private static string Orphans() { var rows = MuleDb.AllLinks(); var text = new StringBuilder(); var bad = 0; foreach (var row in rows) { if (DatabaseManager.Shard.BaseDatabase.GetBiota(row.ContainerId) is null) { bad++; var items = MuleDb.CountInContainers(new[] { row.ContainerId }); text.AppendLine($" row without a container: account {row.AccountId} link {row.Seq} -> 0x{row.ContainerId:X8}, {items} item(s) pointing at it"); } } var claimed = rows.Select(r => r.ContainerId).ToHashSet(); var all = DatabaseManager.Shard.BaseDatabase.GetBiotasByWcid(Mod.Settings.VaultWcid); foreach (var biota in all) { if (claimed.Contains(biota.Id)) continue; bad++; var items = MuleDb.CountInContainers(new[] { biota.Id }); text.AppendLine($" container without a row: 0x{biota.Id:X8}, {items} item(s) in it"); } return bad == 0 ? $"{rows.Count} link(s) checked, every one has its container and every container has its row" : $"{bad} problem(s) found:\n{text.ToString().TrimEnd()}"; } // ---- the harness --------------------------------------------------------- [CommandHandler("mule-test", AccessLevel.Admin, CommandHandlerFlag.None, -1, "STAGING ONLY. Drives the vault's core with a stand-in pack. Settings.AllowTestHarness must be true.", "mule-test pack make or load the stand-in pack\n" + "mule-test fill [wcid|t

] mint n real items into it (t31 = treasure profile 31)\n" + "mule-test deposit deposit n of them\n" + "mule-test withdraw take n back out\n" + "mule-test race two threads deposit at once - proves the lock\n" + "mule-test count memory against the shard, both sides\n" + "mule-test audit where every item it ever made is now\n" + "mule-test pause debug: ms to sleep between the steps of a movement\n" + "mule-test clear destroy the stand-in pack and its items")] public static void HandleTest(Session session, params string[] parameters) { if (!Mod.Settings.AllowTestHarness) { Say(session, "mule-test is off. It mints and destroys real items; set AllowTestHarness true in Settings.json (staging only) and `mule-admin reload`."); return; } var args = parameters ?? Array.Empty(); if (args.Length < 2 || !uint.TryParse(args[1], out var accountId)) { Say(session, "mule-test [...]"); return; } try { Say(session, args[0].ToLowerInvariant() switch { "pack" => MuleTest.Pack(accountId, out var e) is { } p ? $"stand-in pack 0x{p.Guid.Full:X8}, {p.Inventory.Count} item(s), {Mod.Settings.SlotsPerContainer} slots" : $"no stand-in pack: {e}", "fill" => MuleTest.Fill(accountId, Int(args, 2, 10), Wcid(args, 3), Profile(args, 3)), "deposit" => MuleTest.Deposit(accountId, Int(args, 2, 1)), "withdraw" => MuleTest.Withdraw(accountId, Int(args, 2, 1)), "race" => MuleTest.Race(accountId, Int(args, 2, 10)), "count" => MuleTest.Count(accountId), // The crash-test aiming knobs. Two gaps, set separately so a kill can be // aimed at one of them and the other ruled out. "pause" => Pause(Int(args, 2, 0), Int(args, 3, 0)), "audit" => MuleTest.Audit(accountId), "clear" => MuleTest.Clear(accountId), _ => $"'{args[0]}' is not a mule-test verb", }); } catch (Exception ex) { Say(session, $"mule-test threw: {ex.Message}"); ModManager.Log($"[{Mod.Name}] mule-test threw: {ex}", ModManager.LogLevel.Error); } } private static string Pause(int afterRemove, int afterAdd) { Movement.DebugPauseAfterRemoveMs = Math.Clamp(afterRemove, 0, 5000); Movement.DebugPauseAfterAddMs = Math.Clamp(afterAdd, 0, 5000); return $"movements will now sleep {Movement.DebugPauseAfterRemoveMs}ms between leaving the source and arriving in the target, " + $"and {Movement.DebugPauseAfterAddMs}ms between arriving and the commit. `mule-test pause 0 0` puts it back."; } private static int Int(string[] args, int index, int fallback) => args.Length > index && int.TryParse(args[index], out var v) ? v : fallback; ///

`fill`'s third argument: a bare number is a wcid, t<n> is a treasure profile. private static uint Wcid(string[] args, int index) => args.Length > index && !args[index].StartsWith('t') && uint.TryParse(args[index], out var v) ? v : 3000001; private static int Profile(string[] args, int index) => args.Length > index && args[index].StartsWith('t') && int.TryParse(args[index][1..], out var v) ? v : 0; private static bool TryGuid(string s, out uint guid) { s = (s ?? "").Trim(); if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) return uint.TryParse(s[2..], System.Globalization.NumberStyles.HexNumber, null, out guid); return uint.TryParse(s, out guid); } /// /// Console or chat, whichever asked. A console command gets a null session, and /// ModManager.Log is what RemoteConsole reads back out of the journal. /// private static void Say(Session? session, string message) { foreach (var line in (message ?? "").Split('\n')) { var text = line.TrimEnd('\r'); if (string.IsNullOrWhiteSpace(text)) continue; if (session?.Player is not null) session.Player.SendMessage(text); else ModManager.Log($"[{Mod.Name}] {text}"); } } }