From 9306b9372ce45eb1829a841b88cb29d1cd674267 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 02:29:03 +0000 Subject: [PATCH] Award Collateral only for one bullet from one gun, not a shotgun shell [patch] Collateral fired on any second kill by the same attacker in one server tick: one shotgun shell killing two enemies, or a molotov finishing one enemy as a rifle killed another. RoundTracker now counts kills on a tick per weapon, and Collateral takes the GunNotShotgun filter that Point blank and One tap already use. Fixes #94 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5PUccwNk8Pndsg92E --- .../SkillShots/SkillShotCatalog.cs | 2 +- .../Tracking/RoundTracker.cs | 15 +++++++++++---- CounterStrikeProgression.Test/SkillShotTests.cs | 2 ++ CounterStrikeProgression.Test/TrackerTests.cs | 11 +++++++++++ CounterStrikeProgression/CombatHandlers.cs | 7 ++++--- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/CounterStrikeProgression.Core/SkillShots/SkillShotCatalog.cs b/CounterStrikeProgression.Core/SkillShots/SkillShotCatalog.cs index 5b24d1c..3c7c5af 100644 --- a/CounterStrikeProgression.Core/SkillShots/SkillShotCatalog.cs +++ b/CounterStrikeProgression.Core/SkillShots/SkillShotCatalog.cs @@ -97,7 +97,7 @@ internal static class SkillShotCatalog Kill("untouchable", "Untouchable", "Three kills in one life without taking damage", Rarity.Rare, Any, (e, _) => e.KillsUnhurt == 3), Kill("killstreak_10", "10-kill streak", "Ten kills in one life", Rarity.Rare, Any, (e, _) => e.LifeKills == 10), Kill("killstreak_20", "20-kill streak", "Twenty kills in one life", Rarity.Epic, Any, (e, _) => e.LifeKills == 20), - Kill("collateral", "Collateral", "Kill two players with one bullet", Rarity.Epic, Gun, (e, _) => e.KillsWithThisBullet >= 2), + Kill("collateral", "Collateral", "Kill two players with one bullet", Rarity.Epic, GunNotShotgun, (e, _) => e.KillsWithThisBullet >= 2), Kill("opening_kill", "Opening kill", "Get the first kill of the round", Rarity.Common, Any, (e, _) => e.IsOpeningKill), Kill("trade", "Trade", "Kill a teammate's killer right away", Rarity.Common, Any, (e, _) => e.IsTrade), Kill("revenge", "Revenge", "Kill a player who was dominating you", Rarity.Common, Any, (e, _) => e.IsRevenge), diff --git a/CounterStrikeProgression.Core/Tracking/RoundTracker.cs b/CounterStrikeProgression.Core/Tracking/RoundTracker.cs index 66ff7ba..923c9e7 100644 --- a/CounterStrikeProgression.Core/Tracking/RoundTracker.cs +++ b/CounterStrikeProgression.Core/Tracking/RoundTracker.cs @@ -9,7 +9,7 @@ namespace ktsu.CounterStrikeProgression.Core.Tracking; /// /// Whether it's the round's first kill. /// The attacker's kills in the current chain, counting this one. -/// The attacker's kills on this tick, counting this one. +/// The attacker's kills with this weapon on this tick, counting this one. /// Whether the victim had just killed one of the attacker's teammates. internal readonly record struct KillContext(bool IsOpeningKill, int ChainedKills, int KillsWithThisBullet, bool IsTrade); @@ -48,9 +48,13 @@ public void StartRound() /// The victim's id. /// The victim's side. /// The game time in seconds. - /// The server tick, which identifies kills made with one bullet. + /// The server tick, which with the weapon identifies kills made with one bullet. + /// + /// The weapon's key. Kills on one tick count as one bullet only when they're with the same weapon, so a molotov + /// finishing one enemy as a rifle kills another isn't a collateral. + /// /// The round context for the kill. - public KillContext RegisterKill(int attacker, int attackerTeam, int victim, int victimTeam, double time, int tick) + public KillContext RegisterKill(int attacker, int attackerTeam, int victim, int victimTeam, double time, int tick, string? weapon = null) { bool opening = !anyKill; anyKill = true; @@ -63,8 +67,9 @@ public KillContext RegisterKill(int attacker, int attackerTeam, int victim, int chain.Count++; chain.LastKillTime = time; - chain.KillsThisTick = chain.LastTick == tick ? chain.KillsThisTick + 1 : 1; + chain.KillsThisTick = (chain.LastTick == tick) && string.Equals(chain.LastWeapon, weapon, StringComparison.Ordinal) ? chain.KillsThisTick + 1 : 1; chain.LastTick = tick; + chain.LastWeapon = weapon; bool trade = false; if (recentKillsBy.TryGetValue(victim, out List? victimsKills)) @@ -115,6 +120,8 @@ private sealed class KillChain public int LastTick { get; set; } = -1; public int KillsThisTick { get; set; } + + public string? LastWeapon { get; set; } } private sealed record KillRecord(int Victim, int VictimTeam, double Time); diff --git a/CounterStrikeProgression.Test/SkillShotTests.cs b/CounterStrikeProgression.Test/SkillShotTests.cs index 8901c74..0ca341f 100644 --- a/CounterStrikeProgression.Test/SkillShotTests.cs +++ b/CounterStrikeProgression.Test/SkillShotTests.cs @@ -149,6 +149,8 @@ public void SituationalKills() Assert.Contains("triple_kill", Earned(TestHelpers.Kill("ak47") with { ChainedKills = 3 })); Assert.Contains("rampage", Earned(TestHelpers.Kill("ak47") with { ChainedKills = 6 })); Assert.Contains("collateral", Earned(TestHelpers.Kill("awp") with { KillsWithThisBullet = 2 })); + Assert.Contains("collateral", Earned(TestHelpers.Kill("ak47") with { KillsWithThisBullet = 2 })); + Assert.DoesNotContain("collateral", Earned(TestHelpers.Kill("nova") with { KillsWithThisBullet = 2 }), "One shotgun shell is many pellets, not one bullet."); Assert.Contains("opening_kill", Earned(TestHelpers.Kill("ak47") with { IsOpeningKill = true })); Assert.Contains("trade", Earned(TestHelpers.Kill("ak47") with { IsTrade = true })); Assert.Contains("revenge", Earned(TestHelpers.Kill("ak47") with { IsRevenge = true })); diff --git a/CounterStrikeProgression.Test/TrackerTests.cs b/CounterStrikeProgression.Test/TrackerTests.cs index 0affd17..ae377cf 100644 --- a/CounterStrikeProgression.Test/TrackerTests.cs +++ b/CounterStrikeProgression.Test/TrackerTests.cs @@ -49,6 +49,17 @@ public void KillsOnTheSameTickAreACollateral() Assert.AreEqual(1, round.RegisterKill(1, TeamA, 12, TeamB, 1.1, 70).KillsWithThisBullet); } + [TestMethod] + public void KillsOnTheSameTickWithDifferentWeaponsAreNotOneBullet() + { + RoundTracker round = new(new ProgressionSettings()); + round.StartRound(); + + Assert.AreEqual(1, round.RegisterKill(1, TeamA, 10, TeamB, 1.0, 64, "molotov").KillsWithThisBullet); + Assert.AreEqual(1, round.RegisterKill(1, TeamA, 11, TeamB, 1.0, 64, "ak47").KillsWithThisBullet, "A molotov kill and a rifle kill on one tick counted as one bullet."); + Assert.AreEqual(2, round.RegisterKill(1, TeamA, 12, TeamB, 1.0, 64, "ak47").KillsWithThisBullet, "Two rifle kills on one tick are one bullet."); + } + [TestMethod] public void KillingATeammatesKillerQuicklyIsATrade() { diff --git a/CounterStrikeProgression/CombatHandlers.cs b/CounterStrikeProgression/CombatHandlers.cs index df1ae6c..3ed4abc 100644 --- a/CounterStrikeProgression/CombatHandlers.cs +++ b/CounterStrikeProgression/CombatHandlers.cs @@ -256,13 +256,16 @@ private HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) private void CreditKill(EventPlayerDeath @event, CCSPlayerController attacker, CCSPlayerController victim, bool teammatesAreEnemies) { + CCSPlayerPawn? attackerPawn = GameState.PawnOf(attacker); + WeaponInfo? weapon = GameState.ResolveWeapon(@event.Weapon, attackerPawn); KillContext context = services.Round.RegisterKill( attacker.Slot, GameState.SideOf(attacker, teammatesAreEnemies), victim.Slot, GameState.SideOf(victim, teammatesAreEnemies), Server.CurrentTime, - Server.TickCount); + Server.TickCount, + weapon?.Key.WeakString ?? @event.Weapon); CreditAssist(@event, victim); if (!ProgressionServices.IsTeammate(attacker, victim) && (Sessions.Get(attacker) is { Progress: not null } lifeSession)) @@ -270,8 +273,6 @@ private void CreditKill(EventPlayerDeath @event, CCSPlayerController attacker, C lifeSession.Life.Kill(victim.IsBot); } - CCSPlayerPawn? attackerPawn = GameState.PawnOf(attacker); - WeaponInfo? weapon = GameState.ResolveWeapon(@event.Weapon, attackerPawn); if ((weapon is not null) && !ProgressionServices.IsTeammate(attacker, victim) && ((services.KillMaps is not null) || (services.Site is not null)) && (KillMapRecorder.PointOf(Server.MapName ?? string.Empty, attacker, Sessions.Get(attacker)?.SteamId ?? 0, victim, Sessions.Get(victim)?.SteamId ?? 0, weapon, @event.Headshot) is { } point)) {