namespace Aeshnidae.Enlightenment;
///
/// The enlightenment itself: what is taken, what is left alone, what is given.
///
/// This is a reimplementation of ACE's Enlightenment.HandleEnlightenment rather
/// than a set of patches around it, because almost every clause of that method is
/// one the Aeshnidae design changes. Wrapping it would mean letting it wipe society,
/// auras and the xp pool and then putting them back, which is both slower and
/// wrong - RemoveLuminance sends thirteen property updates to the client on its way
/// past, and undoing state is not the same as never touching it.
///
/// Order matters in one place: the dequip runs before the level reset, so items
/// leave the equipment slots while they are still legally wielded. Doing it the
/// other way round leaves ACE holding gear the character can no longer wield.
///
/// Nothing here writes into a CreatureSkill to preserve anything. Aeshnidae.SkillMastery
/// keeps its ranks in a shard table precisely because ResetSkill zeroes Ranks,
/// InitLevel and ExperienceSpent - mastery survives this method by not being
/// reachable from it, and the moment this file tries to help with that, it breaks.
///
public static class Ritual
{
/// Attribute reset certificate handed out on success.
public const uint AttributeResetCertificate = 46421;
public static void Perform(WorldObject npc, Player player)
{
if (player?.Session is null)
return;
if (!Guard.TryEnter(player))
{
ModManager.Log($"[{Mod.Name}] refused re-entrant enlightenment for {player.Name} ({player.Guid.Full:X8})",
ModManager.LogLevel.Warn);
return;
}
var completed = false;
try
{
// Before anything else: is the player still standing at the Font?
//
// This is the hole the old /enlighten command had. That command did the
// reset and then sent the player to their lifestone; run it inside a
// dungeon and the teleport was the only thing standing between them and
// a level 1 character in high-tier content, so players ran it in portal
// space, where the teleport could not land. They kept the location.
//
// Moving to an NPC removes the command but not the gap, because the
// confirmation is asynchronous: use the Font, get the yes/no prompt,
// recall or portal out, and *then* answer yes. The emote fires with the
// player somewhere else entirely. So the payload refuses to run unless
// the player is where they were when they asked.
if (!IsStillAtTheFont(npc, player, out var reason))
{
ModManager.Log($"[{Mod.Name}] refused enlightenment for {player.Name} ({player.Guid.Full:X8}): {reason}",
ModManager.LogLevel.Warn);
player.Session.Network.EnqueueSend(new GameMessageSystemChat(
"You must be standing before the Font of Enlightenment to be enlightened.",
ChatMessageType.Broadcast));
return;
}
// Checked again here, not just at the emote gate. The gate answers a
// question asked before the confirmation prompt; this answers it at the
// moment of payment.
if (!Requirements.Check(player, out var failures))
{
foreach (var failure in failures)
player.Session.Network.EnqueueSend(new GameMessageSystemChat(failure, ChatMessageType.Broadcast));
return;
}
var fromLevel = player.Level ?? 0;
var fromEnlightenment = player.Enlightenment;
if (Mod.Settings.DequipAllItems)
DequipAll(player);
if (!Mod.Settings.KeepSociety)
RemoveSociety(player);
if (!Mod.Settings.KeepLuminanceAuras || !Mod.Settings.KeepLuminanceAccess || !Mod.Settings.KeepLuminanceBalance)
RemoveLuminance(player);
if (!Mod.Settings.KeepAetheria)
RemoveAetheria(player);
if (Mod.Settings.ResetAttributes)
ResetAttributes(player);
if (Mod.Settings.ResetSkills)
ResetSkills(player);
if (Mod.Settings.ResetLevel)
ResetLevel(player);
AddPerks(npc, player);
player.SaveBiotaToDatabase();
completed = true;
// After the perks and the save, never interleaved with the reset above:
// the character is dequipped and mid-change until here.
Ceremony(npc, player);
// Audited unconditionally. An enlightenment is the single largest state
// change a character can undergo, and "it says here you were level 275"
// is the only way to answer a support ticket about one.
ModManager.Log($"[{Mod.Name}] {player.Name} ({player.Guid.Full:X8}) enlightened " +
$"{fromEnlightenment} -> {player.Enlightenment} from level {fromLevel}");
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] enlightenment failed for {player.Name}: {ex}", ModManager.LogLevel.Error);
player.Session?.Network.EnqueueSend(new GameMessageSystemChat(
"Something went wrong during your enlightenment. Please contact an administrator before continuing.",
ChatMessageType.Broadcast));
}
finally
{
Guard.Exit(player, completed);
}
}
///
/// Four conditions, cheapest first. Each one on its own is enough to make the
/// grant unsafe, and none of them is a setting - a ritual that fires while the
/// player is mid-teleport is a bug however the server is configured.
///
private static bool IsStillAtTheFont(WorldObject npc, Player player, out string reason)
{
reason = null!;
// Portal space. The player exists but is attached to no landblock, which is
// exactly the state the old exploit was run from.
if (player.CurrentLandblock is null)
{
reason = "player is in portal space";
return false;
}
if (player.Teleporting)
{
reason = "player is mid-teleport";
return false;
}
if (!Mod.Settings.RequireProximityToNpc)
return true;
// The Font is a fixed world object, so a missing landblock or a different
// one means the player left after opening the prompt.
if (npc?.CurrentLandblock is null || npc.CurrentLandblock != player.CurrentLandblock)
{
reason = "player is no longer in the Font's landblock";
return false;
}
if (!player.IsWithinUseRadiusOf(npc, Mod.Settings.ProximityRadius))
{
reason = $"player is more than {Mod.Settings.ProximityRadius:N1}m from the Font";
return false;
}
return true;
}
// ---- taking -------------------------------------------------------------
private static void DequipAll(Player player)
{
foreach (var equipped in player.EquippedObjects.Keys.ToList())
player.HandleActionPutItemInContainer(equipped.Full, player.Guid.Full, 0);
}
///
/// Retail's society wipe, kept for completeness but off by default. The
/// "Enlightened<Society>Master" stamps are what let the promotions officer
/// restore the rank in one conversation, so they go in even here.
///
private static void RemoveSociety(Player player)
{
player.QuestManager.Erase("SocietyMember");
player.QuestManager.Erase("CelestialHandMember");
player.QuestManager.Erase("EnlightenedCelestialHandMaster");
player.QuestManager.Erase("EldrytchWebMember");
player.QuestManager.Erase("EnlightenedEldrytchWebMaster");
player.QuestManager.Erase("RadiantBloodMember");
player.QuestManager.Erase("EnlightenedRadiantBloodMaster");
if (player.SocietyRankCelhan == 1001)
player.QuestManager.Stamp("EnlightenedCelestialHandMaster");
if (player.SocietyRankEldweb == 1001)
player.QuestManager.Stamp("EnlightenedEldrytchWebMaster");
if (player.SocietyRankRadblo == 1001)
player.QuestManager.Stamp("EnlightenedRadiantBloodMaster");
player.Faction1Bits = null;
Send(player, PropertyInt.Faction1Bits, 0);
player.SocietyRankCelhan = null;
Send(player, PropertyInt.SocietyRankCelhan, 0);
player.SocietyRankEldweb = null;
Send(player, PropertyInt.SocietyRankEldweb, 0);
player.SocietyRankRadblo = null;
Send(player, PropertyInt.SocietyRankRadblo, 0);
}
///
/// Split three ways, because the design keeps the auras and the earning right
/// while retail took both. Each block is guarded independently so an admin can
/// mix them.
///
private static void RemoveLuminance(Player player)
{
if (!Mod.Settings.KeepLuminanceAccess)
{
player.QuestManager.Erase("OracleLuminanceRewardsAccess_1110");
player.QuestManager.Erase("LoyalToShadeOfLadyAdja");
player.QuestManager.Erase("LoyalToKahiri");
player.QuestManager.Erase("LoyalToLiamOfGelid");
player.QuestManager.Erase("LoyalToLordTyragar");
}
if (!Mod.Settings.KeepLuminanceAuras)
{
player.LumAugDamageRating = 0;
Send(player, PropertyInt.LumAugDamageRating, 0);
player.LumAugDamageReductionRating = 0;
Send(player, PropertyInt.LumAugDamageReductionRating, 0);
player.LumAugCritDamageRating = 0;
Send(player, PropertyInt.LumAugCritDamageRating, 0);
player.LumAugCritReductionRating = 0;
Send(player, PropertyInt.LumAugCritReductionRating, 0);
player.LumAugSurgeChanceRating = 0;
Send(player, PropertyInt.LumAugSurgeChanceRating, 0);
player.LumAugItemManaUsage = 0;
Send(player, PropertyInt.LumAugItemManaUsage, 0);
player.LumAugItemManaGain = 0;
Send(player, PropertyInt.LumAugItemManaGain, 0);
// Not zeroed: LumAugVitality. ACE derives the +2 per enlightenment from
// PropertyInt.Enlightenment in CreatureVital and leaves this at whatever
// auras set it, so clearing it here would take vitality the player bought
// rather than vitality enlightenment gave.
player.LumAugHealingRating = 0;
Send(player, PropertyInt.LumAugHealingRating, 0);
player.LumAugSkilledCraft = 0;
Send(player, PropertyInt.LumAugSkilledCraft, 0);
player.LumAugSkilledSpec = 0;
Send(player, PropertyInt.LumAugSkilledSpec, 0);
player.LumAugAllSkills = 0;
Send(player, PropertyInt.LumAugAllSkills, 0);
}
if (!Mod.Settings.KeepLuminanceBalance)
{
player.AvailableLuminance = null;
Send64(player, PropertyInt64.AvailableLuminance, 0);
player.MaximumLuminance = null;
Send64(player, PropertyInt64.MaximumLuminance, 0);
}
player.SendMessage("Your Luminance fades from your spirit.", ChatMessageType.Broadcast);
}
private static void RemoveAetheria(Player player)
{
foreach (var tier in new[] { "EFUL", "EFML", "EFLL" })
{
foreach (var field in new[] { "North", "South", "East", "West", "Center" })
player.QuestManager.Erase($"{tier}{field}ManaFieldUsed");
}
player.AetheriaFlags = AetheriaBitfield.None;
Send(player, PropertyInt.AetheriaBitfield, 0);
player.SendMessage("Your mastery of Aetheric magics fades.", ChatMessageType.Broadcast);
}
private static void ResetAttributes(Player player)
{
var count = Enum.GetNames(typeof(PropertyAttribute)).Length;
for (var i = 1; i < count; i++)
{
var attribute = (PropertyAttribute)i;
player.Attributes[attribute].Ranks = 0;
player.Attributes[attribute].ExperienceSpent = 0;
player.Session.Network.EnqueueSend(new GameMessagePrivateUpdateAttribute(player, player.Attributes[attribute]));
}
// Vitals step by two: the enum interleaves current and max for each vital,
// and only the max entries are trainable.
count = Enum.GetNames(typeof(PropertyAttribute2nd)).Length;
for (var i = 1; i < count; i += 2)
{
var vital = (PropertyAttribute2nd)i;
player.Vitals[vital].Ranks = 0;
player.Vitals[vital].ExperienceSpent = 0;
player.Session.Network.EnqueueSend(new GameMessagePrivateUpdateVital(player, player.Vitals[vital]));
}
player.SendMessage("Your attribute training fades.", ChatMessageType.Broadcast);
}
private static void ResetSkills(Player player)
{
var count = Enum.GetNames(typeof(Skill)).Length;
for (var i = 1; i < count; i++)
player.ResetSkill((Skill)i, false);
if (!Mod.Settings.KeepUnassignedExperience)
{
player.AvailableExperience = 0;
Send64(player, PropertyInt64.AvailableExperience, 0);
}
// Skill credits are recomputed rather than decremented: the character is
// about to be level 1, and ACE re-grants the level-based credits from
// CharacterLevelSkillCreditList on the way back up. Only the permanent
// sources belong here. (Aeshnidae.MaxLevel's credits past 275 are level
// based, so they come back with the levels.)
//
// ACE reads the heritage group unguarded here. This does not, because by
// this point the skills are already reset: throwing would leave a character
// stripped and un-credited, which is the one failure mode worth an extra
// three lines. A null heritage should not be reachable on a level 275.
if (player.Heritage is null ||
!DatManager.PortalDat.CharGen.HeritageGroups.TryGetValue((uint)player.Heritage, out var heritage))
{
ModManager.Log($"[{Mod.Name}] {player.Name} has no usable heritage group " +
$"({player.Heritage?.ToString() ?? "null"}); skill credits left untouched, fix by hand",
ModManager.LogLevel.Error);
return;
}
var credits = (int)heritage.SkillCredits
+ player.QuestManager.GetCurrentSolves("ArantahKill1")
+ player.QuestManager.GetCurrentSolves("OswaldManualCompleted")
+ player.QuestManager.GetCurrentSolves("LumAugSkillQuest");
player.AvailableSkillCredits = credits;
Send(player, PropertyInt.AvailableSkillCredits, credits);
player.SendMessage("Your available skill credits have been adjusted.", ChatMessageType.Broadcast);
}
private static void ResetLevel(Player player)
{
player.TotalExperience = 0;
Send64(player, PropertyInt64.TotalExperience, 0);
player.Level = 1;
Send(player, PropertyInt.Level, 1);
}
// ---- giving -------------------------------------------------------------
///
/// The showpiece. Three beats: the Font wakes and the world goes white for the
/// player alone, with the chant; the augmentation burst on the character for
/// everyone to see; then the fireworks, and the white lifts.
///
/// Runs on the world thread already (we are inside an emote), so the chain is for
/// the timing, not the threading. The fog is the one thing here that can go wrong:
/// it is sticky per player, across landblocks and a relog, so it is set through
/// SetFogColor - which tracks it - and cleared on the same chain, unconditionally.
/// SendEnvironChange would paint it without tracking it, and ClearFogColor would
/// then decline to clear what it did not know about.
///
private static void Ceremony(WorldObject npc, Player player)
{
if (!Mod.Settings.Ceremony)
return;
try
{
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. The
// last beat clears the fog even so - a stuck white screen is the one
// failure that follows a player home.
chain.AddAction(player, () => Beat(player, () =>
{
if (npc?.CurrentLandblock is not null)
npc.ApplyVisualEffects(PlayScript.EnchantUpWhite);
player.SetFogColor(EnvironChangeType.WhiteFog);
player.SendEnvironChange(EnvironChangeType.Chant1Sound);
}));
chain.AddDelaySeconds(1.2);
chain.AddAction(player, () => Beat(player, () => player.ApplyVisualEffects(PlayScript.AugmentationUseOther)));
chain.AddDelaySeconds(1.2);
chain.AddAction(player, () =>
{
Beat(player, () => player.ApplyVisualEffects(PlayScript.WeddingBliss));
try { if (player?.Session is not null) player.ClearFogColor(); } catch { }
});
chain.EnqueueChain();
}
catch (Exception ex)
{
// The fog is the only thing worth a second attempt - a failed particle is
// nothing, a stuck white screen is a support ticket.
try { player.ClearFogColor(); } catch { }
ModManager.Log($"[{Mod.Name}] ceremony 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}] ceremony beat failed: {ex.Message}", ModManager.LogLevel.Warn);
}
}
///
/// The +1 to all trained skills and +2 vitality are not applied here. ACE reads
/// PropertyInt.Enlightenment directly in CreatureSkill.Base and CreatureVital,
/// so incrementing the property below is what grants them - and it grants them
/// to every skill, retroactively, without anything being written into a skill.
///
private static void AddPerks(WorldObject npc, Player player)
{
player.Enlightenment += 1;
Send(player, PropertyInt.Enlightenment, player.Enlightenment);
player.SendMessage("You have become enlightened and view the world with new eyes.", ChatMessageType.Broadcast);
player.SendMessage("You have risen to a higher tier of enlightenment!", ChatMessageType.Broadcast);
if (Mod.Settings.GrantTitles)
AddTitle(player);
if (Mod.Settings.GrantAttributeResetCertificate && npc is not null)
player.GiveFromEmote(npc, AttributeResetCertificate, 1);
if (!Mod.Settings.BroadcastToServer)
return;
var message = $"{player.Name} has achieved the {Requirements.Ordinal(player.Enlightenment)} level of Enlightenment!";
PlayerManager.BroadcastToAll(new GameMessageSystemChat(message, ChatMessageType.WorldBroadcast));
PlayerManager.LogBroadcastChat(Channel.AllBroadcast, null, message);
}
///
/// Five titles exist in the client's dats and no more, so a sixth enlightenment
/// is untitled rather than mistitled. Deliberately silent - the design caps
/// titles at five while leaving enlightenment itself uncapped.
///
private static void AddTitle(Player player)
{
var title = player.Enlightenment switch
{
1 => CharacterTitle.Awakened,
2 => CharacterTitle.Enlightened,
3 => CharacterTitle.Illuminated,
4 => CharacterTitle.Transcended,
5 => CharacterTitle.CosmicConscious,
_ => (CharacterTitle?)null,
};
if (title is not null)
player.AddTitle(title.Value);
}
// ---- plumbing -----------------------------------------------------------
private static void Send(Player player, PropertyInt property, int value) =>
player.Session.Network.EnqueueSend(new GameMessagePrivateUpdatePropertyInt(player, property, value));
private static void Send64(Player player, PropertyInt64 property, long value) =>
player.Session.Network.EnqueueSend(new GameMessagePrivateUpdatePropertyInt64(player, property, value));
}