QoE Update

## Added

- **`CurseSkillPerPlayer`** (config.json) — limits how many curse skills (Darkness, JumpBan, PrimaryBan, Bankrupt and 14 others) can be stacked on a single player per round. `null` = unlimited (default), `1` = one curse per player, `2` = two. When the limit is reached the target is rejected and the curser gets a chat warning. Enforced centrally in `SkillAction`, so no per-skill code is needed.
- **Tripwire cooldown mode** — replaces the fixed wire limit with a Replicator-style cooldown. Players can place as many wires as they want as long as the cooldown is up; remaining time is shown on the HUD. Configurable via `Cooldown` in skillsInfo.json (default 20s).
- **Tripwire team colors** — T wires red (255, 64, 64), CT wires blue (64, 128, 255).

## Fixed

- **ExpensiveAmmo** also charged money for knife swings and grenade throws. It now only charges when a bullet-firing weapon is fired.
- **Knockback** also triggered on grenade throws; bound to the same allowlist.

## Removed

- Dead code: `Earthquake.cs` and `HealingChicken.cs` (neither was registered in the `Skills` enum, so neither ever ran), `EntityManager.CreateTrackedEnvShake`, `EntityManager.CreateTrackedChicken`, `PlayerManager.UpdatePlayerSkill`.

## Changed

- **Tripwire wires are now removed when the owner dies or loses the skill.** Previously they stayed on the map until the end of the round.
- `PlayerEvents.cs` split into three partial files: player events in `PlayerEvents.cs`, round and skill dispatch in `RoundEvents.cs`, entity and weapon hooks in `EntityEvents.cs`. Behaviour is unchanged; all members stay in the same `Event` class.
- 28 copy-pasted enemy-gathering LINQ chains reduced to the shared helper (Bankrupt, CarefulBullets, Darkness, Deactivator, Deaf, ExpensiveAmmo, Giant, Glitch, Jammer, JumpBan, JumpCurse, LifeSwap, Magnifier, MoneySwap, Nightmare, Poison, PrimaryBan, WildThrow).
This commit is contained in:
ByDexter 2026-07-30 20:52:07 +03:00
parent 98f6e65b01
commit b0b50a224f
50 changed files with 1215 additions and 1279 deletions

View file

