QoE Update — 2

## Added

- **`Spectator` use cooldown** (`UseCooldown` in skillsInfo.json, default 0.5s) — every press destroyed and recreated the camera prop, with no rate limit.
- **`SkillUtils.ClearCursesFor(playerIndex)`** — clears a leaving player's curse bookkeeping from both sides (as curser and as victim).
- **`SkillUtils.AnyCurseCapacity(player)`** — reports whether a player still has  any enemy left that is under the curse limit.
- **`Spectator.PlayerDisconnect`** handler.
- **`Thief`** added to the curse skill set.

## Changed

- **`CurseSkillPerPlayer` is ignored in GameMode 1 (TeamSkills), 2 (SameSkills) and 5 (Debug).**
- **Curse target menus no longer go empty.** When every living enemy is already at the limit, the menu falls back to the unfiltered list and the claim is forced through.
- `C4Camouflage` `MaxPerServer` -1 -> 1
This commit is contained in:
ByDexter 2026-07-31 01:38:50 +03:00
parent b0b50a224f
commit 7292ab1787
9 changed files with 101 additions and 35 deletions

View file

@ -104,6 +104,9 @@ namespace src
if (SkillUtils.TryClaimCurse(curser.Index, victimIndex)) return true; if (SkillUtils.TryClaimCurse(curser.Index, victimIndex)) return true;
if (!SkillUtils.AnyCurseCapacity(curser))
return SkillUtils.TryClaimCurse(curser.Index, victimIndex, true);
var curserEvent = PlayerManager.GetPlayerFromEvent(curser); var curserEvent = PlayerManager.GetPlayerFromEvent(curser);
curserEvent?.PrintToChat($" {ChatColors.Red}{curserEvent.GetTranslation("curse_limit_info", victim.PlayerName)}"); curserEvent?.PrintToChat($" {ChatColors.Red}{curserEvent.GetTranslation("curse_limit_info", victim.PlayerName)}");
return false; return false;
@ -122,7 +125,7 @@ namespace src
SkillsUsedThisMap.TryAdd(skill, 0); SkillsUsedThisMap.TryAdd(skill, 0);
} }
if (Enum.TryParse<Skills>(skill, out var parsedSkill) && SkillUtils.IsCurseSkill(parsedSkill)) if (SkillUtils.CurseLimitEnabled && SkillUtils.IsCurseSkill(skill))
{ {
if (methodName == "DisableSkill" && param?.Length > 0 && param[0] is CCSPlayerController curser && curser.IsValid) if (methodName == "DisableSkill" && param?.Length > 0 && param[0] is CCSPlayerController curser && curser.IsValid)
SkillUtils.ReleaseCurse(curser.Index); SkillUtils.ReleaseCurse(curser.Index);

View file

@ -478,6 +478,8 @@ namespace src.player
foreach (var skill in SkillData.Skills) foreach (var skill in SkillData.Skills)
Instance.SkillAction(skill.Skill.ToString(), "PlayerDisconnect", [leavingIndex]); Instance.SkillAction(skill.Skill.ToString(), "PlayerDisconnect", [leavingIndex]);
SkillUtils.ClearCursesFor(leavingIndex);
PlayerManager.UnregisterPlayer(player.Index); PlayerManager.UnregisterPlayer(player.Index);
EntityManager.DestroyPlayerEntities(player.Index); EntityManager.DestroyPlayerEntities(player.Index);

View file

@ -13,6 +13,7 @@ namespace src.player.skills
private const Skills skillName = Skills.Spectator; private const Skills skillName = Skills.Spectator;
private const string cameraViewModel = "models/sprays/spray_plane.vmdl"; private const string cameraViewModel = "models/sprays/spray_plane.vmdl";
private static readonly ConcurrentDictionary<uint, (uint, uint, uint)> cameras = []; private static readonly ConcurrentDictionary<uint, (uint, uint, uint)> cameras = [];
private static readonly ConcurrentDictionary<uint, DateTime> lastUse = [];
public static void LoadSkill() public static void LoadSkill()
{ {
@ -26,6 +27,15 @@ namespace src.player.skills
EntityManager.DestroyEntity(info.Value.Item2); EntityManager.DestroyEntity(info.Value.Item2);
cameras.Clear(); cameras.Clear();
lastUse.Clear();
}
public static void PlayerDisconnect(uint playerIndex)
{
if (cameras.TryRemove(playerIndex, out var cameraInfo) && cameraInfo.Item2 != 0)
EntityManager.DestroyEntity(cameraInfo.Item2);
lastUse.TryRemove(playerIndex, out _);
} }
public static void WeaponPickup(EventItemPickup @event) public static void WeaponPickup(EventItemPickup @event)
@ -45,6 +55,11 @@ namespace src.player.skills
{ {
var playerPawn = player.PlayerPawn.Value; var playerPawn = player.PlayerPawn.Value;
if (playerPawn?.CBodyComponent == null) return; if (playerPawn?.CBodyComponent == null) return;
float cooldown = SkillsInfo.GetValue<float>(skillName, "useCooldown");
if (lastUse.TryGetValue(player.Index, out var last) && (DateTime.Now - last).TotalSeconds < cooldown) return;
lastUse[player.Index] = DateTime.Now;
ChangeCamera(player); ChangeCamera(player);
} }
@ -54,6 +69,7 @@ namespace src.player.skills
ChangeCamera(player, true); ChangeCamera(player, true);
EntityManager.DestroyPlayerEntities(player.Index); EntityManager.DestroyPlayerEntities(player.Index);
cameras.TryRemove(player.Index, out _); cameras.TryRemove(player.Index, out _);
lastUse.TryRemove(player.Index, out _);
} }
public static void OnTick() public static void OnTick()
@ -141,12 +157,19 @@ namespace src.player.skills
p.PlayerPawn.Value.Health > 0).ToList(); p.PlayerPawn.Value.Health > 0).ToList();
if (enemies.Count == 0) if (enemies.Count == 0)
{
EntityManager.DestroyEntity(camera.Index);
return 0; return 0;
}
var enemy = enemies[Instance.Random.Next(enemies.Count)]; var enemy = enemies[Instance.Random.Next(enemies.Count)];
var pawn = enemy.PlayerPawn.Value; var pawn = enemy.PlayerPawn.Value;
if (pawn == null || !pawn.IsValid || pawn.CameraServices == null || pawn.AbsOrigin == null) return 0; if (pawn == null || !pawn.IsValid || pawn.CameraServices == null || pawn.AbsOrigin == null)
{
EntityManager.DestroyEntity(camera.Index);
return 0;
}
QAngle angle = new(0, pawn.EyeAngles.Y, 0); QAngle angle = new(0, pawn.EyeAngles.Y, 0);
@ -204,9 +227,10 @@ namespace src.player.skills
} }
} }
public class SkillConfig(Skills skill = skillName, bool active = true, string color = "#42f5da", CsTeam onlyTeam = CsTeam.None, bool disableOnFreezeTime = false, bool needsTeammates = false, string requiredPermission = "", float? hudDuration = null, float? descriptionHudDuration = null, int maxPerServer = -1, Rarity rarity = Rarity.Common, float distance = 100f) : SkillsInfo.DefaultSkillInfo(skill, active, color, onlyTeam, disableOnFreezeTime, needsTeammates, requiredPermission, hudDuration, descriptionHudDuration, maxPerServer, rarity) public class SkillConfig(Skills skill = skillName, bool active = true, string color = "#42f5da", CsTeam onlyTeam = CsTeam.None, bool disableOnFreezeTime = false, bool needsTeammates = false, string requiredPermission = "", float? hudDuration = null, float? descriptionHudDuration = null, int maxPerServer = -1, Rarity rarity = Rarity.Common, float distance = 100f, float useCooldown = .5f) : SkillsInfo.DefaultSkillInfo(skill, active, color, onlyTeam, disableOnFreezeTime, needsTeammates, requiredPermission, hudDuration, descriptionHudDuration, maxPerServer, rarity)
{ {
public float Distance { get; set; } = distance; public float Distance { get; set; } = distance;
public float UseCooldown { get; set; } = useCooldown;
} }
} }
} }

