using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Text; using UnityEngine; namespace Oxide.Plugins { [Info("Random AI", "Orffyrus", "1.13.0")] [Description("RandomAI: Apex Evolution adds interaction-driven aptitude variation and rare bosses that grow stronger by defeating animals, NPCs, and players.")] public class RandomAI : RustPlugin { private const string AdminPermission = "randomai.npc.admin"; private const int CurrentConfigVersion = 7; private const int CurrentTelemetryDataVersion = 3; private const string TelemetryDataFileName = "RandomAI_Telemetry"; private const string TelemetrySummaryCsvFileName = "RandomAI_Telemetry_Summary.csv"; private const string TelemetryMatrixCsvFileName = "RandomAI_Telemetry_Matrix.csv"; private const float TelemetrySaveIntervalSeconds = 300f; private const float TelemetrySweepIntervalSeconds = 5f; private const float DefaultTelemetryEncounterTimeoutSeconds = 60f; private const float DefaultDebugDamageCooldownSeconds = 0.5f; private const float DefaultAptitudeCheckIntervalSeconds = 60f; private const float DefaultAptitudeRerollChance = 0.05f; private const float DefaultAptitudeCombatCooldownSeconds = 15f; private const float DefaultBehaviorDecisionChanceOnDamage = 0.2f; private const float DefaultBehaviorDecisionCooldownSeconds = 6f; private const float DefaultBehaviorFleeChance = 0.2f; private const float DefaultBehaviorAttackChance = 0.45f; private const float DefaultBehaviorFightToDeathChance = 0.2f; private const float DefaultBossChance = 0.07f; private const float DefaultBossGrowthPerKill = 0.2f; private const float DefaultBossMaxGrowthMultiplier = 2f; private const float DefaultBossMaxAnimalScaleMultiplier = 1.5f; private const float BehaviorTargetMemorySeconds = 30f; private const float MaxAllowedSpeedMultiplier = 5f; private const float MaxAllowedHealthMultiplier = 10f; private const float MaxAllowedDamageMultiplier = 5f; private const string BehaviorNeutral = "neutral"; private const string BehaviorFlee = "flee"; private const string BehaviorAttack = "attack"; private const string BehaviorFightToDeath = "fight-to-death"; private static readonly string[] Gen2SpeedFields = { "sneakSpeed", "walkSpeed", "jogSpeed", "runSpeed", "sprintSpeed", "fullSprintSpeed", "swimSpeed", "swimSprintSpeed" }; private static readonly string[] SharkSpeedFields = { "minSpeed", "maxSpeed", "currentSpeed" }; private static readonly string[] SnakeSpeedFields = { "SlitherSpeed" }; private static readonly HashSet SupportedAnimals = new HashSet(StringComparer.OrdinalIgnoreCase) { "bear", "polarbear", "boar", "chicken", "stag", "wolf", "wolf2", "tiger", "panther", "crocodile", "shark", "simpleshark", "snake.entity" }; private static readonly string[] ExcludedNameFragments = { "horse", "tutorial", "zombie", "corpse", "bottest", "npcplayertest", "frankenstein" }; private ConfigData configData; private readonly Dictionary npcModifiers = new Dictionary(); private readonly Dictionary activeTelemetry = new Dictionary(); private readonly HashSet administrativeRemovals = new HashSet(); private readonly Dictionary lastDebugDamageAt = new Dictionary(); private readonly HashSet behaviorAdapterWarnings = new HashSet(StringComparer.OrdinalIgnoreCase); private TelemetryData telemetryData; public class NpcModifiers { public float SpeedMultiplier { get; set; } = 1f; public float HealthMultiplier { get; set; } = 1f; public float DamageMultiplier { get; set; } = 1f; public float AptitudeSpeedMultiplier { get; set; } = 1f; public float AptitudeHealthMultiplier { get; set; } = 1f; public float AptitudeDamageMultiplier { get; set; } = 1f; public float OriginalMaxHealth { get; set; } public float OriginalPrimarySpeed { get; set; } public string SpeedAdapter { get; set; } = "none"; public Dictionary OriginalSpeedValues { get; set; } = new Dictionary(); public string BehaviorDisposition { get; set; } = BehaviorNeutral; public string BehaviorAdapter { get; set; } = "none"; public float NextAptitudeCheckAt { get; set; } public float LastCombatAt { get; set; } = -1000f; public float LastBehaviorDecisionAt { get; set; } = -1000f; public BaseEntity LastCombatTarget { get; set; } public bool HasOriginalFleeHealthThreshold { get; set; } public float OriginalFleeHealthThreshold { get; set; } public bool HasOriginalLowHealthSpeedSettings { get; set; } public float OriginalLowHealthSpeedTriggerFraction { get; set; } public float OriginalLowHealthMaxSpeedFraction { get; set; } public long AptitudeRerolls { get; set; } public bool IsBoss { get; set; } public long BossKills { get; set; } public float BossGrowthMultiplier { get; set; } = 1f; public bool BossScaleEligible { get; set; } public Vector3 OriginalScale { get; set; } = Vector3.one; public bool OriginalNetworkEntityScale { get; set; } public bool PendingTelemetryAptitudeRoll { get; set; } } public class NpcSettings { public float MinSpeedMultiplier { get; set; } = 0.8f; public float MaxSpeedMultiplier { get; set; } = 1.2f; public float MinHealthMultiplier { get; set; } = 0.8f; public float MaxHealthMultiplier { get; set; } = 1.2f; public float MinDamageMultiplier { get; set; } = 0.8f; public float MaxDamageMultiplier { get; set; } = 1.2f; } public class ConfigData { public int ConfigVersion { get; set; } = CurrentConfigVersion; public bool DebugMode { get; set; } public bool DebugBroadcastToAdmins { get; set; } = true; public bool DebugPlayerCombatOnly { get; set; } = true; public float DebugDamageCooldownSeconds { get; set; } = DefaultDebugDamageCooldownSeconds; public string LogLevel { get; set; } = "Warning"; public bool TelemetryEnabled { get; set; } public float TelemetryEncounterTimeoutSeconds { get; set; } = DefaultTelemetryEncounterTimeoutSeconds; public bool AptitudeRerollEnabled { get; set; } = true; public float AptitudeCheckIntervalSeconds { get; set; } = DefaultAptitudeCheckIntervalSeconds; public float AptitudeRerollChance { get; set; } = DefaultAptitudeRerollChance; public float AptitudeCombatCooldownSeconds { get; set; } = DefaultAptitudeCombatCooldownSeconds; public float BehaviorDecisionChanceOnDamage { get; set; } = DefaultBehaviorDecisionChanceOnDamage; public float BehaviorDecisionCooldownSeconds { get; set; } = DefaultBehaviorDecisionCooldownSeconds; public float BehaviorFleeChance { get; set; } = DefaultBehaviorFleeChance; public float BehaviorAttackChance { get; set; } = DefaultBehaviorAttackChance; public float BehaviorFightToDeathChance { get; set; } = DefaultBehaviorFightToDeathChance; public bool BossesEnabled { get; set; } = true; public float BossChance { get; set; } = DefaultBossChance; public float BossGrowthPerKill { get; set; } = DefaultBossGrowthPerKill; public float BossMaxGrowthMultiplier { get; set; } = DefaultBossMaxGrowthMultiplier; public bool BossScaleAnimals { get; set; } = true; public float BossMaxAnimalScaleMultiplier { get; set; } = DefaultBossMaxAnimalScaleMultiplier; public Dictionary NpcMultipliers { get; set; } = new Dictionary(); } public class TelemetryData { public int DataVersion { get; set; } = CurrentTelemetryDataVersion; public string StartedUtc { get; set; } = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture); public string LastSavedUtc { get; set; } public Dictionary Npcs { get; set; } = new Dictionary(); } public class TelemetryAggregate { public long Spawns { get; set; } public long CompletedSamples { get; set; } public long PlayerCombatEncounters { get; set; } public long PlayerInvolvedDeaths { get; set; } public long PlayerInvolvedEscapes { get; set; } public long Deaths { get; set; } public long TimedDeaths { get; set; } public long PlayerKills { get; set; } public long NpcKills { get; set; } public long EnvironmentalDeaths { get; set; } public long EscapedEncounters { get; set; } public long AdministrativeRemovals { get; set; } public long CensoredSamples { get; set; } public double TotalTimeToKillSeconds { get; set; } public double TotalDamageReceived { get; set; } public double TotalDamageDealt { get; set; } public double SumSpeedMultiplier { get; set; } public double SumSpeedMultiplierSquared { get; set; } public double SumHealthMultiplier { get; set; } public double SumHealthMultiplierSquared { get; set; } public Dictionary SpeedBuckets { get; set; } = new Dictionary(); public Dictionary HealthBuckets { get; set; } = new Dictionary(); public Dictionary TimeToKillBuckets { get; set; } = new Dictionary(); public Dictionary PerformanceCells { get; set; } = new Dictionary(); } public class TelemetryPerformanceCell { public string SpeedBucket { get; set; } public string HealthBucket { get; set; } public long Spawns { get; set; } public long CompletedEncounters { get; set; } public long PlayerCombatEncounters { get; set; } public long PlayerInvolvedDeaths { get; set; } public long PlayerInvolvedEscapes { get; set; } public long Deaths { get; set; } public long PlayerKills { get; set; } public long NpcKills { get; set; } public long EnvironmentalDeaths { get; set; } public long EscapedEncounters { get; set; } public long AdministrativeRemovals { get; set; } public long CensoredSamples { get; set; } public long TimedDeaths { get; set; } public double TotalTimeToKillSeconds { get; set; } public double TotalDamageReceived { get; set; } public double TotalDamageDealt { get; set; } } private class TelemetryEncounter { public NetworkableId EntityId { get; set; } public string PrefabName { get; set; } public string SpeedBucket { get; set; } public string HealthBucket { get; set; } public bool InCombat { get; set; } public bool PlayerInvolved { get; set; } public float CombatStartedAt { get; set; } public float LastCombatAt { get; set; } public double DamageReceived { get; set; } public double DamageDealt { get; set; } } protected override void LoadDefaultConfig() { PrintWarning("Creating a new configuration file for RandomAI..."); configData = CreateDefaultConfig(); Config.WriteObject(configData, true); } protected override void LoadDefaultMessages() { lang.RegisterMessages(new Dictionary { ["NoPermission"] = "You do not have permission to use this command.", ["PlayerOnly"] = "This command can only be used in-game by a player.", ["RespawnSuccessV2"] = "Removed {0} supported NPCs. Population spawners may replace them with new randomized instances.", ["RespawnFailed"] = "Failed to remove supported NPCs: {0}", ["ConfigReloaded"] = "RandomAI config reloaded!", ["ConfigReloadedDetailsHeader"] = "\nLoaded NPC Types:\n", ["ConfigReloadedDetailsLineV2"] = "- {0}: Speed [{1:F2}-{2:F2}], Health [{3:F2}-{4:F2}], Damage [{5:F2}-{6:F2}]\n", ["ReloadConfigFailed"] = "Failed to reload config: {0}", ["NpcInspectInfoV5"] = "NPC {0} (ID: {1})\nSpeed: {2:F2}x via {3} (reference {4:F2}, applied {5:F2})\nHealth: {6:F2}x (current {7:F1}/{8:F1}, original max {9:F1})\nDamage: {10:F2}x\nBehavior: {11} via {12}; aptitude rerolls {13}\nApex boss: {17}; kills {18}; growth {19:F2}x; visual size {20:F2}x\nActive encounter: damage received {14:F1}, damage dealt {15:F1}, player involved {16}", ["NpcNotFound"] = "No NPC found or no modifiers applied.", ["NoNpcInRange"] = "No NPC found within 10 units.", ["InspectFailed"] = "Failed to inspect NPC: {0}", ["DebugUsageV1"] = "Usage: npc.debug [on|off|status]", ["DebugEnabledV1"] = "RandomAI debug enabled. Activation rolls, boss growth, and sampled damage events are written to the server console{0}.", ["DebugDisabledV1"] = "RandomAI debug disabled.", ["DebugStatusV1"] = "RandomAI debug is {0}; admin chat broadcast {1}; damage filter {2}; per-NPC damage cooldown {3:F2}s.", ["RerollSuccessV1"] = "Rerolled {0} (ID: {1}): speed {2:F2}x, health {3:F2}x, damage {4:F2}x, behavior {5}.", ["RerollUsageV1"] = "Look at a supported NPC within 10 units and use /npc.reroll.", ["RerollCombatV1"] = "That NPC has an active telemetry encounter; wait for it to disengage before forcing a numeric reroll.", ["TelemetryStatusV3"] = "Telemetry is {0} ({1:F0}s inactivity timeout). Tracked NPCs: {2}; active encounters: {3}; prefab groups: {4}; recorded aptitude rolls: {5}; completed encounters: {6}.", ["TelemetrySummaryHeaderV2"] = "RandomAI encounter telemetry summary ({0} prefab groups):", ["TelemetrySummaryLineV4"] = "{0}: aptitude rolls {1}, encounters {2}, player-combat {3}, deaths {4} (player {5}, NPC {6}, environment {7}), escaped {8}, admin/censored {9}/{10}, mean TTK {11:F1}s, mean speed {12:F2}x, mean health {13:F2}x, damage in/out {14:F1}/{15:F1}", ["TelemetryNoDataV1"] = "No telemetry data is available for '{0}'.", ["TelemetrySavedV1"] = "RandomAI telemetry saved.", ["TelemetryResetPromptV1"] = "Use 'npc.telemetry reset confirm' to clear all aggregate telemetry data.", ["TelemetryResetEnabledV1"] = "RandomAI aggregate telemetry was reset; active supported NPCs now begin fresh encounter tracking.", ["TelemetryResetDisabledV1"] = "RandomAI aggregate telemetry was reset; encounter tracking remains disabled.", ["TelemetryUsageV3"] = "Usage: npc.telemetry [status|summary [prefab]|matrix |readiness [prefab] [target]|export|save|reset confirm]", ["TelemetryMatrixUsageV1"] = "Usage: npc.telemetry matrix ", ["TelemetryMatrixHeaderV1"] = "RandomAI speed x health matrix for {0} ({1} cells; showing {2}):", ["TelemetryMatrixLineV2"] = "speed {0}x / health {1}x: roll assignments {2}, encounters {3}, player-combat {4}, deaths {5} (P/N/E {6}/{7}/{8}), escaped {9}, admin/censored {10}/{11}, mean TTK {12:F1}s, damage in/out {13:F1}/{14:F1}", ["TelemetryMatrixNoDataV1"] = "No speed x health performance cells are available for '{0}'.", ["TelemetryReadinessHeaderV1"] = "RandomAI tuning readiness (target {0} player-combat encounters per prefab):", ["TelemetryReadinessLineV2"] = "{0}: player-combat {1}/{2} ({3:F0}%), NPC deaths with player involvement {4}, NPC escapes with player involvement {5}, populated roll cells {6}.", ["TelemetryReadinessTargetV1"] = "Readiness target must be an integer from 1 to 100000.", ["TelemetryExportedV1"] = "RandomAI telemetry exported to oxide/data/{0} and oxide/data/{1}.", ["TelemetryExportFailedV1"] = "Could not export RandomAI telemetry: {0}", ["TelemetryConfigStateV2"] = "Telemetry collection: {0} ({1:F0}s inactivity timeout)." }, this); } private void Init() { LoadConfigData(true); LoadTelemetryData(); permission.RegisterPermission(AdminPermission, this); timer.Every(TelemetrySaveIntervalSeconds, () => { if (configData != null && configData.TelemetryEnabled) { SaveTelemetryData(); } }); timer.Every(TelemetrySweepIntervalSeconds, FinalizeInactiveTelemetryEncounters); Log("Plugin initialized successfully.", "Verbose"); } private void OnEntityKill(BaseCombatEntity entity) { if (entity == null || entity.net == null) { return; } NetworkableId id = entity.net.ID; bool administrative = administrativeRemovals.Remove(id); lastDebugDamageAt.Remove(id); if (activeTelemetry.ContainsKey(id)) { EndTelemetryTracking(id, administrative ? "administrative" : "censored", null); } if (npcModifiers.Remove(id)) { Log($"NPC {entity.ShortPrefabName} (ID: {entity.net.ID}) killed; removed from modifier tracking.", "Verbose"); } } private void OnEntityDeath(BaseCombatEntity entity, HitInfo info) { if (entity == null || entity.net == null) { return; } NetworkableId id = entity.net.ID; bool administrative = administrativeRemovals.Remove(id); if (!administrative) { TryGrowBossFromKill(entity, info); } EndTelemetryTracking(id, administrative ? "administrative" : "death", info); } private void TryGrowBossFromKill(BaseCombatEntity victim, HitInfo info) { if (victim == null || info == null || configData == null || !configData.BossesEnabled) { return; } BaseEntity killer = info.Initiator as BaseEntity; NpcModifiers modifiers; if ((killer == null || killer.net == null || !npcModifiers.TryGetValue(killer.net.ID, out modifiers)) && info.InitiatorPlayer != null) { killer = info.InitiatorPlayer; } if (killer == null || killer == victim || killer.IsDestroyed || killer.net == null || !npcModifiers.TryGetValue(killer.net.ID, out modifiers) || modifiers == null || !modifiers.IsBoss || !IsBossGrowthVictim(victim)) { return; } modifiers.BossKills++; float oldGrowth = modifiers.BossGrowthMultiplier; float newGrowth = Mathf.Min( configData.BossMaxGrowthMultiplier, oldGrowth * (1f + configData.BossGrowthPerKill)); if (newGrowth <= oldGrowth || Mathf.Approximately(newGrowth, oldGrowth)) { DebugTrace( $"APEX {NormalizePrefabName(killer.ShortPrefabName)} (ID: {killer.net.ID}) kill #{modifiers.BossKills} " + $"against {NormalizePrefabName(victim.ShortPrefabName)}; growth already capped at {oldGrowth:F2}x."); return; } ApplyBossGrowth(killer, modifiers, newGrowth, victim); } private bool IsBossGrowthVictim(BaseCombatEntity victim) { if (victim == null) { return false; } if (victim is BasePlayer) { return true; } return IsSupportedTarget(NormalizePrefabName(victim.ShortPrefabName)); } private void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) { if (configData == null || info == null) { return; } BaseEntity attacker = info.Initiator as BaseEntity; BasePlayer attackingPlayer = attacker as BasePlayer; BasePlayer victimPlayer = entity as BasePlayer; bool attackerIsPlayer = attackingPlayer != null && !attackingPlayer.IsNpc; bool victimIsPlayer = victimPlayer != null && !victimPlayer.IsNpc; if (attackerIsPlayer) { ActivateForPlayerInteraction(entity, attacker); } if (victimIsPlayer) { ActivateForPlayerInteraction(attacker, entity); } NpcModifiers outgoingModifiers; if (attacker != null && attacker.net != null && npcModifiers.TryGetValue(attacker.net.ID, out outgoingModifiers) && IsFinitePositive(outgoingModifiers.DamageMultiplier) && !Mathf.Approximately(outgoingModifiers.DamageMultiplier, 1f)) { info.damageTypes.ScaleAll(outgoingModifiers.DamageMultiplier); } float damage = info.damageTypes.Total(); if (!IsFinitePositive(damage)) { return; } float effectiveDamage = entity == null ? damage : Mathf.Min(damage, Mathf.Max(0f, entity.Health())); if (!IsFinitePositive(effectiveDamage)) { return; } TryDebugDamage(entity, info, effectiveDamage); TryHandleBehaviorDecision(entity, attacker); if (attacker != entity) { TryHandleBehaviorDecision(attacker, entity); } if (!configData.TelemetryEnabled) { return; } TelemetryEncounter victimEncounter; if (entity != null && entity.net != null && activeTelemetry.TryGetValue(entity.net.ID, out victimEncounter)) { MarkTelemetryCombat(victimEncounter); victimEncounter.PlayerInvolved |= attackerIsPlayer; victimEncounter.DamageReceived += effectiveDamage; } TelemetryEncounter attackerEncounter; if (attacker != null && attacker.net != null && activeTelemetry.TryGetValue(attacker.net.ID, out attackerEncounter)) { MarkTelemetryCombat(attackerEncounter); attackerEncounter.PlayerInvolved |= victimIsPlayer; attackerEncounter.DamageDealt += effectiveDamage; } } private void TryDebugDamage(BaseCombatEntity victim, HitInfo info, float reportedDamage) { if (configData == null || !configData.DebugMode || info == null) { return; } BaseEntity attacker = info.Initiator as BaseEntity; NpcModifiers victimModifiers = null; NpcModifiers attackerModifiers = null; bool victimIsTracked = victim != null && victim.net != null && npcModifiers.TryGetValue(victim.net.ID, out victimModifiers); bool attackerIsTracked = attacker != null && attacker.net != null && npcModifiers.TryGetValue(attacker.net.ID, out attackerModifiers); if (!victimIsTracked && !attackerIsTracked) { return; } BasePlayer victimPlayer = victim as BasePlayer; BasePlayer attackingPlayer = attacker as BasePlayer; bool playerInvolved = (victimPlayer != null && !victimPlayer.IsNpc) || (attackingPlayer != null && !attackingPlayer.IsNpc); if (configData.DebugPlayerCombatOnly && !playerInvolved) { return; } BaseEntity trackedNpc = victimIsTracked ? victim : attacker; NpcModifiers modifiers = victimIsTracked ? victimModifiers : attackerModifiers; NetworkableId trackedId = trackedNpc.net.ID; float now = Time.realtimeSinceStartup; float lastAt; if (lastDebugDamageAt.TryGetValue(trackedId, out lastAt) && now - lastAt < configData.DebugDamageCooldownSeconds) { return; } lastDebugDamageAt[trackedId] = now; float healthBefore = victim == null ? 0f : Mathf.Max(0f, victim.Health()); float maxHealth = victim == null ? 0f : Mathf.Max(0f, victim._maxHealth); string npcName = NormalizePrefabName(trackedNpc.ShortPrefabName); string attackerLabel = GetDebugEntityLabel(attacker); string victimLabel = GetDebugEntityLabel(victim); string direction = victimIsTracked ? "incoming" : "outgoing"; timer.Once(0f, () => { if (configData == null || !configData.DebugMode) { return; } float healthAfter = victim == null || victim.IsDestroyed ? 0f : Mathf.Max(0f, victim.Health()); float actualHealthLoss = Mathf.Max(0f, healthBefore - healthAfter); DebugTrace( $"DAMAGE {npcName} (ID: {trackedId}) {direction}: {attackerLabel} -> {victimLabel}; " + $"reported {reportedDamage:F1}, health loss {actualHealthLoss:F1}, victim health {healthBefore:F1}->{healthAfter:F1}/{maxHealth:F1}; " + $"NPC roll speed {modifiers.SpeedMultiplier:F2}x, health {modifiers.HealthMultiplier:F2}x, damage {modifiers.DamageMultiplier:F2}x; " + $"behavior {modifiers.BehaviorDisposition} via {modifiers.BehaviorAdapter}; " + $"apex {(modifiers.IsBoss ? "yes" : "no")}, kills {modifiers.BossKills}, growth {modifiers.BossGrowthMultiplier:F2}x."); }); } private void ActivateForPlayerInteraction(BaseEntity npcEntity, BaseEntity combatTarget) { if (npcEntity == null || npcEntity.IsDestroyed || npcEntity.net == null || configData == null) { return; } TryApplyModifiers(npcEntity); NpcModifiers modifiers; if (!npcModifiers.TryGetValue(npcEntity.net.ID, out modifiers) || modifiers == null || !configData.AptitudeRerollEnabled) { return; } float now = Time.realtimeSinceStartup; if (modifiers.NextAptitudeCheckAt <= 0f) { ScheduleNextAptitudeCheck(modifiers, true); return; } if (now < modifiers.NextAptitudeCheckAt) { return; } ScheduleNextAptitudeCheck(modifiers, false); if (UnityEngine.Random.value > configData.AptitudeRerollChance) { return; } string previousBehavior = modifiers.BehaviorDisposition; modifiers.BehaviorDisposition = RollBehaviorDisposition(); ApplyBehaviorDisposition(npcEntity, modifiers, combatTarget, "player interaction"); if (now - modifiers.LastCombatAt < configData.AptitudeCombatCooldownSeconds || IsNpcInActiveTelemetryCombat(npcEntity.net.ID)) { DebugTrace( $"BEHAVIOR {NormalizePrefabName(npcEntity.ShortPrefabName)} (ID: {npcEntity.net.ID}) interaction {previousBehavior}->{modifiers.BehaviorDisposition}; " + "numeric aptitude reroll deferred until a later out-of-combat interaction."); return; } RerollNumericAptitudes(npcEntity, modifiers, "player interaction"); } private void ScheduleNextAptitudeCheck(NpcModifiers modifiers, bool initial) { if (modifiers == null || configData == null) { return; } float interval = configData.AptitudeCheckIntervalSeconds; float jitter = initial ? UnityEngine.Random.Range(0.25f, 1f) : UnityEngine.Random.Range(0.9f, 1.1f); modifiers.NextAptitudeCheckAt = Time.realtimeSinceStartup + interval * jitter; } private void TryHandleBehaviorDecision(BaseEntity npcEntity, BaseEntity combatTarget) { if (npcEntity == null || npcEntity.net == null || configData == null) { return; } BaseCombatEntity npcCombatEntity = npcEntity as BaseCombatEntity; if (npcCombatEntity == null || npcCombatEntity.IsDead() || npcCombatEntity.Health() <= 0f) { return; } NpcModifiers modifiers; if (!npcModifiers.TryGetValue(npcEntity.net.ID, out modifiers) || modifiers == null) { return; } float now = Time.realtimeSinceStartup; modifiers.LastCombatAt = now; if (IsUsableCombatTarget(combatTarget) && combatTarget != npcEntity) { modifiers.LastCombatTarget = combatTarget; } if (now - modifiers.LastBehaviorDecisionAt < configData.BehaviorDecisionCooldownSeconds) { return; } if (string.Equals(modifiers.BehaviorDisposition, BehaviorFightToDeath, StringComparison.Ordinal) && IsBehaviorState(npcEntity, BehaviorFlee)) { modifiers.LastBehaviorDecisionAt = now; ApplyBehaviorDisposition(npcEntity, modifiers, modifiers.LastCombatTarget, "low-health flee override"); return; } if (UnityEngine.Random.value > configData.BehaviorDecisionChanceOnDamage) { return; } modifiers.LastBehaviorDecisionAt = now; string previousBehavior = modifiers.BehaviorDisposition; modifiers.BehaviorDisposition = RollBehaviorDisposition(); ApplyBehaviorDisposition(npcEntity, modifiers, modifiers.LastCombatTarget, "combat decision"); DebugTrace( $"BEHAVIOR {NormalizePrefabName(npcEntity.ShortPrefabName)} (ID: {npcEntity.net.ID}) combat {previousBehavior}->{modifiers.BehaviorDisposition} " + $"via {modifiers.BehaviorAdapter}; health {((npcEntity as BaseCombatEntity)?.Health() ?? 0f):F1}."); } private string RollBehaviorDisposition() { float roll = UnityEngine.Random.value; if (roll < configData.BehaviorFleeChance) { return BehaviorFlee; } roll -= configData.BehaviorFleeChance; if (roll < configData.BehaviorAttackChance) { return BehaviorAttack; } roll -= configData.BehaviorAttackChance; if (roll < configData.BehaviorFightToDeathChance) { return BehaviorFightToDeath; } return BehaviorNeutral; } private bool RerollNumericAptitudes(BaseEntity entity, NpcModifiers modifiers, string reason) { if (entity == null || entity.IsDestroyed || entity.net == null || modifiers == null || configData == null) { return false; } BaseCombatEntity combatEntity = entity as BaseCombatEntity; if (combatEntity == null || combatEntity.IsDead() || IsNpcInActiveTelemetryCombat(entity.net.ID)) { return false; } NpcSettings settings; string configKey; if (!TryResolveSettings(NormalizePrefabName(entity.ShortPrefabName), out settings, out configKey)) { return false; } float oldSpeed = modifiers.SpeedMultiplier; float oldHealth = modifiers.HealthMultiplier; float oldDamage = modifiers.DamageMultiplier; float aptitudeSpeed = UnityEngine.Random.Range(settings.MinSpeedMultiplier, settings.MaxSpeedMultiplier); float aptitudeHealth = UnityEngine.Random.Range(settings.MinHealthMultiplier, settings.MaxHealthMultiplier); float aptitudeDamage = UnityEngine.Random.Range(settings.MinDamageMultiplier, settings.MaxDamageMultiplier); float newSpeed = GetBossAdjustedMultiplier(aptitudeSpeed, modifiers.BossGrowthMultiplier, MaxAllowedSpeedMultiplier); float newHealth = GetBossAdjustedMultiplier(aptitudeHealth, modifiers.BossGrowthMultiplier, MaxAllowedHealthMultiplier); float newDamage = GetBossAdjustedMultiplier(aptitudeDamage, modifiers.BossGrowthMultiplier, MaxAllowedDamageMultiplier); if (!ApplySpeedMultiplier(entity, modifiers, newSpeed)) { newSpeed = 1f; aptitudeSpeed = 1f; } float currentMaxHealth = combatEntity._maxHealth; float healthRatio = currentMaxHealth > 0f ? Mathf.Clamp01(combatEntity.Health() / currentMaxHealth) : 1f; float newMaxHealth = modifiers.OriginalMaxHealth * newHealth; combatEntity.SetMaxHealth(newMaxHealth); combatEntity.SetHealth(newMaxHealth * healthRatio); combatEntity.SendNetworkUpdate(); modifiers.SpeedMultiplier = newSpeed; modifiers.HealthMultiplier = newHealth; modifiers.DamageMultiplier = newDamage; modifiers.AptitudeSpeedMultiplier = aptitudeSpeed; modifiers.AptitudeHealthMultiplier = aptitudeHealth; modifiers.AptitudeDamageMultiplier = aptitudeDamage; modifiers.AptitudeRerolls++; RecordOrQueueTelemetryAptitudeRoll(entity, modifiers); DebugTrace( $"REROLL {NormalizePrefabName(entity.ShortPrefabName)} (ID: {entity.net.ID}, {reason}, config: {configKey}, #{modifiers.AptitudeRerolls}): " + $"speed {oldSpeed:F2}->{newSpeed:F2}x, health {oldHealth:F2}->{newHealth:F2}x ({combatEntity.Health():F1}/{newMaxHealth:F1}), " + $"damage {oldDamage:F2}->{newDamage:F2}x, behavior {modifiers.BehaviorDisposition} via {modifiers.BehaviorAdapter}."); return true; } private float GetBossAdjustedMultiplier(float aptitudeMultiplier, float growthMultiplier, float safetyCap) { return Mathf.Min(safetyCap, aptitudeMultiplier * Mathf.Max(1f, growthMultiplier)); } private void ApplyBossGrowth(BaseEntity entity, NpcModifiers modifiers, float newGrowth, BaseCombatEntity victim) { BaseCombatEntity combatEntity = entity as BaseCombatEntity; if (entity == null || entity.IsDestroyed || entity.net == null || combatEntity == null || modifiers == null) { return; } float oldGrowth = modifiers.BossGrowthMultiplier; float oldSpeed = modifiers.SpeedMultiplier; float oldHealth = modifiers.HealthMultiplier; float oldDamage = modifiers.DamageMultiplier; float newSpeed = GetBossAdjustedMultiplier(modifiers.AptitudeSpeedMultiplier, newGrowth, MaxAllowedSpeedMultiplier); float newHealth = GetBossAdjustedMultiplier(modifiers.AptitudeHealthMultiplier, newGrowth, MaxAllowedHealthMultiplier); float newDamage = GetBossAdjustedMultiplier(modifiers.AptitudeDamageMultiplier, newGrowth, MaxAllowedDamageMultiplier); if (!ApplySpeedMultiplier(entity, modifiers, newSpeed)) { newSpeed = oldSpeed; } float currentMaxHealth = combatEntity._maxHealth; float healthRatio = currentMaxHealth > 0f ? Mathf.Clamp01(combatEntity.Health() / currentMaxHealth) : 1f; float newMaxHealth = modifiers.OriginalMaxHealth * newHealth; combatEntity.SetMaxHealth(newMaxHealth); combatEntity.SetHealth(newMaxHealth * healthRatio); modifiers.BossGrowthMultiplier = newGrowth; modifiers.SpeedMultiplier = newSpeed; modifiers.HealthMultiplier = newHealth; modifiers.DamageMultiplier = newDamage; float visualScale = ApplyBossScale(entity, modifiers); combatEntity.SendNetworkUpdate(); RecordOrQueueTelemetryAptitudeRoll(entity, modifiers); DebugTrace( $"APEX {NormalizePrefabName(entity.ShortPrefabName)} (ID: {entity.net.ID}) kill #{modifiers.BossKills} " + $"against {NormalizePrefabName(victim.ShortPrefabName)}: growth {oldGrowth:F2}->{newGrowth:F2}x; " + $"speed {oldSpeed:F2}->{newSpeed:F2}x, health {oldHealth:F2}->{newHealth:F2}x " + $"({combatEntity.Health():F1}/{newMaxHealth:F1}), damage {oldDamage:F2}->{newDamage:F2}x, size {visualScale:F2}x."); } private float ApplyBossScale(BaseEntity entity, NpcModifiers modifiers) { if (entity == null || entity.IsDestroyed || modifiers == null || !modifiers.BossScaleEligible || configData == null) { return 1f; } if (!configData.BossScaleAnimals) { RestoreBossScale(entity, modifiers); return 1f; } float scaleMultiplier = CalculateBossScaleMultiplier(modifiers.BossGrowthMultiplier); entity.transform.localScale = modifiers.OriginalScale * scaleMultiplier; entity.networkEntityScale = true; entity.SendNetworkUpdate(); return scaleMultiplier; } private float CalculateBossScaleMultiplier(float growthMultiplier) { if (configData == null || configData.BossMaxGrowthMultiplier <= 1f) { return 1f; } float progress = Mathf.InverseLerp(1f, configData.BossMaxGrowthMultiplier, growthMultiplier); return Mathf.Lerp(1f, configData.BossMaxAnimalScaleMultiplier, progress); } private float GetCurrentBossScaleMultiplier(NpcModifiers modifiers) { return modifiers != null && modifiers.IsBoss && modifiers.BossScaleEligible && configData != null && configData.BossScaleAnimals ? CalculateBossScaleMultiplier(modifiers.BossGrowthMultiplier) : 1f; } private void SynchronizeBossScaleAfterConfigReload() { foreach (KeyValuePair pair in npcModifiers.ToList()) { NpcModifiers modifiers = pair.Value; if (modifiers == null || !modifiers.IsBoss || !modifiers.BossScaleEligible) { continue; } BaseEntity entity = BaseNetworkable.serverEntities.Find(pair.Key) as BaseEntity; if (entity == null || entity.IsDestroyed) { continue; } if (configData.BossScaleAnimals) { ApplyBossScale(entity, modifiers); } else { RestoreBossScale(entity, modifiers); } } } private void RestoreBossScale(BaseEntity entity, NpcModifiers modifiers) { if (entity == null || entity.IsDestroyed || modifiers == null || !modifiers.BossScaleEligible) { return; } entity.transform.localScale = modifiers.OriginalScale; entity.networkEntityScale = modifiers.OriginalNetworkEntityScale; entity.SendNetworkUpdate(); } private bool ApplySpeedMultiplier(BaseEntity entity, NpcModifiers modifiers, float multiplier) { if (entity == null || modifiers == null || !IsFinitePositive(multiplier) || modifiers.SpeedAdapter == "none") { return false; } if (modifiers.SpeedAdapter == "BaseNavigator") { BaseNavigator navigator = entity.GetComponent(); float originalSpeed; if (navigator == null || !modifiers.OriginalSpeedValues.TryGetValue("Speed", out originalSpeed)) { return false; } navigator.Speed = originalSpeed * multiplier; return true; } object component = modifiers.SpeedAdapter == "SimpleShark" ? entity : FindComponentByTypeName(entity, modifiers.SpeedAdapter); if (component == null) { return false; } int updated = 0; Type componentType = component.GetType(); foreach (KeyValuePair pair in modifiers.OriginalSpeedValues) { FieldInfo field = componentType.GetField(pair.Key, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (field != null && field.FieldType == typeof(float)) { field.SetValue(component, pair.Value * multiplier); updated++; } } return updated > 0; } private bool ApplyBehaviorDisposition(BaseEntity entity, NpcModifiers modifiers, BaseEntity target, string reason) { if (entity == null || entity.IsDestroyed || modifiers == null) { return false; } ApplyFightToDeathOverrides(entity, modifiers); string disposition = modifiers.BehaviorDisposition; bool stopFlee = string.Equals(disposition, BehaviorNeutral, StringComparison.Ordinal); if (stopFlee && !IsBehaviorState(entity, BehaviorFlee)) { return false; } if (!stopFlee && !IsUsableCombatTarget(target)) { return false; } string adapter; if (TrySwitchLegacyBehavior(entity, disposition, target, out adapter) || TrySwitchGen2Behavior(entity, disposition, target, out adapter)) { modifiers.BehaviorAdapter = adapter; DebugTrace( $"STATE {NormalizePrefabName(entity.ShortPrefabName)} (ID: {entity.net.ID}) -> {disposition} via {adapter} ({reason})."); return true; } modifiers.BehaviorAdapter = "unsupported"; return false; } private bool TrySwitchLegacyBehavior(BaseEntity entity, string disposition, BaseEntity target, out string adapter) { adapter = "none"; BaseAIBrain brain = entity.GetComponent(); if (brain == null) { return false; } BaseNpc baseNpc = entity as BaseNpc; if (baseNpc != null && IsUsableCombatTarget(target)) { baseNpc.AttackTarget = target; } AIState[] desiredStates; if (string.Equals(disposition, BehaviorFlee, StringComparison.Ordinal)) { desiredStates = new[] { AIState.Flee }; } else if (string.Equals(disposition, BehaviorNeutral, StringComparison.Ordinal)) { desiredStates = new[] { AIState.Roam, AIState.Idle }; } else { desiredStates = new[] { AIState.Attack, AIState.Combat, AIState.Chase }; } foreach (AIState state in desiredStates) { if (brain.HasState(state) && brain.SwitchToState(state, 0)) { adapter = $"legacy:{state}"; return true; } } return false; } private bool TrySwitchGen2Behavior(BaseEntity entity, string disposition, BaseEntity target, out string adapter) { adapter = "none"; Component fsm = FindComponentByBaseTypeName(entity, "FSMComponent"); if (fsm == null) { return false; } string[] stateFieldNames; if (string.Equals(disposition, BehaviorFlee, StringComparison.Ordinal)) { stateFieldNames = new[] { "flee", "permaFlee", "goHide" }; } else if (string.Equals(disposition, BehaviorNeutral, StringComparison.Ordinal)) { stateFieldNames = new[] { "roam", "idle", "patrol", "randomIdle", "search" }; } else { stateFieldNames = new[] { "attack", "staticAttack", "swimAttack", "dogFight", "chase", "charge", "approach" }; } try { Type fsmType = fsm.GetType(); FieldInfo stateField = stateFieldNames .Select(name => fsmType.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) .FirstOrDefault(field => field != null && field.GetValue(fsm) != null); if (stateField == null) { return false; } object state = stateField.GetValue(fsm); MethodInfo setState = FindInstanceMethod(fsmType, "SetState", 2); if (setState == null) { return false; } ParameterInfo[] parameters = setState.GetParameters(); object payload = Activator.CreateInstance(parameters[1].ParameterType); FieldInfo entityField = parameters[1].ParameterType.GetField("entity", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (entityField != null && IsUsableCombatTarget(target)) { entityField.SetValue(payload, target); } setState.Invoke(fsm, new[] { state, payload }); adapter = $"gen2:{stateField.Name}"; return true; } catch (Exception ex) { string warningKey = $"{entity.GetType().FullName}|{disposition}|{ex.GetType().Name}"; if (behaviorAdapterWarnings.Add(warningKey)) { Log($"Gen2 behavior adapter failed for {entity.ShortPrefabName} ({disposition}): {ex.GetBaseException().Message}", "Warning"); } return false; } } private bool IsBehaviorState(BaseEntity entity, string stateFragment) { if (entity == null) { return false; } BaseAIBrain brain = entity.GetComponent(); if (brain != null && brain.CurrentState != null) { return brain.CurrentState.StateType.ToString().IndexOf(stateFragment, StringComparison.OrdinalIgnoreCase) >= 0; } Component fsm = FindComponentByBaseTypeName(entity, "FSMComponent"); if (fsm == null) { return false; } PropertyInfo currentStateProperty = fsm.GetType().GetProperty("CurrentState", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); object currentState = currentStateProperty == null ? null : currentStateProperty.GetValue(fsm, null); if (currentState == null) { return false; } return currentState.GetType().Name.IndexOf(stateFragment, StringComparison.OrdinalIgnoreCase) >= 0 || currentState.ToString().IndexOf(stateFragment, StringComparison.OrdinalIgnoreCase) >= 0; } private Component FindComponentByBaseTypeName(BaseEntity entity, string typeName) { if (entity == null) { return null; } foreach (Component component in entity.GetComponents()) { Type type = component == null ? null : component.GetType(); while (type != null) { if (string.Equals(type.Name, typeName, StringComparison.OrdinalIgnoreCase)) { return component; } type = type.BaseType; } } return null; } private MethodInfo FindInstanceMethod(Type type, string name, int parameterCount) { while (type != null) { MethodInfo method = type.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly) .FirstOrDefault(candidate => candidate.Name == name && candidate.GetParameters().Length == parameterCount); if (method != null) { return method; } type = type.BaseType; } return null; } private FieldInfo FindInstanceField(Type type, string name) { while (type != null) { FieldInfo field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly); if (field != null) { return field; } type = type.BaseType; } return null; } private bool IsUsableCombatTarget(BaseEntity target) { BaseCombatEntity combatTarget = target as BaseCombatEntity; return combatTarget != null && !combatTarget.IsDestroyed && !combatTarget.IsDead(); } private bool IsNpcInActiveTelemetryCombat(NetworkableId id) { TelemetryEncounter encounter; return activeTelemetry.TryGetValue(id, out encounter) && encounter != null && encounter.InCombat; } private void CaptureBehaviorBaselines(BaseEntity entity, NpcModifiers modifiers) { if (entity == null || modifiers == null) { return; } FieldInfo fleeThreshold = FindInstanceField(entity.GetType(), "fleeHealthThresholdPercentage"); if (fleeThreshold != null && fleeThreshold.FieldType == typeof(float)) { modifiers.HasOriginalFleeHealthThreshold = true; modifiers.OriginalFleeHealthThreshold = (float)fleeThreshold.GetValue(entity); } BaseNavigator navigator = entity.GetComponent(); if (navigator != null) { modifiers.HasOriginalLowHealthSpeedSettings = true; modifiers.OriginalLowHealthSpeedTriggerFraction = navigator.LowHealthSpeedReductionTriggerFraction; modifiers.OriginalLowHealthMaxSpeedFraction = navigator.LowHealthMaxSpeedFraction; } } private void ApplyFightToDeathOverrides(BaseEntity entity, NpcModifiers modifiers) { bool enabled = string.Equals(modifiers.BehaviorDisposition, BehaviorFightToDeath, StringComparison.Ordinal); FieldInfo fleeThreshold = FindInstanceField(entity.GetType(), "fleeHealthThresholdPercentage"); if (modifiers.HasOriginalFleeHealthThreshold && fleeThreshold != null && fleeThreshold.FieldType == typeof(float)) { fleeThreshold.SetValue(entity, enabled ? 0f : modifiers.OriginalFleeHealthThreshold); } BaseNavigator navigator = entity.GetComponent(); if (modifiers.HasOriginalLowHealthSpeedSettings && navigator != null) { navigator.LowHealthSpeedReductionTriggerFraction = enabled ? 0f : modifiers.OriginalLowHealthSpeedTriggerFraction; navigator.LowHealthMaxSpeedFraction = modifiers.OriginalLowHealthMaxSpeedFraction; } } private void RestoreBehaviorBaselines(BaseEntity entity, NpcModifiers modifiers) { if (entity == null || modifiers == null) { return; } FieldInfo fleeThreshold = FindInstanceField(entity.GetType(), "fleeHealthThresholdPercentage"); if (modifiers.HasOriginalFleeHealthThreshold && fleeThreshold != null && fleeThreshold.FieldType == typeof(float)) { fleeThreshold.SetValue(entity, modifiers.OriginalFleeHealthThreshold); } BaseNavigator navigator = entity.GetComponent(); if (modifiers.HasOriginalLowHealthSpeedSettings && navigator != null) { navigator.LowHealthSpeedReductionTriggerFraction = modifiers.OriginalLowHealthSpeedTriggerFraction; navigator.LowHealthMaxSpeedFraction = modifiers.OriginalLowHealthMaxSpeedFraction; } } private void OnServerSave() { List staleIds = npcModifiers.Keys .Where(id => BaseNetworkable.serverEntities.Find(id) == null) .ToList(); foreach (NetworkableId id in staleIds) { npcModifiers.Remove(id); } if (staleIds.Count > 0) { Log($"Cleaned up {staleIds.Count} stale NPC entries.", "Verbose"); } if (configData != null && configData.TelemetryEnabled) { SaveTelemetryData(); } } private void Unload() { foreach (NetworkableId id in activeTelemetry.Keys.ToList()) { EndTelemetryTracking(id, "censored", null); } SaveTelemetryData(); foreach (KeyValuePair pair in npcModifiers.ToList()) { BaseEntity entity = BaseNetworkable.serverEntities.Find(pair.Key) as BaseEntity; if (entity == null || entity.IsDestroyed) { npcModifiers.Remove(pair.Key); continue; } RestoreBehaviorBaselines(entity, pair.Value); RestoreSpeed(entity, pair.Value); RestoreBossScale(entity, pair.Value); BaseCombatEntity combatEntity = entity as BaseCombatEntity; if (combatEntity != null && pair.Value.OriginalMaxHealth > 0f) { float modifiedMaxHealth = combatEntity._maxHealth; float healthRatio = modifiedMaxHealth > 0f ? Mathf.Clamp01(combatEntity.Health() / modifiedMaxHealth) : 1f; combatEntity.SetMaxHealth(pair.Value.OriginalMaxHealth); combatEntity.SetHealth(pair.Value.OriginalMaxHealth * healthRatio); combatEntity.SendNetworkUpdate(); Log($"Restored health for NPC {entity.ShortPrefabName} (ID: {pair.Key}) to max {pair.Value.OriginalMaxHealth:F2}.", "Verbose"); } npcModifiers.Remove(pair.Key); } Log("RandomAI unloaded; all tracked NPC modifiers were restored.", "Verbose"); } private void TryApplyModifiers(BaseEntity entity) { if (entity == null || entity.IsDestroyed || entity.net == null || configData == null) { return; } if (npcModifiers.ContainsKey(entity.net.ID)) { return; } string shortName = NormalizePrefabName(entity.ShortPrefabName); if (!IsSupportedTarget(shortName)) { return; } BaseCombatEntity combatEntity = entity as BaseCombatEntity; if (combatEntity == null) { Log($"Supported prefab {shortName} is not a BaseCombatEntity; no modifiers applied.", "Warning"); return; } if (combatEntity.IsDead()) { return; } NpcSettings settings; string configKey; if (!TryResolveSettings(shortName, out settings, out configKey)) { Log($"No usable configuration was found for supported NPC {shortName}.", "Warning"); return; } float aptitudeSpeed = UnityEngine.Random.Range(settings.MinSpeedMultiplier, settings.MaxSpeedMultiplier); float aptitudeHealth = UnityEngine.Random.Range(settings.MinHealthMultiplier, settings.MaxHealthMultiplier); float aptitudeDamage = UnityEngine.Random.Range(settings.MinDamageMultiplier, settings.MaxDamageMultiplier); bool isBoss = configData.BossesEnabled && UnityEngine.Random.value < configData.BossChance; var modifiers = new NpcModifiers { SpeedMultiplier = aptitudeSpeed, HealthMultiplier = aptitudeHealth, DamageMultiplier = aptitudeDamage, AptitudeSpeedMultiplier = aptitudeSpeed, AptitudeHealthMultiplier = aptitudeHealth, AptitudeDamageMultiplier = aptitudeDamage, BehaviorDisposition = RollBehaviorDisposition(), OriginalMaxHealth = combatEntity._maxHealth, IsBoss = isBoss, BossScaleEligible = isBoss && SupportedAnimals.Contains(shortName), OriginalScale = entity.transform.localScale, OriginalNetworkEntityScale = entity.networkEntityScale }; bool speedApplied = TryApplySpeed(entity, modifiers); if (!speedApplied) { modifiers.SpeedMultiplier = 1f; modifiers.AptitudeSpeedMultiplier = 1f; Log($"NPC {shortName} (ID: {entity.net.ID}) has no supported speed controller; health only was modified.", "Warning"); } float originalHealthRatio = modifiers.OriginalMaxHealth > 0f ? Mathf.Clamp01(combatEntity.Health() / modifiers.OriginalMaxHealth) : 1f; float newMaxHealth = modifiers.OriginalMaxHealth * modifiers.HealthMultiplier; combatEntity.SetMaxHealth(newMaxHealth); combatEntity.SetHealth(newMaxHealth * originalHealthRatio); combatEntity.SendNetworkUpdate(); CaptureBehaviorBaselines(entity, modifiers); ApplyFightToDeathOverrides(entity, modifiers); ScheduleNextAptitudeCheck(modifiers, true); npcModifiers[entity.net.ID] = modifiers; StartTelemetry(entity, modifiers); Log($"NPC {shortName} (config: {configKey}, ID: {entity.net.ID}) activated with speed {modifiers.SpeedMultiplier:F2} via {modifiers.SpeedAdapter}, health {modifiers.HealthMultiplier:F2}, damage {modifiers.DamageMultiplier:F2}, behavior {modifiers.BehaviorDisposition}, apex boss {modifiers.IsBoss}.", "Verbose"); DebugTrace( $"ACTIVATE {shortName} (ID: {entity.net.ID}, config: {configKey}): " + $"speed {modifiers.OriginalPrimarySpeed:F2}->{GetAppliedPrimarySpeed(modifiers):F2} via {modifiers.SpeedAdapter} ({modifiers.SpeedMultiplier:F2}x); " + $"health {modifiers.OriginalMaxHealth:F1}->{newMaxHealth:F1} ({modifiers.HealthMultiplier:F2}x), current {combatEntity.Health():F1}; " + $"damage {modifiers.DamageMultiplier:F2}x; behavior {modifiers.BehaviorDisposition}; " + $"apex boss {(modifiers.IsBoss ? "yes" : "no")}."); } private bool TryApplySpeed(BaseEntity entity, NpcModifiers modifiers) { BaseNavigator navigator = entity.GetComponent(); if (navigator != null && IsFinitePositive(navigator.Speed)) { modifiers.SpeedAdapter = "BaseNavigator"; modifiers.OriginalPrimarySpeed = navigator.Speed; modifiers.OriginalSpeedValues["Speed"] = navigator.Speed; navigator.Speed *= modifiers.SpeedMultiplier; return true; } Component gen2Agent = FindComponentByTypeName(entity, "RustNavMeshAgent"); if (gen2Agent != null && CaptureAndScaleFields(gen2Agent, Gen2SpeedFields, modifiers)) { modifiers.SpeedAdapter = "RustNavMeshAgent"; modifiers.OriginalPrimarySpeed = GetPrimarySpeed(modifiers.OriginalSpeedValues, "runSpeed", "jogSpeed", "walkSpeed"); return true; } if (string.Equals(entity.GetType().Name, "SimpleShark", StringComparison.OrdinalIgnoreCase) && CaptureAndScaleFields(entity, SharkSpeedFields, modifiers)) { modifiers.SpeedAdapter = "SimpleShark"; modifiers.OriginalPrimarySpeed = GetPrimarySpeed(modifiers.OriginalSpeedValues, "maxSpeed", "minSpeed", "currentSpeed"); return true; } Component snakeHazard = FindComponentByTypeName(entity, "SnakeHazard"); if (snakeHazard != null && CaptureAndScaleFields(snakeHazard, SnakeSpeedFields, modifiers)) { modifiers.SpeedAdapter = "SnakeHazard"; modifiers.OriginalPrimarySpeed = GetPrimarySpeed(modifiers.OriginalSpeedValues, "SlitherSpeed"); return true; } return false; } private void RestoreSpeed(BaseEntity entity, NpcModifiers modifiers) { if (modifiers.SpeedAdapter == "none") { return; } if (modifiers.SpeedAdapter == "BaseNavigator") { BaseNavigator navigator = entity.GetComponent(); float originalSpeed; if (navigator != null && modifiers.OriginalSpeedValues.TryGetValue("Speed", out originalSpeed)) { navigator.Speed = originalSpeed; } return; } object component = modifiers.SpeedAdapter == "SimpleShark" ? entity : FindComponentByTypeName(entity, modifiers.SpeedAdapter); if (component == null) { Log($"Could not restore {modifiers.SpeedAdapter} speed fields for {entity.ShortPrefabName}.", "Warning"); return; } Type componentType = component.GetType(); foreach (KeyValuePair pair in modifiers.OriginalSpeedValues) { FieldInfo field = componentType.GetField(pair.Key, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (field != null && field.FieldType == typeof(float)) { field.SetValue(component, pair.Value); } } } private bool CaptureAndScaleFields(object component, IEnumerable fieldNames, NpcModifiers modifiers) { Type componentType = component.GetType(); int scaledFields = 0; foreach (string fieldName in fieldNames) { FieldInfo field = componentType.GetField(fieldName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (field == null || field.FieldType != typeof(float)) { continue; } float originalValue = (float)field.GetValue(component); if (!IsFinitePositive(originalValue)) { continue; } modifiers.OriginalSpeedValues[fieldName] = originalValue; field.SetValue(component, originalValue * modifiers.SpeedMultiplier); scaledFields++; } return scaledFields > 0; } private Component FindComponentByTypeName(BaseEntity entity, string typeName) { return entity.GetComponents() .FirstOrDefault(component => component != null && string.Equals(component.GetType().Name, typeName, StringComparison.OrdinalIgnoreCase)); } private float GetPrimarySpeed(Dictionary values, params string[] preferredFields) { foreach (string fieldName in preferredFields) { float value; if (values.TryGetValue(fieldName, out value)) { return value; } } return values.Count > 0 ? values.First().Value : 0f; } private float GetAppliedPrimarySpeed(NpcModifiers modifiers) { return modifiers == null || !IsFinitePositive(modifiers.OriginalPrimarySpeed) ? 0f : modifiers.OriginalPrimarySpeed * modifiers.SpeedMultiplier; } private void LoadTelemetryData() { try { telemetryData = Oxide.Core.Interface.Oxide.DataFileSystem.ReadObject(TelemetryDataFileName); } catch (Exception ex) { PrintError($"Could not read aggregate telemetry; starting a new in-memory dataset: {ex.Message}"); telemetryData = CreateTelemetryData(); } NormalizeTelemetryData(); } private TelemetryData CreateTelemetryData() { return new TelemetryData { DataVersion = CurrentTelemetryDataVersion, StartedUtc = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture), Npcs = new Dictionary() }; } private void NormalizeTelemetryData() { if (telemetryData == null) { telemetryData = CreateTelemetryData(); } int previousVersion = telemetryData.DataVersion; telemetryData.DataVersion = CurrentTelemetryDataVersion; if (string.IsNullOrWhiteSpace(telemetryData.StartedUtc)) { telemetryData.StartedUtc = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture); } if (telemetryData.Npcs == null) { telemetryData.Npcs = new Dictionary(); } foreach (string key in telemetryData.Npcs.Keys.ToList()) { if (string.IsNullOrWhiteSpace(key) || telemetryData.Npcs[key] == null) { telemetryData.Npcs.Remove(key); continue; } TelemetryAggregate aggregate = telemetryData.Npcs[key]; if (previousVersion < 2) { MigrateTelemetryAggregateToV2(aggregate); } EnsureTelemetryCollections(aggregate); } if (previousVersion > 0 && previousVersion < CurrentTelemetryDataVersion) { if (previousVersion < 2) { PrintWarning("Migrated telemetry to schema v3. Legacy lifecycle measurements were cleared; speed x health performance cells begin fresh."); } else { PrintWarning("Migrated telemetry to schema v3. Existing aggregate encounter data was preserved; speed x health performance cells begin fresh."); } SaveTelemetryData(); } } private void MigrateTelemetryAggregateToV2(TelemetryAggregate aggregate) { aggregate.CompletedSamples = 0; aggregate.PlayerCombatEncounters = 0; aggregate.PlayerInvolvedDeaths = 0; aggregate.PlayerInvolvedEscapes = 0; aggregate.Deaths = 0; aggregate.TimedDeaths = 0; aggregate.PlayerKills = 0; aggregate.NpcKills = 0; aggregate.EnvironmentalDeaths = 0; aggregate.EscapedEncounters = 0; aggregate.AdministrativeRemovals = 0; aggregate.CensoredSamples = 0; aggregate.TotalTimeToKillSeconds = 0d; aggregate.TotalDamageReceived = 0d; aggregate.TotalDamageDealt = 0d; aggregate.TimeToKillBuckets = new Dictionary(); } private void EnsureTelemetryCollections(TelemetryAggregate aggregate) { if (aggregate.SpeedBuckets == null) { aggregate.SpeedBuckets = new Dictionary(); } if (aggregate.HealthBuckets == null) { aggregate.HealthBuckets = new Dictionary(); } if (aggregate.TimeToKillBuckets == null) { aggregate.TimeToKillBuckets = new Dictionary(); } if (aggregate.PerformanceCells == null) { aggregate.PerformanceCells = new Dictionary(); } foreach (string key in aggregate.PerformanceCells.Keys.ToList()) { if (string.IsNullOrWhiteSpace(key) || aggregate.PerformanceCells[key] == null) { aggregate.PerformanceCells.Remove(key); } } } private TelemetryAggregate GetTelemetryAggregate(string prefabName) { NormalizeTelemetryData(); TelemetryAggregate aggregate; if (!telemetryData.Npcs.TryGetValue(prefabName, out aggregate) || aggregate == null) { aggregate = new TelemetryAggregate(); telemetryData.Npcs[prefabName] = aggregate; } EnsureTelemetryCollections(aggregate); return aggregate; } private TelemetryPerformanceCell GetTelemetryPerformanceCell( TelemetryAggregate aggregate, string speedBucket, string healthBucket) { EnsureTelemetryCollections(aggregate); string key = $"{speedBucket}|{healthBucket}"; TelemetryPerformanceCell cell; if (!aggregate.PerformanceCells.TryGetValue(key, out cell) || cell == null) { cell = new TelemetryPerformanceCell { SpeedBucket = speedBucket, HealthBucket = healthBucket }; aggregate.PerformanceCells[key] = cell; } return cell; } private void StartTelemetry(BaseEntity entity, NpcModifiers modifiers) { if (configData == null || !configData.TelemetryEnabled || entity == null || entity.net == null || modifiers == null || activeTelemetry.ContainsKey(entity.net.ID)) { return; } string prefabName = NormalizePrefabName(entity.ShortPrefabName); TelemetryAggregate aggregate = GetTelemetryAggregate(prefabName); string speedBucket = GetMultiplierBucket(modifiers.SpeedMultiplier); string healthBucket = GetMultiplierBucket(modifiers.HealthMultiplier); aggregate.Spawns++; aggregate.SumSpeedMultiplier += modifiers.SpeedMultiplier; aggregate.SumSpeedMultiplierSquared += modifiers.SpeedMultiplier * modifiers.SpeedMultiplier; aggregate.SumHealthMultiplier += modifiers.HealthMultiplier; aggregate.SumHealthMultiplierSquared += modifiers.HealthMultiplier * modifiers.HealthMultiplier; IncrementBucket(aggregate.SpeedBuckets, speedBucket); IncrementBucket(aggregate.HealthBuckets, healthBucket); TelemetryPerformanceCell cell = GetTelemetryPerformanceCell(aggregate, speedBucket, healthBucket); cell.Spawns++; activeTelemetry[entity.net.ID] = new TelemetryEncounter { EntityId = entity.net.ID, PrefabName = prefabName, SpeedBucket = speedBucket, HealthBucket = healthBucket }; } private void RecordOrQueueTelemetryAptitudeRoll(BaseEntity entity, NpcModifiers modifiers) { if (configData == null || !configData.TelemetryEnabled || entity == null || entity.net == null || modifiers == null) { return; } TelemetryEncounter encounter; if (activeTelemetry.TryGetValue(entity.net.ID, out encounter) && encounter != null && encounter.InCombat) { modifiers.PendingTelemetryAptitudeRoll = true; return; } modifiers.PendingTelemetryAptitudeRoll = false; RecordTelemetryAptitudeRoll(entity, modifiers); } private void RecordTelemetryAptitudeRoll(BaseEntity entity, NpcModifiers modifiers) { if (configData == null || !configData.TelemetryEnabled || telemetryData == null || entity == null || entity.net == null || modifiers == null) { return; } TelemetryEncounter encounter; if (!activeTelemetry.TryGetValue(entity.net.ID, out encounter) || encounter == null || encounter.InCombat) { return; } string prefabName = NormalizePrefabName(entity.ShortPrefabName); string speedBucket = GetMultiplierBucket(modifiers.SpeedMultiplier); string healthBucket = GetMultiplierBucket(modifiers.HealthMultiplier); TelemetryAggregate aggregate = GetTelemetryAggregate(prefabName); aggregate.Spawns++; aggregate.SumSpeedMultiplier += modifiers.SpeedMultiplier; aggregate.SumSpeedMultiplierSquared += modifiers.SpeedMultiplier * modifiers.SpeedMultiplier; aggregate.SumHealthMultiplier += modifiers.HealthMultiplier; aggregate.SumHealthMultiplierSquared += modifiers.HealthMultiplier * modifiers.HealthMultiplier; IncrementBucket(aggregate.SpeedBuckets, speedBucket); IncrementBucket(aggregate.HealthBuckets, healthBucket); GetTelemetryPerformanceCell(aggregate, speedBucket, healthBucket).Spawns++; encounter.PrefabName = prefabName; encounter.SpeedBucket = speedBucket; encounter.HealthBucket = healthBucket; } private void StartTelemetryForTrackedEntities() { foreach (KeyValuePair pair in npcModifiers.ToList()) { BaseEntity entity = BaseNetworkable.serverEntities.Find(pair.Key) as BaseEntity; if (entity != null && !entity.IsDestroyed) { StartTelemetry(entity, pair.Value); } } } private void MarkTelemetryCombat(TelemetryEncounter encounter) { float now = Time.realtimeSinceStartup; if (!encounter.InCombat) { encounter.InCombat = true; encounter.CombatStartedAt = now; encounter.DamageReceived = 0d; encounter.DamageDealt = 0d; } encounter.LastCombatAt = now; } private void FinalizeInactiveTelemetryEncounters() { if (configData == null || !configData.TelemetryEnabled) { return; } float now = Time.realtimeSinceStartup; float timeout = configData.TelemetryEncounterTimeoutSeconds; foreach (TelemetryEncounter encounter in activeTelemetry.Values.ToList()) { if (encounter.InCombat && now - encounter.LastCombatAt >= timeout) { FinalizeTelemetrySession(encounter, "escaped", null); } } } private void EndTelemetryTracking(NetworkableId id, string outcome, HitInfo hitInfo) { TelemetryEncounter encounter; if (!activeTelemetry.TryGetValue(id, out encounter)) { return; } if (encounter.InCombat) { FinalizeTelemetrySession(encounter, outcome, hitInfo); } activeTelemetry.Remove(id); } private void FinalizeTelemetrySession(TelemetryEncounter encounter, string outcome, HitInfo hitInfo) { TelemetryAggregate aggregate = GetTelemetryAggregate(encounter.PrefabName); TelemetryPerformanceCell cell = GetTelemetryPerformanceCell( aggregate, encounter.SpeedBucket, encounter.HealthBucket); float duration = Mathf.Max(0f, Time.realtimeSinceStartup - encounter.CombatStartedAt); aggregate.CompletedSamples++; aggregate.TotalDamageReceived += encounter.DamageReceived; aggregate.TotalDamageDealt += encounter.DamageDealt; cell.CompletedEncounters++; cell.TotalDamageReceived += encounter.DamageReceived; cell.TotalDamageDealt += encounter.DamageDealt; if (encounter.PlayerInvolved && (outcome == "death" || outcome == "escaped")) { aggregate.PlayerCombatEncounters++; cell.PlayerCombatEncounters++; if (outcome == "death") { aggregate.PlayerInvolvedDeaths++; cell.PlayerInvolvedDeaths++; } else { aggregate.PlayerInvolvedEscapes++; cell.PlayerInvolvedEscapes++; } } if (outcome == "administrative") { aggregate.AdministrativeRemovals++; cell.AdministrativeRemovals++; } else if (outcome == "escaped") { aggregate.EscapedEncounters++; cell.EscapedEncounters++; } else if (outcome != "death") { aggregate.CensoredSamples++; cell.CensoredSamples++; } else { aggregate.Deaths++; aggregate.TimedDeaths++; aggregate.TotalTimeToKillSeconds += duration; IncrementBucket(aggregate.TimeToKillBuckets, GetTimeToKillBucket(duration)); cell.Deaths++; cell.TimedDeaths++; cell.TotalTimeToKillSeconds += duration; BaseEntity attacker = hitInfo == null ? null : hitInfo.Initiator as BaseEntity; BasePlayer player = attacker as BasePlayer; if (player != null && !player.IsNpc) { aggregate.PlayerKills++; cell.PlayerKills++; } else if (attacker != null && attacker.net != null && activeTelemetry.ContainsKey(attacker.net.ID)) { aggregate.NpcKills++; cell.NpcKills++; } else { aggregate.EnvironmentalDeaths++; cell.EnvironmentalDeaths++; } } encounter.InCombat = false; encounter.PlayerInvolved = false; encounter.CombatStartedAt = 0f; encounter.LastCombatAt = 0f; encounter.DamageReceived = 0d; encounter.DamageDealt = 0d; NpcModifiers modifiers; if (npcModifiers.TryGetValue(encounter.EntityId, out modifiers) && modifiers != null && modifiers.PendingTelemetryAptitudeRoll) { BaseEntity entity = BaseNetworkable.serverEntities.Find(encounter.EntityId) as BaseEntity; modifiers.PendingTelemetryAptitudeRoll = false; if (entity != null && !entity.IsDestroyed) { RecordTelemetryAptitudeRoll(entity, modifiers); } } } private void IncrementBucket(Dictionary buckets, string key) { long count; buckets.TryGetValue(key, out count); buckets[key] = count + 1; } private string GetMultiplierBucket(float multiplier) { const float bucketSize = 0.1f; int index = Mathf.FloorToInt(multiplier / bucketSize); float lower = index * bucketSize; float upper = lower + bucketSize; return $"{lower.ToString("0.0", CultureInfo.InvariantCulture)}-{upper.ToString("0.0", CultureInfo.InvariantCulture)}"; } private string GetTimeToKillBucket(float seconds) { if (seconds < 15f) return "<15s"; if (seconds < 30f) return "15-30s"; if (seconds < 60f) return "30-60s"; if (seconds < 120f) return "60-120s"; if (seconds < 300f) return "120-300s"; if (seconds < 600f) return "300-600s"; return "600s+"; } private void SaveTelemetryData() { if (telemetryData == null) { return; } try { telemetryData.LastSavedUtc = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture); Oxide.Core.Interface.Oxide.DataFileSystem.WriteObject(TelemetryDataFileName, telemetryData); } catch (Exception ex) { PrintError($"Could not save aggregate telemetry: {ex.Message}"); } } private void ExportTelemetryCsv() { StringBuilder summaryCsv = new StringBuilder(); summaryCsv.AppendLine("prefab,spawns,completed_encounters,player_combat_encounters,player_involved_deaths,player_involved_escapes,deaths,player_kills,npc_kills,environmental_deaths,escaped_encounters,administrative_removals,censored_samples,mean_ttk_seconds,mean_speed_multiplier,mean_health_multiplier,total_damage_received,total_damage_dealt,populated_roll_cells"); StringBuilder matrixCsv = new StringBuilder(); matrixCsv.AppendLine("prefab,speed_bucket,health_bucket,spawns,completed_encounters,player_combat_encounters,player_involved_deaths,player_involved_escapes,deaths,player_kills,npc_kills,environmental_deaths,escaped_encounters,administrative_removals,censored_samples,mean_ttk_seconds,total_damage_received,total_damage_dealt"); foreach (KeyValuePair pair in telemetryData.Npcs .Where(value => value.Value != null) .OrderBy(value => value.Key, StringComparer.OrdinalIgnoreCase)) { TelemetryAggregate aggregate = pair.Value; EnsureTelemetryCollections(aggregate); double meanTimeToKill = aggregate.TimedDeaths > 0 ? aggregate.TotalTimeToKillSeconds / aggregate.TimedDeaths : 0d; double meanSpeed = aggregate.Spawns > 0 ? aggregate.SumSpeedMultiplier / aggregate.Spawns : 0d; double meanHealth = aggregate.Spawns > 0 ? aggregate.SumHealthMultiplier / aggregate.Spawns : 0d; int populatedCells = aggregate.PerformanceCells.Values.Count(value => value != null && value.Spawns > 0); summaryCsv.AppendLine(string.Join(",", new[] { EscapeCsv(pair.Key), FormatCsv(aggregate.Spawns), FormatCsv(aggregate.CompletedSamples), FormatCsv(aggregate.PlayerCombatEncounters), FormatCsv(aggregate.PlayerInvolvedDeaths), FormatCsv(aggregate.PlayerInvolvedEscapes), FormatCsv(aggregate.Deaths), FormatCsv(aggregate.PlayerKills), FormatCsv(aggregate.NpcKills), FormatCsv(aggregate.EnvironmentalDeaths), FormatCsv(aggregate.EscapedEncounters), FormatCsv(aggregate.AdministrativeRemovals), FormatCsv(aggregate.CensoredSamples), FormatCsv(meanTimeToKill), FormatCsv(meanSpeed), FormatCsv(meanHealth), FormatCsv(aggregate.TotalDamageReceived), FormatCsv(aggregate.TotalDamageDealt), FormatCsv(populatedCells) })); foreach (TelemetryPerformanceCell cell in aggregate.PerformanceCells.Values .Where(value => value != null && value.Spawns > 0) .OrderBy(value => value.SpeedBucket, StringComparer.OrdinalIgnoreCase) .ThenBy(value => value.HealthBucket, StringComparer.OrdinalIgnoreCase)) { double cellMeanTimeToKill = cell.TimedDeaths > 0 ? cell.TotalTimeToKillSeconds / cell.TimedDeaths : 0d; matrixCsv.AppendLine(string.Join(",", new[] { EscapeCsv(pair.Key), EscapeCsv(cell.SpeedBucket), EscapeCsv(cell.HealthBucket), FormatCsv(cell.Spawns), FormatCsv(cell.CompletedEncounters), FormatCsv(cell.PlayerCombatEncounters), FormatCsv(cell.PlayerInvolvedDeaths), FormatCsv(cell.PlayerInvolvedEscapes), FormatCsv(cell.Deaths), FormatCsv(cell.PlayerKills), FormatCsv(cell.NpcKills), FormatCsv(cell.EnvironmentalDeaths), FormatCsv(cell.EscapedEncounters), FormatCsv(cell.AdministrativeRemovals), FormatCsv(cell.CensoredSamples), FormatCsv(cellMeanTimeToKill), FormatCsv(cell.TotalDamageReceived), FormatCsv(cell.TotalDamageDealt) })); } } string dataDirectory = Oxide.Core.Interface.Oxide.DataDirectory; Directory.CreateDirectory(dataDirectory); UTF8Encoding utf8WithoutBom = new UTF8Encoding(false); File.WriteAllText(Path.Combine(dataDirectory, TelemetrySummaryCsvFileName), summaryCsv.ToString(), utf8WithoutBom); File.WriteAllText(Path.Combine(dataDirectory, TelemetryMatrixCsvFileName), matrixCsv.ToString(), utf8WithoutBom); } private string FormatCsv(long value) { return value.ToString(CultureInfo.InvariantCulture); } private string FormatCsv(double value) { return value.ToString("0.###", CultureInfo.InvariantCulture); } private string EscapeCsv(string value) { if (string.IsNullOrEmpty(value)) { return string.Empty; } if (value.IndexOfAny(new[] { ',', '"', '\r', '\n' }) < 0) { return value; } return "\"" + value.Replace("\"", "\"\"") + "\""; } private void SynchronizeTelemetryAfterConfigReload(bool wasEnabled) { if (!wasEnabled && configData.TelemetryEnabled) { StartTelemetryForTrackedEntities(); return; } if (wasEnabled && !configData.TelemetryEnabled) { foreach (NetworkableId id in activeTelemetry.Keys.ToList()) { EndTelemetryTracking(id, "censored", null); } SaveTelemetryData(); } } private bool IsSupportedTarget(string shortName) { if (string.IsNullOrEmpty(shortName) || ExcludedNameFragments.Any(fragment => shortName.IndexOf(fragment, StringComparison.OrdinalIgnoreCase) >= 0)) { return false; } if (SupportedAnimals.Contains(shortName)) { return true; } return shortName.StartsWith("scientistnpc_", StringComparison.OrdinalIgnoreCase) || shortName.Equals("scientist2", StringComparison.OrdinalIgnoreCase) || shortName.StartsWith("scientist2.", StringComparison.OrdinalIgnoreCase) || shortName.Equals("npc_tunneldweller", StringComparison.OrdinalIgnoreCase) || shortName.Equals("npc_tunneldwellerspawned", StringComparison.OrdinalIgnoreCase) || shortName.Equals("npc_underwaterdweller", StringComparison.OrdinalIgnoreCase); } private bool TryResolveSettings(string shortName, out NpcSettings settings, out string configKey) { settings = null; configKey = null; if (configData.NpcMultipliers == null) { return false; } var visited = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (string candidate in GetConfigKeys(shortName)) { if (!visited.Add(candidate)) { continue; } NpcSettings candidateSettings; if (configData.NpcMultipliers.TryGetValue(candidate, out candidateSettings) && candidateSettings != null) { settings = candidateSettings; configKey = candidate; return true; } } return false; } private IEnumerable GetConfigKeys(string shortName) { yield return shortName; if (shortName.Equals("wolf2", StringComparison.OrdinalIgnoreCase)) { yield return "wolf"; } else if (shortName.Equals("simpleshark", StringComparison.OrdinalIgnoreCase)) { yield return "shark"; } else if (shortName.Equals("snake.entity", StringComparison.OrdinalIgnoreCase)) { yield return "snake"; } else if (shortName.Equals("npc_tunneldweller", StringComparison.OrdinalIgnoreCase) || shortName.Equals("npc_tunneldwellerspawned", StringComparison.OrdinalIgnoreCase)) { yield return "scientistnpc_tunnel"; } else if (shortName.Equals("npc_underwaterdweller", StringComparison.OrdinalIgnoreCase)) { yield return "scientistnpc_underwater"; } else if (shortName.Equals("scientist2.heavy", StringComparison.OrdinalIgnoreCase)) { yield return "scientistnpc_heavy"; } else if (shortName.StartsWith("scientist2", StringComparison.OrdinalIgnoreCase)) { yield return "scientist2"; } else if (shortName.StartsWith("scientistnpc_cargo", StringComparison.OrdinalIgnoreCase)) { yield return "scientistnpc_cargo"; } else if (shortName.StartsWith("scientistnpc_patrol", StringComparison.OrdinalIgnoreCase)) { yield return "scientistnpc_patrol"; } else if (shortName.Equals("scientistnpc_bradley_heavy", StringComparison.OrdinalIgnoreCase)) { yield return "scientistnpc_heavy"; } else if (shortName.StartsWith("scientistnpc_bradley", StringComparison.OrdinalIgnoreCase) || shortName.StartsWith("scientistnpc_full", StringComparison.OrdinalIgnoreCase) || shortName.Equals("scientistnpc_oilrig", StringComparison.OrdinalIgnoreCase)) { yield return "scientistnpc_military_base"; } if (shortName.StartsWith("scientistnpc_", StringComparison.OrdinalIgnoreCase)) { yield return "scientistnpc"; } yield return "default"; } private string NormalizePrefabName(string prefabName) { return string.IsNullOrEmpty(prefabName) ? string.Empty : prefabName.ToLowerInvariant(); } private bool LoadConfigData(bool saveIfChanged) { bool changed = false; try { configData = Config.ReadObject(); } catch (Exception ex) { PrintError($"Could not read RandomAI config; using safe defaults: {ex.Message}"); configData = CreateDefaultConfig(); changed = true; } if (configData == null) { configData = CreateDefaultConfig(); changed = true; } if (configData.NpcMultipliers == null) { configData.NpcMultipliers = new Dictionary(); changed = true; } ConfigData defaults = CreateDefaultConfig(); foreach (KeyValuePair pair in defaults.NpcMultipliers) { if (!configData.NpcMultipliers.ContainsKey(pair.Key)) { configData.NpcMultipliers[pair.Key] = CloneSettings(pair.Value); changed = true; } } foreach (KeyValuePair pair in configData.NpcMultipliers.ToList()) { if (!string.IsNullOrWhiteSpace(pair.Key) && IsValidSettings(pair.Value)) { continue; } NpcSettings defaultSettings; if (!string.IsNullOrWhiteSpace(pair.Key) && defaults.NpcMultipliers.TryGetValue(pair.Key, out defaultSettings)) { configData.NpcMultipliers[pair.Key] = CloneSettings(defaultSettings); PrintWarning($"Invalid settings for {pair.Key}; only that entry was reset to defaults."); } else { configData.NpcMultipliers.Remove(pair.Key); PrintWarning($"Removed invalid custom NPC settings entry '{pair.Key}'."); } changed = true; } string normalizedLogLevel = NormalizeLogLevel(configData.LogLevel); if (!string.Equals(configData.LogLevel, normalizedLogLevel, StringComparison.Ordinal)) { configData.LogLevel = normalizedLogLevel; changed = true; } if (!IsFinitePositive(configData.TelemetryEncounterTimeoutSeconds) || configData.TelemetryEncounterTimeoutSeconds < 10f || configData.TelemetryEncounterTimeoutSeconds > 600f) { configData.TelemetryEncounterTimeoutSeconds = DefaultTelemetryEncounterTimeoutSeconds; PrintWarning("TelemetryEncounterTimeoutSeconds must be between 10 and 600 seconds; reset to 60 seconds."); changed = true; } if (!IsFinitePositive(configData.DebugDamageCooldownSeconds) || configData.DebugDamageCooldownSeconds < 0.1f || configData.DebugDamageCooldownSeconds > 10f) { configData.DebugDamageCooldownSeconds = DefaultDebugDamageCooldownSeconds; PrintWarning("DebugDamageCooldownSeconds must be between 0.1 and 10 seconds; reset to 0.5 seconds."); changed = true; } if (!IsFinitePositive(configData.AptitudeCheckIntervalSeconds) || configData.AptitudeCheckIntervalSeconds < 5f || configData.AptitudeCheckIntervalSeconds > 600f) { configData.AptitudeCheckIntervalSeconds = DefaultAptitudeCheckIntervalSeconds; PrintWarning("AptitudeCheckIntervalSeconds must be between 5 and 600 seconds; reset to 60 seconds."); changed = true; } if (!IsFiniteProbability(configData.AptitudeRerollChance)) { configData.AptitudeRerollChance = DefaultAptitudeRerollChance; PrintWarning("AptitudeRerollChance must be between 0 and 1; reset to 0.05."); changed = true; } if (!IsFiniteNonNegative(configData.AptitudeCombatCooldownSeconds) || configData.AptitudeCombatCooldownSeconds > 600f) { configData.AptitudeCombatCooldownSeconds = DefaultAptitudeCombatCooldownSeconds; PrintWarning("AptitudeCombatCooldownSeconds must be between 0 and 600 seconds; reset to 15 seconds."); changed = true; } if (!IsFiniteProbability(configData.BehaviorDecisionChanceOnDamage)) { configData.BehaviorDecisionChanceOnDamage = DefaultBehaviorDecisionChanceOnDamage; PrintWarning("BehaviorDecisionChanceOnDamage must be between 0 and 1; reset to 0.20."); changed = true; } if (!IsFinitePositive(configData.BehaviorDecisionCooldownSeconds) || configData.BehaviorDecisionCooldownSeconds < 0.1f || configData.BehaviorDecisionCooldownSeconds > 60f) { configData.BehaviorDecisionCooldownSeconds = DefaultBehaviorDecisionCooldownSeconds; PrintWarning("BehaviorDecisionCooldownSeconds must be between 0.1 and 60 seconds; reset to 6 seconds."); changed = true; } float behaviorChanceTotal = configData.BehaviorFleeChance + configData.BehaviorAttackChance + configData.BehaviorFightToDeathChance; if (!IsFiniteProbability(configData.BehaviorFleeChance) || !IsFiniteProbability(configData.BehaviorAttackChance) || !IsFiniteProbability(configData.BehaviorFightToDeathChance) || float.IsNaN(behaviorChanceTotal) || float.IsInfinity(behaviorChanceTotal) || behaviorChanceTotal > 1f) { configData.BehaviorFleeChance = DefaultBehaviorFleeChance; configData.BehaviorAttackChance = DefaultBehaviorAttackChance; configData.BehaviorFightToDeathChance = DefaultBehaviorFightToDeathChance; PrintWarning("Behavior disposition chances must each be between 0 and 1 and total no more than 1; reset to defaults."); changed = true; } if (!IsFiniteProbability(configData.BossChance)) { configData.BossChance = DefaultBossChance; PrintWarning("BossChance must be between 0 and 1; reset to 0.07."); changed = true; } if (!IsFinitePositive(configData.BossGrowthPerKill) || configData.BossGrowthPerKill > 1f) { configData.BossGrowthPerKill = DefaultBossGrowthPerKill; PrintWarning("BossGrowthPerKill must be greater than 0 and no more than 1; reset to 0.20."); changed = true; } if (!IsFinitePositive(configData.BossMaxGrowthMultiplier) || configData.BossMaxGrowthMultiplier < 1f || configData.BossMaxGrowthMultiplier > 3f) { configData.BossMaxGrowthMultiplier = DefaultBossMaxGrowthMultiplier; PrintWarning("BossMaxGrowthMultiplier must be between 1 and 3; reset to 2.00."); changed = true; } if (!IsFinitePositive(configData.BossMaxAnimalScaleMultiplier) || configData.BossMaxAnimalScaleMultiplier < 1f || configData.BossMaxAnimalScaleMultiplier > 2f) { configData.BossMaxAnimalScaleMultiplier = DefaultBossMaxAnimalScaleMultiplier; PrintWarning("BossMaxAnimalScaleMultiplier must be between 1 and 2; reset to 1.50."); changed = true; } if (configData.ConfigVersion != CurrentConfigVersion) { configData.ConfigVersion = CurrentConfigVersion; changed = true; } if (saveIfChanged && changed) { Config.WriteObject(configData, true); } return true; } private ConfigData CreateDefaultConfig() { return new ConfigData { ConfigVersion = CurrentConfigVersion, DebugMode = false, DebugBroadcastToAdmins = true, DebugPlayerCombatOnly = true, DebugDamageCooldownSeconds = DefaultDebugDamageCooldownSeconds, LogLevel = "Warning", TelemetryEnabled = false, TelemetryEncounterTimeoutSeconds = DefaultTelemetryEncounterTimeoutSeconds, AptitudeRerollEnabled = true, AptitudeCheckIntervalSeconds = DefaultAptitudeCheckIntervalSeconds, AptitudeRerollChance = DefaultAptitudeRerollChance, AptitudeCombatCooldownSeconds = DefaultAptitudeCombatCooldownSeconds, BehaviorDecisionChanceOnDamage = DefaultBehaviorDecisionChanceOnDamage, BehaviorDecisionCooldownSeconds = DefaultBehaviorDecisionCooldownSeconds, BehaviorFleeChance = DefaultBehaviorFleeChance, BehaviorAttackChance = DefaultBehaviorAttackChance, BehaviorFightToDeathChance = DefaultBehaviorFightToDeathChance, BossesEnabled = true, BossChance = DefaultBossChance, BossGrowthPerKill = DefaultBossGrowthPerKill, BossMaxGrowthMultiplier = DefaultBossMaxGrowthMultiplier, BossScaleAnimals = true, BossMaxAnimalScaleMultiplier = DefaultBossMaxAnimalScaleMultiplier, NpcMultipliers = new Dictionary { ["default"] = Settings(0.8f, 1.2f, 0.8f, 1.2f), ["scientistnpc"] = Settings(0.85f, 1.15f, 0.85f, 1.15f), ["scientist2"] = Settings(0.85f, 1.15f, 0.85f, 1.15f), ["scientistnpc_peacekeeper"] = Settings(0.7f, 1.3f, 0.8f, 1.1f), ["scientistnpc_heavy"] = Settings(0.6f, 1.2f, 0.9f, 1.4f), ["scientistnpc_cargo"] = Settings(0.7f, 1.4f, 0.8f, 1.2f), ["scientistnpc_tunnel"] = Settings(0.7f, 1.3f, 0.8f, 1.2f), ["scientistnpc_underwater"] = Settings(0.6f, 1.2f, 0.9f, 1.3f), ["scientistnpc_excavator"] = Settings(0.7f, 1.3f, 0.8f, 1.2f), ["scientistnpc_patrol"] = Settings(0.8f, 1.5f, 0.8f, 1.2f), ["scientistnpc_military_base"] = Settings(0.7f, 1.4f, 0.9f, 1.3f), ["boar"] = Settings(0.6f, 1.4f, 0.7f, 1.3f), ["bear"] = Settings(0.4f, 1.2f, 0.9f, 1.4f), ["polarbear"] = Settings(0.4f, 1.2f, 0.9f, 1.5f), ["chicken"] = Settings(0.7f, 1.5f, 0.8f, 1.2f), ["wolf"] = Settings(0.8f, 2.2f, 0.8f, 1.3f), ["stag"] = Settings(0.7f, 1.6f, 0.7f, 1.2f), ["shark"] = Settings(0.6f, 2.0f, 0.9f, 1.4f), ["snake"] = Settings(0.75f, 1.5f, 0.8f, 1.25f), ["crocodile"] = Settings(0.5f, 1.8f, 0.9f, 1.5f), ["panther"] = Settings(0.7f, 2.5f, 0.8f, 1.3f), ["tiger"] = Settings(0.7f, 2.6f, 0.8f, 1.4f) } }; } private NpcSettings Settings( float minSpeed, float maxSpeed, float minHealth, float maxHealth, float minDamage = 0.8f, float maxDamage = 1.2f) { return new NpcSettings { MinSpeedMultiplier = minSpeed, MaxSpeedMultiplier = maxSpeed, MinHealthMultiplier = minHealth, MaxHealthMultiplier = maxHealth, MinDamageMultiplier = minDamage, MaxDamageMultiplier = maxDamage }; } private NpcSettings CloneSettings(NpcSettings settings) { return Settings( settings.MinSpeedMultiplier, settings.MaxSpeedMultiplier, settings.MinHealthMultiplier, settings.MaxHealthMultiplier, settings.MinDamageMultiplier, settings.MaxDamageMultiplier); } private bool IsValidSettings(NpcSettings settings) { return settings != null && IsFinitePositive(settings.MinSpeedMultiplier) && IsFinitePositive(settings.MaxSpeedMultiplier) && IsFinitePositive(settings.MinHealthMultiplier) && IsFinitePositive(settings.MaxHealthMultiplier) && IsFinitePositive(settings.MinDamageMultiplier) && IsFinitePositive(settings.MaxDamageMultiplier) && settings.MaxSpeedMultiplier >= settings.MinSpeedMultiplier && settings.MaxHealthMultiplier >= settings.MinHealthMultiplier && settings.MaxDamageMultiplier >= settings.MinDamageMultiplier && settings.MaxSpeedMultiplier <= MaxAllowedSpeedMultiplier && settings.MaxHealthMultiplier <= MaxAllowedHealthMultiplier && settings.MaxDamageMultiplier <= MaxAllowedDamageMultiplier; } private bool IsFinitePositive(float value) { return value > 0f && !float.IsNaN(value) && !float.IsInfinity(value); } private bool IsFiniteNonNegative(float value) { return value >= 0f && !float.IsNaN(value) && !float.IsInfinity(value); } private bool IsFiniteProbability(float value) { return IsFiniteNonNegative(value) && value <= 1f; } private string NormalizeLogLevel(string level) { if (string.Equals(level, "Verbose", StringComparison.OrdinalIgnoreCase)) { return "Verbose"; } if (string.Equals(level, "Error", StringComparison.OrdinalIgnoreCase)) { return "Error"; } return "Warning"; } private string Msg(string key, string userId, params object[] args) { string message = lang.GetMessage(key, this, userId); return args.Length > 0 ? string.Format(message, args) : message; } private void Reply(BasePlayer player, string key, params object[] args) { string message = Msg(key, player == null ? null : player.UserIDString, args); if (player == null) { Puts(message); } else { SendReply(player, message); } } private bool HasCommandPermission(BasePlayer player) { return player == null || player.IsAdmin || permission.UserHasPermission(player.UserIDString, AdminPermission); } private string[] GetConsoleArgs(ConsoleSystem.Arg arg) { if (arg == null || arg.Args == null) { return new string[0]; } return arg.Args.Select(value => value.ToString()).ToArray(); } [ChatCommand("npc.debug")] private void DebugChatCommand(BasePlayer player, string command, string[] args) { ExecuteDebugCommand(player, args); } [ConsoleCommand("npc.debug")] private void DebugConsoleCommand(ConsoleSystem.Arg arg) { ExecuteDebugCommand(arg == null ? null : arg.Player(), GetConsoleArgs(arg)); } private void ExecuteDebugCommand(BasePlayer player, string[] args) { if (!HasCommandPermission(player)) { Reply(player, "NoPermission"); return; } string action = args.Length == 0 ? "status" : args[0].ToLowerInvariant(); if (action == "on") { configData.DebugMode = true; lastDebugDamageAt.Clear(); Config.WriteObject(configData, true); Reply(player, "DebugEnabledV1", configData.DebugBroadcastToAdmins ? " and authorized admin chat" : string.Empty); DebugTrace("Debug mode enabled; use /npc.inspect while looking at an NPC for its live values."); return; } if (action == "off") { configData.DebugMode = false; lastDebugDamageAt.Clear(); Config.WriteObject(configData, true); Reply(player, "DebugDisabledV1"); return; } if (action != "status") { Reply(player, "DebugUsageV1"); return; } Reply( player, "DebugStatusV1", configData.DebugMode ? "enabled" : "disabled", configData.DebugBroadcastToAdmins ? "enabled" : "disabled", configData.DebugPlayerCombatOnly ? "player combat only" : "all NPC combat", configData.DebugDamageCooldownSeconds); } [ChatCommand("npc.respawn"), Permission(AdminPermission)] private void RespawnChatCommand(BasePlayer player, string command, string[] args) { ExecuteRespawnCommand(player); } [ConsoleCommand("npc.respawn")] private void RespawnConsoleCommand(ConsoleSystem.Arg arg) { ExecuteRespawnCommand(arg == null ? null : arg.Player()); } private void ExecuteRespawnCommand(BasePlayer player) { if (!HasCommandPermission(player)) { Reply(player, "NoPermission"); return; } int respawnCount = 0; try { foreach (BaseEntity entity in BaseNetworkable.serverEntities.OfType().ToList()) { if (entity == null || entity.IsDestroyed || !IsSupportedTarget(NormalizePrefabName(entity.ShortPrefabName))) { continue; } if (entity.net != null) { administrativeRemovals.Add(entity.net.ID); } entity.Kill(); respawnCount++; } Reply(player, "RespawnSuccessV2", respawnCount); } catch (Exception ex) { Reply(player, "RespawnFailed", ex.Message); Log($"Respawn command error: {ex}", "Error"); } } [ChatCommand("npc.reloadconfig"), Permission(AdminPermission)] private void ReloadConfigChatCommand(BasePlayer player, string command, string[] args) { ExecuteReloadConfigCommand(player, args); } [ConsoleCommand("npc.reloadconfig")] private void ReloadConfigConsoleCommand(ConsoleSystem.Arg arg) { ExecuteReloadConfigCommand(arg == null ? null : arg.Player(), GetConsoleArgs(arg)); } private void ExecuteReloadConfigCommand(BasePlayer player, string[] args) { if (!HasCommandPermission(player)) { Reply(player, "NoPermission"); return; } try { bool telemetryWasEnabled = configData != null && configData.TelemetryEnabled; LoadConfigData(true); SynchronizeTelemetryAfterConfigReload(telemetryWasEnabled); SynchronizeBossScaleAfterConfigReload(); string message = Msg("ConfigReloaded", player == null ? null : player.UserIDString); message += "\n" + Msg( "TelemetryConfigStateV2", player == null ? null : player.UserIDString, configData.TelemetryEnabled ? "enabled" : "disabled", configData.TelemetryEncounterTimeoutSeconds); if (args.Length > 0 && string.Equals(args[0], "details", StringComparison.OrdinalIgnoreCase)) { message += Msg("ConfigReloadedDetailsHeader", player == null ? null : player.UserIDString); foreach (KeyValuePair pair in configData.NpcMultipliers.OrderBy(pair => pair.Key)) { message += Msg( "ConfigReloadedDetailsLineV2", player == null ? null : player.UserIDString, pair.Key, pair.Value.MinSpeedMultiplier, pair.Value.MaxSpeedMultiplier, pair.Value.MinHealthMultiplier, pair.Value.MaxHealthMultiplier, pair.Value.MinDamageMultiplier, pair.Value.MaxDamageMultiplier); } } if (player == null) { Puts(message); } else { SendReply(player, message); } } catch (Exception ex) { Reply(player, "ReloadConfigFailed", ex.Message); Log($"Reload config command error: {ex}", "Error"); } } [ChatCommand("npc.telemetry"), Permission(AdminPermission)] private void TelemetryChatCommand(BasePlayer player, string command, string[] args) { ExecuteTelemetryCommand(player, args); } [ConsoleCommand("npc.telemetry")] private void TelemetryConsoleCommand(ConsoleSystem.Arg arg) { ExecuteTelemetryCommand(arg == null ? null : arg.Player(), GetConsoleArgs(arg)); } private void ExecuteTelemetryCommand(BasePlayer player, string[] args) { if (!HasCommandPermission(player)) { Reply(player, "NoPermission"); return; } string action = args.Length == 0 ? "status" : args[0].ToLowerInvariant(); if (action == "status") { long spawns = telemetryData.Npcs.Values.Sum(value => value == null ? 0L : value.Spawns); long completed = telemetryData.Npcs.Values.Sum(value => value == null ? 0L : value.CompletedSamples); Reply( player, "TelemetryStatusV3", configData.TelemetryEnabled ? "enabled" : "disabled", configData.TelemetryEncounterTimeoutSeconds, activeTelemetry.Count, activeTelemetry.Values.Count(value => value.InCombat), telemetryData.Npcs.Count, spawns, completed); return; } if (action == "save") { SaveTelemetryData(); Reply(player, "TelemetrySavedV1"); return; } if (action == "export") { try { SaveTelemetryData(); ExportTelemetryCsv(); Reply(player, "TelemetryExportedV1", TelemetrySummaryCsvFileName, TelemetryMatrixCsvFileName); } catch (Exception ex) { Reply(player, "TelemetryExportFailedV1", ex.Message); Log($"Telemetry CSV export error: {ex}", "Error"); } return; } if (action == "reset") { if (args.Length < 2 || !string.Equals(args[1], "confirm", StringComparison.OrdinalIgnoreCase)) { Reply(player, "TelemetryResetPromptV1"); return; } telemetryData = CreateTelemetryData(); activeTelemetry.Clear(); if (configData.TelemetryEnabled) { StartTelemetryForTrackedEntities(); } SaveTelemetryData(); Reply(player, configData.TelemetryEnabled ? "TelemetryResetEnabledV1" : "TelemetryResetDisabledV1"); return; } if (action == "matrix") { ExecuteTelemetryMatrixCommand(player, args); return; } if (action == "readiness") { ExecuteTelemetryReadinessCommand(player, args); return; } if (action != "summary") { Reply(player, "TelemetryUsageV3"); return; } IEnumerable> rows = telemetryData.Npcs .Where(pair => pair.Value != null); string requestedPrefab = args.Length > 1 ? NormalizePrefabName(args[1]) : null; if (!string.IsNullOrEmpty(requestedPrefab)) { rows = rows.Where(pair => string.Equals(pair.Key, requestedPrefab, StringComparison.OrdinalIgnoreCase)); } List> selectedRows = rows .OrderByDescending(pair => pair.Value.CompletedSamples) .ThenByDescending(pair => pair.Value.Spawns) .Take(string.IsNullOrEmpty(requestedPrefab) ? 10 : 1) .ToList(); if (selectedRows.Count == 0) { Reply(player, "TelemetryNoDataV1", requestedPrefab ?? "any prefab"); return; } string userId = player == null ? null : player.UserIDString; string summary = Msg("TelemetrySummaryHeaderV2", userId, telemetryData.Npcs.Count); foreach (KeyValuePair pair in selectedRows) { TelemetryAggregate aggregate = pair.Value; double meanTimeToKill = aggregate.TimedDeaths > 0 ? aggregate.TotalTimeToKillSeconds / aggregate.TimedDeaths : 0d; double meanSpeed = aggregate.Spawns > 0 ? aggregate.SumSpeedMultiplier / aggregate.Spawns : 0d; double meanHealth = aggregate.Spawns > 0 ? aggregate.SumHealthMultiplier / aggregate.Spawns : 0d; summary += "\n" + Msg( "TelemetrySummaryLineV4", userId, pair.Key, aggregate.Spawns, aggregate.CompletedSamples, aggregate.PlayerCombatEncounters, aggregate.Deaths, aggregate.PlayerKills, aggregate.NpcKills, aggregate.EnvironmentalDeaths, aggregate.EscapedEncounters, aggregate.AdministrativeRemovals, aggregate.CensoredSamples, meanTimeToKill, meanSpeed, meanHealth, aggregate.TotalDamageReceived, aggregate.TotalDamageDealt); } ReplyText(player, summary); } private void ExecuteTelemetryMatrixCommand(BasePlayer player, string[] args) { if (args.Length < 2) { Reply(player, "TelemetryMatrixUsageV1"); return; } string prefabName = NormalizePrefabName(args[1]); TelemetryAggregate aggregate; if (string.IsNullOrEmpty(prefabName) || !telemetryData.Npcs.TryGetValue(prefabName, out aggregate) || aggregate == null) { Reply(player, "TelemetryMatrixNoDataV1", prefabName); return; } EnsureTelemetryCollections(aggregate); List cells = aggregate.PerformanceCells.Values .Where(value => value != null && value.Spawns > 0) .OrderByDescending(value => value.CompletedEncounters) .ThenByDescending(value => value.Spawns) .ThenBy(value => value.SpeedBucket) .ThenBy(value => value.HealthBucket) .Take(12) .ToList(); if (cells.Count == 0) { Reply(player, "TelemetryMatrixNoDataV1", prefabName); return; } string userId = player == null ? null : player.UserIDString; string summary = Msg( "TelemetryMatrixHeaderV1", userId, prefabName, aggregate.PerformanceCells.Count, cells.Count); foreach (TelemetryPerformanceCell cell in cells) { double meanTimeToKill = cell.TimedDeaths > 0 ? cell.TotalTimeToKillSeconds / cell.TimedDeaths : 0d; summary += "\n" + Msg( "TelemetryMatrixLineV2", userId, cell.SpeedBucket, cell.HealthBucket, cell.Spawns, cell.CompletedEncounters, cell.PlayerCombatEncounters, cell.Deaths, cell.PlayerKills, cell.NpcKills, cell.EnvironmentalDeaths, cell.EscapedEncounters, cell.AdministrativeRemovals, cell.CensoredSamples, meanTimeToKill, cell.TotalDamageReceived, cell.TotalDamageDealt); } ReplyText(player, summary); } private void ExecuteTelemetryReadinessCommand(BasePlayer player, string[] args) { const int defaultTarget = 200; string requestedPrefab = null; int target = defaultTarget; int parsedTarget; if (args.Length > 1) { if (int.TryParse(args[1], out parsedTarget)) { target = parsedTarget; } else { requestedPrefab = NormalizePrefabName(args[1]); } } if (args.Length > 2 && !int.TryParse(args[2], out target)) { Reply(player, "TelemetryReadinessTargetV1"); return; } if (target < 1 || target > 100000) { Reply(player, "TelemetryReadinessTargetV1"); return; } IEnumerable> rows = telemetryData.Npcs .Where(pair => pair.Value != null); if (!string.IsNullOrEmpty(requestedPrefab)) { rows = rows.Where(pair => string.Equals(pair.Key, requestedPrefab, StringComparison.OrdinalIgnoreCase)); } List> selectedRows = rows .OrderByDescending(pair => pair.Value.PlayerCombatEncounters) .ThenByDescending(pair => pair.Value.Spawns) .Take(string.IsNullOrEmpty(requestedPrefab) ? 10 : 1) .ToList(); if (selectedRows.Count == 0) { Reply(player, "TelemetryNoDataV1", requestedPrefab ?? "any prefab"); return; } string userId = player == null ? null : player.UserIDString; string summary = Msg("TelemetryReadinessHeaderV1", userId, target); foreach (KeyValuePair pair in selectedRows) { TelemetryAggregate aggregate = pair.Value; EnsureTelemetryCollections(aggregate); int populatedCells = aggregate.PerformanceCells.Values.Count(value => value != null && value.PlayerCombatEncounters > 0); double percent = Math.Min(100d, aggregate.PlayerCombatEncounters * 100d / target); summary += "\n" + Msg( "TelemetryReadinessLineV2", userId, pair.Key, aggregate.PlayerCombatEncounters, target, percent, aggregate.PlayerInvolvedDeaths, aggregate.PlayerInvolvedEscapes, populatedCells); } ReplyText(player, summary); } private void ReplyText(BasePlayer player, string message) { if (player == null) { Puts(message); } else { SendReply(player, message); } } [ChatCommand("npc.reroll")] private void RerollChatCommand(BasePlayer player, string command, string[] args) { if (player == null) { Puts(Msg("PlayerOnly", null)); return; } if (!HasCommandPermission(player)) { Reply(player, "NoPermission"); return; } RaycastHit hit; if (!Physics.Raycast(player.eyes.HeadRay(), out hit, 10f)) { Reply(player, "RerollUsageV1"); return; } BaseEntity entity = hit.GetEntity() as BaseEntity; TryApplyModifiers(entity); NpcModifiers modifiers; if (entity == null || entity.net == null || !npcModifiers.TryGetValue(entity.net.ID, out modifiers)) { Reply(player, "NpcNotFound"); return; } if (IsNpcInActiveTelemetryCombat(entity.net.ID)) { Reply(player, "RerollCombatV1"); return; } modifiers.BehaviorDisposition = RollBehaviorDisposition(); ApplyBehaviorDisposition(entity, modifiers, modifiers.LastCombatTarget, "admin reroll"); if (!RerollNumericAptitudes(entity, modifiers, "admin")) { Reply(player, "RerollCombatV1"); return; } Reply( player, "RerollSuccessV1", entity.ShortPrefabName, entity.net.ID, modifiers.SpeedMultiplier, modifiers.HealthMultiplier, modifiers.DamageMultiplier, modifiers.BehaviorDisposition); } [ChatCommand("npc.inspect")] private void InspectNpcCommand(BasePlayer player, string command, string[] args) { if (player == null) { Puts(Msg("PlayerOnly", null)); return; } if (!HasCommandPermission(player)) { Reply(player, "NoPermission"); return; } try { RaycastHit hit; if (Physics.Raycast(player.eyes.HeadRay(), out hit, 10f)) { BaseEntity entity = hit.GetEntity() as BaseEntity; TryApplyModifiers(entity); NpcModifiers modifiers; if (entity != null && entity.net != null && npcModifiers.TryGetValue(entity.net.ID, out modifiers)) { BaseCombatEntity combatEntity = entity as BaseCombatEntity; TelemetryEncounter encounter; bool hasEncounter = activeTelemetry.TryGetValue(entity.net.ID, out encounter); Reply( player, "NpcInspectInfoV5", entity.ShortPrefabName, entity.net.ID, modifiers.SpeedMultiplier, modifiers.SpeedAdapter, modifiers.OriginalPrimarySpeed, GetAppliedPrimarySpeed(modifiers), modifiers.HealthMultiplier, combatEntity == null ? 0f : combatEntity.Health(), combatEntity == null ? 0f : combatEntity._maxHealth, modifiers.OriginalMaxHealth, modifiers.DamageMultiplier, modifiers.BehaviorDisposition, modifiers.BehaviorAdapter, modifiers.AptitudeRerolls, hasEncounter ? encounter.DamageReceived : 0d, hasEncounter ? encounter.DamageDealt : 0d, hasEncounter ? (encounter.PlayerInvolved ? "yes" : "no") : "not tracked", modifiers.IsBoss ? "yes" : "no", modifiers.BossKills, modifiers.BossGrowthMultiplier, GetCurrentBossScaleMultiplier(modifiers)); return; } Reply(player, "NpcNotFound"); } else { Reply(player, "NoNpcInRange"); } } catch (Exception ex) { Reply(player, "InspectFailed", ex.Message); Log($"Inspect command error: {ex}", "Error"); } } private string GetDebugEntityLabel(BaseEntity entity) { if (entity == null) { return "environment"; } BasePlayer player = entity as BasePlayer; if (player != null && !player.IsNpc) { return "player"; } string shortName = NormalizePrefabName(entity.ShortPrefabName); return entity.net == null ? shortName : $"{shortName}#{entity.net.ID}"; } private void DebugTrace(string message) { if (configData == null || !configData.DebugMode) { return; } Puts($"[Debug] {message}"); if (!configData.DebugBroadcastToAdmins) { return; } foreach (BasePlayer player in BasePlayer.activePlayerList) { if (player != null && player.IsConnected && HasCommandPermission(player)) { SendReply(player, $"[RandomAI Debug] {message}"); } } } private void Log(string message, string level) { string normalizedLevel = NormalizeLogLevel(level); int configuredRank = LogRank(configData == null ? "Warning" : configData.LogLevel); int messageRank = LogRank(normalizedLevel); if (messageRank < configuredRank || (normalizedLevel == "Verbose" && (configData == null || !configData.DebugMode))) { return; } if (normalizedLevel == "Error") { PrintError(message); } else if (normalizedLevel == "Warning") { PrintWarning(message); } else { Puts($"[Verbose] {message}"); } } private int LogRank(string level) { if (string.Equals(level, "Error", StringComparison.OrdinalIgnoreCase)) { return 2; } if (string.Equals(level, "Warning", StringComparison.OrdinalIgnoreCase)) { return 1; } return 0; } } }