using System.Runtime.CompilerServices;
namespace Aeshnidae.InstancesNoDat;
///
/// Enlightenment scaling: a copy is harder, and richer, in proportion to the
/// enlightenment of whoever owns it.
///
/// Two facts from ACE make this safe to do by editing creatures in place, and both are
/// worth stating because the obvious worry is that a buff applied to one drudge leaks
/// to every drudge on the server:
///
/// 1. Skills and vitals are DEEP COPIED out of the cached weenie, unconditionally -
/// WeenieConverter.ConvertToBiota calls Clone() on each. The
/// referenceWeenieCollectionsForCommonProperties optimisation that WorldObject
/// passes as true covers PropertiesBodyPart and nothing else. So a mutation here
/// lands on one creature, in one copy.
///
/// 2. None of it persists. CreatureSkill.InitLevel and CreatureVital.StartingValue
/// are the two setters on those classes that deliberately do not raise
/// ChangesDetected, and landblock spawns are not saved to the shard in the first
/// place - IsStaticThatShouldPersistToShard only answers true for statics whose
/// biota came from the database, which is houses and slumlords. This mod also
/// skips SpawnDynamicShardObjects inside copies entirely.
///
/// So a scaled monster lives and dies inside its copy, and the master landblock and
/// every other copy are untouched.
///
public static class Scaling
{
///
/// (landblock, copy) -> the enlightenment this copy was opened for.
///
/// Captured when the copy is opened rather than read when a creature spawns,
/// because Landblock.Init spawns on a BACKGROUND TASK: by the time the first drudge
/// is being added, the Context.Assign that would have named the owner may not have
/// run yet. Both places that open a copy record the owner before calling
/// GetOrCreate, which is what makes the answer deterministic rather than a race.
///
/// Held per copy rather than re-read per creature so that enlightening mid-run does
/// not change the dungeon under your feet - a copy keeps the difficulty it opened
/// with, and the next one you open is the harder one.
///
private static readonly ConcurrentDictionary<(ushort Landblock, int Copy), int> _owners = new();
///
/// Creatures already scaled.
///
/// A guid set would be wrong here: landblock-instance guids are deterministic, so
/// the same drudge is 0x7002B668 in every copy and they would mark each other as
/// done. Keyed on the object itself instead, weakly, so entries leave with the
/// creature rather than needing to be swept when a copy closes.
///
/// Needed at all because AddWorldObject is not once per creature: a relocation
/// re-adds, and without a marker the bonus would stack every time.
///
private static readonly ConditionalWeakTable _scaled = new();
private static readonly object Marker = new();
/// The enlightenment a copy about to be opened should be built for.
public static void Remember(ushort landblock, int copy, Player player)
{
if (copy <= 0)
return;
_owners[(landblock, copy)] = player.Enlightenment;
}
public static void Forget(ushort landblock, int copy) => _owners.TryRemove((landblock, copy), out _);
public static void ForgetAll() => _owners.Clear();
/// What a copy was opened for, or 0 for an unowned one.
public static int EnlightenmentOf(ushort landblock, int copy) =>
_owners.TryGetValue((landblock, copy), out var el) ? el : 0;
/// Enlightenments this copy is scaled by, after the cap in settings.
public static int Counted(ushort landblock, int copy)
{
var el = EnlightenmentOf(landblock, copy);
var cap = Mod.Settings.MaxEnlightenmentsCounted;
return cap > 0 ? Math.Min(el, cap) : el;
}
///
/// The difficulty of a copy, in the words a player should see.
///
/// Reports what this copy WAS BUILT WITH rather than what the settings say now.
/// Those come apart in two ordinary ways - the owner enlightened without closing
/// the dungeon, or the percentages were retuned while copies were open - and in
/// both cases the monsters standing in the room are the older answer. A readout
/// that quoted current settings would be confidently wrong exactly when somebody
/// is asking because something looks off.
///
/// Levers that are switched off are not mentioned, so this cannot promise 5% more
/// damage from a copy where damage scaling is disabled.
///
public static IEnumerable Describe(ushort landblock, int copy)
{
var settings = Mod.Settings;
if (!settings.ScaleWithEnlightenment || Mod.ScopeFor(landblock) != InstanceScope.Personal)
{
yield return "Dungeon Difficulty: Enlightenment Tier 0";
yield return " Unscaled - stock monsters, stock rewards.";
yield break;
}
var tier = Counted(landblock, copy);
yield return $"Dungeon Difficulty: Enlightenment Tier {tier}";
if (tier <= 0)
{
yield return " Stock monsters, stock rewards. Enlighten to raise it.";
yield break;
}
var harder = new List();
if (settings.ScaleCreatureSkills) harder.Add("skills");
if (settings.ScaleCreatureHealth) harder.Add("health");
if (settings.ScaleCreatureDamage) harder.Add("damage");
var richer = new List();
if (settings.ScaleExperience) richer.Add("experience");
if (settings.ScaleLuminance) richer.Add("luminance");
if (harder.Count > 0)
yield return $" Monsters: +{tier * settings.DifficultyPercentPerEnlightenment:0.##}% {Join(harder)}";
if (richer.Count > 0)
yield return $" Rewards: +{tier * settings.RewardPercentPerEnlightenment:0.##}% {Join(richer)}";
if (harder.Count == 0 && richer.Count == 0)
yield return " Every scaling lever is switched off, so this copy is stock.";
var actual = EnlightenmentOf(landblock, copy);
if (settings.MaxEnlightenmentsCounted > 0 && actual > settings.MaxEnlightenmentsCounted)
yield return $" Capped at tier {settings.MaxEnlightenmentsCounted} " +
$"(this copy was opened at enlightenment {actual}).";
}
/// "skills, health and damage" - an Oxford-comma-free list for chat.
private static string Join(List parts) => parts.Count switch
{
0 => "",
1 => parts[0],
_ => string.Join(", ", parts.Take(parts.Count - 1)) + " and " + parts[^1],
};
///
/// Scales one creature on its way into a copy.
///
/// Called from the AddWorldObject postfix, which covers both the initial spawn and
/// every generator respawn during the run, since those route through
/// LandblockManager.AddObject into the same method.
///
public static void Apply(Landblock block, WorldObject wo)
{
var settings = Mod.Settings;
if (!settings.ScaleWithEnlightenment)
return;
// Players enter through this same call. So do corpses and dropped loot.
if (wo is not Creature creature || creature is Player || !creature.IsMonster)
return;
var copy = InstanceWorld.CopyOf(block);
if (copy <= 0)
return;
var landblock = (ushort)((block.Id.LandblockX << 8) | block.Id.LandblockY);
// Personal copies only, and this is a correctness rule rather than a preference,
// so it is not a setting.
//
// Scaling is defined as "the enlightenment of whoever owns this copy", and only
// a personal copy has one of those. A fellowship copy is keyed on the leader and
// an allegiance copy on the monarch, so a shared dungeon would take its
// difficulty from whichever member happens to hold the key - and change under
// everyone the moment the fellowship leader passed. There is no obviously right
// answer for a group (lowest? highest? mean?), so this declines to invent one.
if (Mod.ScopeFor(landblock) != InstanceScope.Personal)
return;
var enlightenment = Counted(landblock, copy);
if (enlightenment <= 0)
return;
// Idempotence before any mutation, so a re-add is a no-op rather than a second
// helping.
if (_scaled.TryGetValue(creature, out _))
return;
_scaled.Add(creature, Marker);
var difficulty = enlightenment * settings.DifficultyPercentPerEnlightenment / 100.0;
var reward = enlightenment * settings.RewardPercentPerEnlightenment / 100.0;
if (settings.ScaleCreatureSkills)
ScaleSkills(creature, difficulty);
if (settings.ScaleCreatureHealth)
ScaleHealth(creature, difficulty);
if (settings.ScaleCreatureDamage)
ScaleDamage(creature, enlightenment, settings.DifficultyPercentPerEnlightenment);
if (settings.ScaleExperience)
creature.XpOverride = Grow(creature.XpOverride, reward);
if (settings.ScaleLuminance)
creature.LuminanceAward = Grow(creature.LuminanceAward, reward);
}
///
/// Attack and defence both read GetCreatureSkill(...).Current, and for a non-player
/// that is the attribute formula plus InitLevel plus Ranks. So a percentage of the
/// EFFECTIVE skill added to InitLevel is what "5% better at fighting" means - taking
/// a percentage of InitLevel alone would be a percentage of a number that is often
/// zero on a monster weenie.
///
private static void ScaleSkills(Creature creature, double difficulty)
{
foreach (var skill in creature.Skills.Values)
{
var before = skill.Current;
if (before == 0)
continue;
skill.InitLevel += (uint)Math.Round(before * difficulty);
}
}
///
/// MaxValue is StartingValue + Ranks + attribute formula, so the raise goes into
/// StartingValue. Current has to be moved with it: the creature was built, and had
/// its health filled to the old maximum, well before this runs.
///
private static void ScaleHealth(Creature creature, double difficulty)
{
var health = creature.Vitals[ACE.Entity.Enum.Properties.PropertyAttribute2nd.MaxHealth];
var before = health.MaxValue;
if (before == 0)
return;
health.StartingValue += (uint)Math.Round(before * difficulty);
health.Current = health.MaxValue;
}
///
/// Damage rating is already a percentage - GetDamageRating reads DamageRating
/// straight off the creature for monsters, and the modifier it feeds is
/// (100 + rating) / 100. So a rating of 5 IS five percent more damage, and the
/// setting maps onto it one for one with no conversion.
///
private static void ScaleDamage(Creature creature, int enlightenment, double percentEach)
{
var bonus = (int)Math.Round(enlightenment * percentEach);
if (bonus == 0)
return;
creature.DamageRating = (creature.DamageRating ?? 0) + bonus;
}
///
/// Grows a reward, saturating rather than wrapping. Ten enlightenments is only
/// +50%, but the settings are open and nothing here should turn a large reward
/// negative because someone typed 5000.
///
private static int? Grow(int? value, double reward)
{
if (value is not { } amount || amount <= 0)
return value;
var grown = amount * (1.0 + reward);
return grown >= int.MaxValue ? int.MaxValue : (int)Math.Round(grown);
}
}