View file

@ -25,21 +25,7 @@ namespace src.player.skills
var playerInfo = PlayerManager.GetPlayerByIndex(player!.Index); var playerInfo = PlayerManager.GetPlayerByIndex(player!.Index);
if (playerInfo?.Skill != skillName) continue; if (playerInfo?.Skill != skillName) continue;
var enemies = PlayerManager.GetTickPlayers().Where(p => var enemies = SkillUtils.GetSelectableEnemies(player, true);
p != null &&
p.IsValid)
.Select(p => PlayerManager.GetPlayerEvent(p))
.Where(p =>
p != null &&
p.IsValid &&
p.Team != player.Team &&
p.PlayerPawn?.Value != null &&
p.PlayerPawn.Value.IsValid &&
p.PlayerPawn.Value.Health > 0 &&
!p.IsHLTV &&
p.Team != CsTeam.Spectator
&& p.Team != CsTeam.None
).ToArray();
ConcurrentBag<(string, string)> menuItems = []; ConcurrentBag<(string, string)> menuItems = [];
foreach (var enemy in enemies) foreach (var enemy in enemies)
@ -106,14 +92,7 @@ namespace src.player.skills
var playerEvent = PlayerManager.GetPlayerFromEvent(player); var playerEvent = PlayerManager.GetPlayerFromEvent(player);
if (playerEvent == null || !playerEvent.IsValid) return; if (playerEvent == null || !playerEvent.IsValid) return;
var enemies = PlayerManager.GetTickPlayers() var enemies = SkillUtils.GetSelectableEnemies(player, true);
.Where(p => p != null
&& p.IsValid
&& p.Team != player.Team
&& p.PlayerPawn?.Value?.Health > 0
&& p.Team != CsTeam.Spectator
&& p.Team != CsTeam.None)
.ToArray();
if (enemies.Length > 0) if (enemies.Length > 0)
{ {

View file

@ -21,6 +21,8 @@ namespace src.player.skills
private static readonly Color terroristWire = Color.FromArgb(255, 255, 64, 64); private static readonly Color terroristWire = Color.FromArgb(255, 255, 64, 64);
private static readonly Color counterTerroristWire = Color.FromArgb(255, 64, 128, 255); private static readonly Color counterTerroristWire = Color.FromArgb(255, 64, 128, 255);
private const double attemptCooldown = .5;
public static void LoadSkill() public static void LoadSkill()
{ {
SkillUtils.RegisterSkill(skillName, SkillsInfo.GetValue<string>(skillName, "color")); SkillUtils.RegisterSkill(skillName, SkillsInfo.GetValue<string>(skillName, "color"));
@ -125,6 +127,9 @@ namespace src.player.skills
if (!SkillPlayerInfo.TryGetValue(player.Index, out var skillInfo) || !skillInfo.CanUse) return; if (!SkillPlayerInfo.TryGetValue(player.Index, out var skillInfo) || !skillInfo.CanUse) return;
if ((DateTime.Now - skillInfo.LastAttempt).TotalSeconds < attemptCooldown) return;
skillInfo.LastAttempt = DateTime.Now;
if (!TryPlaceWire(player)) if (!TryPlaceWire(player))
{ {
playerEvent.PrintToChat($" {ChatColors.Red}" + playerEvent.GetTranslation("tripwire_no_wall_info")); playerEvent.PrintToChat($" {ChatColors.Red}" + playerEvent.GetTranslation("tripwire_no_wall_info"));
@ -306,6 +311,7 @@ namespace src.player.skills
{ {
public bool CanUse { get; set; } public bool CanUse { get; set; }
public DateTime Cooldown { get; set; } public DateTime Cooldown { get; set; }
public DateTime LastAttempt { get; set; }
} }
public class SkillConfig(Skills skill = skillName, bool active = true, string color = "#ff3b3b", CsTeam onlyTeam = CsTeam.None, bool disableOnFreezeTime = false, bool needsTeammates = false, string requiredPermission = "", float? hudDuration = null, float? descriptionHudDuration = null, int maxPerServer = -1, Rarity rarity = Rarity.Rare, float radarDuration = 5f, float triggerRadius = 24f, float wireHeight = 30f, float wireWidth = 1.5f, float maxWallDistance = 400f, float cooldown = 20f) : SkillsInfo.DefaultSkillInfo(skill, active, color, onlyTeam, disableOnFreezeTime, needsTeammates, requiredPermission, hudDuration, descriptionHudDuration, maxPerServer, rarity) public class SkillConfig(Skills skill = skillName, bool active = true, string color = "#ff3b3b", CsTeam onlyTeam = CsTeam.None, bool disableOnFreezeTime = false, bool needsTeammates = false, string requiredPermission = "", float? hudDuration = null, float? descriptionHudDuration = null, int maxPerServer = -1, Rarity rarity = Rarity.Rare, float radarDuration = 5f, float triggerRadius = 24f, float wireHeight = 30f, float wireWidth = 1.5f, float maxWallDistance = 400f, float cooldown = 20f) : SkillsInfo.DefaultSkillInfo(skill, active, color, onlyTeam, disableOnFreezeTime, needsTeammates, requiredPermission, hudDuration, descriptionHudDuration, maxPerServer, rarity)

View file

@ -373,17 +373,35 @@ namespace src.utils
Skills.Deaf, Skills.ExpensiveAmmo, Skills.Giant, Skills.Glitch, Skills.Deaf, Skills.ExpensiveAmmo, Skills.Giant, Skills.Glitch,
Skills.Jammer, Skills.JumpBan, Skills.JumpCurse, Skills.LifeSwap, Skills.Jammer, Skills.JumpBan, Skills.JumpCurse, Skills.LifeSwap,
Skills.Magnifier, Skills.MoneySwap, Skills.Nightmare, Skills.Poison, Skills.Magnifier, Skills.MoneySwap, Skills.Nightmare, Skills.Poison,
Skills.PrimaryBan, Skills.WildThrow Skills.PrimaryBan, Skills.Thief, Skills.WildThrow
]; ];
private static readonly HashSet<string> curseSkillNames = new(curseSkills.Select(s => s.ToString()), StringComparer.Ordinal);
private static readonly Dictionary<uint, int> curseCounts = []; private static readonly Dictionary<uint, int> curseCounts = [];
private static readonly Dictionary<uint, uint> curserToVictim = []; private static readonly Dictionary<uint, uint> curserToVictim = [];
private static readonly object curseLock = new(); private static readonly object curseLock = new();
private static readonly Config.GameModes[] sharedSkillModes =
[Config.GameModes.TeamSkills, Config.GameModes.SameSkills, Config.GameModes.Debug];
public static bool CurseLimitEnabled
{
get
{
if (Config.LoadedConfig.CurseSkillPerPlayer is not int limit || limit <= 0) return false;
return Array.IndexOf(sharedSkillModes, (Config.GameModes)Config.LoadedConfig.GameMode) < 0;
}
}
public static bool IsCurseSkill(Skills skill) => curseSkills.Contains(skill); public static bool IsCurseSkill(Skills skill) => curseSkills.Contains(skill);
public static bool IsCurseSkill(string skill) => curseSkillNames.Contains(skill);
public static void ClearCurses() public static void ClearCurses()
{ {
if (!CurseLimitEnabled) return;
lock (curseLock) lock (curseLock)
{ {
curseCounts.Clear(); curseCounts.Clear();
@ -393,23 +411,24 @@ namespace src.utils
public static bool CanCurse(uint victimIndex) public static bool CanCurse(uint victimIndex)
{ {
int? limit = Config.LoadedConfig.CurseSkillPerPlayer; if (!CurseLimitEnabled) return true;
if (limit == null || limit <= 0) return true; int limit = Config.LoadedConfig.CurseSkillPerPlayer!.Value;
lock (curseLock) lock (curseLock)
return !curseCounts.TryGetValue(victimIndex, out int used) || used < limit; return !curseCounts.TryGetValue(victimIndex, out int used) || used < limit;
} }
public static bool TryClaimCurse(uint curserIndex, uint victimIndex) public static bool TryClaimCurse(uint curserIndex, uint victimIndex, bool force = false)
{ {
int? limit = Config.LoadedConfig.CurseSkillPerPlayer; if (!CurseLimitEnabled) return true;
int limit = Config.LoadedConfig.CurseSkillPerPlayer!.Value;
lock (curseLock) lock (curseLock)
{ {
ReleaseCurseLocked(curserIndex); ReleaseCurseLocked(curserIndex);
curseCounts.TryGetValue(victimIndex, out int used); curseCounts.TryGetValue(victimIndex, out int used);
if (limit != null && limit > 0 && used >= limit) return false; if (!force && used >= limit) return false;
curseCounts[victimIndex] = used + 1; curseCounts[victimIndex] = used + 1;
curserToVictim[curserIndex] = victimIndex; curserToVictim[curserIndex] = victimIndex;
@ -419,9 +438,25 @@ namespace src.utils
public static void ReleaseCurse(uint curserIndex) public static void ReleaseCurse(uint curserIndex)
{ {
if (!CurseLimitEnabled) return;
lock (curseLock) ReleaseCurseLocked(curserIndex); lock (curseLock) ReleaseCurseLocked(curserIndex);
} }
public static void ClearCursesFor(uint playerIndex)
{
if (!CurseLimitEnabled) return;
lock (curseLock)
{
ReleaseCurseLocked(playerIndex);
curseCounts.Remove(playerIndex);
foreach (var curser in curserToVictim.Where(kvp => kvp.Value == playerIndex).Select(kvp => kvp.Key).ToList())
curserToVictim.Remove(curser);
}
}
private static void ReleaseCurseLocked(uint curserIndex) private static void ReleaseCurseLocked(uint curserIndex)
{ {
if (!curserToVictim.Remove(curserIndex, out uint victimIndex)) return; if (!curserToVictim.Remove(curserIndex, out uint victimIndex)) return;
@ -435,13 +470,29 @@ namespace src.utils
{ {
if (player == null || !player.IsValid) return []; if (player == null || !player.IsValid) return [];
var enemies = GetAliveEnemies(player);
if (!respectCurseLimit || !CurseLimitEnabled || enemies.Length == 0) return enemies;
var withCapacity = enemies.Where(p => CanCurse(p.Index)).ToArray();
return withCapacity.Length > 0 ? withCapacity : enemies;
}
public static bool AnyCurseCapacity(CCSPlayerController player)
{
if (!CurseLimitEnabled) return true;
if (player == null || !player.IsValid) return true;
return GetAliveEnemies(player).Any(p => CanCurse(p.Index));
}
private static CCSPlayerController[] GetAliveEnemies(CCSPlayerController player)
{
return [.. PlayerManager.GetTickPlayers() return [.. PlayerManager.GetTickPlayers()
.Where(p => p != null && p.IsValid) .Where(p => p != null && p.IsValid)
.Select(PlayerManager.GetPlayerEvent) .Select(PlayerManager.GetPlayerEvent)
.Where(p => p != null && p.IsValid && p.Team != player.Team .Where(p => p != null && p.IsValid && p.Team != player.Team
&& p.PlayerPawn?.Value != null && p.PlayerPawn.Value.IsValid && p.PlayerPawn.Value.Health > 0 && p.PlayerPawn?.Value != null && p.PlayerPawn.Value.IsValid && p.PlayerPawn.Value.Health > 0
&& !p.IsHLTV && p.Team != CsTeam.Spectator && p.Team != CsTeam.None && !p.IsHLTV && p.Team != CsTeam.Spectator && p.Team != CsTeam.None)
&& (!respectCurseLimit || CanCurse(p.Index)))
.Cast<CCSPlayerController>()]; .Cast<CCSPlayerController>()];
} }

View file

@ -206,6 +206,7 @@
}, },
{ {
"Distance": 100.0, "Distance": 100.0,
"UseCooldown": 0.5,
"NeedsTeammates": false, "NeedsTeammates": false,
"DisableOnFreezeTime": false, "DisableOnFreezeTime": false,
"OnlyTeam": 0, "OnlyTeam": 0,
@ -1708,7 +1709,7 @@
"HudDuration": null, "HudDuration": null,
"DescriptionHudDuration": null, "DescriptionHudDuration": null,
"RequiredPermission": "", "RequiredPermission": "",
"MaxPerServer": -1, "MaxPerServer": 1,
"Rarity": "Uncommon" "Rarity": "Uncommon"
}, },
{ {