namespace Aeshnidae.SkillMastery; /// /// Mastery is computed, never stored on the skill. /// /// The first cut of this wrote the bonus into CreatureSkill.InitLevel, which the /// client already renders. That was wrong: TrainSkill, SpecializeSkill, UntrainSkill, /// UnspecializeSkill, ResetSkill and AugmentationDevice all bare-assign InitLevel, so /// a player who enlightened and then re-trained had the bonus silently wiped a moment /// after it was restored. Chasing six writers with six postfixes would have held only /// until ACE added a seventh. /// /// So nothing of ours goes into the skill at all. Base and Current are postfixed to /// add the bonus on the way out, and the two serializers that send skills to the /// client are patched to include it in what they transmit. ACE can assign InitLevel /// as freely as it likes - we are not in its way and it is not in ours, and /// enlightenment stops being a special case entirely. /// /// Base and Current are read constantly during combat, so the lookup is served from /// an in-memory cache and never touches the database. /// public static class Mastery { /// character guid -> skill -> mastery ranks. Loaded at login. private static readonly ConcurrentDictionary> _cache = new(); private static readonly AccessTools.FieldRef _creatureOf = AccessTools.FieldRefAccess("creature"); /// Skill points granted by a rank count - whole points only. public static int SkillPointsFor(int ranks) => Mod.Settings.RanksPerSkillPoint < 1 ? ranks : ranks / Mod.Settings.RanksPerSkillPoint; /// /// A rank count as points with the fraction showing: 37 ranks at ten per point is /// "3.7". With one rank per point it is just the number, so the readouts read the /// same whichever way the server is set. /// public static string Points(int ranks) { var per = Math.Max(1, Mod.Settings.RanksPerSkillPoint); if (per == 1) return ranks.ToString("N0"); return $"{ranks / per:N0}.{ranks % per}"; } /// What one rank is called in chat: "a point" or "a tenth of a point". public static string RankWord() { var per = Math.Max(1, Mod.Settings.RanksPerSkillPoint); return per switch { 1 => "a point", 10 => "a tenth of a point", _ => $"1/{per} of a point", }; } public static void LoadInto(Player player) { if (!MasteryDb.Ready) return; var rows = MasteryDb.Load(player.Guid.Full); var skills = new Dictionary(); foreach (var (skill, ranks) in rows) { if (ranks > 0) skills[skill] = ranks; } _cache[player.Guid.Full] = skills; } public static int RanksOf(Player player, Skill skill) => _cache.TryGetValue(player.Guid.Full, out var skills) && skills.TryGetValue(skill, out var ranks) ? ranks : 0; private static void SetRanks(Player player, Skill skill, int ranks) { var skills = _cache.GetOrAdd(player.Guid.Full, _ => new Dictionary()); lock (skills) skills[skill] = ranks; MasteryDb.Save(player.Guid.Full, skill, ranks); } /// /// The skill points mastery is currently contributing. Zero unless the skill is /// trained or specialized - mastery in a skill you have dropped is banked, not /// spent, and comes back the moment you train it again. /// public static int BonusFor(CreatureSkill creatureSkill) { if (!Mod.Settings.Enabled || creatureSkill is null) return 0; if (creatureSkill.AdvancementClass < SkillAdvancementClass.Trained) return 0; if (_creatureOf(creatureSkill) is not Player player) return 0; if (!_cache.TryGetValue(player.Guid.Full, out var skills)) return 0; lock (skills) return skills.TryGetValue(creatureSkill.Skill, out var ranks) ? SkillPointsFor(ranks) : 0; } /// /// The mastery bonus as it should land on Current, scaled by the same enchantment /// multiplier and vitae that retail ranks get. /// /// CreatureSkill.Current multiplies its base by enchantments and vitae before we /// ever see the result, so a flat postfix would leave mastery unbuffable - strictly /// worse than an equivalent retail rank for anyone with a buff up, which is most /// people most of the time. Mastery already pays a heavy tax in RanksPerSkillPoint; /// it should not pay a second one by being excluded from buffs. /// /// Applying the same factors here is arithmetically equal to injecting the bonus /// into the base, without needing a transpiler. The one difference is rounding: /// ACE rounds its total once at the end and this adds a separately rounded term, so /// the result can sit a single point away from a true inline injection. /// public static int ScaledBonusFor(CreatureSkill creatureSkill) { var bonus = BonusFor(creatureSkill); if (bonus <= 0) return 0; var creature = _creatureOf(creatureSkill); if (creature is null) return bonus; var multiplier = creature.EnchantmentManager?.GetSkillMod_Multiplier(creatureSkill.Skill) ?? 1.0f; // Vitae is a death penalty below 1.0; retail ranks are scaled by it, so mastery is too. var vitae = creature is Player player && player.Vitae != 1.0f ? player.Vitae : 1.0f; return (int)Math.Max(0, Math.Round(bonus * multiplier * vitae)); } /// /// What buying permanent power looks like. Retail plays AugmentationUseSkill when /// an augmentation gem spends experience on a skill and WeddingBliss - the /// fireworks - the moment a skill reaches its ceiling; /raise is both of those, so /// players already read the effects as "I bought power" and "I hit a milestone". /// The burst and the RaiseTrait chime on every purchase; the fireworks on the /// first point and every tenth after, which with one rank per point means every /// tenth /raise rather than every one. /// /// Raise runs on the command thread, so the broadcasts go through an ActionChain /// onto the player's landblock. Nothing here can affect the purchase: it has /// already been paid for and saved by the time this is called. /// private static void Celebrate(Player player, int pointsBefore, int pointsAfter) { if (!Mod.Settings.Effects || pointsAfter <= pointsBefore) return; try { var milestone = pointsBefore == 0 || pointsAfter / 10 > pointsBefore / 10; var chain = new ActionChain(); // Each beat guards itself: the action queue has no catch of its own, and a // player who logs out between beats has no session and no landblock. chain.AddAction(player, () => Beat(player, () => { player.ApplyVisualEffects(PlayScript.AugmentationUseSkill); player.EnqueueBroadcast(new GameMessageSound(player.Guid, Sound.RaiseTrait, 1.0f)); })); if (milestone) { chain.AddDelaySeconds(1.0); chain.AddAction(player, () => Beat(player, () => player.ApplyVisualEffects(PlayScript.WeddingBliss))); } chain.EnqueueChain(); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] mastery effect failed for {player.Name}: {ex.Message}", ModManager.LogLevel.Warn); } } private static void Beat(Player player, Action act) { try { if (player?.Session is null || player.CurrentLandblock is null) return; act(); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] mastery effect beat failed: {ex.Message}", ModManager.LogLevel.Warn); } } /// /// The last retail rank's price for this advancement class - what a /// CostPerSkillPoint of 1.0 means. Read from the dat so it stays right if the /// table ever changes underneath us. /// public static long BaseRankCost(SkillAdvancementClass advancementClass) { var table = DatManager.PortalDat?.XpTable; if (table is null) return 0; var list = advancementClass == SkillAdvancementClass.Specialized ? table.SpecializedSkillXpList : table.TrainedSkillXpList; return list.Count < 2 ? 0 : list[^1] - list[^2]; } /// /// Price of the next rank, given how many skill points are already held. Growth /// compounds only from the band's own start, so editing one band never shifts the /// ones after it. /// /// One price for every skill unless PriceByAdvancementClass says otherwise: the /// ranks bought are class-agnostic (BonusFor applies them to a trained or a /// specialized skill alike), so a price that depended on the class at the moment /// of purchase could be dodged by ordering the purchases. /// public static long CostOfNextRank(int currentRanks, SkillAdvancementClass advancementClass) { var points = SkillPointsFor(currentRanks); var band = Mod.Settings.BandFor(points); var priceClass = Mod.Settings.PriceByAdvancementClass ? advancementClass : SkillAdvancementClass.Trained; var baseCost = BaseRankCost(priceClass); // The band prices a whole skill point; split it across the ranks that make // one up, so RanksPerSkillPoint changes granularity and never total cost. var cost = baseCost * band.CostPerSkillPoint / Math.Max(1, Mod.Settings.RanksPerSkillPoint); if (band.Growth is > 0 and not 1.0) cost *= Math.Pow(band.Growth, points - Mod.Settings.BandStart(band)); return (long)Math.Max(1, Math.Round(cost)); } /// /// Total price of buying ranks from where the character /// stands. Priced rank by rank, so a purchase that crosses a band boundary pays /// the right price on each side of it. /// public static long CostOfRanks(int currentRanks, int count, SkillAdvancementClass advancementClass) { long total = 0; for (var i = 0; i < count; i++) total += CostOfNextRank(currentRanks + i, advancementClass); return total; } /// /// Buys ranks with the account's banked Radiance. Returns a message either /// way - this is only ever called from a command, and the player wants to know why /// nothing happened as much as they want to know when it worked. /// public static bool Raise(Player player, Skill skill, int count, out string message) { message = ""; if (!Mod.Settings.Enabled) { message = "Skill mastery is turned off on this server."; return false; } if (!MasteryDb.Ready) { message = $"Mastery storage is unavailable: {MasteryDb.LastError}"; return false; } if (count < 1) { message = "Raise by at least one rank."; return false; } var creatureSkill = player.GetCreatureSkill(skill, false); if (creatureSkill is null || creatureSkill.AdvancementClass < SkillAdvancementClass.Trained) { message = $"You must be trained in {skill.ToSentence()} before you can master it."; return false; } if (Mod.Settings.RequireRetailCap && !creatureSkill.IsMaxRank) { message = $"{skill.ToSentence()} is not at its retail maximum yet - raise it the normal way first."; return false; } var currentRanks = RanksOf(player, skill); var maxRanks = Mod.Settings.MaxSkillPoints * Math.Max(1, Mod.Settings.RanksPerSkillPoint); if (currentRanks + count > maxRanks) { message = $"That would pass the mastery ceiling of {Mod.Settings.MaxSkillPoints:N0} skill points."; return false; } var cost = CostOfRanks(currentRanks, count, creatureSkill.AdvancementClass); if (player.Account is null) { message = "Mastery is paid for in Radiance from your account bank, and this character has no account."; return false; } // Radiance, from the account's bank balance. The debit is guarded in SQL, so the // balance check here is for the message; the UPDATE is what actually decides. var accountId = player.Account.AccountId; var available = MasteryDb.RadianceBalance(accountId); if (cost > available) { message = $"{skill.ToSentence()} needs {cost:N0} Radiance for {count} rank(s); your account has {available:N0} banked. " + "(Radiance earned in the last few seconds may not be banked yet.)"; return false; } if (!MasteryDb.TryDebitRadiance(accountId, cost, out var remaining)) { message = "That could not be paid for - your Radiance balance changed while the purchase was being made. Try again."; return false; } var newRanks = currentRanks + count; SetRanks(player, skill, newRanks); // Re-send the skill so the client picks up the new total straight away. player.Session?.Network.EnqueueSend(new GameMessagePrivateUpdateSkill(player, creatureSkill)); var before = SkillPointsFor(currentRanks); var after = SkillPointsFor(newRanks); var perPoint = Math.Max(1, Mod.Settings.RanksPerSkillPoint); Celebrate(player, before, after); message = $"Your {skill.ToSentence()} mastery rises to {Points(newRanks)} skill points, for {cost:N0} Radiance ({remaining:N0} left)."; if (perPoint > 1) { var toNext = perPoint - newRanks % perPoint; message += after > before ? $" That is +{after:N0} on the skill now." : $" The skill shows +{after:N0} until the next whole point - {toNext} more raise{(toNext == 1 ? "" : "s")}."; } return true; } }