namespace Aeshnidae.Awakens; /// /// The awakening itself, and the count of them. /// /// The count lives on the character as a server-only integer property, id 9040 - /// a value ACE's PropertyInt enum does not define, so it is never sent to the /// client (only [SendOnLogin] members are), never shown, and simply rides in the /// biota: saved with the character, backed up with the character, and left alone /// by retail enlightenment, which resets only the properties it names. /// Aeshnidae.InstancesNoDat reads the same id for its tier; LevelCap reads it for /// the character's level cap. /// public static class Awakening { /// PropertyInt 9040: how many times this character has awakened. public const PropertyInt Property = (PropertyInt)9040; public static int CountOf(Player player) => player?.GetProperty(Property) ?? 0; private static readonly ConcurrentDictionary _confirmations = new(); /// The reason a character cannot awaken right now, or null if they can. public static string? Refusal(Player player) { var s = Mod.Settings; if (!s.Enabled) return "Awakening is turned off on this server."; var level = player.Level ?? 1; var cap = LevelCap.Of(player); if (level < cap) return $"You must be at your level cap to awaken - {cap} for you; you are {level}."; var count = CountOf(player); if (s.MaxAwakenings > 0 && count >= s.MaxAwakenings) return $"You have awakened {count} times, which is the most this server allows."; if (player.IsDead) return "Not while you are dead."; if (player.Teleporting || player.IsInDeathProcess) return "Not in the middle of a portal."; if (player.CombatMode != CombatMode.NonCombat) return "Leave combat mode first."; // Everything worn goes to the pack with the reset, and ACE's put-in-container // refuses silently while the character is busy (a pickup walk, an item in use) // or trading - which would have left level-275 gear on a level-1 character with // no skills, and nothing ever re-checks worn gear (exploit review, 2026-09-19). if (s.ResetSkillsAndAttributes) { if (player.IsBusy || player.PickupState != PickupState.None) return "Finish what you are doing first."; if (player.IsTrading) return "Not while trading."; var worn = player.EquippedObjects.Count; var free = player.GetFreeInventorySlots(includeSidePacks: false); if (free < worn) return $"You need {worn} free slots in your main pack for what you are wearing; you have {free}."; } return null; } /// First /awaken: arm the confirmation. public static void Arm(Player player) => _confirmations[player.Guid.Full] = DateTime.UtcNow; public static bool IsArmed(Player player) => _confirmations.TryGetValue(player.Guid.Full, out var when) && (DateTime.UtcNow - when).TotalSeconds <= Mod.Settings.ConfirmSeconds; /// /// The awakening. Everything here is what retail enlightenment does to the /// level and nothing it does to anything else: TotalExperience to 0, Level to 1, /// the client told both; unassigned experience, skills, attributes, credits, /// society, luminance, auras and titles untouched. Level-gated gear to the pack /// first, while the character is still high enough that the move is ordinary. /// public static string Do(Player player) { _confirmations.TryRemove(player.Guid.Full, out _); var refusal = Refusal(player); if (refusal is not null) return refusal; var before = player.Level ?? 1; var reset = Mod.Settings.ResetSkillsAndAttributes; var moved = 0; if (reset) { // Retail enlightenment's own routines, in its order: everything off first // (skill-gated gear cannot stay on once the skills are gone), then the // attributes, then the skills - which also zeroes unassigned experience // and puts skill credits back to heritage plus quest credits. moved = player.EquippedObjects.Count; Enlightenment.DequipAllItems(player); // Nothing may stay on. If anything did (a refusal we did not foresee), stop // here with the character untouched rather than reset around it. if (player.EquippedObjects.Count > 0) { ModManager.Log($"[{Mod.Name}] {player.Name}: {player.EquippedObjects.Count} item(s) would not come off; awakening refused", ModManager.LogLevel.Warn); return "Something you are wearing would not come off. Take everything off and try again."; } Enlightenment.RemoveAttributes(player); Enlightenment.RemoveSkills(player); // RemoveSkills puts Available back to heritage plus quest credits; Total // would otherwise keep growing by the table's credits every climb. player.TotalSkillCredits = player.AvailableSkillCredits; player.Session.Network.EnqueueSend(new GameMessagePrivateUpdatePropertyInt(player, PropertyInt.TotalSkillCredits, player.TotalSkillCredits ?? 0)); } else if (Mod.Settings.DequipLevelGatedItems) moved = DequipLevelGated(player); player.TotalExperience = 0; player.Session.Network.EnqueueSend(new GameMessagePrivateUpdatePropertyInt64(player, PropertyInt64.TotalExperience, 0)); player.Level = 1; player.Session.Network.EnqueueSend(new GameMessagePrivateUpdatePropertyInt(player, PropertyInt.Level, 1)); // The level changed under a fellowship's and an allegiance's noses: the share // rules are recomputed on level-up only, so tell them as a level-up would. try { player.Fellowship?.OnFellowLevelUp(player); } catch { } try { player.AllegianceNode?.OnLevelUp(); } catch { } var count = CountOf(player) + 1; player.SetProperty(Property, count); // Resolve (Resolve.cs) is deliberately untouched here. It is bought with seals and // removed with erasers, both taking effect the moment they are used, so an // awakening neither raises it, lowers it nor resets it - the two ladders do not // meet. An earlier design deferred a lowering to this point; seals replaced it. // The whole character and every possession - the dequipped items must be saved // without their wielder, or a relog puts them back on. player.SavePlayerToDatabase(); ModManager.Log($"[{Mod.Name}] {player.Name} awakened (#{count}) from level {before}; {(reset ? "skills and attributes reset; " : "")}{moved} item(s) to the pack"); Celebrate(player); if (Mod.Settings.BroadcastToServer) PlayerManager.BroadcastToAll(new GameMessageSystemChat( $"{player.Name} has awakened{(count > 1 ? $" for the {Ordinal(count)} time" : "")} - back to level 1{(reset ? " to climb again" : " with everything they earned")}.", ChatMessageType.WorldBroadcast)); return (reset ? $"You awaken. Level {before} becomes level 1; your skills, attributes and unassigned experience begin again, as they would in enlightenment. Your luminance, auras, society, titles, Radiance and raises stay yours. " : $"You awaken. Level {before} becomes level 1; every skill, attribute, credit and point of unassigned experience stays yours. ") + $"This is your {Ordinal(count)} awakening: your dungeon copies are now tier {count} harder and richer, and your level cap is {LevelCap.Of(player)}." + (moved > 0 ? $" {moved} item{(moved == 1 ? "" : "s")} went to your pack." : ""); } /// Equipped items whose wield requirement is a level go to the pack; returns how many. private static int DequipLevelGated(Player player) { var moved = 0; foreach (var item in player.EquippedObjects.Values.ToList()) { var gated = item.WieldRequirements == WieldRequirement.Level || item.WieldRequirements2 == WieldRequirement.Level || item.WieldRequirements3 == WieldRequirement.Level || item.WieldRequirements4 == WieldRequirement.Level; if (!gated) continue; player.HandleActionPutItemInContainer(item.Guid.Full, player.Guid.Full, 0); moved++; } return moved; } private static void Celebrate(Player player) { if (!Mod.Settings.Effects) return; try { var chain = new ActionChain(); chain.AddAction(player, () => Beat(player, () => { player.ApplyVisualEffects(PlayScript.LevelUp); player.EnqueueBroadcast(new GameMessageSound(player.Guid, Sound.RaiseTrait, 1.0f)); })); chain.AddDelaySeconds(1.2); chain.AddAction(player, () => Beat(player, () => player.ApplyVisualEffects(PlayScript.WeddingBliss))); chain.EnqueueChain(); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] awakening 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}] awakening beat failed: {ex.Message}", ModManager.LogLevel.Warn); } } public static string Ordinal(int n) => n switch { 1 => "first", 2 => "second", 3 => "third", 4 => "fourth", 5 => "fifth", 6 => "sixth", 7 => "seventh", 8 => "eighth", 9 => "ninth", 10 => "tenth", _ => (n % 100 is 11 or 12 or 13) ? $"{n}th" : (n % 10) switch { 1 => $"{n}st", 2 => $"{n}nd", 3 => $"{n}rd", _ => $"{n}th" }, }; }