@ -90,6 +90,7 @@ namespace src.utils
public bool TraceRayBeam { get; set; }
public string DisableHUDOnDeathPermission { get; set; }
public bool DisableSkillsOnRoundEnd { get; set; }
public int? CurseSkillPerPlayer { get; set; }
public LanguageSystem LanguageSystem { get; set; }
public HtmlHudCustomisation HtmlHudCustomisation { get; set; }
public ChatMessage ChatMessage { get; set; }
@ -119,6 +120,7 @@ namespace src.utils
DisableSpectateHUD = false;
DisableHUDOnDeathPermission = "@jRandomSkills/death";
DisableSkillsOnRoundEnd = false;
CurseSkillPerPlayer = null;
LanguageSystem = new LanguageSystem
{

View file

@ -123,44 +123,6 @@ namespace src.utils
return CreateTrackedDynamicProp(playerIndex, "prop_dynamic_override");
}
public static CEnvShake? CreateTrackedEnvShake(uint playerIndex)
{
try
{
if (OverBudget()) return null;
var shake = Utilities.CreateEntityByName<CEnvShake>("env_shake");
if (shake == null || !shake.IsValid) return null;
shake.DispatchSpawn();
RegisterEntity(shake.Index, playerIndex, "env_shake");
return shake;
}
catch (Exception ex)
{
Server.PrintToConsole($"[EntityManager] CreateTrackedEnvShake: {ex.Message}");
return null;
}
}
public static CChicken? CreateTrackedChicken(uint playerIndex)
{
try
{
if (OverBudget()) return null;
var chicken = Utilities.CreateEntityByName<CChicken>("chicken");
if (chicken == null || !chicken.IsValid) return null;
chicken.DispatchSpawn();
RegisterEntity(chicken.Index, playerIndex, "chicken");
return chicken;
}
catch (Exception ex)
{
Server.PrintToConsole($"[EntityManager] CreateTrackedChicken: {ex.Message}");
return null;
}
}
public static CPhysicsPropMultiplayer? CreateTrackedPhysicsProp(uint playerIndex)
{
try

View file

@ -103,17 +103,6 @@ namespace src.utils
return playersByIndex.Values.Count(p => p.Skill == skills);
}
public static bool UpdatePlayerSkill(uint playerIndex, Skills skill, Skills specialSkill = Skills.None)
{
if (playersByIndex.TryGetValue(playerIndex, out var playerInfo))
{
playerInfo.Skill = skill;
playerInfo.SpecialSkill = specialSkill;
return true;
}
return false;
}
public static void Clear()
{
playersByIndex.Clear();

View file

@ -346,6 +346,105 @@ namespace src.utils
pendingKillCredits.Clear();
}
private static readonly HashSet<string> bulletWeapons = new(StringComparer.Ordinal)
{
"deagle", "revolver", "glock", "usp_silencer", "cz75a",
"fiveseven", "p250", "tec9", "elite", "hkp2000",
"mp9", "mac10", "bizon", "mp7", "ump45", "p90", "mp5sd",
"famas", "galilar", "m4a1", "m4a1_silencer", "ak47", "aug", "sg553",
"ssg08", "awp", "scar20", "g3sg1",
"nova", "xm1014", "mag7", "sawedoff",
"m249", "negev"
};
public static bool FiresBullets(string? weapon)
{
if (string.IsNullOrEmpty(weapon)) return false;
if (weapon.StartsWith("weapon_", StringComparison.Ordinal))
weapon = weapon["weapon_".Length..];
return bulletWeapons.Contains(weapon);
}
private static readonly HashSet<Skills> curseSkills =
[
Skills.Bankrupt, Skills.CarefulBullets, Skills.Darkness, Skills.Deactivator,
Skills.Deaf, Skills.ExpensiveAmmo, Skills.Giant, Skills.Glitch,
Skills.Jammer, Skills.JumpBan, Skills.JumpCurse, Skills.LifeSwap,
Skills.Magnifier, Skills.MoneySwap, Skills.Nightmare, Skills.Poison,
Skills.PrimaryBan, Skills.WildThrow
];
private static readonly Dictionary<uint, int> curseCounts = [];
private static readonly Dictionary<uint, uint> curserToVictim = [];
private static readonly object curseLock = new();
public static bool IsCurseSkill(Skills skill) => curseSkills.Contains(skill);
public static void ClearCurses()
{
lock (curseLock)
{
curseCounts.Clear();
curserToVictim.Clear();
}
}
public static bool CanCurse(uint victimIndex)
{
int? limit = Config.LoadedConfig.CurseSkillPerPlayer;
if (limit == null || limit <= 0) return true;
lock (curseLock)
return !curseCounts.TryGetValue(victimIndex, out int used) || used < limit;
}
public static bool TryClaimCurse(uint curserIndex, uint victimIndex)
{
int? limit = Config.LoadedConfig.CurseSkillPerPlayer;
lock (curseLock)
{
ReleaseCurseLocked(curserIndex);
curseCounts.TryGetValue(victimIndex, out int used);
if (limit != null && limit > 0 && used >= limit) return false;
curseCounts[victimIndex] = used + 1;
curserToVictim[curserIndex] = victimIndex;
return true;
}
}
public static void ReleaseCurse(uint curserIndex)
{
lock (curseLock) ReleaseCurseLocked(curserIndex);
}
private static void ReleaseCurseLocked(uint curserIndex)
{
if (!curserToVictim.Remove(curserIndex, out uint victimIndex)) return;
if (!curseCounts.TryGetValue(victimIndex, out int used)) return;
if (used <= 1) curseCounts.Remove(victimIndex);
else curseCounts[victimIndex] = used - 1;
}
public static CCSPlayerController[] GetSelectableEnemies(CCSPlayerController player, bool respectCurseLimit = false)
{
if (player == null || !player.IsValid) return [];
return [.. PlayerManager.GetTickPlayers()
.Where(p => p != null && p.IsValid)
.Select(PlayerManager.GetPlayerEvent)
.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
&& (!respectCurseLimit || CanCurse(p.Index)))
.Cast<CCSPlayerController>()];
}
public static bool TakeHealth(CCSPlayerPawn? pawn, int damage, CCSPlayerController? damageAttacker = null, string? damageWeapon = null)
{
if (pawn == null || !pawn.IsValid || pawn.LifeState != (byte)LifeState_t.LIFE_ALIVE)