namespace Aeshnidae.SkillMastery; /// /// Breaks a skill down into everything that feeds it, so a number can be explained /// rather than guessed at. /// /// Two rules keep this honest. Components are derived arithmetically from ACE's own /// Base and Current rather than recomputed, so they always sum correctly. And the /// final effective figure for a defence is taken from Creature.GetEffectiveDefenseSkill /// itself - the same call the combat system makes - so what this prints is what you /// are actually defending with, not this mod's opinion of it. /// public static class CheckCommand { [CommandHandler("check", AccessLevel.Player, CommandHandlerFlag.RequiresWorld, 1, "Show what a skill actually works out to, and where each part of it comes from.", "/check ")] public static void HandleCheck(Session session, params string[] parameters) { var player = session.Player; if (player is null || parameters.Length == 0) return; var name = string.Join(" ", parameters); if (!TryParseSkill(name, out var skill)) { Reply(session, $"No skill called '{name}'."); return; } var creatureSkill = player.GetCreatureSkill(skill, false); if (creatureSkill is null) { Reply(session, $"You have no {skill.ToSentence()} skill."); return; } var mastery = Mastery.BonusFor(creatureSkill); var augBase = creatureSkill.GetAugBonus_Base(player); var baseTotal = creatureSkill.Base; // includes mastery, via our postfix var current = creatureSkill.Current; // base, then enchantments and vitae // Whatever Base holds that is not ranks, init, augs or mastery is the attribute // contribution - derived rather than recomputed so the column always adds up. var attributes = Math.Max(0, (long)baseTotal - creatureSkill.Ranks - creatureSkill.InitLevel - augBase - mastery); var sb = new StringBuilder(); sb.AppendLine($"{skill.ToSentence()} ({creatureSkill.AdvancementClass})"); sb.AppendLine($" attributes : {attributes,10:N0}"); sb.AppendLine($" retail ranks : {creatureSkill.Ranks,10:N0}"); if (creatureSkill.InitLevel > 0) sb.AppendLine($" init/spec bonus : {creatureSkill.InitLevel,10:N0}"); if (augBase > 0) sb.AppendLine($" augmentations : {augBase,10:N0}"); var heldRanks = Mastery.RanksOf(player, skill); var partial = heldRanks % Math.Max(1, Mod.Settings.RanksPerSkillPoint) != 0; sb.AppendLine($" mastery : {mastery,10:N0}" + (creatureSkill.AdvancementClass < SkillAdvancementClass.Trained ? " (dormant - not trained)" : partial ? $" ({Mastery.Points(heldRanks)} held; whole points count)" : "")); sb.AppendLine($" base : {baseTotal,10:N0}"); sb.AppendLine($" buffs/debuffs : {(long)current - baseTotal,+10:N0}" + (baseTotal > 0 ? $" (x{(double)current / baseTotal:0.000})" : "")); sb.AppendLine($" current : {current,10:N0}"); AppendCombat(sb, player, skill, current); Reply(session, sb.ToString().TrimEnd()); } /// /// The combat layer, which lives outside CreatureSkill entirely and is therefore /// the part people forget. Each branch mirrors ACE's own function and then prints /// that function's result, so the total is never this mod's arithmetic: /// /// melee/missile defence Round(Current * weapon * burden * stance + imbues) /// magic defence Round(Current * weapon + imbues) /// attack Round(Current * accuracy * offense) /// private static void AppendCombat(StringBuilder sb, Player player, Skill skill, uint current) { if (skill is Skill.MeleeDefense or Skill.MissileDefense) { var combatType = skill == Skill.MissileDefense ? CombatType.Missile : CombatType.Melee; var weaponMod = combatType == CombatType.Missile ? WorldObject.GetWeaponMissileDefenseModifier(player) : WorldObject.GetWeaponMeleeDefenseModifier(player); var imbueType = combatType == CombatType.Missile ? ImbuedEffectType.MissileDefense : ImbuedEffectType.MeleeDefense; sb.AppendLine(" --- in combat ---"); sb.AppendLine($" weapon : {"x" + weaponMod.ToString("0.000"),10}"); sb.AppendLine($" burden : {"x" + player.GetBurdenMod().ToString("0.000"),10}"); sb.AppendLine($" stance : {"x" + player.GetDefenseStanceMod().ToString("0.000"),10}"); sb.AppendLine($" armour imbues : {player.GetDefenseImbues(imbueType),+10:N0}"); sb.AppendLine($" EFFECTIVE : {player.GetEffectiveDefenseSkill(combatType),10:N0}"); AppendCaveats(sb, player); return; } if (skill == Skill.MagicDefense) { sb.AppendLine(" --- in combat ---"); sb.AppendLine($" weapon : {"x" + WorldObject.GetWeaponMagicDefenseModifier(player).ToString("0.000"),10}"); sb.AppendLine($" armour imbues : {player.GetDefenseImbues(ImbuedEffectType.MagicDefense),+10:N0}"); sb.AppendLine($" EFFECTIVE : {player.GetEffectiveMagicDefense(),10:N0}"); AppendCaveats(sb, player); return; } // Attack. ACE composes this from whatever weapon is actually equipped, so the // figure is only meaningful for the skill that weapon uses. var currentWeaponSkill = player.GetCurrentWeaponSkill(); if (skill != currentWeaponSkill) { sb.Append($" (equip a {skill.ToSentence()} weapon to see its effective attack; " + $"currently wielding {currentWeaponSkill.ToSentence()})"); return; } var offenseMod = WorldObject.GetWeaponOffenseModifier(player); var effective = player.GetEffectiveAttackSkill(); sb.AppendLine(" --- in combat ---"); sb.AppendLine($" weapon offense : {"x" + offenseMod.ToString("0.000"),10}"); // Accuracy is derived rather than read, so it always reconciles the two numbers. if (current > 0 && offenseMod > 0) sb.AppendLine($" accuracy : {"x" + (effective / (current * offenseMod)).ToString("0.000"),10}"); sb.AppendLine($" EFFECTIVE : {effective,10:N0}"); AppendCaveats(sb, player); } private static void AppendCaveats(StringBuilder sb, Player player) { if (player.CombatMode == CombatMode.NonCombat) sb.AppendLine(" (out of combat - weapon modifiers do not apply until you draw)"); if (player.IsExhausted) sb.AppendLine(" (exhausted)"); } private static bool TryParseSkill(string name, out Skill skill) { var cleaned = name.Replace(" ", "").Replace("_", ""); foreach (Skill candidate in Enum.GetValues(typeof(Skill))) { if (candidate.ToString().Equals(cleaned, StringComparison.OrdinalIgnoreCase)) { skill = candidate; return true; } } skill = Skill.None; return false; } /// /// One chat message per line, with any carriage return stripped. /// /// StringBuilder.AppendLine emits Environment.NewLine, which is CR LF on Windows. /// The client breaks the line on LF and renders the stray CR as an unmapped glyph - /// those are the musical notes. Every other mod here splits the same way; this is /// the house pattern, not a local workaround. /// private static void Reply(Session session, string message) { foreach (var line in (message ?? "").Split('\n')) { var text = line.TrimEnd('\r'); if (!string.IsNullOrWhiteSpace(text)) session.Network.EnqueueSend(new GameMessageSystemChat(text, ChatMessageType.Broadcast)); } } }