namespace Aeshnidae.AwakenedRewards; /// /// /awakened-rewards and its short form /ar: the terms in force, your own count, /// and - for the item you last appraised - whether it carries either stamp. /// /awakened-rewards-reload re-reads Settings.json. /// /// /ar and not a single letter: the client keeps /a /c /f /g /m /p /v /t /r /e for /// its own channels and emotes and never sends them (CLAUDE.md); two letters /// go through. /// public static class Commands { private const string Description = "Awakened rewards: what loot from scaled dungeon copies and re-earned quest items gain from awakenings, and what the item you last appraised carries."; private const string Usage = "/awakened-rewards (or /ar) the terms, your count, and the stamps on the item you last appraised"; [CommandHandler("awakened-rewards", AccessLevel.Player, CommandHandlerFlag.RequiresWorld, 0, Description, Usage)] [CommandHandler("ar", AccessLevel.Player, CommandHandlerFlag.RequiresWorld, 0, Description, Usage)] public static void HandleShow(Session session, params string[] parameters) { var player = session?.Player; if (player is null) return; var s = Mod.Settings; var sb = new StringBuilder(); if (!s.Enabled) { Reply(session, "Awakened rewards are switched off on this server."); return; } sb.AppendLine("Awakened rewards - what awakening earns beyond the harder dungeons:"); if (s.ScaleCopyLoot) { sb.AppendLine($" loot in a scaled copy : +{s.LootQualityPercentPerTier:0.##}% quality per tier" + (s.LootTierStepEveryTiers > 0 ? $", one loot tier up every {s.LootTierStepEveryTiers} tiers (to tier 8)" : "") + (s.MinimumAwakeningsToWieldLoot ? $"; wearing it takes the copy's tier minus {s.WieldGateBelowTier} in awakenings" : "; no wield gate")); } else sb.AppendLine(" loot in a scaled copy : retail (off)"); if (s.ScaleAttunedItems) { sb.AppendLine($" attuned quest items : +{s.PercentPerAwakening:0.##}% per awakening of whoever first earns them" + (s.MaxPercent > 0 ? $", at most +{s.MaxPercent:0.##}%" : ", uncapped") + " - damage, armour, weapon and damage modifiers, ratings; once, on first ownership"); } else sb.AppendLine(" attuned quest items : retail (off)"); sb.AppendLine($" names : {(s.StampNames ? "\"(Awakened N)\" is added to anything either touched" : "left alone; only the hidden stamps are written")}"); var count = Stamps.AwakeningsOf(player); var percent = count * s.PercentPerAwakening; if (s.MaxPercent > 0) percent = Math.Min(percent, s.MaxPercent); sb.AppendLine($" you : awakened {count} time{(count == 1 ? "" : "s")}" + (s.ScaleAttunedItems ? $" - quest items you earn now are +{percent:0.##}%" : "") + (s.ScaleCopyLoot && s.MinimumAwakeningsToWieldLoot ? $"; you can wear copy loot up to tier {count + Math.Max(0, s.WieldGateBelowTier)}" : "")); sb.Append(DescribeAppraised(player)); if (session!.AccessLevel >= AccessLevel.Advocate) sb.Append($"\n InstancesNoDat : {Instances.Status}"); Reply(session, sb.ToString()); } /// What the last appraised item carries, if anything - one line. private static string DescribeAppraised(Player player) { var guid = player.CurrentAppraisalTarget; if (guid is null || guid == 0) return " last appraised : nothing appraised yet - appraise an item, then /ar"; var item = player.FindObject(guid.Value, Player.SearchLocations.Everywhere); if (item is null) return " last appraised : that object is no longer in reach"; var quest = item.GetProperty(Stamps.QuestAwakeningTier); var loot = item.GetProperty(Stamps.LootAwakeningTier); var sb = new StringBuilder($" last appraised : {item.Name}"); if (quest is null && loot is null) { sb.Append(" - retail, no awakened stamp"); if (item.Attuned == AttunedStatus.Attuned) sb.Append(" (attuned; not yet first-owned since this began, or earned before it)"); return sb.ToString(); } if (loot is int tier) { sb.Append($" - loot from a tier {tier} copy"); var gated = Mod.Settings.MinimumAwakeningsToWieldLoot && (item.ValidLocations ?? EquipMask.None) != EquipMask.None; var need = Mod.Settings.WieldGateFor(tier); sb.Append(gated && need > 0 ? $", needs {need} awakening{(need == 1 ? "" : "s")} to wear" : gated ? ", anyone may wear it" : ", no wield gate"); } if (quest is int n) sb.Append(n > 0 ? $" - quest item scaled by its first owner's {n} awakening{(n == 1 ? "" : "s")}" : " - quest item, first owned at no awakenings (retail numbers)"); return sb.ToString(); } /// /// Re-tiers one stamped loot item a player holds, as if it had dropped at another /// tier: its numbers are divided by the old tier's factor and multiplied by the new /// one's (Attuned.Apply with the ratio), the 9043 stamp and the name follow, so the /// wield gate does too. For a test character whose awakenings were moved under its /// gear (Buffy, 2026-09-22: 400 -> 30 left a tier-200 bow unwieldable). /// /// The factor is today's PercentPerAwakening / MaxPercent for both tiers - right for /// an item stamped under the same settings, approximate for one stamped under others. /// [CommandHandler("ar-restamp", AccessLevel.Admin, CommandHandlerFlag.None, 3, "Re-tier a stamped loot item a player holds: rescale its numbers, stamp and name to another tier.", "/ar-restamp ")] public static void HandleRestamp(Session session, params string[] parameters) { if (!int.TryParse(parameters[^1], out var tier) || tier < 0 || !uint.TryParse(parameters[^2].Replace("0x", "", StringComparison.OrdinalIgnoreCase), System.Globalization.NumberStyles.HexNumber, null, out var guid)) { Reply(session, "/ar-restamp "); return; } var name = string.Join(" ", parameters[..^2]).Trim(); var player = PlayerManager.GetOnlinePlayer(name) ?? PlayerManager.GetOnlinePlayer("+" + name); if (player is null) { Reply(session, $"{name} is not online."); return; } var item = player.FindObject(guid, Player.SearchLocations.MyInventory | Player.SearchLocations.MyEquippedItems); if (item is null) { Reply(session, $"{player.Name} holds no item 0x{guid:X8}."); return; } var old = item.GetProperty(Stamps.LootAwakeningTier) ?? 0; if (old <= 0) { Reply(session, $"{item.Name} carries no loot tier stamp; nothing to re-tier."); return; } double Factor(int t) { var percent = t * Mod.Settings.PercentPerAwakening; if (Mod.Settings.MaxPercent > 0) percent = Math.Min(percent, Mod.Settings.MaxPercent); return 1.0 + percent / 100.0; } var ratio = Factor(tier) / Factor(old); var changed = Attuned.Apply(item, ratio); var baseName = item.Name ?? ""; var cut = baseName.IndexOf(" (Awakened ", StringComparison.Ordinal); if (cut >= 0) baseName = baseName[..cut]; if (tier > 0) { item.SetProperty(Stamps.LootAwakeningTier, tier); item.Name = baseName; Stamps.StampName(item, tier); } else { item.RemoveProperty(Stamps.LootAwakeningTier); item.Name = baseName; } item.SaveBiotaToDatabase(); player.Session?.Network.EnqueueSend(new GameMessageUpdateObject(item)); var line = $"[{Mod.Name}] {player.Name}'s {item.Name} (0x{guid:X8}) re-tiered {old} -> {tier} (x{ratio:0.####}): {changed}"; ModManager.Log(line); Reply(session, line); player.SendMessage($"Your {item.Name} now counts as tier {tier} loot."); } [CommandHandler("awakened-rewards-reload", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Restart the Aeshnidae.AwakenedRewards mod, re-reading Settings.json.", "/awakened-rewards-reload")] public static void HandleReload(Session session, params string[] parameters) { var container = Mod.Container; if (container is null) { Reply(session, $"{Mod.Name} is not loaded."); return; } container.Restart(); Reply(session, $"{Mod.Name} restarted."); } [CommandHandler("ar-lootlog", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Diagnostic: log what goes into every corpse a player makes (one line per corpse). Console-safe; not kept across a reload.", "/ar-lootlog on [player] start (only that player's kills, if named)\n" + "/ar-lootlog off stop\n" + "/ar-lootlog status: corpses logged, how many had a healing or stamina kit")] public static void HandleLootLog(Session session, params string[] parameters) { var verb = parameters.Length > 0 ? parameters[0].ToLowerInvariant() : ""; if (verb == "on") { LootLog.Player = parameters.Length > 1 ? string.Join(" ", parameters.Skip(1)) : ""; LootLog.Logged = LootLog.WithKit = LootLog.RolledKit = 0; LootLog.On = true; } else if (verb == "off") { LootLog.On = false; } Reply(session, $"lootlog {(LootLog.On ? "ON" : "off")}{(LootLog.Player.Length > 0 ? $" for {LootLog.Player}" : "")}: " + $"{LootLog.Logged} corpse(s) logged, a kit rolled for {LootLog.RolledKit}, a kit in the corpse for {LootLog.WithKit}"); } [CommandHandler("ar-tierfeed", AccessLevel.Admin, CommandHandlerFlag.None, 0, "The tier feed (PropertyInt 9051 to each player's own client): every player online with the tier where they stand and the tier last sent, and every open copy with its tier. Console-safe.", "/ar-tierfeed the readout\n" + "/ar-tierfeed send re-send every online player their tier now")] public static void HandleTierFeed(Session session, params string[] parameters) { var s = Mod.Settings; if (parameters.Length > 0 && parameters[0].Equals("send", StringComparison.OrdinalIgnoreCase)) { foreach (var p in PlayerManager.GetAllOnline()) TierFeed.Check(p, "ar-tierfeed send", force: true); } var sb = new StringBuilder(); sb.AppendLine($"tier feed {(s.SendTierToClient ? "ON" : "off")}: PropertyInt {TierFeed.PropertyId}, private, not saved; " + $"{TierFeed.Sends} send(s) since load; InstancesNoDat {Instances.Status}"); var online = PlayerManager.GetAllOnline(); sb.AppendLine($" players online: {online.Count}"); foreach (var p in online.OrderBy(p => p.Name)) { var block = p.CurrentLandblock; var where = block is null ? "not placed" : $"{block.Id.Landblock:X4}"; sb.AppendLine($" {p.Name}: at {where}, tier here {TierFeed.TierHere(p)}, last sent {TierFeed.LastSent(p)?.ToString() ?? "nothing"}"); } var copies = Instances.OpenCopies(); sb.AppendLine($" open copies: {copies.Count}"); foreach (var c in copies.OrderBy(c => c.Landblock).ThenBy(c => c.Copy)) sb.AppendLine($" {c.Landblock:X4} copy {c.Copy}: tier {c.Tier}"); Reply(session, sb.ToString()); } 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.Network.EnqueueSend(new GameMessageSystemChat(text, ChatMessageType.Broadcast)); } } }