diff --git a/src/Matchmaking/Modules/TeamVersusStats.cs b/src/Matchmaking/Modules/TeamVersusStats.cs index b64da1a0..e302efc2 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; @@ -983,6 +990,15 @@ 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); + } // Notify listeners of the updated kills/deaths stats (e.g., for Simple statbox display). Arena? matchArena = matchData.Arena; if (matchArena is not null) @@ -1015,6 +1031,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(); @@ -1696,7 +1724,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); @@ -2261,8 +2289,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) @@ -2307,7 +2333,29 @@ private void Callback_PlayerDamage(Player player, ServerTick timestamp, ReadOnly attackerPlayer.Freq, attackerStats.SlotStats!.Slot!.SlotIdx), }; - playerStats.RecentDamageTaken.AddLast(node); + 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; + playerStats.FreezeHolder = attackerStats; + } + } } // @@ -2520,7 +2568,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) @@ -2536,7 +2592,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 = @@ -2780,7 +2837,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; @@ -2796,87 +2853,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 = energyWindowOverride ?? 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; - 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 += 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 += (int)(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 int existingDamage)) + damageDictionary[damageInfo.Attacker] = existingDamage + damage; else - { damageDictionary.Add(damageInfo.Attacker, damage); - } } - node = previous; + node = node.Next; } } finally @@ -2885,6 +2910,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) @@ -2971,7 +2999,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; @@ -3063,7 +3091,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}" + @@ -3091,7 +3119,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}" + $" " + @@ -3180,13 +3208,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 @@ -3810,14 +3831,37 @@ private class MemberStats : IMemberStats /// public int ForcedRepDamage; + private readonly LinkedList _recentDamageTaken = new(); + /// - /// Recent damage taken in order from oldest to newest. + /// 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; + + /// + /// 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); + } + + /// + /// 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 @@ -3948,8 +3992,10 @@ private class MemberStats : IMemberStats /// /// 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. @@ -4013,22 +4059,68 @@ 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) { - // 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); + 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.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; + } - // Remove nodes that are too old to be relevant. - ServerTick cutoff = ServerTick.Now - fullEnergyTicks; - LinkedListNode? node = RecentDamageTaken.First; + node = _recentDamageTaken.First; while (node is not null) { - if (node.ValueRef.Timestamp + node.ValueRef.EmpBombShutdownTicks < cutoff) + uint recoveryTicksNeeded = (uint)float.Ceiling(node.ValueRef.Damage * 1000f / rechargeRate); + ServerTick damageTimestamp = node.ValueRef.Timestamp; + + // 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) + { + 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. LinkedListNode? next = node.Next; - RecentDamageTaken.Remove(node); + _recentDamageTaken.Remove(node); s_damageInfoLinkedListNodePool.Return(node); node = next; } @@ -4043,9 +4135,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); } } @@ -4069,6 +4161,8 @@ public void Reset() KillDamage = 0; TeamKillDamage = 0; ClearRecentDamage(); + FreezeEndTick = default; + FreezeHolder = null; // accuracy fields GunFireCount = 0;