C#:
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Libraries.Covalence;
using Oxide.Core.Plugins;
using Oxide.Game.Rust.Cui;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("RHG Killstreak Rewards", "RHG", "1.2.0")]
[Description("Awards tiered rewards and temporary micro-buffs at 3/5/8/10/12/15/20 kill milestones.")]
public class RHGKillstreakRewards : CovalencePlugin
{
#region Permissions & Consts
private const string PermAdmin = "rhgkillstreakrewards.admin";
private const string UiName = "RHGKS.UI";
private const string UiBuffName = "RHGKS.BUFF";
private const string UiStreakBreakName = "RHGKS.SBREAK";
private const float UiDefaultSeconds = 4f;
#endregion
#region Data Models
private class RewardItem
{
public string ShortName;
public int Amount;
public ulong Skin;
}
private class BuffSpec
{
public float OutgoingDamageMultiplier = 1.00f;
public float IncomingDamageMultiplier = 1.00f;
public float SpeedMultiplier = 1.00f;
public float HealPerKill = 0f;
public float DurationSeconds = 30f;
public string Label = "";
}
private class ThresholdReward
{
public int Kills;
public List<RewardItem> Items = new List<RewardItem>();
public BuffSpec Buff = new BuffSpec();
public string AnnounceText = "";
public string TierColor = "1 0.5 0 0.85";
}
private class AntiAbuse
{
public bool PreventSameVictimChain = true;
public float MinSecondsSameVictim = 20f;
public bool CountOnlyPlayers = true;
public bool RequireAttackerAlive = true;
}
private class UiConfig
{
public bool Enabled = true;
public float ShowSeconds = UiDefaultSeconds;
public string AnchorMin = "0.35 0.88";
public string AnchorMax = "0.65 0.99";
public float BackgroundAlpha = 0.75f;
public bool BuffBarEnabled = true;
public string BuffBarAnchorMin = "0.78 0.12";
public string BuffBarAnchorMax = "0.995 0.185";
}
private class AnnounceConfig
{
public bool BroadcastChat = true;
public string ChatPrefix = "<color=#7FFFD4>[KS]</color>";
public bool NotifyVictimOfStreak = true;
public bool BroadcastStreakBreak = true;
public int StreakBreakMinKills = 5;
}
private class KSConfig
{
public List<ThresholdReward> Rewards = new List<ThresholdReward>();
public AntiAbuse AntiAbuse = new AntiAbuse();
public UiConfig UI = new UiConfig();
public AnnounceConfig Announce = new AnnounceConfig();
}
private class PlayerState
{
public int Streak = 0;
public int TopStreak = 0;
public ulong LastVictimId = 0;
public double LastVictimTime = 0;
public Timer BuffTimer = null;
public BuffSpec ActiveBuff = null;
public double BuffEndTime = 0;
public Timer BuffBarTimer = null;
}
#endregion
#region Fields
private KSConfig _config;
private readonly Dictionary<ulong, PlayerState> _states = new Dictionary<ulong, PlayerState>();
private readonly Dictionary<ulong, double> _lastInputApply = new Dictionary<ulong, double>();
// ── PI integration (optional) ─────────────────────────────────────
[PluginReference] private Plugin RHGPlayerIntelligence;
// Event reward multiplier set by RHGEventIntelligence via KSR_SetEventMultiplier.
// Scoped per event ID so multiple concurrent events don't conflict.
private readonly Dictionary<string, float> _eventMultipliers = new Dictionary<string, float>();
// PI local risk cache — maintained via PI_OnPlayerUpdated push hook.
// Used to adjust reward item amounts: trusted players get slightly more, high-risk get less.
private readonly Dictionary<ulong, int> _piRiskCache = new Dictionary<ulong, int>();
private readonly Dictionary<ulong, int> _piTrustCache = new Dictionary<ulong, int>();
private readonly Dictionary<ulong, string> _piTagCache = new Dictionary<ulong, string>();
private void PI_OnPlayerUpdated(string userId, int risk, int trust, string tag, int rep)
{
ulong uid;
if (string.IsNullOrEmpty(userId) || !ulong.TryParse(userId, out uid)) return;
_piRiskCache[uid] = risk;
_piTrustCache[uid] = trust;
_piTagCache[uid] = tag;
}
#endregion
#region External API
// Called by RHGEventIntelligence to set a DNA-learned reward multiplier for an active event.
// eventId: the event ID string. multiplier: e.g. 1.3 = 30% more items.
// Pass 0f or 1f to clear/reset.
[HookMethod("KSR_SetEventMultiplier")]
public void KSR_SetEventMultiplier(string eventId, float multiplier)
{
if (string.IsNullOrEmpty(eventId)) return;
if (multiplier <= 0f || Math.Abs(multiplier - 1f) < 0.001f)
_eventMultipliers.Remove(eventId);
else
_eventMultipliers[eventId] = multiplier;
}
// Called by RHGEventIntelligence when an event ends to clean up the multiplier.
[HookMethod("KSR_ClearEventMultiplier")]
public void KSR_ClearEventMultiplier(string eventId)
{
if (!string.IsNullOrEmpty(eventId))
_eventMultipliers.Remove(eventId);
}
// Returns the current streak count for a player (0 if not tracked).
[HookMethod("KSR_GetStreak")]
public int KSR_GetStreak(string userId)
{
ulong uid;
if (!ulong.TryParse(userId, out uid)) return 0;
PlayerState s;
return _states.TryGetValue(uid, out s) ? s.Streak : 0;
}
#endregion
#region Oxide Hooks
private void Init()
{
permission.RegisterPermission(PermAdmin, this);
LoadConfigValues();
}
protected override void LoadConfig()
{
base.LoadConfig();
try
{
_config = Config.ReadObject<KSConfig>();
if (_config == null) throw new Exception("Config null");
}
catch
{
PrintWarning("Config is corrupted, resetting to defaults.");
LoadDefaultConfig();
}
}
protected override void LoadDefaultConfig()
{
_config = DefaultConfig();
SaveConfig();
}
protected override void SaveConfig() => Config.WriteObject(_config, true);
private KSConfig DefaultConfig()
{
return new KSConfig
{
Rewards = new List<ThresholdReward>
{
new ThresholdReward
{
Kills = 3,
Items = new List<RewardItem>
{
new RewardItem{ ShortName="scrap", Amount=50 },
new RewardItem{ ShortName="metal.fragments", Amount=100 },
new RewardItem{ ShortName="ammo.rifle", Amount=50 }
},
Buff = new BuffSpec
{
OutgoingDamageMultiplier = 1.05f,
IncomingDamageMultiplier = 1.00f,
DurationSeconds = 30f,
Label = "+5% Damage (30s)"
},
AnnounceText = "🔥 {player} is on a 3 Kill Streak!",
TierColor = "1 0.55 0.05 0.85"
},
new ThresholdReward
{
Kills = 5,
Items = new List<RewardItem>
{
new RewardItem{ ShortName="scrap", Amount=150 },
new RewardItem{ ShortName="hq.metal.ore", Amount=200 },
new RewardItem{ ShortName="pistol.python", Amount=1 }
},
Buff = new BuffSpec
{
OutgoingDamageMultiplier = 1.10f,
IncomingDamageMultiplier = 1.00f,
DurationSeconds = 45f,
Label = "+10% Damage (45s)"
},
AnnounceText = "⚡ {player} is rising with a 5 Kill Streak!",
TierColor = "1 0.15 0.1 0.85"
},
new ThresholdReward
{
Kills = 8,
Items = new List<RewardItem>
{
new RewardItem{ ShortName="scrap", Amount=300 },
new RewardItem{ ShortName="sulfur.ore", Amount=500 },
new RewardItem{ ShortName="explosive.timed", Amount=1 }
},
Buff = new BuffSpec
{
OutgoingDamageMultiplier = 1.00f,
IncomingDamageMultiplier = 0.85f,
DurationSeconds = 60f,
Label = "-15% Incoming Damage (60s)"
},
AnnounceText = "🏆 {player} is dominating with an 8 Kill Streak!",
TierColor = "0.72 0.3 1 0.9"
},
new ThresholdReward
{
Kills = 10,
Items = new List<RewardItem>
{
new RewardItem{ ShortName="sulfur.ore", Amount=500 },
new RewardItem{ ShortName="metal.fragments", Amount=300 },
new RewardItem{ ShortName="syringe.medical", Amount=2 }
},
Buff = new BuffSpec
{
SpeedMultiplier = 1.15f,
DurationSeconds = 25f,
Label = "+15% Speed (25s)"
},
AnnounceText = "💨 {player} hits 10 Kill Streak — fast as the wind!",
TierColor = "0.05 0.75 0.85 0.88"
},
new ThresholdReward
{
Kills = 12,
Items = new List<RewardItem>
{
new RewardItem{ ShortName="scrap", Amount=400 },
new RewardItem{ ShortName="hq.metal.ore", Amount=500 },
new RewardItem{ ShortName="rifle.ak", Amount=1 }
},
Buff = new BuffSpec
{
OutgoingDamageMultiplier = 1.12f,
IncomingDamageMultiplier = 0.90f,
DurationSeconds = 50f,
Label = "+12% Damage / -10% Incoming (50s)"
},
AnnounceText = "💎 {player} 12 Kill Streak — unstoppable!",
TierColor = "0.85 0.15 0.75 0.88"
},
new ThresholdReward
{
Kills = 15,
Items = new List<RewardItem>
{
new RewardItem{ ShortName="scrap", Amount=600 },
new RewardItem{ ShortName="largemedkit", Amount=3 },
new RewardItem{ ShortName="ammo.rocket.hv", Amount=5 }
},
Buff = new BuffSpec
{
HealPerKill = 15f,
DurationSeconds = 40f,
Label = "Vampire: +15 HP per Kill (40s)"
},
AnnounceText = "🧛 {player} 15 Kill Streak — vampire mode activated!",
TierColor = "1 0.75 0.0 0.92"
},
new ThresholdReward
{
Kills = 20,
Items = new List<RewardItem>
{
new RewardItem{ ShortName="scrap", Amount=1000 },
new RewardItem{ ShortName="sulfur.ore", Amount=1000 },
new RewardItem{ ShortName="explosive.timed", Amount=5 },
new RewardItem{ ShortName="rocket.launcher", Amount=1 }
},
Buff = new BuffSpec
{
OutgoingDamageMultiplier = 1.20f,
IncomingDamageMultiplier = 0.80f,
SpeedMultiplier = 1.10f,
HealPerKill = 10f,
DurationSeconds = 90f,
Label = "LEGEND: +20% DMG / -20% INC / +10% SPD / 10 HP/Kill (90s)"
},
AnnounceText = "👑 {player} 20 KILL STREAK — LEGEND MODE ACTIVATED!",
TierColor = "1 0.92 0.3 0.97"
}
},
AntiAbuse = new AntiAbuse(),
UI = new UiConfig(),
Announce = new AnnounceConfig
{
BroadcastChat = true,
ChatPrefix = "<color=#7FFFD4>[KS]</color>",
NotifyVictimOfStreak = true,
BroadcastStreakBreak = true,
StreakBreakMinKills = 5
}
};
}
private void OnServerInitialized()
{
foreach (var p in BasePlayer.activePlayerList)
EnsureState(p.userID);
}
private void OnPlayerConnected(BasePlayer player)
{
EnsureState(player.userID);
}
private void Unload()
{
foreach (var p in BasePlayer.activePlayerList)
{
if (p == null) continue;
ClearBuff(p.userID);
DestroyUi(p);
DestroyBuffBar(p);
DestroyStreakBreakUi(p);
}
}
private void OnPlayerDisconnected(BasePlayer player, string reason)
{
ClearState(player.userID);
}
private void OnPlayerRespawned(BasePlayer player)
{
ResetStreak(player);
}
private void OnEntityDeath(BaseCombatEntity entity, HitInfo info)
{
try
{
if (entity == null || info == null) return;
var victim = entity as BasePlayer;
if (victim == null || victim.userID == 0) return;
// CountOnlyPlayers gate: ignore NPC/bot kills
if (_config.AntiAbuse.CountOnlyPlayers && !victim.userID.IsSteamId()) return;
var attacker = info.InitiatorPlayer;
if (attacker == null || attacker.userID == 0) return;
if (attacker.userID == victim.userID) return;
if (_config.AntiAbuse.RequireAttackerAlive && !attacker.IsAlive()) return;
// CanCountKill BEFORE AnnounceStreakBreak so abused kills don't trigger announcements
if (!CanCountKill(attacker.userID, victim.userID)) return;
AnnounceStreakBreak(victim, attacker);
IncrementStreak(attacker, victim);
}
catch (Exception e)
{
PrintWarning($"OnEntityDeath error: {e}");
}
}
private object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info)
{
try
{
if (entity == null || info == null) return null;
var attacker = info.InitiatorPlayer;
if (attacker != null && _states.TryGetValue(attacker.userID, out var s) && s.ActiveBuff != null)
{
if (s.ActiveBuff.OutgoingDamageMultiplier != 1.0f)
info.damageTypes.ScaleAll(s.ActiveBuff.OutgoingDamageMultiplier);
}
var targetPlayer = entity as BasePlayer;
if (targetPlayer != null && _states.TryGetValue(targetPlayer.userID, out var s2) && s2.ActiveBuff != null)
{
if (s2.ActiveBuff.IncomingDamageMultiplier != 1.0f)
info.damageTypes.ScaleAll(s2.ActiveBuff.IncomingDamageMultiplier);
}
}
catch (Exception e)
{
PrintWarning($"OnEntityTakeDamage error: {e}");
}
return null;
}
private void OnPlayerInput(BasePlayer player, InputState input)
{
try
{
if (player == null || input == null) return;
if (!_states.TryGetValue(player.userID, out var state)) return;
if (state.ActiveBuff == null || state.ActiveBuff.SpeedMultiplier <= 1.0f) return;
if (!player.IsAlive() || player.IsSleeping()) return;
// Throttle to max once per 50 ms to reduce per-frame CPU load
var now = Interface.Oxide.Now;
double lastApply;
if (_lastInputApply.TryGetValue(player.userID, out lastApply) && now - lastApply < 0.05) return;
_lastInputApply[player.userID] = now;
float extra = (state.ActiveBuff.SpeedMultiplier - 1.0f) * 0.09f;
var dir = Vector3.zero;
if (input.IsDown(BUTTON.FORWARD)) dir += player.eyes.BodyForward();
if (input.IsDown(BUTTON.BACKWARD)) dir -= player.eyes.BodyForward();
if (input.IsDown(BUTTON.RIGHT)) dir += player.eyes.BodyRight();
if (input.IsDown(BUTTON.LEFT)) dir -= player.eyes.BodyRight();
dir.y = 0f;
if (dir.sqrMagnitude < 0.01f) return;
dir.Normalize();
player.MovePosition(player.transform.position + dir * extra);
}
catch { }
}
private object OnPlayerViolation(BasePlayer player, AntiHackType type, float amount)
{
if (player == null) return null;
if (!_states.TryGetValue(player.userID, out var state)) return null;
if (state.ActiveBuff == null || state.ActiveBuff.SpeedMultiplier <= 1.0f) return null;
if (type == AntiHackType.FlyHack || type == AntiHackType.SpeedHack)
return true;
return null;
}
#endregion
#region Core Logic
private void IncrementStreak(BasePlayer attacker, BasePlayer victim)
{
var now = Interface.Oxide.Now;
var state = EnsureState(attacker.userID);
state.Streak++;
if (state.Streak > state.TopStreak) state.TopStreak = state.Streak;
state.LastVictimId = victim.userID;
state.LastVictimTime = now;
if (_config.Announce.NotifyVictimOfStreak && state.Streak >= 3 && victim.IsConnected)
victim.ChatMessage($"{_config.Announce.ChatPrefix} <color=#FF6B6B>☠ {attacker.displayName} killed you while on a x{state.Streak} Kill Streak!</color>");
// HealPerKill — restore health on each kill if buff is active
if (state.ActiveBuff != null && state.ActiveBuff.HealPerKill > 0f)
{
float newHealth = Mathf.Min(attacker.health + state.ActiveBuff.HealPerKill, attacker.MaxHealth());
attacker.SetHealth(newHealth);
attacker.ChatMessage($"{_config.Announce.ChatPrefix} <color=#7CFC00>+{state.ActiveBuff.HealPerKill} HP (Vampire)</color>");
}
var reward = _config.Rewards.OrderBy(r => r.Kills).LastOrDefault(r => r.Kills == state.Streak);
if (reward != null)
{
GiveReward(attacker, reward);
ApplyBuff(attacker, reward.Buff);
Announce(attacker.displayName, reward);
ShowUi(attacker, state.Streak, reward);
// Milestone sound effect
Effect.server.Run("assets/prefabs/misc/xmas/presents/effects/unwrap.prefab", attacker.transform.position);
}
else ShowUi(attacker, state.Streak, null);
}
private bool CanCountKill(ulong attackerId, ulong victimId)
{
var s = EnsureState(attackerId);
if (_config.AntiAbuse.PreventSameVictimChain &&
s.LastVictimId == victimId &&
Interface.Oxide.Now - s.LastVictimTime < _config.AntiAbuse.MinSecondsSameVictim)
return false;
return true;
}
private void GiveReward(BasePlayer player, ThresholdReward reward)
{
// ── Step 1: Event DNA multiplier (set by RHGEventIntelligence) ──────────────
float mult = 1.0f;
if (_eventMultipliers.Count > 0)
{
float best = 1.0f;
foreach (var kv in _eventMultipliers)
if (kv.Value > best) best = kv.Value;
mult = best;
}
// ── Step 2: PI behavior gate + scaling ───────────────────────────────────────
// When RHGPlayerIntelligence is loaded, BehaviorTag and RiskScore drive rewards.
// This is what turns the free plugin into a premium experience with core installed.
//
// Toxic / Griefer / Menace → reward BLOCKED (0 items)
// Risk > 70 → 50% reward (soft deterrent)
// Trusted / Veteran → +20% bonus
// Survivor / Hunter → +10% bonus
// Others → neutral (no change)
if (RHGPlayerIntelligence != null)
{
string tag;
int risk;
_piTagCache.TryGetValue(player.userID, out tag);
_piRiskCache.TryGetValue(player.userID, out risk);
// Hard block: toxic players get no kill rewards
if (tag == "Toxic" || tag == "Griefer" || tag == "Menace")
{
player.ChatMessage(string.Concat(
"<color=#ff4444>[KS]</color> Your behavior record (",
tag ?? "unknown",
") blocks streak rewards."));
return; // skip items entirely — buff still applied by caller
}
// Soft penalty: high risk (not yet tagged as toxic — warning zone)
if (risk > 70)
mult *= 0.5f;
// Bonus: experienced/trusted players
if (tag == "Veteran" || tag == "Trusted")
mult *= 1.20f;
else if (tag == "Survivor" || tag == "Hunter")
mult *= 1.10f;
}
// ── Step 3: Give items with final multiplier ──────────────────────────────────
foreach (var it in reward.Items)
{
var def = ItemManager.FindItemDefinition(it.ShortName);
if (def == null) continue;
int baseAmt = Math.Max(1, Mathf.RoundToInt(it.Amount * mult));
int left = baseAmt;
while (left > 0)
{
int give = Math.Min(left, def.stackable > 0 ? def.stackable : left);
var item = ItemManager.Create(def, give, it.Skin);
if (!item.MoveToContainer(player.inventory.containerMain) &&
!item.MoveToContainer(player.inventory.containerBelt))
item.Drop(player.transform.position + Vector3.up, Vector3.zero);
left -= give;
}
}
}
private void ApplyBuff(BasePlayer player, BuffSpec buff)
{
ClearBuff(player.userID);
if (buff == null) return;
var state = EnsureState(player.userID);
state.ActiveBuff = buff;
state.BuffEndTime = Interface.Oxide.Now + buff.DurationSeconds;
state.BuffTimer = timer.Once(buff.DurationSeconds, () =>
{
var p = BasePlayer.FindByID(player.userID);
ClearBuff(player.userID);
if (p != null && p.IsConnected)
{
ShowUi(p, state.Streak, null, "<color=#FF8C00>⚡ Buff expired!</color>");
p.ChatMessage($"{_config.Announce.ChatPrefix} <color=#FFA500>Your buff has ended: {buff.Label}</color>");
}
});
// Draw initial bar then use Repeat (one persistent timer instead of recursive Once)
DrawBuffBar(player, buff, state);
if (_config.UI.BuffBarEnabled)
{
state.BuffBarTimer = timer.Repeat(1f, 0, () =>
{
if (!player.IsConnected || !_states.TryGetValue(player.userID, out var st) || st.ActiveBuff == null)
{
DestroyBuffBar(player);
state.BuffBarTimer?.Destroy();
state.BuffBarTimer = null;
return;
}
DrawBuffBar(player, buff, st);
});
}
}
private void ClearBuff(ulong userID)
{
if (_states.TryGetValue(userID, out var s))
{
s.ActiveBuff = null;
s.BuffEndTime = 0;
s.BuffTimer?.Destroy();
s.BuffTimer = null;
s.BuffBarTimer?.Destroy();
s.BuffBarTimer = null;
}
var player = BasePlayer.FindByID(userID);
if (player != null) DestroyBuffBar(player);
}
private void ResetStreak(BasePlayer player)
{
var s = EnsureState(player.userID);
s.Streak = 0;
s.LastVictimId = 0;
s.LastVictimTime = 0;
ClearBuff(player.userID);
DestroyUi(player);
DestroyStreakBreakUi(player);
}
private PlayerState EnsureState(ulong userID)
{
if (!_states.TryGetValue(userID, out var s))
{
s = new PlayerState();
_states[userID] = s;
}
return s;
}
private void ClearState(ulong userID)
{
if (_states.TryGetValue(userID, out var s))
{
s.BuffTimer?.Destroy();
s.BuffBarTimer?.Destroy();
_states.Remove(userID);
}
}
#endregion
#region UI & Announce
private void Announce(string playerName, ThresholdReward reward)
{
if (!_config.Announce.BroadcastChat || reward == null) return;
var msg = reward.AnnounceText.Replace("{player}", playerName);
server.Broadcast($"{_config.Announce.ChatPrefix} {msg}");
Interface.CallHook("KSR_OnStreakReached", playerName, reward.Kills, msg);
}
private void ShowUi(BasePlayer player, int streak, ThresholdReward reward, string custom = null)
{
if (!_config.UI.Enabled || player == null) return;
DestroyUi(player);
var nextReward = _config.Rewards.OrderBy(r => r.Kills).FirstOrDefault(r => r.Kills > streak);
int killsToNext = nextReward != null ? nextReward.Kills - streak : 0;
string bgColor = reward != null ? reward.TierColor : $"0.05 0.05 0.05 {_config.UI.BackgroundAlpha}";
var container = new CuiElementContainer();
var bg = container.Add(new CuiPanel
{
Image = { Color = bgColor },
RectTransform = { AnchorMin = _config.UI.AnchorMin, AnchorMax = _config.UI.AnchorMax },
CursorEnabled = false
}, "Overlay", UiName);
// Accent bar at top for reward tiers
if (reward != null)
container.Add(new CuiPanel
{
Image = { Color = "1 1 1 0.20" },
RectTransform = { AnchorMin = "0 0.91", AnchorMax = "1 1" }
}, bg);
// Title row
string titleText = custom ?? $"🔥 KILLSTREAK x{streak}";
container.Add(new CuiLabel
{
Text = { Text = titleText, FontSize = 17, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1", Font = "robotocondensed-bold.ttf" },
RectTransform = { AnchorMin = "0.03 0.54", AnchorMax = "0.97 0.90" }
}, bg);
// Middle row: reward items or active buff
string midText = "";
if (custom == null)
{
if (reward != null)
midText = string.Join(" · ", reward.Items.Select(i => $"{i.Amount}x {i.ShortName}"));
else
{
var st = EnsureState(player.userID);
if (st.ActiveBuff != null) midText = $"Active Buff: {st.ActiveBuff.Label}";
}
}
if (!string.IsNullOrEmpty(midText))
container.Add(new CuiLabel
{
Text = { Text = midText, FontSize = 11, Align = TextAnchor.MiddleCenter, Color = "1 1 0.65 1" },
RectTransform = { AnchorMin = "0.03 0.28", AnchorMax = "0.97 0.54" }
}, bg);
// Bottom row: buff label or next threshold hint
string bottomText = "";
if (custom == null)
{
if (reward != null)
bottomText = $"⚡ {reward.Buff?.Label ?? ""}";
else if (nextReward != null)
bottomText = $"Next reward in {killsToNext} kill(s) (threshold: {nextReward.Kills})";
else
bottomText = "All thresholds conquered! 🏆";
}
if (!string.IsNullOrEmpty(bottomText))
container.Add(new CuiLabel
{
Text = { Text = bottomText, FontSize = 11, Align = TextAnchor.MiddleCenter, Color = "0.8 0.95 1 0.9" },
RectTransform = { AnchorMin = "0.03 0.04", AnchorMax = "0.97 0.28" }
}, bg);
CuiHelper.AddUi(player, container);
timer.Once(Mathf.Max(1f, _config.UI.ShowSeconds), () => DestroyUi(player));
}
private void DestroyUi(BasePlayer player)
{
if (player == null) return;
CuiHelper.DestroyUi(player, UiName);
}
// ShowBuffBar kept for backward compat (external calls); delegates to DrawBuffBar
private void ShowBuffBar(BasePlayer player, BuffSpec buff, PlayerState state)
{
DrawBuffBar(player, buff, state);
}
private void DrawBuffBar(BasePlayer player, BuffSpec buff, PlayerState state)
{
if (!_config.UI.BuffBarEnabled || player == null || buff == null) return;
DestroyBuffBar(player);
double remaining = state.BuffEndTime - Interface.Oxide.Now;
if (remaining <= 0) return;
float fraction = Mathf.Clamp01((float)(remaining / buff.DurationSeconds));
var container = new CuiElementContainer();
var bg = container.Add(new CuiPanel
{
Image = { Color = "0.04 0.04 0.04 0.82" },
RectTransform = { AnchorMin = _config.UI.BuffBarAnchorMin, AnchorMax = _config.UI.BuffBarAnchorMax },
CursorEnabled = false
}, "Hud", UiBuffName);
// Bar track
container.Add(new CuiPanel
{
Image = { Color = "0.18 0.18 0.18 1" },
RectTransform = { AnchorMin = "0.02 0.06", AnchorMax = "0.98 0.38" }
}, bg);
// Active fill
if (fraction > 0f)
container.Add(new CuiPanel
{
Image = { Color = "0.2 0.85 0.45 0.9" },
RectTransform = { AnchorMin = "0.02 0.06", AnchorMax = $"{0.02f + 0.96f * fraction:F3} 0.38" }
}, bg);
// Label
var parts = new List<string>();
if (buff.OutgoingDamageMultiplier != 1.0f) parts.Add($"+{(int)((buff.OutgoingDamageMultiplier - 1f) * 100)}% DMG");
if (buff.IncomingDamageMultiplier != 1.0f) parts.Add($"-{(int)((1f - buff.IncomingDamageMultiplier) * 100)}% INC");
if (buff.SpeedMultiplier != 1.0f) parts.Add($"+{(int)((buff.SpeedMultiplier - 1f) * 100)}% SPD");
if (buff.HealPerKill > 0f) parts.Add($"{buff.HealPerKill} HP/Kill");
string summary = parts.Count > 0 ? string.Join(" · ", parts) : buff.Label;
container.Add(new CuiLabel
{
Text = { Text = $"⚡ {summary} {(int)remaining}s", FontSize = 12, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" },
RectTransform = { AnchorMin = "0.02 0.40", AnchorMax = "0.98 0.96" }
}, bg);
CuiHelper.AddUi(player, container);
}
private void DestroyBuffBar(BasePlayer player)
{
if (player == null) return;
CuiHelper.DestroyUi(player, UiBuffName);
}
private void DestroyStreakBreakUi(BasePlayer player)
{
if (player == null) return;
CuiHelper.DestroyUi(player, UiStreakBreakName);
}
private void ShowStreakBreakUi(BasePlayer player, string victimName, int streak)
{
if (!_config.UI.Enabled || player == null) return;
DestroyStreakBreakUi(player);
var container = new CuiElementContainer();
var bg = container.Add(new CuiPanel
{
Image = { Color = "0.05 0.55 0.15 0.85" },
RectTransform = { AnchorMin = _config.UI.AnchorMin, AnchorMax = _config.UI.AnchorMax },
CursorEnabled = false
}, "Overlay", UiStreakBreakName);
container.Add(new CuiLabel
{
Text = { Text = "🎯 STREAK BROKEN!", FontSize = 18, Align = TextAnchor.MiddleCenter, Color = "1 1 0.2 1", Font = "robotocondensed-bold.ttf" },
RectTransform = { AnchorMin = "0.03 0.52", AnchorMax = "0.97 0.92" }
}, bg);
container.Add(new CuiLabel
{
Text = { Text = $"You ended {victimName}'s x{streak} Kill Streak!", FontSize = 12, Align = TextAnchor.MiddleCenter, Color = "0.9 1 0.9 1" },
RectTransform = { AnchorMin = "0.03 0.08", AnchorMax = "0.97 0.52" }
}, bg);
CuiHelper.AddUi(player, container);
timer.Once(Mathf.Max(1f, _config.UI.ShowSeconds), () => DestroyStreakBreakUi(player));
}
private void AnnounceStreakBreak(BasePlayer victim, BasePlayer attacker)
{
if (!_states.TryGetValue(victim.userID, out var victimState)) return;
int streak = victimState.Streak;
if (streak < _config.Announce.StreakBreakMinKills) return;
attacker.ChatMessage($"{_config.Announce.ChatPrefix} <color=#FFD700>🎯 You ended {victim.displayName}'s x{streak} Kill Streak!</color>");
if (_config.Announce.BroadcastStreakBreak)
server.Broadcast($"{_config.Announce.ChatPrefix} <color=#FF6347>💥 {attacker.displayName} ended {victim.displayName}'s x{streak} Kill Streak!</color>");
Interface.CallHook("KSR_OnStreakBroken", attacker.UserIDString, attacker.displayName, victim.displayName, streak);
ShowStreakBreakUi(attacker, victim.displayName, streak);
}
#endregion
#region Commands
[Command("ks")]
private void CmdKS(IPlayer iplayer, string command, string[] args)
{
var bp = iplayer.Object as BasePlayer;
if (bp == null) { iplayer.Reply("In-game use only."); return; }
var s = EnsureState(bp.userID);
var buff = s.ActiveBuff != null ? s.ActiveBuff.Label : "None";
iplayer.Reply($"[KS] Streak: {s.Streak} | Buff: {buff}");
}
[Command("ks.reset")]
private void CmdKSReset(IPlayer iplayer, string command, string[] args)
{
if (!iplayer.HasPermission(PermAdmin)) { iplayer.Reply("No permission."); return; }
if (args.Length < 1) { iplayer.Reply("Usage: /ks.reset <name|id>"); return; }
var target = FindPlayer(args[0]);
if (target?.Object is BasePlayer bp)
{
ResetStreak(bp);
iplayer.Reply($"[KS] {bp.displayName}'s streak has been reset.");
}
else iplayer.Reply("Player not found.");
}
[Command("ks.reload")]
private void CmdKSReload(IPlayer iplayer, string command, string[] args)
{
if (!iplayer.HasPermission(PermAdmin)) { iplayer.Reply("No permission."); return; }
LoadConfigValues();
iplayer.Reply("[KS] Config reloaded.");
}
[Command("ks.test")]
private void CmdKSTest(IPlayer iplayer, string command, string[] args)
{
if (!iplayer.HasPermission(PermAdmin)) { iplayer.Reply("No permission."); return; }
if (args.Length < 2) { iplayer.Reply("Usage: /ks.test <name|id> <3|5|8|10|12|15|20>"); return; }
var target = FindPlayer(args[0]);
if (!(target?.Object is BasePlayer bp)) { iplayer.Reply("Player not found."); return; }
if (!int.TryParse(args[1], out var thr) || !_config.Rewards.Any(r => r.Kills == thr))
{
iplayer.Reply("Invalid threshold. Valid values: 3, 5, 8, 10, 12, 15, 20");
return;
}
var rew = _config.Rewards.First(r => r.Kills == thr);
// Sync streak state so UI shows the correct value
var st = EnsureState(bp.userID);
st.Streak = thr;
if (st.Streak > st.TopStreak) st.TopStreak = st.Streak;
GiveReward(bp, rew);
ApplyBuff(bp, rew.Buff);
Announce(bp.displayName, rew);
ShowUi(bp, thr, rew);
iplayer.Reply($"[KS] Test reward ({thr} kills) applied to {bp.displayName}.");
}
[Command("ks.setstreak")]
private void CmdKSSetStreak(IPlayer iplayer, string command, string[] args)
{
if (!iplayer.HasPermission(PermAdmin)) { iplayer.Reply("No permission."); return; }
if (args.Length < 2) { iplayer.Reply("Usage: /ks.setstreak <name|id> <amount>"); return; }
var target = FindPlayer(args[0]);
if (!(target?.Object is BasePlayer bp)) { iplayer.Reply("Player not found."); return; }
if (!int.TryParse(args[1], out var amount) || amount < 0) { iplayer.Reply("Invalid amount (must be >= 0)."); return; }
var st = EnsureState(bp.userID);
st.Streak = amount;
if (amount > st.TopStreak) st.TopStreak = amount;
iplayer.Reply($"[KS] {bp.displayName}'s streak set to {amount}.");
bp.ChatMessage($"{_config.Announce.ChatPrefix} <color=#FFA500>Your streak was set to {amount} by an admin.</color>");
}
[Command("ks.top")]
private void CmdKSTop(IPlayer iplayer, string command, string[] args)
{
var bp = iplayer.Object as BasePlayer;
var entries = new List<KeyValuePair<string, int>>();
foreach (var kv in _states)
{
var p = BasePlayer.FindByID(kv.Key);
if (p == null) continue;
entries.Add(new KeyValuePair<string, int>(p.displayName, kv.Value.TopStreak));
}
entries.Sort((a, b) => b.Value.CompareTo(a.Value));
var sb = new System.Text.StringBuilder();
sb.AppendLine($"{_config.Announce.ChatPrefix} <color=#FFD700>★ Top Killstreaks (Session)</color>");
int rank = 1;
foreach (var e in entries)
{
if (rank > 10) break;
sb.AppendLine($" <color=#aaaaaa>{rank}.</color> {e.Key} <color=#FFD700>{e.Value}</color> kills");
rank++;
}
if (entries.Count == 0) sb.AppendLine(" No data yet.");
iplayer.Reply(sb.ToString());
}
#endregion
#region Helpers
private void LoadConfigValues()
{
LoadConfig();
SaveConfig();
}
private IPlayer FindPlayer(string nameOrId)
{
var target = covalence.Players.FindPlayerById(nameOrId) ?? covalence.Players.FindPlayer(nameOrId);
return target?.IsConnected == true ? target : null;
}
#endregion
}
}