using System; using System.Collections.Generic; using System.Reflection; using BepInEx; using BepInEx.Logging; using BepInEx.Unity.IL2CPP; using FishNet.Object.Synchronizing; using HarmonyLib; using Il2CppInterop.Runtime.InteropTypes.Arrays; using UnityEngine; namespace Skopje.Horde { [BepInPlugin(PluginGuid, PluginName, PluginVersion)] public class HordePlugin : BasePlugin { public const string PluginGuid = "com.skopje.horde"; public const string PluginName = "Skopje Horde"; public const string PluginVersion = "1.0.0"; internal static ManualLogSource LogSource; static Harmony _harmony; public override void Load() { LogSource = Log; HordeConfig.Bind(Config); _harmony = new Harmony(PluginGuid); Patch(AccessTools.Method(typeof(SpawnSystem), nameof(SpawnSystem.Awake)), nameof(HordePatches.AfterSpawnSystemAwake), null); Patch(AccessTools.Method(typeof(SpawnSystem), nameof(SpawnSystem.OnStartServer)), nameof(HordePatches.AfterSpawnSystemReady), null); Patch(AccessTools.Method(typeof(SpawnZone), nameof(SpawnZone.OnStartServer)), nameof(HordePatches.AfterZoneServerStart), nameof(HordePatches.BeforeZoneServerStart)); Patch(AccessTools.Method(typeof(SpawnZone), nameof(SpawnZone.ResetZone)), null, nameof(HordePatches.BeforeZoneServerStart)); Patch(AccessTools.Method(typeof(SpawnZone), "get_EnemiesLeft"), nameof(HordePatches.AfterReadEnemiesLeft), null); Patch(AccessTools.Method(typeof(SpawnZone), "set_EnemiesLeft"), null, nameof(HordePatches.BeforeWriteEnemiesLeft)); Patch(AccessTools.Method(typeof(RunSaveManager), "SaveSpawnZones"), nameof(HordePatches.AfterSaveZones), nameof(HordePatches.BeforeSaveZones)); Patch(AccessTools.Method(typeof(RunSaveManager), "LoadSpawnZones"), nameof(HordePatches.AfterLoadZones), nameof(HordePatches.BeforeLoadZones)); Patch(AccessTools.Method(typeof(EnemyControlAStar), nameof(EnemyControlAStar.OnStartServer)), nameof(HordePatches.AfterEnemyServerStart), null); Patch(AccessTools.Method(typeof(EnemyControlAStar), nameof(EnemyControlAStar.OnStartClient)), nameof(HordePatches.AfterEnemyClientStart), null); Patch(AccessTools.Method(typeof(EnemyControlAStar), nameof(EnemyControlAStar.OnStopServer)), nameof(HordePatches.AfterEnemyServerStop), null); Patch(AccessTools.Method(typeof(PawnStatus), "get_Damage"), nameof(HordePatches.AfterReadDamage), null); LogSource.LogInfo($"{PluginName} {PluginVersion} loaded. Global population x{HordeConfig.GlobalPopulation}, stats {HordeConfig.GlobalStat:0%}. Config: {Config.ConfigFilePath}"); } public override bool Unload() { if (_harmony != null) _harmony.UnpatchSelf(); HordePatches.Clear(); return true; } static void Patch(MethodInfo method, string postfix, string prefix) { if (method == null) { LogSource.LogWarning("Horde hook was not found: " + (postfix ?? prefix)); return; } _harmony.Patch( method, prefix: prefix == null ? null : new HarmonyMethod(typeof(HordePatches), prefix), postfix: postfix == null ? null : new HarmonyMethod(typeof(HordePatches), postfix)); } } static class HordePatches { sealed class CapState { public float Vanilla; public float Applied; public bool Captured; } static readonly Dictionary _vanillaInitial = new Dictionary(); static readonly Dictionary _appliedPopulation = new Dictionary(); static readonly Dictionary _loadedLeft = new Dictionary(); static readonly Dictionary _vanillaBudget = new Dictionary(); static readonly Dictionary _trackedZones = new Dictionary(); static readonly Dictionary _caps = new Dictionary(); static readonly HashSet _healthScaled = new HashSet(); static readonly Dictionary _pawnStats = new Dictionary(); static readonly Dictionary _pawnByEnemy = new Dictionary(); static readonly HashSet _unknownZones = new HashSet(); static readonly List _statCandidates = new List(); static float _budgetMultiplier; static int _loading; static bool _saving; static bool _applying; public static void Clear() { _vanillaInitial.Clear(); _appliedPopulation.Clear(); _loadedLeft.Clear(); _vanillaBudget.Clear(); _trackedZones.Clear(); _caps.Clear(); _healthScaled.Clear(); _pawnStats.Clear(); _pawnByEnemy.Clear(); _unknownZones.Clear(); _budgetMultiplier = 0; _loading = 0; _saving = false; _applying = false; } public static void AfterSpawnSystemAwake(SpawnSystem __instance) { ScaleBudget(__instance); } public static void AfterSpawnSystemReady(SpawnSystem __instance) { ScaleBudget(__instance); } public static void BeforeZoneServerStart(SpawnZone __instance) { ScaleQuota(__instance); } public static void AfterZoneServerStart(SpawnZone __instance) { if (__instance == null) return; int id = __instance.GetInstanceID(); if (!_loadedLeft.Remove(id, out int left) || __instance.EnemiesLeft == left) return; _applying = true; try { __instance.EnemiesLeft = left; } finally { _applying = false; } } public static void BeforeSaveZones() { _saving = true; } public static void AfterSaveZones() { _saving = false; } public static void BeforeLoadZones() { _loading++; } public static void AfterLoadZones() { if (_loading > 0) _loading--; foreach (KeyValuePair pair in _trackedZones) { if (pair.Value != null) ApplyCap(pair.Value); } } // Saves store the vanilla kill count. While a save is reading the property, report that count. public static void AfterReadEnemiesLeft(SpawnZone __instance, ref int __result) { if (!_saving || __instance == null) return; int id = __instance.GetInstanceID(); if (!_vanillaInitial.ContainsKey(id)) return; float population = AppliedPopulation(__instance); if (population <= 0f) return; __result = Mathf.CeilToInt(__result / population); } // Loaded kills are the vanilla remaining count. Stretch them to the horde quota. public static void BeforeWriteEnemiesLeft(SpawnZone __instance, [HarmonyArgument(0)] ref int value) { if (_applying || _loading <= 0) return; value = ScaleCount(value, AppliedPopulation(__instance)); if (__instance != null) _loadedLeft[__instance.GetInstanceID()] = value; } public static void AfterEnemyServerStart(EnemyControlAStar __instance) { try { Weaken(__instance); } catch (Exception ex) { HordePlugin.LogSource.LogWarning("Horde could not weaken a zone enemy: " + ex.Message); } } public static void AfterEnemyClientStart(EnemyControlAStar __instance) { try { MatchClientHealth(__instance); } catch (Exception ex) { HordePlugin.LogSource.LogWarning("Horde could not match client health: " + ex.Message); } } public static void AfterEnemyServerStop(EnemyControlAStar __instance) { if (__instance == null) return; int enemyId = __instance.GetInstanceID(); _healthScaled.Remove(enemyId); if (_pawnByEnemy.TryGetValue(enemyId, out int pawnId)) { _pawnStats.Remove(pawnId); _pawnByEnemy.Remove(enemyId); } } public static void AfterReadDamage(PawnStatus __instance, ref float __result) { if (__instance == null) return; if (_pawnStats.TryGetValue(__instance.GetInstanceID(), out float multiplier)) __result *= multiplier; } static void ScaleBudget(SpawnSystem system) { if (system == null) return; int id = system.GetInstanceID(); if (!_vanillaBudget.TryGetValue(id, out int vanilla)) { int cap = system.EnemiesCap; if (cap <= 0) return; vanilla = cap; _vanillaBudget[id] = vanilla; } float budget = HordeConfig.BudgetPopulation(); if (budget <= 0f) budget = 1f; _budgetMultiplier = budget; int scaled = ScaleCount(vanilla, budget); if (system.EnemiesCap != scaled) { system.EnemiesCap = scaled; HordePlugin.LogSource.LogInfo($"Horde enemy cap {vanilla} -> {scaled}."); } foreach (KeyValuePair pair in _trackedZones) { if (pair.Value != null) ApplyCap(pair.Value); } } static void ScaleQuota(SpawnZone zone) { if (zone == null) return; int id = zone.GetInstanceID(); _trackedZones[id] = zone; NoteZone(zone); if (!_vanillaInitial.TryGetValue(id, out int vanilla)) { vanilla = zone.InitialEnemies; _vanillaInitial[id] = vanilla; } float population = HordeConfig.Population(zone); if (population <= 0f) population = 1f; _appliedPopulation[id] = population; int scaled = ScaleCount(vanilla, population); if (zone.InitialEnemies != scaled) zone.InitialEnemies = scaled; ApplyCap(zone); } static float AppliedPopulation(SpawnZone zone) { if (zone != null && _appliedPopulation.TryGetValue(zone.GetInstanceID(), out float applied) && applied > 0f) return applied; float population = HordeConfig.Population(zone); return population <= 0f ? 1f : population; } static int ScaleCount(int count, float multiplier) { if (count <= 0 || multiplier <= 0f) return 0; return Mathf.Max(0, Mathf.RoundToInt(count * multiplier)); } // The shared cap is one number. A zone's own share is its multiplier over that shared budget. static void ApplyCap(SpawnZone zone) { float population = HordeConfig.Population(zone); if (population <= 0f) population = 1f; float budget = _budgetMultiplier > 0f ? _budgetMultiplier : HordeConfig.BudgetPopulation(); if (budget <= 0f) budget = 1f; int id = zone.GetInstanceID(); if (!_caps.TryGetValue(id, out CapState state)) { state = new CapState(); _caps[id] = state; } float current = zone.CapMultiplier; if (!state.Captured) { state.Vanilla = current; state.Captured = true; } else if (Mathf.Abs(current - state.Applied) > 0.0001f) { state.Vanilla = current; } float next = state.Vanilla * population / budget; state.Applied = next; if (Mathf.Abs(current - next) > 0.0001f) zone.CapMultiplier = next; } static void NoteZone(SpawnZone zone) { if (HordeConfig.TryZone(zone, out _)) return; string name = zone.ZoneName; if (string.IsNullOrEmpty(name) || !_unknownZones.Add(name)) return; HordePlugin.LogSource.LogInfo("Horde has no config entry for zone '" + name + "'. Using global multipliers."); } static void Weaken(EnemyControlAStar enemy) { if (enemy == null || enemy.SpawnZone == null) return; PawnStatus status = enemy.PawnStatus; if (status == null) return; int enemyId = enemy.GetInstanceID(); if (!_healthScaled.Add(enemyId)) return; float stat = HordeConfig.Stat(enemy.SpawnZone); if (Mathf.Abs(stat - 1f) < 0.0001f) { _healthScaled.Remove(enemyId); return; } status.InitialHealth *= stat; SyncVar health = status.Health; if (health != null) health.Value *= stat; ScaleParts(enemy.damageableParts, stat); int pawnId = status.GetInstanceID(); _pawnStats[pawnId] = stat; _pawnByEnemy[enemyId] = pawnId; } // The zone reference is not networked. A client sees the replicated health already // halved and has to lower the local max so the bar still starts full. static void MatchClientHealth(EnemyControlAStar enemy) { if (enemy == null) return; int enemyId = enemy.GetInstanceID(); if (_healthScaled.Contains(enemyId)) return; PawnStatus status = enemy.PawnStatus; if (status == null) return; float stat; if (enemy.SpawnZone != null) { stat = HordeConfig.Stat(enemy.SpawnZone); } else { SyncVar health = status.Health; float initial = status.InitialHealth; if (health == null || initial <= 1f || !TryMatchStat(health.Value, initial, out stat)) return; } if (Mathf.Abs(stat - 1f) < 0.0001f) return; _healthScaled.Add(enemyId); status.InitialHealth *= stat; } static bool TryMatchStat(float health, float initial, out float multiplier) { multiplier = 1f; HordeConfig.CollectStats(_statCandidates); float best = 0.05f; bool found = false; for (int i = 0; i < _statCandidates.Count; i++) { float candidate = _statCandidates[i]; float delta = Mathf.Abs(health - initial * candidate); if (delta > best) continue; best = delta; multiplier = candidate; found = true; } return found; } static void ScaleParts(Il2CppReferenceArray parts, float stat) { if (parts == null) return; for (int i = 0; i < parts.Length; i++) { DamageablePart part = parts[i]; if (part == null || part.MaxHealth <= 0f) continue; part.MaxHealth *= stat; SyncVar current = part.CurrentHealth; if (current != null) current.Value *= stat; } } } }