Fix leaked skill state, second chance revives, and unrestored server cvars

General:
- Jester's no-damage state no longer applies to players who do not hold the
  skill. The shared health helper checked Jester's internal table without
  verifying the current assignment, so a single stale entry silently cancelled
  damage across every skill that deals damage through it.
- Jester's per-round reset no longer aborts when the holder is dead, which
  previously left the table populated for the following rounds.
- The per-round reset now covers every skill used on the current map instead of
  only the ones held in the round that just ended, so a skill nobody drew still
  clears state left over from an earlier round.
- Skills that track another player as a target are now notified when that player
  disconnects. Controller indexes are reused, so a leftover entry applied to
  whoever connected into that index next. Affects Poison, Deaf, Darkness,
  Glitch, Magnifier, Legless, No Rifles, Jammer and Jester.
- Aimbot no longer retains raw native pointers after a hit is handled. They were
  never cleared and were written back to later, which could corrupt memory the
  engine had already freed.
- An exception inside a skill's OnTick no longer cancels the tick of every skill
  after it in the same frame.
- Cooldowns configured low enough to truncate to zero no longer throw (Poison,
  Regeneration, Hot Bomb, Healing Smoke, Toxic Smoke).
- Round changes are cheaper: Long Knife no longer detaches a detour it never
  attached, and Wild Throw skips its player sweep when it has nothing to unwind.

Skill improvements:
- Second Chance: now survives several lethal hits landing in the same tick
  (shotgun, two attackers) and restores health inside the damage hook instead of
  a frame later. It also runs after skills that modify damage, so One-Shot and
  Soldier are seen at their final value instead of the raw one.
- Spectator: returning to your own view no longer leaves the camera handle set,
  which made the tick handler reset the weapon cooldown every frame and play as
  rapid fire. The manual toggle is unchanged.
- Radar Hack: players hidden behind a replacement model (Chicken) are shown on
  the radar again. Only Ghost, Ninja and C4 Camouflage hide the pawn itself, so
  render alpha alone no longer decides.
- Focus: weapon_accuracy_nospread is reference counted, so one holder losing the
  skill no longer removes the effect from the others still holding it.
- Friendly Fire: mp_autokick is restored at the end of the round instead of
  staying disabled for the rest of the map, and is no longer executed on every
  friendly hit.
- Reactive Armor / Iana: no longer restore health for a player who no longer
  holds the skill.
- Free Planter / Short Fuse: each restores mp_c4timer only when it actually
  changed it, so overlapping rounds cannot write back the wrong value.
- Illusionist: the replica movement timer now stops on map change.
- Ghost / Ninja / C4 Camouflage: the bomb is located once per transmit instead of
  once per receiver and hidden player, and the pass is skipped when nobody is
  hidden.
This commit is contained in:
ByDexter 2026-07-21 19:44:13 +03:00
parent 92175f6949
commit 21e702654d
34 changed files with 316 additions and 102 deletions

View file

