C#:
// OmniRPGEngine.cs
// Core XP + Rage discipline system with UI shell, leaderboard, admin config editor, and BotReSpawn profile XP UI.
// NOTE: Drop into oxide/plugins (or carbon/plugins) as-is. Data/config files will be created on first load.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Oxide.Core;
using Oxide.Core.Configuration;
using Oxide.Core.Plugins;
using Newtonsoft.Json;
using Oxide.Game.Rust.Cui;
using Rust;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("OmniRPGEngine", "Somescrub", "0.3.1")]
[Description("Universal RPG framework providing XP, levels and discipline-based skill trees (Rage MVP).")]
public class OmniRPGEngine : RustPlugin
{
#region Permissions / References
private const string PERM_USE = "omnirpgengine.use";
private const string PERM_ADMIN = "omnirpgengine.admin";
[PluginReference] private Plugin PermissionsManager;
[PluginReference("ImageLibrary")] private Plugin ImageLibrary;
[PluginReference("BotReSpawn")] private Plugin BotReSpawn; // Used to detect BotReSpawn NPCs
[PluginReference]
private Plugin Economics;
[PluginReference]
private Plugin ServerRewards;
// Track BotReSpawn NPC userIDs so they never pollute player stats / leaderboard
private readonly HashSet<ulong> botReSpawnIds = new HashSet<ulong>();
// Per-player Bot XP page index for pagination
private readonly Dictionary<ulong, int> botXpPage = new Dictionary<ulong, int>();
#endregion
#region Config
private PluginConfig config;
private class PluginConfig
{
public string DataFileName = "OmniRPGEngine_Data";
public XpSettings XP = new XpSettings();
public RageSettings Rage = new RageSettings();
public UiSettings UI = new UiSettings();
public static PluginConfig DefaultConfig()
{
return new PluginConfig();
}
}
private class UiSettings
{
public string ProfileCommand = "orpgxp";
public string RageCommand = "orpgrage";
public string UiCommand = "orpgui";
public float AnchorMinX = 0.2f;
public float AnchorMinY = 0.1f;
public float AnchorMaxX = 0.8f;
public float AnchorMaxY = 0.9f;
}
private class XpSettings
{
public double BaseKillNpc = 25;
public double BaseKillPlayer = 50;
public double BaseGatherOre = 2;
public double BaseGatherWood = 1;
public double BaseGatherPlants = 1.5;
// Per-source multipliers so server owners can tune them
public double BotReSpawnMultiplier = 1.0;
public double NpcSpawnMultiplier = 1.0;
public double ZombieHordeMultiplier = 1.0;
public double LevelCurveBase = 100; // XP for level 1→2
public double LevelCurveGrowth = 1.25; // Each level multiplies required XP by this
// Legacy per BotReSpawn profile multipliers (for migration)
public Dictionary<string, double> BotReSpawnProfileMultipliers = new Dictionary<string, double>();
// New full settings (per profile)
public Dictionary<string, BotProfileXpSettings> BotReSpawnProfiles = new Dictionary<string, BotProfileXpSettings>();
}
private class RageSettings
{
public bool Enabled = true;
// How many discipline points per character level
public double CorePointsPerLevel = 1.0;
// Fury shared settings
public float FuryDurationSeconds = 10f;
public float FuryMaxBonusDamage = 0.3f; // 30% at max Fury
public float FuryOnKillGain = 0.15f; // 15% fury per qualifying kill
// Weapon specialization nodes – values here are per level
const string RageIconBase = "https://raw.githubusercontent.com/jmg5215/OmniRPGEngine/main/assets/ui_images/Disciplines/RageNodeIcons/";
const string RageTreeBase = RageIconBase + "tree/";
public Dictionary<string, RageNodeConfig> Nodes = new Dictionary<string, RageNodeConfig>
{
// Tier 1 (core + weapon masteries)
["core_t1"] = new RageNodeConfig
{
DisplayName = "Rage Core",
MaxLevel = 20,
DamageBonusPerLevel = 0.01f,
RecoilReductionPerLevel = 0.005f,
MoveSpeedPerLevel = 0.0025f,
Tier = 1,
TreeIconImage = RageTreeBase + "Core_Skills.png",
DetailIconImage = RageIconBase + "core/T1_Rage_Core.png",
ParentNodeId = null
},
["rifle_t1"] = new RageNodeConfig
{
DisplayName = "Predator's Focus",
MaxLevel = 10,
DamageBonusPerLevel = 0.02f,
CritChancePerLevel = 0.01f,
Tier = 1,
TreeIconImage = RageTreeBase + "Branch_Skills.png",
DetailIconImage = RageIconBase + "rifle/T2_Rage_Predators_Focus.png",
ParentNodeId = "core_t1"
},
["shotgun_t1"] = new RageNodeConfig
{
DisplayName = "Bonebreaker Load",
MaxLevel = 10,
DamageBonusPerLevel = 0.02f,
BleedChancePerLevel = 0.015f,
Tier = 1,
TreeIconImage = RageTreeBase + "Branch_Skills.png",
DetailIconImage = RageIconBase + "shotgun/T2_Rage_Bonebreaker_Load.png",
ParentNodeId = "core_t1"
},
["pistol_t1"] = new RageNodeConfig
{
DisplayName = "Quickkill Reflexes",
MaxLevel = 10,
DamageBonusPerLevel = 0.015f,
CritDamagePerLevel = 0.015f,
Tier = 1,
TreeIconImage = RageTreeBase + "Branch_Skills.png",
DetailIconImage = RageIconBase + "pistol/T2_Rage_Quickkill_Reflexes.png",
ParentNodeId = "core_t1"
},
// Tier 2 (branch outward)
["core_t2_bloodlust"] = new RageNodeConfig
{
DisplayName = "Bloodlust",
MaxLevel = 5,
Tier = 2,
TreeIconImage = RageTreeBase + "Core_Skills.png",
DetailIconImage = RageIconBase + "core/T2_Rage_Core_Bloodlust.png",
ParentNodeId = "core_t1"
},
["rifle_t2_predators_focus"] = new RageNodeConfig
{
DisplayName = "Overcharge Rounds",
MaxLevel = 5,
Tier = 2,
TreeIconImage = RageTreeBase + "Branch_Skills.png",
DetailIconImage = RageIconBase + "rifle/T3_Rage_Overcharge_Rounds.png",
ParentNodeId = "rifle_t1"
},
["shotgun_t2_bonebreaker"] = new RageNodeConfig
{
DisplayName = "Sawed-Off Fury",
MaxLevel = 5,
Tier = 2,
TreeIconImage = RageTreeBase + "Branch_Skills.png",
DetailIconImage = RageIconBase + "shotgun/T3_Rage_SawedOff_Fury.png",
ParentNodeId = "shotgun_t1"
},
["pistol_t2_quickkill_reflexes"] = new RageNodeConfig
{
DisplayName = "Street Executioner",
MaxLevel = 5,
Tier = 2,
TreeIconImage = RageTreeBase + "Branch_Skills.png",
DetailIconImage = RageIconBase + "pistol/T3_Rage_Street_Executioner.png",
ParentNodeId = "pistol_t1"
},
// Tier 3 (outer ring)
["core_t3_berserker_instinct"] = new RageNodeConfig
{
DisplayName = "Berserker Instinct",
MaxLevel = 5,
Tier = 3,
TreeIconImage = RageTreeBase + "Core_Skills.png",
DetailIconImage = RageIconBase + "core/T3_Rage_Core_Berserker_Instinct.png",
ParentNodeId = "core_t2_bloodlust"
},
["rifle_t3_overcharge_rounds"] = new RageNodeConfig
{
DisplayName = "Deathline Marksman",
MaxLevel = 5,
Tier = 3,
TreeIconImage = RageTreeBase + "Branch_Skills.png",
DetailIconImage = RageIconBase + "rifle/T4_Rage_Deathline_Marksman.png",
ParentNodeId = "rifle_t2_predators_focus"
},
["shotgun_t3_sawedoff_fury"] = new RageNodeConfig
{
DisplayName = "Shrapnel Storm",
MaxLevel = 5,
Tier = 3,
TreeIconImage = RageTreeBase + "Branch_Skills.png",
DetailIconImage = RageIconBase + "shotgun/T4_Rage_Shrapnel_Storm.png",
ParentNodeId = "shotgun_t2_bonebreaker"
},
["pistol_t3_street_executioner"] = new RageNodeConfig
{
DisplayName = "Gunslinger's Frenzy",
MaxLevel = 5,
Tier = 3,
TreeIconImage = RageTreeBase + "Branch_Skills.png",
DetailIconImage = RageIconBase + "pistol/T4_Rage_Gunslingers_Frenzy.png",
ParentNodeId = "pistol_t2_quickkill_reflexes"
}
};
// NEW: respec settings
public RespecSettings Respec = new RespecSettings();
}
private class RageNodeConfig
{
public string DisplayName = "Unnamed";
public int MaxLevel = 10;
public float DamageBonusPerLevel = 0f;
public float CritChancePerLevel = 0f;
public float CritDamagePerLevel = 0f;
public float BleedChancePerLevel = 0f;
public float MoveSpeedPerLevel = 0f;
public float RecoilReductionPerLevel = 0f;
public int Tier = 1; // 1 = inner ring, 2 = mid ring, 3 = outer ring
public string TreeIconImage = null; // small icon used on the skill tree nodes
public string DetailIconImage = null; // large art shown on the right-hand description panel
public string ParentNodeId = null; // optional – who this node branches from
}
// NEW: RespecSettings definition
private class RespecSettings
{
[JsonProperty("Respec Enabled")]
public bool Enabled = true;
// economics / serverrewards / item / none
[JsonProperty("Respec Mode")]
public string Mode = "economics";
[JsonProperty("Economics Cost")]
public double EconomicsCost = 0;
[JsonProperty("ServerRewards Cost (RP)")]
public int ServerRewardsCost = 0;
[JsonProperty("Item Shortname")]
public string ItemShortname = "scrap";
[JsonProperty("Item Amount")]
public int ItemAmount = 0;
}
private class BotProfileXpSettings
{
public double Multiplier = 1.0;
public double FlatXp = 0.0;
}
protected override void LoadDefaultConfig()
{
config = PluginConfig.DefaultConfig();
SaveConfig();
}
protected override void LoadConfig()
{
base.LoadConfig();
try
{
config = Config.ReadObject<PluginConfig>();
if (config == null)
{
throw new Exception("Config file is null");
}
if (config.XP.BotReSpawnProfiles == null)
config.XP.BotReSpawnProfiles = new Dictionary<string, BotProfileXpSettings>();
// Migration from legacy BotReSpawnProfileMultipliers if needed
if (config.XP.BotReSpawnProfileMultipliers != null &&
config.XP.BotReSpawnProfileMultipliers.Count > 0 &&
(config.XP.BotReSpawnProfiles == null || config.XP.BotReSpawnProfiles.Count == 0))
{
foreach (var kvp in config.XP.BotReSpawnProfileMultipliers)
{
config.XP.BotReSpawnProfiles[kvp.Key] = new BotProfileXpSettings
{
Multiplier = kvp.Value,
FlatXp = 0.0
};
}
}
}
catch
{
PrintWarning("Config file is malformed or missing, creating new one.");
LoadDefaultConfig();
}
}
protected override void SaveConfig()
{
Config.WriteObject(config, true);
}
#endregion
#region Data
private DynamicConfigFile dataFile;
private Dictionary<ulong, PlayerData> players = new Dictionary<ulong, PlayerData>();
private class PlayerData
{
public ulong UserId;
public string LastKnownName;
public double TotalXp;
public int Level;
public double CurrentXp; // XP accumulated since last level
public double XpToNextLevel;
public int UnspentDisciplinePoints;
public RageData Rage = new RageData();
public long PlayerKills;
public long NpcKills;
public long Deaths;
public double TotalPlayTimeSeconds;
[NonSerialized] public float SessionStartTime;
public PlayerData()
{
}
public PlayerData(ulong id, string name)
{
UserId = id;
LastKnownName = name;
Level = 1;
XpToNextLevel = 100;
}
}
private class RageData
{
public int TreeLevel;
public int UnspentPoints;
public Dictionary<string, int> NodeLevels = new Dictionary<string, int>();
// Fury
public float FuryAmount; // 0–1
public double FuryExpireTimestamp;
// UI helpers
public string LastUpgradedNodeId;
public double LastUpgradeFlashTime;
public string SelectedNodeId;
// Progress: highest unlocked Rage tier (1 = Tier 1 only, 2 = Tier 2 unlocked, etc.)
public int MaxUnlockedTier = 1;
public RageData()
{
FuryAmount = 0f;
FuryExpireTimestamp = 0;
LastUpgradedNodeId = null;
LastUpgradeFlashTime = 0;
SelectedNodeId = null;
MaxUnlockedTier = 1;
}
}
private void LoadData()
{
dataFile = Interface.Oxide.DataFileSystem.GetFile(config.DataFileName);
try
{
var stored = dataFile.ReadObject<Dictionary<ulong, PlayerData>>();
if (stored != null)
{
players = stored;
}
}
catch
{
PrintWarning("Data file corrupted or missing, starting fresh.");
players = new Dictionary<ulong, PlayerData>();
}
}
private void SaveData()
{
dataFile.WriteObject(players);
}
private PlayerData GetOrCreatePlayerData(BasePlayer player)
{
if (!IsHumanBasePlayer(player))
return null;
var id = player.userID;
PlayerData data;
if (!players.TryGetValue(id, out data))
{
data = new PlayerData(id, player.displayName);
data.XpToNextLevel = GetRequiredXpForLevel(1);
players[id] = data;
}
else
{
data.LastKnownName = player.displayName;
}
return data;
}
#endregion
#region Oxide Hooks
private void Init()
{
LoadConfig();
LoadData();
permission.RegisterPermission(PERM_USE, this);
permission.RegisterPermission(PERM_ADMIN, this);
cmd.AddChatCommand(config.UI.ProfileCommand, this, CmdProfile);
cmd.AddChatCommand(config.UI.RageCommand, this, CmdRage);
cmd.AddChatCommand(config.UI.UiCommand, this, CmdOpenUi);
cmd.AddChatCommand("orpg", this, CmdOpenUi);
cmd.AddChatCommand("orpgadmin", this, CmdAdminUi);
cmd.AddConsoleCommand("omnirpg.ui", this, "CCmdOpenUi");
cmd.AddConsoleCommand("omnirpg.rage.upgrade", this, "CCmdRageUpgrade");
cmd.AddConsoleCommand("omnirpg.rage.inspect", this, "CCmdRageInspect");
cmd.AddConsoleCommand("omnirpg.rage.respec", this, "CCmdRageRespec");
// Admin config editor commands
cmd.AddConsoleCommand("omnirpg.admin.adjust", this, "CCmdAdminAdjust");
cmd.AddConsoleCommand("omnirpg.admin.toggle", this, "CCmdAdminToggle");
cmd.AddConsoleCommand("omnirpg.admin.save", this, "CCmdAdminSave");
// Bot XP editor commands
cmd.AddConsoleCommand("omnirpg.botxp.mult", this, "CCmdBotXpMult");
cmd.AddConsoleCommand("omnirpg.botxp.flat", this, "CCmdBotXpFlat");
cmd.AddConsoleCommand("omnirpg.botxp.page", this, "CCmdBotXpPage");
// Register images after server starts
// (RegisterImages itself is invoked from OnServerInitialized)
}
private void RegisterImages()
{
if (ImageLibrary == null)
{
Puts("[OmniRPG] ImageLibrary is not loaded, cannot register images.");
return;
}
// Use raw GitHub URL for the Disciplines background
string url = "https://raw.githubusercontent.com/jmg5215/OmniRPGEngine/refs/heads/main/assets/ui_images/Disciplines/disciplines_bg.png";
ImageLibrary.Call("AddImage", url, IMAGE_DISCIPLINES_BG, 0UL);
Puts($"[OmniRPG] Queued Disciplines background image: {url}");
}
private void OnServerInitialized()
{
RegisterImages();
}
private void OnServerSave()
{
foreach (var bp in BasePlayer.activePlayerList)
{
var data = GetOrCreatePlayerData(bp);
if (data != null)
{
data.TotalPlayTimeSeconds += Time.realtimeSinceStartup - data.SessionStartTime;
data.SessionStartTime = Time.realtimeSinceStartup;
}
}
SaveData();
}
private void Unload()
{
SaveData();
foreach (var pl in BasePlayer.activePlayerList)
{
DestroyProfileUi(pl);
}
}
private void OnPlayerInit(BasePlayer player)
{
var data = GetOrCreatePlayerData(player);
if (data != null)
{
data.SessionStartTime = Time.realtimeSinceStartup;
}
}
private void OnPlayerDisconnected(BasePlayer player, string reason)
{
var data = GetOrCreatePlayerData(player);
if (data != null)
{
data.TotalPlayTimeSeconds += Time.realtimeSinceStartup - data.SessionStartTime;
}
SaveData();
DestroyProfileUi(player);
}
private void OnEntityDeath(BaseCombatEntity entity, HitInfo info)
{
if (entity == null || info == null) return;
var killer = info.InitiatorPlayer;
if (killer == null || !killer.IsConnected) return;
if (!permission.UserHasPermission(killer.UserIDString, PERM_USE))
return;
// Identify victim types
var victimPlayer = entity as BasePlayer;
var victimNpc = entity as BaseNpc;
// Skip BotReSpawn NPCs entirely — they are handled by OnBotReSpawnNPCKilled
if (victimPlayer != null && IsBotReSpawnNpc(victimPlayer))
return;
var killerData = GetOrCreatePlayerData(killer);
if (killerData == null) return;
double xp = 0;
if (victimPlayer != null)
{
if (victimPlayer == killer)
{
// Suicide or self-inflicted, no XP
xp = 0;
}
else
{
// Real player kill
xp = config.XP.BaseKillPlayer;
killerData.PlayerKills++;
var victimData = GetOrCreatePlayerData(victimPlayer);
if (victimData != null)
victimData.Deaths++;
}
}
else if (victimNpc != null)
{
// Non-player NPCs (animals, etc.)
xp = config.XP.BaseKillNpc;
killerData.NpcKills++;
}
if (xp <= 0) return;
AwardXp(killer, xp, "Kill");
OnRageKillEvent(killer, entity);
SaveData();
}
private void OnDispenserGather(ResourceDispenser dispenser, BaseEntity entity, Item item)
{
var player = entity as BasePlayer;
if (player == null) return;
if (!permission.UserHasPermission(player.UserIDString, PERM_USE)) return;
if (!IsHumanBasePlayer(player)) return;
double xp = 0;
if (dispenser.gatherType == ResourceDispenser.GatherType.Ore)
{
xp = config.XP.BaseGatherOre * item.amount;
}
else if (dispenser.gatherType == ResourceDispenser.GatherType.Tree)
{
xp = config.XP.BaseGatherWood * item.amount;
}
if (xp > 0)
AwardXp(player, xp, "Gather");
}
private void OnCollectiblePickup(Item item, BasePlayer player)
{
if (player == null) return;
if (!permission.UserHasPermission(player.UserIDString, PERM_USE)) return;
if (!IsHumanBasePlayer(player)) return;
var def = item.info;
if (def == null) return;
double xp = 0;
if (def.shortname.Contains("corn") || def.shortname.Contains("pumpkin") ||
def.shortname.Contains("hemp") || def.shortname.Contains("mushroom"))
{
xp = config.XP.BaseGatherPlants * item.amount;
}
if (xp > 0)
AwardXp(player, xp, "Plants");
}
#endregion
#region BotReSpawn Integration
// Called when a BotReSpawn NPC is spawned
private void OnBotReSpawnNPCSpawned(ScientistNPC npc, string profilename, string group)
{
if (npc == null) return;
if (!botReSpawnIds.Contains(npc.userID))
botReSpawnIds.Add(npc.userID);
// Ensure we have a config entry for this profile
EnsureBotProfileSettings(profilename);
}
// Called when a BotReSpawn NPC is killed
private void OnBotReSpawnNPCKilled(ScientistNPC npc, string profilename, string group, HitInfo info)
{
if (npc == null || info == null) return;
botReSpawnIds.Add(npc.userID);
var killer = info.InitiatorPlayer;
if (killer == null || !killer.IsConnected) return;
if (!permission.UserHasPermission(killer.UserIDString, PERM_USE)) return;
if (!IsHumanBasePlayer(killer)) return;
var killerData = GetOrCreatePlayerData(killer);
if (killerData == null) return;
killerData.NpcKills++;
var settings = EnsureBotProfileSettings(profilename);
// Base XP from global + profile multiplier
var baseXp = config.XP.BaseKillNpc * config.XP.BotReSpawnMultiplier * settings.Multiplier;
// Additive flat XP per your choice (B)
var xp = baseXp + settings.FlatXp;
AwardXp(killer, xp, $"BotReSpawn ({profilename})");
OnRageKillEvent(killer, npc);
SaveData();
}
#endregion
#region External API (XPerience-Compatible)
[HookMethod("GiveXPID")]
public void GiveXPID(ulong playerId, double amount)
{
var player = BasePlayer.FindByID(playerId);
if (player == null || amount == 0) return;
AwardXp(player, amount, "API_ID");
}
[HookMethod("GiveXP")]
public void GiveXP(BasePlayer player, double amount)
{
if (player == null || amount == 0) return;
AwardXp(player, amount, "API");
}
[HookMethod("GiveXPBasic")]
public void GiveXPBasic(BasePlayer player, double amount)
{
if (player == null || amount == 0) return;
AwardXp(player, amount, "API_BASIC");
}
#endregion
#region XP Core
private double GetRequiredXpForLevel(int level)
{
return config.XP.LevelCurveBase * Math.Pow(config.XP.LevelCurveGrowth, level - 1);
}
private void AwardXp(BasePlayer player, double amount, string reason)
{
if (player == null || amount <= 0) return;
if (!IsHumanBasePlayer(player)) return;
var data = GetOrCreatePlayerData(player);
if (data == null) return;
data.TotalXp += amount;
data.CurrentXp += amount;
while (data.CurrentXp >= data.XpToNextLevel)
{
data.CurrentXp -= data.XpToNextLevel;
data.Level++;
data.XpToNextLevel = GetRequiredXpForLevel(data.Level);
var gainedPoints = config.Rage.CorePointsPerLevel;
data.UnspentDisciplinePoints += (int)Math.Round(gainedPoints);
data.Rage.UnspentPoints++;
player.ChatMessage(
$"<color=#ffb74d>[OmniRPG]</color> You reached <color=#ffeb3b>level {data.Level}</color>! " +
$"You gained <color=#b3e5fc>+{gainedPoints:0}</color> discipline point(s) and <color=#f48fb1>+1</color> Rage point.");
}
player.SendConsoleCommand("gametip.showtoast", 1, $"<size=14><color=#ffb74d>+{amount:0} XP</color> ({reason})</size>");
SaveData();
}
#endregion
#region Utility
private string FormatTime(double seconds)
{
if (seconds <= 0) return "0m";
var t = TimeSpan.FromSeconds(seconds);
if (t.TotalHours >= 1)
return $"{(int)t.TotalHours}h {t.Minutes}m";
return $"{t.Minutes}m {t.Seconds}s";
}
// Basic SteamID64 sanity check – real players only
private bool IsLikelyHumanSteamId(ulong id)
{
var idStr = id.ToString();
if (idStr.Length != 17) return false;
if (!idStr.StartsWith("7656")) return false;
return true;
}
private bool IsBotReSpawnId(ulong id)
{
if (botReSpawnIds.Contains(id))
return true;
if (BotReSpawn != null)
{
try
{
var result = BotReSpawn.Call("IsBotReSpawn", id);
if (result is bool b && b)
return true;
}
catch { }
}
return false;
}
private bool IsBotReSpawnNpc(BasePlayer player)
{
if (player == null) return false;
// Ask BotReSpawn directly if this NPC is one of its bots
if (BotReSpawn != null && player is NPCPlayer npc)
{
try
{
var result = BotReSpawn.Call("IsBotReSpawn", npc);
if (result is bool b && b)
return true;
}
catch
{
// ignore errors from BotReSpawn
}
}
// Check our tracked BotReSpawn IDs
if (botReSpawnIds.Contains(player.userID))
return true;
// Fallback: use the ID-based API
return IsBotReSpawnId(player.userID);
}
private bool IsHumanBasePlayer(BasePlayer player)
{
if (player == null) return false;
if (player.IsNpc) return false;
if (IsBotReSpawnNpc(player)) return false;
return IsLikelyHumanSteamId(player.userID);
}
private bool IsHumanPlayerData(PlayerData data)
{
if (data == null) return false;
if (!IsLikelyHumanSteamId(data.UserId)) return false;
if (IsBotReSpawnId(data.UserId)) return false;
return true;
}
// Ensure a BotReSpawn profile has settings in config, return current settings
private BotProfileXpSettings EnsureBotProfileSettings(string profileName)
{
if (string.IsNullOrEmpty(profileName))
profileName = "UnnamedProfile";
if (config.XP.BotReSpawnProfiles == null)
config.XP.BotReSpawnProfiles = new Dictionary<string, BotProfileXpSettings>();
BotProfileXpSettings settings;
if (!config.XP.BotReSpawnProfiles.TryGetValue(profileName, out settings))
{
settings = new BotProfileXpSettings { Multiplier = 1.0, FlatXp = 0.0 };
config.XP.BotReSpawnProfiles[profileName] = settings;
SaveConfig();
}
return settings;
}
private int GetBotXpPage(BasePlayer player)
{
int page;
if (!botXpPage.TryGetValue(player.userID, out page))
page = 0;
return page;
}
private void SetBotXpPage(BasePlayer player, int page)
{
botXpPage[player.userID] = page;
}
#endregion
#region Rage Discipline
private RageNodeConfig GetRageNodeConfig(string nodeId)
{
RageNodeConfig def;
if (!config.Rage.Nodes.TryGetValue(nodeId, out def))
return null;
return def;
}
private int GetRageNodeLevel(PlayerData data, string nodeId)
{
int lvl;
if (data.Rage.NodeLevels.TryGetValue(nodeId, out lvl))
return lvl;
return 0;
}
private string GetRageNodeDescription(string nodeId)
{
switch (nodeId)
{
// Tier 1
case "core_t1": return "Rage Core: global bonuses to damage, recoil and movement.";
case "rifle_t1": return "Predators Focus: improves rifle handling and headshot potential.";
case "shotgun_t1": return "Bonebreaker Load: enhances close-range impact and bleed chance.";
case "pistol_t1": return "Quickkill Reflexes: boosts precision and critical damage output.";
// Tier 2
case "core_t2_bloodlust": return "Bloodlust: feeds ferocity, paving the path to Berserker.";
case "rifle_t2_predators_focus": return "Predator's Focus: unwavering aim in sustained engagements.";
case "shotgun_t2_bonebreaker": return "Bonebreaker Load: pulverizes defenses at point blank.";
case "pistol_t2_quickkill_reflexes": return "Quickkill Reflexes: rapid target acquisition and execution.";
// Tier 3
case "core_t3_berserker_instinct": return "Berserker Instinct: primal, explosive combat intuition.";
case "rifle_t3_overcharge_rounds": return "Overcharge Rounds: destabilizing, amplified projectile impact.";
case "shotgun_t3_sawedoff_fury": return "Sawed-Off Fury: brutal, uncompromising close-range carnage.";
case "pistol_t3_street_executioner": return "Street Executioner: ruthless finishing efficiency.";
default: return string.Empty;
}
}
// Uses ImageLibrary (if present) to fetch a sprite ID for Rage node icons.
// Configure images in ImageLibrary with these keys:
// omnirpg_rage_core, omnirpg_rage_rifle, omnirpg_rage_shotgun, omnirpg_rage_pistol
private string GetRageNodeIconSprite(string nodeId)
{
// Legacy fallback only; primary icon is now RageNodeConfig.TreeIconImage
if (ImageLibrary == null) return string.Empty;
string key = null;
switch (nodeId)
{
case "core_t1": key = "omnirpg_rage_core"; break;
case "rifle_t1": key = "omnirpg_rage_rifle"; break;
case "shotgun_t1": key = "omnirpg_rage_shotgun"; break;
case "pistol_t1": key = "omnirpg_rage_pistol"; break;
}
if (string.IsNullOrEmpty(key)) return string.Empty;
try
{
var result = ImageLibrary.Call("GetImage", key);
if (result is string s && !string.IsNullOrEmpty(s)) return s;
}
catch { }
return string.Empty;
}
private float GetRageDamageBonus(PlayerData data, Item weapon)
{
if (!config.Rage.Enabled) return 0f;
if (data == null) return 0f;
float bonus = 0f;
var coreCfg = GetRageNodeConfig("core_t1");
if (coreCfg != null)
{
var coreLvl = GetRageNodeLevel(data, "core_t1");
bonus += coreCfg.DamageBonusPerLevel * coreLvl;
}
if (weapon != null)
{
var shortname = weapon.info.shortname;
if (shortname.Contains("rifle"))
{
var cfg = GetRageNodeConfig("rifle_t1");
if (cfg != null)
{
var lvl = GetRageNodeLevel(data, "rifle_t1");
bonus += cfg.DamageBonusPerLevel * lvl;
}
}
else if (shortname.Contains("shotgun"))
{
var cfg = GetRageNodeConfig("shotgun_t1");
if (cfg != null)
{
var lvl = GetRageNodeLevel(data, "shotgun_t1");
bonus += cfg.DamageBonusPerLevel * lvl;
}
}
else if (shortname.Contains("pistol") || shortname.Contains("revolver") ||
shortname.Contains("nailgun"))
{
var cfg = GetRageNodeConfig("pistol_t1");
if (cfg != null)
{
var lvl = GetRageNodeLevel(data, "pistol_t1");
bonus += cfg.DamageBonusPerLevel * lvl;
}
}
}
if (data.Rage.FuryAmount > 0f && Time.realtimeSinceStartup < data.Rage.FuryExpireTimestamp)
{
bonus += config.Rage.FuryMaxBonusDamage * Mathf.Clamp01(data.Rage.FuryAmount);
}
return bonus;
}
private void OnRageKillEvent(BasePlayer killer, BaseCombatEntity victim)
{
if (!config.Rage.Enabled) return;
if (!IsHumanBasePlayer(killer)) return;
var data = GetOrCreatePlayerData(killer);
if (data == null) return;
data.Rage.FuryAmount = Mathf.Clamp01(data.Rage.FuryAmount + config.Rage.FuryOnKillGain);
data.Rage.FuryExpireTimestamp = Time.realtimeSinceStartup + config.Rage.FuryDurationSeconds;
killer.SendConsoleCommand("gametip.showtoast", 1,
$"<size=14><color=#e57373>Fury</color> {data.Rage.FuryAmount * 100f:0}%</size>");
}
private void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info)
{
if (entity == null || info == null) return;
var attacker = info.InitiatorPlayer;
if (attacker == null) return;
if (!permission.UserHasPermission(attacker.UserIDString, PERM_USE)) return;
if (!IsHumanBasePlayer(attacker)) return;
var data = GetOrCreatePlayerData(attacker);
if (data == null) return;
var weapon = info.Weapon?.GetItem();
var bonus = GetRageDamageBonus(data, weapon);
if (bonus <= 0f) return;
info.damageTypes.ScaleAll(1f + bonus);
}
private bool TryChargeRespecCost(BasePlayer player)
{
if (player == null)
return false;
var r = config.Rage.Respec;
if (!r.Enabled || string.IsNullOrEmpty(r.Mode))
{
// Respec is effectively free / disabled cost
return true;
}
string mode = r.Mode.ToLowerInvariant();
if (mode == "economics")
{
if (Economics == null)
{
player.ChatMessage("<color=#ffb74d>[OmniRPG]</color> Respec cost is set to Economics, but the Economics plugin is not loaded.");
return false;
}
double balance = 0;
var result = Economics.Call("Balance", player.UserIDString);
if (result is double d) balance = d;
else if (result is float f) balance = f;
else if (result is int i) balance = i;
if (balance < r.EconomicsCost)
{
player.ChatMessage($"<color=#ffb74d>[OmniRPG]</color> You need <color=#e57373>{r.EconomicsCost:0}</color> coins to respec.");
return false;
}
Economics.Call("Withdraw", player.UserIDString, r.EconomicsCost);
player.ChatMessage($"<color=#ffb74d>[OmniRPG]</color> Spent <color=#e57373>{r.EconomicsCost:0}</color> coins to respec.");
return true;
}
if (mode == "serverrewards" || mode == "rustrewards" || mode == "rp")
{
if (ServerRewards == null)
{
player.ChatMessage("<color=#ffb74d>[OmniRPG]</color> Respec cost is set to ServerRewards, but that plugin is not loaded.");
return false;
}
int cost = r.ServerRewardsCost;
if (cost <= 0)
return true;
// ServerRewards API: bool TakePoints(BasePlayer player, int amount)
bool success = false;
var result = ServerRewards.Call("TakePoints", player, cost);
if (result is bool b) success = b;
if (!success)
{
player.ChatMessage($"<color=#ffb74d>[OmniRPG]</color> You need <color=#e57373>{cost}</color> RP to respec.");
return false;
}
player.ChatMessage($"<color=#ffb74d>[OmniRPG]</color> Spent <color=#e57373>{cost}</color> RP to respec.");
return true;
}
if (mode == "item")
{
if (string.IsNullOrEmpty(r.ItemShortname) || r.ItemAmount <= 0)
return true;
var def = ItemManager.FindItemDefinition(r.ItemShortname);
if (def == null)
{
player.ChatMessage($"<color=#ffb74d>[OmniRPG]</color> Respec item shortname '<color=#e57373>{r.ItemShortname}</color>' is invalid.");
return false;
}
int have = player.inventory.GetAmount(def.itemid);
if (have < r.ItemAmount)
{
player.ChatMessage($"<color=#ffb74d>[OmniRPG]</color> You need <color=#e57373>{r.ItemAmount}</color> x <color=#e57373>{def.displayName.english}</color> to respec.");
return false;
}
player.inventory.Take(null, def.itemid, r.ItemAmount);
try
{
// Ensure the player's inventory is updated on the server/client
// Oxide v2 PlayerInventory.ServerUpdate requires a float delta parameter
player.inventory?.ServerUpdate(0f);
}
catch
{
// Some server builds may not expose ServerUpdate; ignore if unavailable
}
player.ChatMessage($"<color=#ffb74d>[OmniRPG]</color> Spent <color=#e57373>{r.ItemAmount}</color> x <color=#e57373>{def.displayName.english}</color> to respec.");
return true;
}
// Unknown mode or "none" -> treat as free
return true;
}
#endregion
#region Chat + Console Commands
private bool HasPerm(BasePlayer player, string perm, bool notify = true)
{
if (player == null) return false;
if (!permission.UserHasPermission(player.UserIDString, perm))
{
if (notify)
player.ChatMessage("<color=#ffb74d>[OmniRPG]</color> You don't have permission to use this command.");
return false;
}
return true;
}
private void CmdProfile(BasePlayer player, string command, string[] args)
{
if (!HasPerm(player, PERM_USE)) return;
var data = GetOrCreatePlayerData(player);
if (data == null)
{
player.ChatMessage("<color=#ffb74d>[OmniRPG]</color> No profile data found. Reconnect if this persists.");
return;
}
player.ChatMessage(
$"<color=#ffb74d>[OmniRPG]</color> Level <color=#ffeb3b>{data.Level}</color> " +
$"XP <color=#b3e5fc>{data.CurrentXp:0}/{data.XpToNextLevel:0}</color> " +
$"Total XP <color=#b3e5fc>{data.TotalXp:0}</color>\n" +
$"Discipline Points: <color=#f48fb1>{data.UnspentDisciplinePoints}</color> " +
$"Rage Points: <color=#e57373>{data.Rage.UnspentPoints}</color> " +
$"Fury: <color=#e57373>{data.Rage.FuryAmount * 100f:0}%</color>\n" +
$"Player Kills: <color=#ffcc80>{data.PlayerKills}</color> | " +
$"NPC Kills: <color=#ffcc80>{data.NpcKills}</color> | " +
$"Deaths: <color=#ffcc80>{data.Deaths}</color>");
}
private void CmdRage(BasePlayer player, string command, string[] args)
{
if (!HasPerm(player, PERM_USE)) return;
var data = GetOrCreatePlayerData(player);
if (data == null) return;
if (args == null || args.Length == 0)
{
ShowRageSummary(player, data);
return;
}
var sub = args[0].ToLower();
if (sub == "add" && args.Length >= 3)
{
if (!HasPerm(player, PERM_ADMIN)) return;
var nodeId = args[1].ToLower();
int points;
if (!int.TryParse(args[2], out points) || points <= 0)
{
player.ChatMessage("<color=#ffb74d>[OmniRPG]</color> Usage: /orpgrage add <node> <points>");
return;
}
AllocateRagePoints(player, data, nodeId, points);
}
else if (sub == "respec")
{
if (!HasPerm(player, PERM_ADMIN)) return;
ResetRageTree(player, data);
}
else
{
ShowRageSummary(player, data);
}
}
private void AllocateRagePoints(BasePlayer player, PlayerData data, string nodeId, int points)
{
RageNodeConfig cfg;
if (!config.Rage.Nodes.TryGetValue(nodeId, out cfg))
{
player.ChatMessage($"<color=#ffb74d>[OmniRPG]</color> Unknown Rage node '{nodeId}'.");
return;
}
int current = GetRageNodeLevel(data, nodeId);
int maxAlloc = cfg.MaxLevel - current;
if (maxAlloc <= 0)
{
player.ChatMessage(
$"<color=#ffb74d>[OmniRPG]</color> Node <color=#e57373>{cfg.DisplayName}</color> is already at max level.");
return;
}
int spend = Math.Min(points, maxAlloc);
if (spend > data.Rage.UnspentPoints)
{
player.ChatMessage(
$"<color=#ffb74d>[OmniRPG]</color> Not enough Rage points. You have <color=#e57373>{data.Rage.UnspentPoints}</color>.");
return;
}
data.Rage.UnspentPoints -= spend;
int newLevel = current + spend;
data.Rage.NodeLevels[nodeId] = newLevel;
// Record for tree flash highlight
data.Rage.LastUpgradedNodeId = nodeId;
data.Rage.LastUpgradeFlashTime = Time.realtimeSinceStartup;
// Tier unlock: core node is the Tier 1 "Super Skill".
// When it reaches max level, unlock Tier 2.
if (nodeId.Equals("core_t1", StringComparison.OrdinalIgnoreCase) &&
newLevel >= cfg.MaxLevel &&
data.Rage.MaxUnlockedTier < 2)
{
data.Rage.MaxUnlockedTier = 2;
player.ChatMessage("<color=#ffb74d>[OmniRPG]</color> Your mastery of Rage unlocks <color=#e57373>Tier 2</color>!");
}
player.ChatMessage(
$"<color=#ffb74d>[OmniRPG]</color> Allocated <color=#e57373>{spend}</color> point(s) to " +
$"<color=#e57373>{cfg.DisplayName}</color>. New level: <color=#e57373>{newLevel}/{cfg.MaxLevel}</color>. " +
$"Remaining Rage points: <color=#e57373>{data.Rage.UnspentPoints}</color>");
SaveData();
}
private void ResetRageTree(BasePlayer player, PlayerData data)
{
int spent = data.Rage.NodeLevels.Values.Sum();
data.Rage.NodeLevels.Clear();
data.Rage.UnspentPoints += spent;
// Reset Rage tier progression and UI helpers
data.Rage.MaxUnlockedTier = 1;
data.Rage.LastUpgradedNodeId = null;
data.Rage.LastUpgradeFlashTime = 0;
data.Rage.SelectedNodeId = null;
player.ChatMessage(
$"<color=#ffb74d>[OmniRPG]</color> Rage tree reset. Refunded <color=#e57373>{spent}</color> points."
// ... [Code preview truncated due to length. Full source in attached file] ...