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