namespace Aeshnidae.Enlightenment;
///
/// Every rule of enlightenment, in one file.
///
/// Retail enlightenment is a single hardcoded bargain: reset to level 1 and lose
/// society, luminance, auras, aetheria and your unspent xp, in exchange for +1 to
/// all skills, +2 vitality and a title. Aeshnidae keeps the shape and changes the
/// terms, so every clause of that bargain is a field here rather than a literal in
/// ACE's Enlightenment.cs.
///
/// The requirement gate is enforced here too, not in the world database. Retail
/// encodes it as an emote chain on the Font of Enlightenment (wcid 53412) - a
/// Goto/InqIntStat state machine with the numbers baked into two rows. That cannot
/// express "275 plus one per enlightenment you already have", and editing it means
/// a SQL migration every time a number moves. So the mod intercepts the chain at
/// its first Goto and answers the whole question in C#. The world database is left
/// exactly as it shipped.
///
/// One band of the post-enlightenment passup table: from this level, this much of what vassals pass up is received.
public class PassupBand
{
public int FromLevel { get; set; }
/// 0 to 100. 100 is retail's full passup; 0 receives nothing.
public int Percent { get; set; }
}
public class Settings
{
public const string FileName = "Settings.json";
///
/// Master switch. Off leaves ACE's retail enlightenment running untouched -
/// the patches stay applied but hand every call straight back.
///
public bool Enabled { get; set; } = true;
// ---- who may enlighten --------------------------------------------------
/// Level needed for a first enlightenment.
public int BaseLevelRequirement { get; set; } = 275;
///
/// Added to for each enlightenment already
/// held, so the second costs 276, the third 277, and so on. Retail charged a
/// flat 275 every time.
///
/// Note this is the reason the gate cannot live in the world database: the
/// InqIntStat row that guards it holds one min and one max, and this is a
/// function of player state.
///
public int LevelRequirementPerEnlightenment { get; set; } = 1;
///
/// Hard ceiling on enlightenments. 0 is uncapped, which is the Aeshnidae
/// default - retail stopped at 5 because that is how many titles exist, and
/// handles that separately.
///
public int MaxEnlightenments { get; set; } = 0;
/// Free slots needed in the main pack, for the dequip and the certificate.
public int RequiredFreeInventorySlots { get; set; } = 25;
/// Require Master rank in one of the three societies.
public bool RequireSocietyMaster { get; set; } = true;
///
/// Require the full 65 luminance aura credits (everything except the two skill
/// credit auras). Since is on, this is bought
/// once and satisfies every later enlightenment for free.
///
public bool RequireAllLuminanceAuras { get; set; } = true;
// ---- what survives ------------------------------------------------------
///
/// Keep society membership and rank. Retail wiped both and stamped an
/// "Enlightened<Society>Master" flag so the promotions officer could hand
/// the rank straight back - a re-grind that existed only to be skipped.
///
public bool KeepSociety { get; set; } = true;
/// Keep the LumAug* aura ratings. Retail zeroed all thirteen.
public bool KeepLuminanceAuras { get; set; } = true;
///
/// Keep the quest flags that permit earning luminance at all. Retail erased
/// them, so an enlightened character could not gain luminance until level 200
/// and a re-run of Nalicana's Test.
///
public bool KeepLuminanceAccess { get; set; } = true;
///
/// Keep banked AvailableLuminance / MaximumLuminance. Not mentioned either way
/// in the Aeshnidae design; kept because wiping the small unspent remainder
/// while handing back all thirteen auras it buys would be noise, not cost.
///
public bool KeepLuminanceBalance { get; set; } = true;
///
/// Keep the unspent xp pool (AvailableExperience). Retail zeroed it.
///
/// This is the clause that closes Aeshnidae.XpCurrency's open hole rather than
/// patching it. While retail zeroed the pool, a player could /xp it to a friend,
/// enlighten, and have it sent back - the transfer dodged a cost that this
/// server no longer charges. With the pool surviving by design there is nothing
/// to dodge, and the exploit stops being one.
///
/// Turning this off reopens it. logs a warning
/// at startup if you do while XpCurrency is loaded.
///
/// Only the *unspent* pool survives. Xp already sunk into levels, attributes and
/// skill ranks is destroyed by the reset below, and that is still the bulk of a
/// 275's lifetime earnings - so the cost of enlightening is unchanged in
/// practice.
///
public bool KeepUnassignedExperience { get; set; } = true;
///
/// Keep aetheria slots and the mana field flags that open them. Off, per the
/// design: losing the use of aetheria above your level requirement is one of
/// the two things enlightenment is supposed to cost.
///
public bool KeepAetheria { get; set; } = false;
// ---- what resets --------------------------------------------------------
/// Reset TotalExperience and Level to 1. This is what makes the rest bite.
public bool ResetLevel { get; set; } = true;
/// Reset skill ranks and recompute available skill credits.
public bool ResetSkills { get; set; } = true;
///
/// Reset attribute and vital ranks. Not named in the design's keep or lose
/// list; on, because a level 1 character holding maxed attributes would make
/// cosmetic.
///
public bool ResetAttributes { get; set; } = true;
///
/// Move everything equipped into the pack. The design's "lose the ability to
/// use weapons and armour over your level requirement" is enforced by ACE's own
/// wield requirements once the level drops; this is what stops you keeping the
/// gear on in the meantime.
///
public bool DequipAllItems { get; set; } = true;
// ---- what you gain ------------------------------------------------------
///
/// Award a title for the first five enlightenments (Awakened, Enlightened,
/// Illuminated, Transcended, Cosmic Conscious). There is no sixth title in the
/// client's dats, so enlightenments past five are silently untitled.
///
public bool GrantTitles { get; set; } = true;
/// Hand over an attribute reset certificate, wcid 46421.
public bool GrantAttributeResetCertificate { get; set; } = true;
/// Announce each enlightenment on the world broadcast channel.
public bool BroadcastToServer { get; set; } = true;
///
/// The three-beat effect at the Font: a private white-out and chant, the
/// augmentation burst, the fireworks. Purely visual, and off means the
/// enlightenment happens with only the chat lines.
///
public bool Ceremony { get; set; } = true;
// ---- the emote gate -----------------------------------------------------
///
/// The Goto label on the Font of Enlightenment that starts its requirement
/// chain. Intercepting it is what lets every setting above take effect without
/// a SQL change - see EmoteGatePatch.
///
public string GateLabel { get; set; } = "EnlightenmentCheck";
///
/// The Goto label of the confirmation prompt to jump to once the mod's own
/// checks pass. On wcid 53412 this is the InqYesNo that leads to the
/// Enlightenment emote (type 9001).
///
public string ConfirmLabel { get; set; } = "AbleToEnlighten";
///
/// Seconds after an enlightenment during which the same character cannot start
/// another. Belt and braces: the level reset already fails the gate on a second
/// attempt, but this closes the window between two confirmations answered in
/// the same tick, which is the shape most emote-chain double-fires take.
///
public double ReentryGuardSeconds { get; set; } = 30.0;
///
/// Require the player to still be standing at the Font when the grant fires,
/// not merely when they asked.
///
/// This is the clause that closes the exploit the NPC was meant to fix. The old
/// /enlighten command reset the character and then sent them to their lifestone;
/// run in portal space the teleport could not land, so the player kept their
/// position and got a level 1 character inside high-tier content. Moving to an
/// NPC removes the command but not the gap - the yes/no confirmation is
/// asynchronous, so a player can open the prompt at the Font, recall away, and
/// answer yes from somewhere else.
///
/// Portal space and mid-teleport are refused regardless of this setting; it only
/// governs the proximity test, which is the part with a tunable number in it.
///
public bool RequireProximityToNpc { get; set; } = true;
///
/// Metres the player may be from the Font when the grant fires. Generous - this
/// is meant to catch recalls and portals, not to punish someone who stepped
/// back while reading the prompt.
///
public float ProximityRadius { get; set; } = 15.0f;
// ---- passup after enlightenment ----------------------------------------
///
/// Scale the allegiance passup an enlightened character RECEIVES from their
/// vassals by their own level. Tom, 2026-09-16: enlightenment should be hard, as
/// on retail, and a freshly enlightened patron was being carried through the
/// early levels on their vassals' experience. What vassals generate, and ACE's
/// loyalty and leadership arithmetic, are untouched; every receiver up the tree
/// is scaled by their own level. The unenlightened are not touched at all.
///
public bool ScalePassupAfterEnlightenment { get; set; } = true;
///
/// The bands, ascending by : a character at or
/// above a band's level receives that band's percent, until the next band. A
/// level below the first band receives nothing. The last band is the ceiling.
///
public List PassupAfterEnlightenment { get; set; } = new()
{
new() { FromLevel = 0, Percent = 0 },
new() { FromLevel = 40, Percent = 20 },
new() { FromLevel = 80, Percent = 40 },
new() { FromLevel = 100, Percent = 60 },
new() { FromLevel = 120, Percent = 100 },
};
// ---- notes, not settings ------------------------------------------------
///
/// Two couplings this mod deliberately does not own, recorded where whoever
/// edits the numbers above will see them.
///
/// Aeshnidae.SkillMastery: mastery ranks survive enlightenment and retail ranks
/// do not, so mastery priced below the retail rank it competes with makes
/// everything on this page toothless - players would skip retail progression
/// entirely and keep their whole build through the reset. That invariant is
/// SkillMastery's to hold, in its own cost bands. Nothing here should try to
/// preserve or restore mastery: it survives because it is stored outside the
/// skill, not because anyone special-cases it, and this mod must never write
/// into a CreatureSkill to "keep" anything - ResetSkill zeroes Ranks, InitLevel
/// and ExperienceSpent, and six other ACE methods bare-assign InitLevel.
///
/// The +1 to all skills and +2 vitality per enlightenment are not settings here
/// because they are not this mod's to set: ACE applies them in CreatureSkill.Base
/// and CreatureVital, derived from PropertyInt.Enlightenment. They already match
/// the design. Making them tunable would mean a second postfix on
/// CreatureSkill.Base, where SkillMastery already lives, and SkillMastery's
/// client-display patches add their bonus to InitLevel independently rather than
/// reading Base - so a delta added here would apply on the server and not show on
/// the client. Left alone on purpose.
///
[JsonIgnore]
public string DesignNotes => "see Settings.cs";
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
public static Settings Load(string modPath)
{
var path = Path.Combine(modPath, FileName);
try
{
if (File.Exists(path))
return JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) ?? new Settings();
var defaults = new Settings();
defaults.Save(modPath);
ModManager.Log($"[{Mod.Name}] wrote default settings to {path}");
return defaults;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not read {path}, using defaults: {ex.Message}", ModManager.LogLevel.Warn);
return new Settings();
}
}
public void Save(string modPath)
{
try
{
File.WriteAllText(Path.Combine(modPath, FileName), JsonSerializer.Serialize(this, JsonOptions));
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not save settings: {ex.Message}", ModManager.LogLevel.Error);
}
}
}