@ -138,24 +138,56 @@ namespace src.player
return candidates[Random.Shared.Next(candidates.Count)];
}
private static readonly Skills[] lateDamageSkills = [Skills.SecondLife];
private static readonly HashSet<Skills> tickFailuresLogged = [];
private static void InvokeSkill(Skills skill, string methodName, object[] args)
{
try
{
Instance.SkillAction(skill.ToString(), methodName, args);
}
catch (Exception ex)
{
Server.PrintToConsole($"[jRandomSkills] {skill}.{methodName} failed: {ex.InnerException?.Message ?? ex.Message}");
}
}
private static void DispatchToActiveSkills(string methodName, params object[] args)
{
var seen = new HashSet<Skills>();
foreach (var p in Instance.SkillPlayer)
{
if (p.IsDrawing || !seen.Add(p.Skill)) continue;
try
{
Instance.SkillAction(p.Skill.ToString(), methodName, args);
}
catch (Exception ex)
{
// One skill's failure must not break the dispatch chain.
Server.PrintToConsole($"[jRandomSkills] {p.Skill}.{methodName} failed: {ex.InnerException?.Message ?? ex.Message}");
}
InvokeSkill(p.Skill, methodName, args);
}
}
private static void DispatchOnTakeDamage(DynamicHook h)
{
object[] args = [h];
var seen = new HashSet<Skills>();
List<Skills>? deferred = null;
foreach (var p in Instance.SkillPlayer)
{
if (p.IsDrawing || !seen.Add(p.Skill)) continue;
if (Array.IndexOf(lateDamageSkills, p.Skill) >= 0)
{
(deferred ??= []).Add(p.Skill);
continue;
}
InvokeSkill(p.Skill, "OnTakeDamage", args);
}
if (deferred == null) return;
foreach (var skill in deferred)
InvokeSkill(skill, "OnTakeDamage", args);
}
private static HookResult PlayerMakeSound(UserMessage um)
{
lock (setLock)
@ -322,7 +354,7 @@ namespace src.player
{
lock (setLock)
{
DispatchToActiveSkills("OnTakeDamage", h);
DispatchOnTakeDamage(h);
if (Fortnite.skillInThisRound == true &&
!Instance.SkillPlayer.Any(p => !p.IsDrawing && p.Skill == Skills.Fortnite))
@ -468,7 +500,17 @@ namespace src.player
foreach (var skill in _activeSkillsList)
{
if (freeze && _freezeDisabledSkills.Contains(skill)) continue;
Instance.SkillAction(_skillNames[skill], "OnTick");
try
{
Instance.SkillAction(_skillNames[skill], "OnTick");
}
catch (Exception ex)
{
// Without this one throwing skill cancels every later skill's tick, every frame.
// Logged once per skill per round; at 64 ticks a repeat would flood the console.
if (tickFailuresLogged.Add(skill))
Server.PrintToConsole($"[jRandomSkills] {skill}.OnTick failed: {ex.InnerException?.Message ?? ex.Message}");
}
}
}
PerfLog.Sample("OnTick(skills)", perfStart);
@ -541,6 +583,10 @@ namespace src.player
Instance.SkillAction(skillPlayer.Skill.ToString(), "DisableSkill", [player]);
uint leavingIndex = player.Index;
foreach (var skill in SkillData.Skills)
Instance.SkillAction(skill.Skill.ToString(), "PlayerDisconnect", [leavingIndex]);
PlayerManager.UnregisterPlayer(player.Index);
EntityManager.DestroyPlayerEntities(player.Index);
@ -660,8 +706,12 @@ namespace src.player
if (playerInfo == null) continue;
ActiveSkillsThisRound.TryAdd(playerInfo.Skill.ToString(), 0);
SkillsUsedThisMap.TryAdd(playerInfo.Skill.ToString(), 0);
if (playerInfo.SpecialSkill != noneSkill.Skill)
{
ActiveSkillsThisRound.TryAdd(playerInfo.SpecialSkill.ToString(), 0);
SkillsUsedThisMap.TryAdd(playerInfo.SpecialSkill.ToString(), 0);
}
Instance.SkillAction(playerInfo.Skill.ToString(), "DisableSkill", [player]);
@ -674,9 +724,13 @@ namespace src.player
RestorePlayer(player);
}
foreach (var skillName in ActiveSkillsThisRound.Keys)
// Reset every skill used so far on this map, not only the ones held this round: a skill
// nobody drew now would otherwise never clear state left over from an earlier round.
// Skills that never ran cannot hold state, so they stay out of the sweep.
foreach (var skillName in SkillsUsedThisMap.Keys)
Instance.SkillAction(skillName, "NewRound");
ActiveSkillsThisRound.Clear();
tickFailuresLogged.Clear();
}
}
@ -712,6 +766,7 @@ namespace src.player
EntityManager.SuppressKills = false;
ActiveSkillsThisRound.Clear();
SkillsUsedThisMap.Clear();
nextRoundPicks.Clear();
playersSkills.Clear();