From 2cfd452c181ffb3d758114817d9d4428b93cb3b1 Mon Sep 17 00:00:00 2001 From: Crescendo Date: Fri, 27 Mar 2026 18:04:37 -0500 Subject: [PATCH 1/5] Fix logic in determining which damage instances to throw away --- src/Matchmaking/Modules/TeamVersusStats.cs | 54 ++++++++++++++++++++-- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/src/Matchmaking/Modules/TeamVersusStats.cs b/src/Matchmaking/Modules/TeamVersusStats.cs index 7900991b..69218f9e 100644 --- a/src/Matchmaking/Modules/TeamVersusStats.cs +++ b/src/Matchmaking/Modules/TeamVersusStats.cs @@ -3952,15 +3952,59 @@ public void Initialize(SlotStats slotStats, string playerName, bool isInitial) public void RemoveOldRecentDamage(short maximumEnergy, short rechargeRate) { - // How many ticks it takes for the player's ship to reach full energy from empty (maximum energy and recharge rate assumed). - uint fullEnergyTicks = (uint)float.Ceiling(maximumEnergy * 1000f / rechargeRate); + // Starting from the oldest damage event, we cull the event if the damage that was incurred + // has already been recharged. The rechargeRate parameterizes how long instances of damage + // stay in the RecentDamageTaken list, a higher recharge rate means that each damage event + // has a shorter lifetime in this list. - // Remove nodes that are too old to be relevant. - ServerTick cutoff = ServerTick.Now - fullEnergyTicks; + ServerTick now = ServerTick.Now; + + // Collect EMP freeze intervals and merge any that overlap. EMPs refresh the freeze timer + // rather than accumulating, so overlapping intervals must not be double-counted. + // RecentDamageTaken is ordered oldest to newest, so EMP intervals are already sorted by start time. + List<(ServerTick Start, ServerTick End)> mergedEmpIntervals = []; LinkedListNode? node = RecentDamageTaken.First; while (node is not null) { - if (node.ValueRef.Timestamp + node.ValueRef.EmpBombShutdownTicks < cutoff) + if (node.ValueRef.EmpBombShutdownTicks > 0) + { + ServerTick empStart = node.ValueRef.Timestamp; + ServerTick empEnd = empStart + node.ValueRef.EmpBombShutdownTicks; + if (mergedEmpIntervals.Count == 0 || empStart > mergedEmpIntervals[^1].End) + { + mergedEmpIntervals.Add((empStart, empEnd)); + } + else + { + // Overlapping: merge by extending the end of the last interval if needed. + (ServerTick lastStart, ServerTick lastEnd) = mergedEmpIntervals[^1]; + if (empEnd > lastEnd) + mergedEmpIntervals[^1] = (lastStart, empEnd); + } + } + node = node.Next; + } + + node = RecentDamageTaken.First; + while (node is not null) + { + uint recoveryTicksNeeded = (uint)float.Ceiling(node.ValueRef.Damage * 1000f / rechargeRate); + ServerTick damageTimestamp = node.ValueRef.Timestamp; + + // Compute how many ticks since this damage could not be used for recharging due to EMP freeze. + // This is the total length of the merged EMP intervals clipped to [damageTimestamp, now]. + uint frozenTicks = 0; + foreach ((ServerTick empStart, ServerTick empEnd) in mergedEmpIntervals) + { + ServerTick clampedStart = empStart > damageTimestamp ? empStart : damageTimestamp; + ServerTick clampedEnd = empEnd < now ? empEnd : now; + if (clampedStart < clampedEnd) + frozenTicks += (uint)(clampedEnd - clampedStart); + } + + int actualRechargeTicksElapsed = (int)(now - damageTimestamp) - (int)frozenTicks; + + if (actualRechargeTicksElapsed >= (int)recoveryTicksNeeded) { // The node represents damage taken from too long ago. // Discard the node and continue with the next node. From b36215597b3421654689278bbbfaea6a81972917 Mon Sep 17 00:00:00 2001 From: Crescendo Date: Sat, 28 Mar 2026 11:34:35 -0500 Subject: [PATCH 2/5] Refactors --- src/Matchmaking/Modules/TeamVersusStats.cs | 136 ++++++++------------- 1 file changed, 54 insertions(+), 82 deletions(-) diff --git a/src/Matchmaking/Modules/TeamVersusStats.cs b/src/Matchmaking/Modules/TeamVersusStats.cs index 69218f9e..6752775a 100644 --- a/src/Matchmaking/Modules/TeamVersusStats.cs +++ b/src/Matchmaking/Modules/TeamVersusStats.cs @@ -2256,8 +2256,6 @@ private void Callback_PlayerDamage(Player player, ServerTick timestamp, ReadOnly short maximumEnergy = GetClientSetting(player, _shipClientSettingIds[shipIndex].MaximumEnergyId, shipSettings.MaximumEnergy); - playerStats.RemoveOldRecentDamage(maximumEnergy, maximumRecharge); - // Calculate emp shutdown time. uint empShutdownTicks = 0; if (damageData.WeaponData.Type == WeaponCodes.Bomb || damageData.WeaponData.Type == WeaponCodes.ProxBomb) @@ -2302,7 +2300,7 @@ private void Callback_PlayerDamage(Player player, ServerTick timestamp, ReadOnly attackerPlayer.Freq, attackerStats.SlotStats!.Slot!.SlotIdx), }; - playerStats.RecentDamageTaken.AddLast(node); + playerStats.AddRecentDamageTaken(node); } // @@ -2775,7 +2773,7 @@ private void RemoveDamageWatch(Player player, PlayerData playerData) } } - private void CalculateDamageSources(ServerTick asOfTick, MemberStats memberStats, ShipType ship, Dictionary damageDictionary) + private void CalculateDamageSources(ServerTick asOfTick, MemberStats memberStats, ShipType ship, Dictionary damageDictionary) { if (memberStats is null || damageDictionary is null) return; @@ -2791,87 +2789,55 @@ private void CalculateDamageSources(ServerTick asOfTick, MemberStats memberStats return; ShipSettings killedShipSettings = arenaSettings.ShipSettings[(int)ship]; - short maximumEnergy = killedShipSettings.MaximumEnergy; short rechargeRate = killedShipSettings.MaximumRecharge; + short maximumEnergy = killedShipSettings.MaximumEnergy; - memberStats.RemoveOldRecentDamage(maximumEnergy, rechargeRate); - - // How many ticks it takes for the player's ship to reach full energy from empty (maximum energy and recharge rate assumed). + // Only damage within the recharge window is still relevant at asOfTick. uint fullEnergyTicks = (uint)float.Ceiling(maximumEnergy * 1000f / rechargeRate); ServerTick cutoff = asOfTick - fullEnergyTicks; - // TODO: Maybe add a parameter for an additional logic when calculating damage for a kill, to add a check if the last damage record was for the killing blow. - // If not and there is at least one damage record, use the latest record to figure out how much damage the killer would have needed to inflict, - // accounting for how much the player would have recharged. Assume the killer did half the damage? + LinkedList recentDamageTaken = memberStats.GetRecentDamageTaken(rechargeRate); - // This calculator will assist with calculating how much damage to award due to EMP recharge shutdown. - // It will help us to determine how much shutdown time to allow when one EMP overlaps with another. - // Here we start with the most recent damage first. So, as we iterate, we're adding in older damage after more recent damage. - // For example, if we have damage from two EMPs and their shutdown time ranges overlap, the shutdown time awarded - // for the EMP that came earlier will not include the time that intersects with the more recent EMP. - // Note, this works for any # of overlapping EMPs too. Any range of overlap that is already counted will not be counted twice. + // We iterate oldest to newest so that the first-arriving EMP's attacker gets credit for + // the full duration of their freeze. A later EMP's attacker only gets credit for any ticks + // by which they extended the total freeze window beyond what was already claimed. TickRangeCalculator empShutdownCalculator = s_tickRangeCalculatorPool.Get(); try { - LinkedListNode? node = memberStats.RecentDamageTaken.Last; + LinkedListNode? node = recentDamageTaken.First; while (node is not null) { - LinkedListNode? previous = node.Previous; ref DamageInfo damageInfo = ref node.ValueRef; - int damage = 0; + uint damage = 0; - if (damageInfo.Timestamp < cutoff) - { - // The damage happened outside of the recharge window. - if (damageInfo.EmpBombShutdownTicks > 0) - { - // It was an emp, so we might be able to count emp recharge loss that crossed into the window. - ServerTick empShutdownEndTick = damageInfo.Timestamp + damageInfo.EmpBombShutdownTicks; - if (empShutdownEndTick > cutoff) - { - int empShutdownTicks = empShutdownCalculator.Add(cutoff, empShutdownEndTick); - damage = (int)((rechargeRate / 1000f) * empShutdownTicks); - } - else - { - // The damage did not extend into the recharge window. - // However, there can still be earlier emp damage with a shutdown time that is long enough. - // So, we don't stop processing here, we want to keep reading previous nodes. - } - } - } - else - { - // The damage happened inside of the recharge window. - // We can count the full amount, minus any overlapping emp recharge damage. - int empShutdownDamage = 0; - if (damageInfo.EmpBombShutdownTicks > 0) - { - ServerTick empStartTimestamp = damageInfo.Timestamp; - ServerTick empEndTimestamp = empStartTimestamp + damageInfo.EmpBombShutdownTicks; + // Count direct weapon damage only if it falls within the recharge window. + if (damageInfo.Timestamp >= cutoff) + damage += (uint)damageInfo.Damage; - // Calculate how much emp shutdown time is allowed. - int empShutdownTicks = empShutdownCalculator.Add(empStartTimestamp, empEndTimestamp); - empShutdownDamage = (int)((rechargeRate / 1000f) * empShutdownTicks); - } + // Convert EMP freeze ticks to an energy damage equivalent: the energy the victim + // would have recharged had the freeze not occurred. Clip the freeze window to + // [cutoff, asOfTick] so we only count ticks within the recharge window. + if (damageInfo.EmpBombShutdownTicks > 0) + { + ServerTick empStart = damageInfo.Timestamp > cutoff ? damageInfo.Timestamp : cutoff; + ServerTick empEnd = damageInfo.Timestamp + damageInfo.EmpBombShutdownTicks; + if (empEnd > asOfTick) + empEnd = asOfTick; - damage = damageInfo.Damage + empShutdownDamage; + int empTicks = empShutdownCalculator.Add(empStart, empEnd); + damage += (uint)(rechargeRate / 1000f * empTicks); } if (damage > 0) { - if (damageDictionary.TryGetValue(damageInfo.Attacker, out int totalDamage)) - { - damageDictionary[damageInfo.Attacker] = totalDamage + damage; - } + if (damageDictionary.TryGetValue(damageInfo.Attacker, out uint existingDamage)) + damageDictionary[damageInfo.Attacker] = existingDamage + damage; else - { damageDictionary.Add(damageInfo.Attacker, damage); - } } - node = previous; + node = node.Next; } } finally @@ -3117,13 +3083,6 @@ private static void ResetMatchStats(MatchStats matchStats) { foreach (MemberStats memberStats in slotStats.Members) { - LinkedListNode? node; - while ((node = memberStats.RecentDamageTaken.First) is not null) - { - memberStats.RecentDamageTaken.Remove(node); - s_damageInfoLinkedListNodePool.Return(node); - } - memberStats.Reset(); // TODO: return memberStats to a pool @@ -3747,14 +3706,25 @@ private class MemberStats : IMemberStats /// public int ForcedRepDamage; + private readonly LinkedList _recentDamageTaken = new(); + + public void AddRecentDamageTaken(LinkedListNode node) + { + _recentDamageTaken.AddLast(node); + } + /// - /// Recent damage taken in order from oldest to newest. + /// Prunes stale damage entries and returns the remaining recent damage taken, ordered oldest to newest. /// /// /// Used upon death to calculate , , , and . /// Used upon repel usage to calculate and . /// - public readonly LinkedList RecentDamageTaken = new(); + public LinkedList GetRecentDamageTaken(short rechargeRate) + { + RemoveOldRecentDamage(rechargeRate); + return _recentDamageTaken; + } #endregion @@ -3950,20 +3920,22 @@ public void Initialize(SlotStats slotStats, string playerName, bool isInitial) RatingChange = 0; } - public void RemoveOldRecentDamage(short maximumEnergy, short rechargeRate) + /// + /// Starting from the oldest damage event, we cull the event if the damage that was incurred + /// has already been recharged. The rechargeRate parameterizes how long instances of damage + /// stay in the RecentDamageTaken list, a higher recharge rate means that each damage event + /// has a shorter lifetime in this list. + /// + /// + private void RemoveOldRecentDamage(short rechargeRate) { - // Starting from the oldest damage event, we cull the event if the damage that was incurred - // has already been recharged. The rechargeRate parameterizes how long instances of damage - // stay in the RecentDamageTaken list, a higher recharge rate means that each damage event - // has a shorter lifetime in this list. - ServerTick now = ServerTick.Now; // Collect EMP freeze intervals and merge any that overlap. EMPs refresh the freeze timer // rather than accumulating, so overlapping intervals must not be double-counted. // RecentDamageTaken is ordered oldest to newest, so EMP intervals are already sorted by start time. List<(ServerTick Start, ServerTick End)> mergedEmpIntervals = []; - LinkedListNode? node = RecentDamageTaken.First; + LinkedListNode? node = _recentDamageTaken.First; while (node is not null) { if (node.ValueRef.EmpBombShutdownTicks > 0) @@ -3985,13 +3957,13 @@ public void RemoveOldRecentDamage(short maximumEnergy, short rechargeRate) node = node.Next; } - node = RecentDamageTaken.First; + node = _recentDamageTaken.First; while (node is not null) { uint recoveryTicksNeeded = (uint)float.Ceiling(node.ValueRef.Damage * 1000f / rechargeRate); ServerTick damageTimestamp = node.ValueRef.Timestamp; - // Compute how many ticks since this damage could not be used for recharging due to EMP freeze. + // Compute how many ticks recharging was disabled after this damage due to EMP freeze. // This is the total length of the merged EMP intervals clipped to [damageTimestamp, now]. uint frozenTicks = 0; foreach ((ServerTick empStart, ServerTick empEnd) in mergedEmpIntervals) @@ -4009,7 +3981,7 @@ public void RemoveOldRecentDamage(short maximumEnergy, short rechargeRate) // The node represents damage taken from too long ago. // Discard the node and continue with the next node. LinkedListNode? next = node.Next; - RecentDamageTaken.Remove(node); + _recentDamageTaken.Remove(node); s_damageInfoLinkedListNodePool.Return(node); node = next; } @@ -4024,9 +3996,9 @@ public void RemoveOldRecentDamage(short maximumEnergy, short rechargeRate) public void ClearRecentDamage() { LinkedListNode? node; - while ((node = RecentDamageTaken.First) is not null) + while ((node = _recentDamageTaken.First) is not null) { - RecentDamageTaken.Remove(node); + _recentDamageTaken.Remove(node); s_damageInfoLinkedListNodePool.Return(node); } } From 1e92bc4095fdc3d3e26f61f10e83dce6c6d6cb19 Mon Sep 17 00:00:00 2001 From: Crescendo Date: Sat, 28 Mar 2026 11:52:03 -0500 Subject: [PATCH 3/5] Account for EMP in damage dealt and damage taken --- src/Matchmaking/Modules/TeamVersusStats.cs | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/Matchmaking/Modules/TeamVersusStats.cs b/src/Matchmaking/Modules/TeamVersusStats.cs index 6752775a..50eedd27 100644 --- a/src/Matchmaking/Modules/TeamVersusStats.cs +++ b/src/Matchmaking/Modules/TeamVersusStats.cs @@ -2301,6 +2301,25 @@ private void Callback_PlayerDamage(Player player, ServerTick timestamp, ReadOnly attackerStats.SlotStats!.Slot!.SlotIdx), }; playerStats.AddRecentDamageTaken(node); + + if (empShutdownTicks > 0 && player.Freq != attackerPlayer.Freq) + { + // Credit the attacker only for the ticks by which this EMP extends the active freeze window. + // If a freeze is already in progress, the first attacker already holds those ticks; + // the new attacker earns only the extension beyond what remains. + uint remaining = playerStats.FreezeEndTick > timestamp ? (uint)(playerStats.FreezeEndTick - timestamp) : 0u; + uint extension = empShutdownTicks > remaining ? empShutdownTicks - remaining : 0u; + if (extension > 0) + { + int freezeDamage = (int)(extension * maximumRecharge / 1000f); + playerStats.DamageTakenBombs += freezeDamage; + attackerStats.DamageDealtBombs += freezeDamage; + } + + ServerTick newFreezeEnd = timestamp + empShutdownTicks; + if (newFreezeEnd > playerStats.FreezeEndTick) + playerStats.FreezeEndTick = newFreezeEnd; + } } // @@ -3708,6 +3727,12 @@ private class MemberStats : IMemberStats private readonly LinkedList _recentDamageTaken = new(); + /// + /// The tick at which the current EMP freeze on this player expires. Default (0) means no active freeze. + /// Updated whenever an EMP hit arrives; used to compute how much a new EMP extends the freeze window. + /// + public ServerTick FreezeEndTick; + public void AddRecentDamageTaken(LinkedListNode node) { _recentDamageTaken.AddLast(node); @@ -4022,6 +4047,7 @@ public void Reset() KillDamage = 0; TeamKillDamage = 0; ClearRecentDamage(); + FreezeEndTick = default; // accuracy fields GunFireCount = 0; From 2c7f9e0735df39664ba88cfba186ed956b598b82 Mon Sep 17 00:00:00 2001 From: Crescendo Date: Sat, 28 Mar 2026 12:09:22 -0500 Subject: [PATCH 4/5] ForcedRep tracking adjustments --- src/Matchmaking/Modules/TeamVersusStats.cs | 47 ++++++++++++++++------ 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/src/Matchmaking/Modules/TeamVersusStats.cs b/src/Matchmaking/Modules/TeamVersusStats.cs index 50eedd27..5b3860e3 100644 --- a/src/Matchmaking/Modules/TeamVersusStats.cs +++ b/src/Matchmaking/Modules/TeamVersusStats.cs @@ -56,6 +56,13 @@ static TeamVersusStats() private const int DefaultRating = 500; private const int MinimumRating = 100; + /// + /// The energy window used when attributing a forced repel. Only damage dealt within the + /// equivalent recharge time for this much energy is considered when computing each attacker's + /// proportional share of the repel credit. + /// + private const short ForcedRepEnergyWindow = 800; + private readonly IArenaManager _arenaManager; private readonly IChat _chat; private readonly IClientSettings _clientSettings; @@ -1691,7 +1698,7 @@ async Task SaveGameToDatabase(IMatchData matchData, ITeam? winningTeam, MatchSta writer.WriteNumber("knockouts"u8, memberStats.Knockouts); writer.WriteNumber("solo_kills"u8, memberStats.SoloKills); writer.WriteNumber("assists"u8, memberStats.Assists); - writer.WriteNumber("forced_reps"u8, memberStats.ForcedReps); + writer.WriteNumber("forced_reps"u8, RoundToHalf(memberStats.ForcedReps)); writer.WriteNumber("gun_damage_dealt"u8, memberStats.DamageDealtBullets); writer.WriteNumber("bomb_damage_dealt"u8, memberStats.DamageDealtBombs); writer.WriteNumber("team_damage_dealt"u8, memberStats.DamageDealtTeam); @@ -2532,7 +2539,15 @@ private void Callback_PlayerPositionPacket(Player player, ref readonly C2S_Posit try { - CalculateDamageSources(ServerTick.Now, memberStats, player.Ship, damageDictionary); + CalculateDamageSources(ServerTick.Now, memberStats, player.Ship, damageDictionary, ForcedRepEnergyWindow); + + // Sum enemy damage to use as the denominator for proportional ForcedReps attribution. + int totalEnemyDamage = 0; + foreach ((PlayerTeamSlot attacker, int damage) in damageDictionary) + { + if (memberStats.TeamStats!.Team!.Freq != attacker.Freq) + totalEnemyDamage += damage; + } // Update attacker stats. foreach ((PlayerTeamSlot attacker, int damage) in damageDictionary) @@ -2548,7 +2563,8 @@ private void Callback_PlayerPositionPacket(Player player, ref readonly C2S_Posit continue; attackerMemberStats.ForcedRepDamage += damage; - attackerMemberStats.ForcedReps++; // TODO: do we want more criteria (a certain amount of damage or an even smaller damage window)? + if (totalEnemyDamage > 0) + attackerMemberStats.ForcedReps += (float)damage / totalEnemyDamage; // Rating for forced rep to the attacker. float ratingRatio = @@ -2792,7 +2808,7 @@ private void RemoveDamageWatch(Player player, PlayerData playerData) } } - private void CalculateDamageSources(ServerTick asOfTick, MemberStats memberStats, ShipType ship, Dictionary damageDictionary) + private void CalculateDamageSources(ServerTick asOfTick, MemberStats memberStats, ShipType ship, Dictionary damageDictionary, short? energyWindowOverride = null) { if (memberStats is null || damageDictionary is null) return; @@ -2809,7 +2825,7 @@ private void CalculateDamageSources(ServerTick asOfTick, MemberStats memberStats ShipSettings killedShipSettings = arenaSettings.ShipSettings[(int)ship]; short rechargeRate = killedShipSettings.MaximumRecharge; - short maximumEnergy = killedShipSettings.MaximumEnergy; + short maximumEnergy = energyWindowOverride ?? killedShipSettings.MaximumEnergy; // Only damage within the recharge window is still relevant at asOfTick. uint fullEnergyTicks = (uint)float.Ceiling(maximumEnergy * 1000f / rechargeRate); @@ -2828,11 +2844,11 @@ private void CalculateDamageSources(ServerTick asOfTick, MemberStats memberStats while (node is not null) { ref DamageInfo damageInfo = ref node.ValueRef; - uint damage = 0; + int damage = 0; // Count direct weapon damage only if it falls within the recharge window. if (damageInfo.Timestamp >= cutoff) - damage += (uint)damageInfo.Damage; + damage += damageInfo.Damage; // Convert EMP freeze ticks to an energy damage equivalent: the energy the victim // would have recharged had the freeze not occurred. Clip the freeze window to @@ -2845,12 +2861,12 @@ private void CalculateDamageSources(ServerTick asOfTick, MemberStats memberStats empEnd = asOfTick; int empTicks = empShutdownCalculator.Add(empStart, empEnd); - damage += (uint)(rechargeRate / 1000f * empTicks); + damage += (int)(rechargeRate / 1000f * empTicks); } if (damage > 0) { - if (damageDictionary.TryGetValue(damageInfo.Attacker, out uint existingDamage)) + if (damageDictionary.TryGetValue(damageInfo.Attacker, out int existingDamage)) damageDictionary[damageInfo.Attacker] = existingDamage + damage; else damageDictionary.Add(damageInfo.Attacker, damage); @@ -2865,6 +2881,9 @@ private void CalculateDamageSources(ServerTick asOfTick, MemberStats memberStats } } + /// Rounds a value to the nearest 0.5. + private static float RoundToHalf(float value) => MathF.Round(value * 2f) / 2f; + private void PrintMatchStats(HashSet notifySet, MatchStats matchStats, MatchEndReason? reason, ITeam? winningTeam) { if (notifySet is null || matchStats is null) @@ -2951,7 +2970,7 @@ private void PrintMatchStats(HashSet notifySet, MatchStats matchStats, M int totalTeamKills = 0; int totalSoloKills = 0; int totalAssists = 0; - int totalForcedReps = 0; + float totalForcedReps = 0f; int totalWastedRepels = 0; int totalWastedRockets = 0; int totalLagOuts = 0; @@ -3043,7 +3062,7 @@ private void PrintMatchStats(HashSet notifySet, MatchStats matchStats, M $" {memberStats.TeamKills,2}" + $" {memberStats.SoloKills,2}" + $" {memberStats.Assists,2}" + - $" {memberStats.ForcedReps,2}" + + $" {RoundToHalf(memberStats.ForcedReps),4:F1}" + $" {memberStats.WastedRepels,2}" + $" {memberStats.WastedRockets,3}" + $" {wastedEnergy,4}" + @@ -3071,7 +3090,7 @@ private void PrintMatchStats(HashSet notifySet, MatchStats matchStats, M $" {totalTeamKills,2}" + $" {totalSoloKills,2}" + $" {totalAssists,2}" + - $" {totalForcedReps,2}" + + $" {RoundToHalf(totalForcedReps),4:F1}" + $" {totalWastedRepels,2}" + $" {totalWastedRockets,3}" + $" " + @@ -3880,8 +3899,10 @@ public LinkedList GetRecentDamageTaken(short rechargeRate) /// /// Repels forced out of enemy players. + /// Accumulated as fractional contributions: each repel event adds each attacker's share + /// of the recent enemy damage (their damage / total enemy damage in the window). /// - public short ForcedReps; + public float ForcedReps; /// /// Duration that the player was assigned to a slot in the game. From 04690e4ead1683df471cfb216f0d87fd9b95c6d9 Mon Sep 17 00:00:00 2001 From: Crescendo Date: Sat, 28 Mar 2026 20:33:13 -0500 Subject: [PATCH 5/5] Fix overcounting of EMP damage dealt/taken after player dies --- src/Matchmaking/Modules/TeamVersusStats.cs | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/Matchmaking/Modules/TeamVersusStats.cs b/src/Matchmaking/Modules/TeamVersusStats.cs index 5b3860e3..bf73b90a 100644 --- a/src/Matchmaking/Modules/TeamVersusStats.cs +++ b/src/Matchmaking/Modules/TeamVersusStats.cs @@ -990,6 +990,16 @@ async Task ITeamVersusStatsBehavior.PlayerKilledAsync( short xCoord = killed.Position.X; short yCoord = killed.Position.Y; + // Capture the killed player's maximumRecharge before the delay, for EMP overcounting correction at death. + short killedMaximumRecharge = 0; + if (killedShip != ShipType.Spec + && killed.Arena is not null + && _arenaSettings.TryGetValue(killed.Arena.BaseName, out ArenaSettings? killedArenaSettings)) + { + int shipIndex = (int)killedShip; + killedMaximumRecharge = GetClientSetting(killed, _shipClientSettingIds[shipIndex].MaximumRechargeId, killedArenaSettings.ShipSettings[shipIndex].MaximumRecharge); + } + // // Delay processing the kill to allow time for the final C2S damage packet to make it to the server. // This gives a chance for C2S Damage packets to make it to the server and therefore more accurate damage stats. @@ -1017,6 +1027,18 @@ async Task ITeamVersusStatsBehavior.PlayerKilledAsync( try { + // Correct any EMP freeze damage that was overcounted for ticks extending past the player's death. + if (killedMaximumRecharge > 0 && killedMemberStats.FreezeEndTick > timestampTick) + { + uint overcountedTicks = (uint)(killedMemberStats.FreezeEndTick - timestampTick); + int overcountedDamage = (int)(overcountedTicks * killedMaximumRecharge / 1000f); + killedMemberStats.DamageTakenBombs = Math.Max(0, killedMemberStats.DamageTakenBombs - overcountedDamage); + if (killedMemberStats.FreezeHolder is not null) + killedMemberStats.FreezeHolder.DamageDealtBombs = Math.Max(0, killedMemberStats.FreezeHolder.DamageDealtBombs - overcountedDamage); + } + killedMemberStats.FreezeEndTick = default; + killedMemberStats.FreezeHolder = null; + // Calculate damage stats. CalculateDamageSources(timestampTick, killedMemberStats, killedShip, damageDictionary); killedMemberStats.ClearRecentDamage(); @@ -2325,7 +2347,10 @@ private void Callback_PlayerDamage(Player player, ServerTick timestamp, ReadOnly ServerTick newFreezeEnd = timestamp + empShutdownTicks; if (newFreezeEnd > playerStats.FreezeEndTick) + { playerStats.FreezeEndTick = newFreezeEnd; + playerStats.FreezeHolder = attackerStats; + } } } @@ -3752,6 +3777,12 @@ private class MemberStats : IMemberStats /// public ServerTick FreezeEndTick; + /// + /// The of the attacker who last extended the EMP freeze window on this player. + /// Used at death time to correct any overcounted freeze damage for ticks that extended past the death. + /// + public MemberStats? FreezeHolder; + public void AddRecentDamageTaken(LinkedListNode node) { _recentDamageTaken.AddLast(node); @@ -4069,6 +4100,7 @@ public void Reset() TeamKillDamage = 0; ClearRecentDamage(); FreezeEndTick = default; + FreezeHolder = null; // accuracy fields GunFireCount = 0;