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" },
};
}