using System; using System.Collections.Generic; using System.Runtime.InteropServices; using JetBrains.Annotations; using UnityEngine; namespace RoR2.Stats; internal class StatManager { private struct DamageEvent { [CanBeNull] public CharacterMaster attackerMaster; public BodyIndex attackerBodyIndex; [CanBeNull] public CharacterMaster attackerOwnerMaster; public BodyIndex attackerOwnerBodyIndex; [CanBeNull] public CharacterMaster victimMaster; public BodyIndex victimBodyIndex; public bool victimIsElite; public float damageDealt; public DotController.DotIndex dotType; } private struct DeathEvent { public DamageReport damageReport; public bool victimWasBurning; } private struct HealingEvent { [CanBeNull] public GameObject healee; public float healAmount; } private struct GoldEvent { [CanBeNull] public CharacterMaster characterMaster; public ulong amount; } [StructLayout(LayoutKind.Sequential, Size = 1)] private struct PurchaseStatEvent { } public struct CharacterUpdateEvent { public PlayerStatsComponent statsComponent; public float additionalDistanceTraveled; public float additionalTimeAlive; public int level; public float runTime; } private struct ItemCollectedEvent { [CanBeNull] public Inventory inventory; public ItemIndex itemIndex; public int quantity; public int newCount; } private static BodyIndex crocoBodyIndex = BodyIndex.None; private static readonly Queue damageEvents = new Queue(); private static readonly Queue deathEvents = new Queue(); private static readonly Queue healingEvents = new Queue(); private static readonly Queue goldCollectedEvents = new Queue(); private static readonly Queue purchaseStatEvents = new Queue(); private static readonly Queue characterUpdateEvents = new Queue(); private static readonly Queue itemCollectedEvents = new Queue(); [SystemInitializer(new Type[] { typeof(BodyCatalog), typeof(ItemCatalog), typeof(EquipmentCatalog), typeof(PickupCatalog) })] private static void Init() { GlobalEventManager.onServerDamageDealt += OnDamageDealt; GlobalEventManager.onCharacterDeathGlobal += OnCharacterDeath; GlobalEventManager.onServerCharacterExecuted += OnCharacterExecute; HealthComponent.onCharacterHealServer += OnCharacterHeal; Run.onPlayerFirstCreatedServer += OnPlayerFirstCreatedServer; Run.onServerGameOver += OnServerGameOver; Stage.onServerStageComplete += OnServerStageComplete; Stage.onServerStageBegin += OnServerStageBegin; Inventory.onServerItemGiven += OnServerItemGiven; RoR2Application.onFixedUpdate += ProcessEvents; EquipmentSlot.onServerEquipmentActivated += OnEquipmentActivated; InfiniteTowerRun.onWaveInitialized += OnInfiniteTowerWaveInitialized; crocoBodyIndex = BodyCatalog.FindBodyIndex("CrocoBody"); } private static void OnInfiniteTowerWaveInitialized(InfiniteTowerWaveController waveController) { ulong statValue = (ulong)((Run.instance as InfiniteTowerRun)?.waveIndex ?? 0); foreach (PlayerStatsComponent instances in PlayerStatsComponent.instancesList) { if (!instances.playerCharacterMasterController.isConnected) { continue; } PerBodyStatDef perBodyStatDef = null; switch (Run.instance.selectedDifficulty) { case DifficultyIndex.Easy: perBodyStatDef = PerBodyStatDef.highestInfiniteTowerWaveReachedEasy; break; case DifficultyIndex.Normal: perBodyStatDef = PerBodyStatDef.highestInfiniteTowerWaveReachedNormal; break; case DifficultyIndex.Hard: perBodyStatDef = PerBodyStatDef.highestInfiniteTowerWaveReachedHard; break; } StatSheet currentStats = instances.currentStats; currentStats.PushStatValue(StatDef.highestInfiniteTowerWaveReached, statValue); if (perBodyStatDef != null) { CharacterBody body = instances.characterMaster.GetBody(); if ((bool)body) { string bodyName = BodyCatalog.GetBodyName(body.bodyIndex); currentStats.PushStatValue(perBodyStatDef.FindStatDef(bodyName ?? ""), statValue); } } } } private static void OnServerGameOver(Run run, GameEndingDef gameEndingDef) { if (!gameEndingDef.isWin || !(run.GetType() == typeof(Run))) { return; } foreach (PlayerStatsComponent instances in PlayerStatsComponent.instancesList) { if (instances.playerCharacterMasterController.isConnected) { instances.currentStats.PushStatValue(PerBodyStatDef.totalWins.FindStatDef(instances.characterMaster.bodyPrefab?.name ?? ""), 1uL); } } } private static void OnPlayerFirstCreatedServer(Run run, PlayerCharacterMasterController playerCharacterMasterController) { playerCharacterMasterController.master.onBodyStart += OnBodyFirstStart; } private static void OnBodyFirstStart(CharacterBody body) { CharacterMaster master = body.master; if (!master) { return; } master.onBodyStart -= OnBodyFirstStart; PlayerCharacterMasterController component = master.GetComponent(); if ((bool)component) { PlayerStatsComponent component2 = component.GetComponent(); if ((bool)component2) { StatSheet currentStats = component2.currentStats; currentStats.PushStatValue(PerBodyStatDef.timesPicked.FindStatDef(body.name), 1uL); currentStats.PushStatValue(StatDef.totalGamesPlayed, 1uL); } } } public static void ForceUpdate() { ProcessEvents(); } private static void ProcessEvents() { ProcessDamageEvents(); ProcessDeathEvents(); ProcessHealingEvents(); ProcessGoldEvents(); ProcessItemCollectedEvents(); ProcessCharacterUpdateEvents(); } public static void OnCharacterHeal(HealthComponent healthComponent, float amount, ProcChainMask procChainMask) { healingEvents.Enqueue(new HealingEvent { healee = healthComponent.gameObject, healAmount = amount }); } public static void OnDamageDealt(DamageReport damageReport) { damageEvents.Enqueue(new DamageEvent { attackerMaster = damageReport.attackerMaster, attackerBodyIndex = damageReport.attackerBodyIndex, attackerOwnerMaster = damageReport.attackerOwnerMaster, attackerOwnerBodyIndex = damageReport.attackerOwnerBodyIndex, victimMaster = damageReport.victimMaster, victimBodyIndex = damageReport.victimBodyIndex, victimIsElite = damageReport.victimIsElite, damageDealt = damageReport.damageDealt, dotType = damageReport.dotType }); } public static void OnCharacterExecute(DamageReport damageReport, float executionHealthLost) { damageEvents.Enqueue(new DamageEvent { attackerMaster = damageReport.attackerMaster, attackerBodyIndex = damageReport.attackerBodyIndex, attackerOwnerMaster = damageReport.attackerOwnerMaster, attackerOwnerBodyIndex = damageReport.attackerOwnerBodyIndex, victimMaster = damageReport.victimMaster, victimBodyIndex = damageReport.victimBodyIndex, victimIsElite = damageReport.victimIsElite, damageDealt = executionHealthLost, dotType = damageReport.dotType }); } public static void OnCharacterDeath(DamageReport damageReport) { DotController dotController = DotController.FindDotController(damageReport.victim.gameObject); bool victimWasBurning = false; if ((bool)dotController) { victimWasBurning = dotController.HasDotActive(DotController.DotIndex.Burn) | dotController.HasDotActive(DotController.DotIndex.PercentBurn) | dotController.HasDotActive(DotController.DotIndex.Helfire) | dotController.HasDotActive(DotController.DotIndex.StrongerBurn); } deathEvents.Enqueue(new DeathEvent { damageReport = damageReport, victimWasBurning = victimWasBurning }); } private static void ProcessHealingEvents() { while (healingEvents.Count > 0) { HealingEvent healingEvent = healingEvents.Dequeue(); ulong statValue = (ulong)healingEvent.healAmount; PlayerStatsComponent.FindBodyStatSheet(healingEvent.healee)?.PushStatValue(StatDef.totalHealthHealed, statValue); } } private static void ProcessDamageEvents() { while (damageEvents.Count > 0) { DamageEvent damageEvent = damageEvents.Dequeue(); ulong statValue = (ulong)damageEvent.damageDealt; StatSheet statSheet = PlayerStatsComponent.FindMasterStatSheet(damageEvent.victimMaster); StatSheet statSheet2 = PlayerStatsComponent.FindMasterStatSheet(damageEvent.attackerMaster); StatSheet statSheet3 = PlayerStatsComponent.FindMasterStatSheet(damageEvent.attackerOwnerMaster); if (statSheet != null) { statSheet.PushStatValue(StatDef.totalDamageTaken, statValue); if (damageEvent.attackerBodyIndex != BodyIndex.None) { statSheet.PushStatValue(PerBodyStatDef.damageTakenFrom, damageEvent.attackerBodyIndex, statValue); } if (damageEvent.victimBodyIndex != BodyIndex.None) { statSheet.PushStatValue(PerBodyStatDef.damageTakenAs, damageEvent.victimBodyIndex, statValue); } } if (statSheet2 != null) { statSheet2.PushStatValue(StatDef.totalDamageDealt, statValue); statSheet2.PushStatValue(StatDef.highestDamageDealt, statValue); if (damageEvent.attackerBodyIndex != BodyIndex.None) { statSheet2.PushStatValue(PerBodyStatDef.damageDealtAs, damageEvent.attackerBodyIndex, statValue); } if (damageEvent.victimBodyIndex != BodyIndex.None) { statSheet2.PushStatValue(PerBodyStatDef.damageDealtTo, damageEvent.victimBodyIndex, statValue); } } if (statSheet3 != null) { statSheet3.PushStatValue(StatDef.totalMinionDamageDealt, statValue); if (damageEvent.attackerOwnerBodyIndex != BodyIndex.None) { statSheet3.PushStatValue(PerBodyStatDef.minionDamageDealtAs, damageEvent.attackerOwnerBodyIndex, statValue); } } } } private static void ProcessDeathEvents() { while (deathEvents.Count > 0) { DeathEvent deathEvent = deathEvents.Dequeue(); DamageReport damageReport = deathEvent.damageReport; StatSheet statSheet = PlayerStatsComponent.FindMasterStatSheet(damageReport.victimMaster); StatSheet statSheet2 = PlayerStatsComponent.FindMasterStatSheet(damageReport.attackerMaster); StatSheet statSheet3 = PlayerStatsComponent.FindMasterStatSheet(damageReport.attackerOwnerMaster); if (statSheet != null) { statSheet.PushStatValue(StatDef.totalDeaths, 1uL); statSheet.PushStatValue(PerBodyStatDef.deathsAs, damageReport.victimBodyIndex, 1uL); if (damageReport.attackerBodyIndex != BodyIndex.None) { statSheet.PushStatValue(PerBodyStatDef.deathsFrom, damageReport.attackerBodyIndex, 1uL); } if (damageReport.dotType != DotController.DotIndex.None) { DotController.DotIndex dotType = damageReport.dotType; if ((uint)(dotType - 1) <= 2u || dotType == DotController.DotIndex.StrongerBurn) { statSheet.PushStatValue(StatDef.totalBurnDeaths, 1uL); } } if (deathEvent.victimWasBurning) { statSheet.PushStatValue(StatDef.totalDeathsWhileBurning, 1uL); } } if (statSheet2 != null) { statSheet2.PushStatValue(StatDef.totalKills, 1uL); statSheet2.PushStatValue(PerBodyStatDef.killsAs, damageReport.attackerBodyIndex, 1uL); if (damageReport.victimBodyIndex != BodyIndex.None) { statSheet2.PushStatValue(PerBodyStatDef.killsAgainst, damageReport.victimBodyIndex, 1uL); if (damageReport.victimIsElite) { statSheet2.PushStatValue(StatDef.totalEliteKills, 1uL); statSheet2.PushStatValue(PerBodyStatDef.killsAgainstElite, damageReport.victimBodyIndex, 1uL); } } if (damageReport.attackerBodyIndex == crocoBodyIndex && damageReport.combinedHealthBeforeDamage <= 1f) { statSheet2.PushStatValue(StatDef.totalCrocoWeakEnemyKills, 1uL); } string text = damageReport.victimBody?.customKillTotalStatName; if (!string.IsNullOrEmpty(text)) { StatDef statDef = StatDef.Find(text); if (statDef == null) { Debug.LogWarningFormat("Stat def \"{0}\" could not be found.", text); } else { statSheet2.PushStatValue(statDef, 1uL); } } } if (statSheet3 != null) { statSheet3.PushStatValue(StatDef.totalMinionKills, 1uL); if (damageReport.attackerOwnerBodyIndex != BodyIndex.None) { statSheet3.PushStatValue(PerBodyStatDef.minionKillsAs, damageReport.attackerOwnerBodyIndex, 1uL); } } if (!damageReport.victimIsBoss) { continue; } int i = 0; for (int count = PlayerStatsComponent.instancesList.Count; i < count; i++) { PlayerStatsComponent playerStatsComponent = PlayerStatsComponent.instancesList[i]; if (playerStatsComponent.characterMaster.hasBody) { playerStatsComponent.currentStats.PushStatValue(StatDef.totalTeleporterBossKillsWitnessed, 1uL); } } } } public static void OnGoldCollected(CharacterMaster characterMaster, ulong amount) { goldCollectedEvents.Enqueue(new GoldEvent { characterMaster = characterMaster, amount = amount }); } private static void ProcessGoldEvents() { while (goldCollectedEvents.Count > 0) { GoldEvent goldEvent = goldCollectedEvents.Dequeue(); StatSheet statSheet = goldEvent.characterMaster?.GetComponent()?.currentStats; if (statSheet != null) { statSheet.PushStatValue(StatDef.goldCollected, goldEvent.amount); statSheet.PushStatValue(StatDef.maxGoldCollected, statSheet.GetStatValueULong(StatDef.goldCollected)); } } } public static void OnPurchase(CharacterBody characterBody, CostTypeIndex costType, T statDefsToIncrement) where T : IEnumerable { StatSheet statSheet = PlayerStatsComponent.FindBodyStatSheet(characterBody); if (statSheet == null) { return; } StatDef statDef = null; StatDef statDef2 = null; switch (costType) { case CostTypeIndex.Money: statDef = StatDef.totalGoldPurchases; statDef2 = StatDef.highestGoldPurchases; break; case CostTypeIndex.PercentHealth: statDef = StatDef.totalBloodPurchases; statDef2 = StatDef.highestBloodPurchases; break; case CostTypeIndex.LunarCoin: statDef = StatDef.totalLunarPurchases; statDef2 = StatDef.highestLunarPurchases; break; case CostTypeIndex.WhiteItem: statDef = StatDef.totalTier1Purchases; statDef2 = StatDef.highestTier1Purchases; break; case CostTypeIndex.GreenItem: statDef = StatDef.totalTier2Purchases; statDef2 = StatDef.highestTier2Purchases; break; case CostTypeIndex.RedItem: statDef = StatDef.totalTier3Purchases; statDef2 = StatDef.highestTier3Purchases; break; } statSheet.PushStatValue(StatDef.totalPurchases, 1uL); statSheet.PushStatValue(StatDef.highestPurchases, statSheet.GetStatValueULong(StatDef.totalPurchases)); if (statDef != null) { statSheet.PushStatValue(statDef, 1uL); if (statDef2 != null) { statSheet.PushStatValue(statDef2, statSheet.GetStatValueULong(statDef)); } } if (statDefsToIncrement == null) { return; } foreach (StatDef item in statDefsToIncrement) { if (item != null) { statSheet.PushStatValue(item, 1uL); } } } public static void OnEquipmentActivated(EquipmentSlot activator, EquipmentIndex equipmentIndex) { PlayerStatsComponent.FindBodyStatSheet(activator.characterBody)?.PushStatValue(PerEquipmentStatDef.totalTimesFired.FindStatDef(equipmentIndex), 1uL); } public static void PushCharacterUpdateEvent(CharacterUpdateEvent e) { characterUpdateEvents.Enqueue(e); } private static void ProcessCharacterUpdateEvents() { while (characterUpdateEvents.Count > 0) { CharacterUpdateEvent characterUpdateEvent = characterUpdateEvents.Dequeue(); if (!characterUpdateEvent.statsComponent) { continue; } StatSheet currentStats = characterUpdateEvent.statsComponent.currentStats; if (currentStats != null) { BodyIndex bodyIndex = characterUpdateEvent.statsComponent.characterMaster.GetBody()?.bodyIndex ?? BodyIndex.None; currentStats.PushStatValue(StatDef.totalTimeAlive, characterUpdateEvent.additionalTimeAlive); currentStats.PushStatValue(StatDef.highestLevel, (ulong)characterUpdateEvent.level); currentStats.PushStatValue(StatDef.totalDistanceTraveled, characterUpdateEvent.additionalDistanceTraveled); if (bodyIndex != BodyIndex.None) { currentStats.PushStatValue(PerBodyStatDef.totalTimeAlive, bodyIndex, characterUpdateEvent.additionalTimeAlive); currentStats.PushStatValue(PerBodyStatDef.longestRun, bodyIndex, characterUpdateEvent.runTime); } EquipmentIndex currentEquipmentIndex = characterUpdateEvent.statsComponent.characterMaster.inventory.currentEquipmentIndex; if (currentEquipmentIndex != EquipmentIndex.None) { currentStats.PushStatValue(PerEquipmentStatDef.totalTimeHeld.FindStatDef(currentEquipmentIndex), characterUpdateEvent.additionalTimeAlive); } } } } private static void OnServerItemGiven(Inventory inventory, ItemIndex itemIndex, int quantity) { itemCollectedEvents.Enqueue(new ItemCollectedEvent { inventory = inventory, itemIndex = itemIndex, quantity = quantity, newCount = inventory.GetItemCount(itemIndex) }); } private static void ProcessItemCollectedEvents() { while (itemCollectedEvents.Count > 0) { ItemCollectedEvent itemCollectedEvent = itemCollectedEvents.Dequeue(); if ((bool)itemCollectedEvent.inventory) { StatSheet statSheet = itemCollectedEvent.inventory.GetComponent()?.currentStats; if (statSheet != null) { statSheet.PushStatValue(StatDef.totalItemsCollected, (ulong)itemCollectedEvent.quantity); statSheet.PushStatValue(StatDef.highestItemsCollected, statSheet.GetStatValueULong(StatDef.totalItemsCollected)); statSheet.PushStatValue(PerItemStatDef.totalCollected.FindStatDef(itemCollectedEvent.itemIndex), (ulong)itemCollectedEvent.quantity); statSheet.PushStatValue(PerItemStatDef.highestCollected.FindStatDef(itemCollectedEvent.itemIndex), (ulong)itemCollectedEvent.newCount); } } } } private static void OnServerStageBegin(Stage stage) { foreach (PlayerStatsComponent instances in PlayerStatsComponent.instancesList) { if (instances.playerCharacterMasterController.isConnected) { StatSheet currentStats = instances.currentStats; StatDef statDef = PerStageStatDef.totalTimesVisited.FindStatDef(stage.sceneDef ? stage.sceneDef.baseSceneName : string.Empty); if (statDef != null) { currentStats.PushStatValue(statDef, 1uL); } } } } private static void OnServerStageComplete(Stage stage) { foreach (PlayerStatsComponent instances in PlayerStatsComponent.instancesList) { if (instances.playerCharacterMasterController.isConnected) { StatSheet currentStats = instances.currentStats; if (SceneInfo.instance.countsAsStage) { currentStats.PushStatValue(StatDef.totalStagesCompleted, 1uL); currentStats.PushStatValue(StatDef.highestStagesCompleted, currentStats.GetStatValueULong(StatDef.totalStagesCompleted)); } StatDef statDef = PerStageStatDef.totalTimesCleared.FindStatDef(stage.sceneDef ? stage.sceneDef.baseSceneName : string.Empty); if (statDef != null) { currentStats.PushStatValue(statDef, 1uL); } } } } }