C#:
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Plugins;
using Oxide.Game.Rust.Cui;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("Hellis Plugin", "Arena2", "1.1.0")]
[Description("Arena duel plugin with random mode selection, global leaderboard, customizable loadouts, and statistics tracking")]
class HellisPlugin : RustPlugin
{
#region Fields
private QueueManager queueManager;
private ArenaManager arenaManager;
private LoadoutManager loadoutManager;
private AimTrainManager aimTrainManager;
private Dictionary<ulong, ActiveMatch> activeMatches = new Dictionary<ulong, ActiveMatch>();
private Dictionary<ulong, PlayerData> playerData = new Dictionary<ulong, PlayerData>();
private Dictionary<ulong, ArenaBuilder> arenaBuilders = new Dictionary<ulong, ArenaBuilder>();
private HashSet<ulong> defeatedThisTick = new HashSet<ulong>(); // Track defeated players to prevent race conditions
private HashSet<ulong> activeLeaderboardUIs = new HashSet<ulong>(); // Track players with leaderboard UI
private HashSet<ulong> activeJoinButtons = new HashSet<ulong>(); // Track players with join button
private HashSet<ulong> activeLeaveButtons = new HashSet<ulong>(); // Track players with leave button
private HashSet<ulong> autoRequeueOptOut = new HashSet<ulong>(); // Track players who opted out of auto-requeue
private List<ArenaConfig> arenas = new List<ArenaConfig>(); // Arena storage (stored in data file, not config)
// Lobby browser system
private Dictionary<QueueType, List<ulong>> queuesByType = new Dictionary<QueueType, List<ulong>>();
private Dictionary<string, PrivateRoom> privateRooms = new Dictionary<string, PrivateRoom>();
#endregion
#region Configuration
private Configuration config;
// Configuration class - stores all server settings
// Note: Player data is stored in HellisPlugin_PlayerData
// Note: Arena data is stored in HellisPlugin_Arenas
// Note: Loadouts can be customized per weapon mode (see ADMIN_GUIDE.md)
public class Configuration
{
[JsonProperty("Server Name")]
public string ServerName = "Hellis Server";
[JsonProperty("Enable Aim Training")]
public bool EnableAimTraining = true;
[JsonProperty("Enable 1v1 Duels")]
public bool EnableDuels = true;
[JsonProperty("Enable Speargun Mode")]
public bool EnableSpeargun = true;
[JsonProperty("Countdown Duration (seconds)")]
public int CountdownDuration = 3;
[JsonProperty("Best of X Rounds")]
public int BestOfRounds = 3;
[JsonProperty("Auto Requeue After Match")]
public bool AutoRequeue = true;
[JsonProperty("Max Instances Per Arena")]
public int MaxInstancesPerArena = 5;
// NOTE: Lobby configuration has been moved to ArenaConfig (HellisPlugin_Arenas data file)
// NOTE: Duel arenas are now stored in HellisPlugin_Arenas data file, not in config
[JsonProperty("Aim Train Arena Position")]
public Vector3 AimTrainArena = new Vector3(-100, 0, -100);
[JsonProperty("Aim Train Arena Radius")]
public float AimTrainArenaRadius = 30f;
[JsonProperty("Enable Zone Enforcement")]
public bool EnableZoneEnforcement = false;
[JsonProperty("Leaderboard Time Window (minutes)")]
public int LeaderboardTimeWindowMinutes = 5;
[JsonProperty("UI Auto-Refresh Interval (seconds, 0 = disabled)")]
public int UIRefreshInterval = 30;
[JsonProperty("Loadouts")]
public Dictionary<string, LoadoutConfig> Loadouts = GetDefaultLoadouts();
private static Dictionary<string, LoadoutConfig> GetDefaultLoadouts()
{
return new Dictionary<string, LoadoutConfig>
{
["AK47"] = new LoadoutConfig
{
Items = new List<LoadoutItemConfig>
{
new LoadoutItemConfig { ShortName = "rifle.ak", Amount = 1 },
new LoadoutItemConfig { ShortName = "ammo.rifle", Amount = 120 },
new LoadoutItemConfig { ShortName = "metal.plate.torso", Amount = 1 },
new LoadoutItemConfig { ShortName = "metal.facemask", Amount = 1 },
new LoadoutItemConfig { ShortName = "roadsign.kilt", Amount = 1 },
new LoadoutItemConfig { ShortName = "syringe.medical", Amount = 4 }
}
},
["SAR"] = new LoadoutConfig
{
Items = new List<LoadoutItemConfig>
{
new LoadoutItemConfig { ShortName = "rifle.semiauto", Amount = 1 },
new LoadoutItemConfig { ShortName = "ammo.rifle", Amount = 96 },
new LoadoutItemConfig { ShortName = "metal.plate.torso", Amount = 1 },
new LoadoutItemConfig { ShortName = "coffeecan.helmet", Amount = 1 },
new LoadoutItemConfig { ShortName = "roadsign.kilt", Amount = 1 },
new LoadoutItemConfig { ShortName = "syringe.medical", Amount = 4 }
}
},
["Speargun"] = new LoadoutConfig
{
Items = new List<LoadoutItemConfig>
{
new LoadoutItemConfig { ShortName = "speargun", Amount = 1 },
new LoadoutItemConfig { ShortName = "speargun.spear", Amount = 16 },
new LoadoutItemConfig { ShortName = "diving.fins", Amount = 1 },
new LoadoutItemConfig { ShortName = "diving.mask", Amount = 1 },
new LoadoutItemConfig { ShortName = "diving.tank", Amount = 1 },
new LoadoutItemConfig { ShortName = "diving.wetsuit", Amount = 1 },
new LoadoutItemConfig { ShortName = "syringe.medical", Amount = 3 }
}
},
["Bow"] = new LoadoutConfig
{
Items = new List<LoadoutItemConfig>
{
new LoadoutItemConfig { ShortName = "bow.hunting", Amount = 1 },
new LoadoutItemConfig { ShortName = "arrow.wooden", Amount = 60 },
new LoadoutItemConfig { ShortName = "attire.hide.vest", Amount = 1 },
new LoadoutItemConfig { ShortName = "bandage", Amount = 6 }
}
},
["Revolver"] = new LoadoutConfig
{
Items = new List<LoadoutItemConfig>
{
new LoadoutItemConfig { ShortName = "pistol.revolver", Amount = 1 },
new LoadoutItemConfig { ShortName = "ammo.pistol", Amount = 64 },
new LoadoutItemConfig { ShortName = "burlap.shirt", Amount = 1 },
new LoadoutItemConfig { ShortName = "burlap.trousers", Amount = 1 },
new LoadoutItemConfig { ShortName = "bandage", Amount = 4 }
}
}
};
}
}
// Loadout configuration for a specific weapon mode
// Each mode (AK47, SAR, Bow, Revolver, Speargun) has its own loadout
// Customize items, amounts, armor, and attachments via config (see ADMIN_GUIDE.md)
public class LoadoutConfig
{
[JsonProperty("Items")]
public List<LoadoutItemConfig> Items = new List<LoadoutItemConfig>();
}
// Individual item in a loadout
// ShortName: Rust item shortname (e.g., "rifle.ak", "ammo.rifle", "metal.plate.torso")
// Amount: Quantity to give player
public class LoadoutItemConfig
{
[JsonProperty("ShortName")]
public string ShortName;
[JsonProperty("Amount")]
public int Amount;
}
// Arena configuration - defines duel arena spawn points
// Stored in HellisPlugin_Arenas data file (not in main config)
public class ArenaConfig
{
public string Name;
public Vector3 Spawn1;
public Vector3 Spawn2;
[JsonProperty("Radius")]
public float Radius = 30f;
// Lobby configuration (moved from main config)
public Vector3 LobbyPosition;
public float LobbyRadius = 10f;
public bool LobbyPositionSet;
}
protected override void LoadConfig()
{
base.LoadConfig();
try
{
config = Config.ReadObject<Configuration>();
if (config == null)
{
throw new Exception();
}
}
catch
{
// Only write error config if it's not null
if (config != null)
{
Config.WriteObject(config, false, $"{Interface.Oxide.ConfigDirectory}/{Name}.jsonError");
}
PrintError("The configuration file contains an error and has been replaced with a default config.\n" +
"The error configuration file was saved in the .jsonError extension");
LoadDefaultConfig();
SaveConfig(); // Only save when creating new default config
}
}
protected override void LoadDefaultConfig() => config = new Configuration();
protected override void SaveConfig() => Config.WriteObject(config);
#endregion
#region Hooks
private void Init()
{
queueManager = new QueueManager();
arenaManager = new ArenaManager(arenas, config.MaxInstancesPerArena);
loadoutManager = new LoadoutManager(config);
aimTrainManager = new AimTrainManager();
// Initialize lobby browser queues
foreach (QueueType queueType in Enum.GetValues(typeof(QueueType)))
{
queuesByType[queueType] = new List<ulong>();
}
// Migration: Move lobby from old config to arena data (one-time)
// This happens automatically when plugin loads with old config
// Note: We can't access config.LobbyPosition here since it's been removed
// Migration is handled by preserving data through ArenaConfig loading
permission.RegisterPermission("hellisplugin.use", this);
permission.RegisterPermission("hellisplugin.admin", this);
// Load persistent player data
LoadData();
Puts($"Hellis Plugin v1.0.0 loaded - {config.ServerName}");
Puts($"Multi-instance arenas enabled: {config.MaxInstancesPerArena} instances per arena");
// Auto-save player data every 5 minutes
timer.Every(300f, () => SaveData());
// Refresh UI for all connected players after config loads (fixes missing buttons after reload)
timer.Once(2f, () => RefreshAllPlayerUI());
// Auto-refresh leaderboard for rolling 5-minute window (if enabled)
if (config.UIRefreshInterval > 0)
{
timer.Repeat(config.UIRefreshInterval, 0, () => RefreshAllPlayerUI());
}
// Cleanup old stat events every 60 seconds to keep data manageable
timer.Repeat(60f, 0, () => CleanupOldEvents());
// Zone enforcement check every 5 seconds (if enabled)
if (config.EnableZoneEnforcement)
{
timer.Every(5f, () => CheckZoneEnforcement());
Puts("Zone enforcement enabled - players will be kept in designated zones");
}
}
private void OnServerInitialized()
{
if (config.EnableAimTraining)
{
Puts("Aim Training mode enabled");
}
if (config.EnableDuels)
{
Puts("1v1 Duel mode enabled");
}
timer.Every(1f, () => ProcessMatchmaking());
}
private object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info)
{
if (entity is BasePlayer player && player != null)
{
// Check if player is in an active match
if (activeMatches.ContainsKey(player.userID))
{
// Check if player already defeated this tick (prevents multiple headshots race condition)
if (defeatedThisTick.Contains(player.userID))
{
// Player is already being defeated - ignore all subsequent damage
info.damageTypes = new Rust.DamageTypeList();
info.DoHitEffects = false;
info.HitMaterial = 0;
return true; // Block all damage
}
// Calculate if this damage would bring player below 10 HP threshold
float totalDamage = info?.damageTypes?.Total() ?? 0f;
// Intercept at 10 HP threshold to prevent death screen and ensure clean reset
// This also prevents bleeding/poison from killing the player after the hit
if (player.health <= 10f || player.health - totalDamage <= 10f)
{
// Player is below/would be below 10 HP - intercept and reset!
var match = activeMatches[player.userID];
// Mark player as defeated IMMEDIATELY to prevent race conditions with rapid hits
defeatedThisTick.Add(player.userID);
// Cancel all damage to prevent any effects
info.damageTypes = new Rust.DamageTypeList();
info.DoHitEffects = false;
info.HitMaterial = 0;
// Handle the "defeat" with full reset IMMEDIATELY (not NextTick!)
// This prevents race conditions where multiple headshots could kill the player
// before the deferred action runs
HandleMatchPlayerDefeat(player, match);
// Clear the defeated flag after a short delay to allow for round reset
timer.Once(0.5f, () => defeatedThisTick.Remove(player.userID));
// Prevent the damage from being applied
return true;
}
}
}
return null; // Allow normal damage processing
}
private void HandleMatchPlayerDefeat(BasePlayer player, ActiveMatch match)
{
if (player == null || !player.IsConnected) return;
// FULL RESET - Set health to maximum (100 HP by default)
player.health = player.MaxHealth();
// STOP ALL BLEEDING - Critical to prevent death after reset
player.metabolism.bleeding.value = 0f;
// CLEAR ALL DAMAGE OVER TIME EFFECTS
player.metabolism.poison.value = 0f;
player.metabolism.radiation_level.value = 0f;
player.metabolism.radiation_poison.value = 0f;
// RESET METABOLISM - Full health state
player.metabolism.calories.value = player.metabolism.calories.max;
player.metabolism.hydration.value = player.metabolism.hydration.max;
player.metabolism.temperature.value = 32f; // Normal body temperature
player.metabolism.wetness.value = 0f;
player.metabolism.dirtyness.value = 0f;
player.metabolism.oxygen.value = player.metabolism.oxygen.max;
// Make player invisible/frozen during transition
player.SetPlayerFlag(BasePlayer.PlayerFlags.Spectating, true);
player.SetPlayerFlag(BasePlayer.PlayerFlags.ThirdPersonViewmode, true);
// Clear inventory to prevent item usage during transition
player.inventory.Strip();
// Record the defeat for match logic
match.OnPlayerDeath(player.userID);
// Note: Kills/Deaths are tracked in UpdatePlayerStats based on round scores
// to avoid double-counting
// Check if round/match should end
CheckMatchEnd(match);
}
private object OnPlayerDeath(BasePlayer player, HitInfo info)
{
// CRITICAL: Prevent death screen for match players
// This is a failsafe in case damage interception somehow fails
if (player != null && activeMatches.ContainsKey(player.userID))
{
// Player should NEVER actually die during a match due to damage interception
// But if they somehow do, prevent the death and death screen
var match = activeMatches[player.userID];
// Record the defeat
match.OnPlayerDeath(player.userID);
// Check match end
CheckMatchEnd(match);
// PREVENT DEATH - cancel the death event and death screen
// Returning false prevents the death from processing
return false;
}
// Allow normal deaths for non-match players
return null;
}
// OnPlayerRespawn hook removed - no longer needed!
// With damage interception, players never die during matches, so they never respawn.
// Players are frozen and reset in place instead.
// This also prevents conflicts with other spawn plugins like SpawnCore.
private void OnPlayerDisconnected(BasePlayer player)
{
if (player == null) return;
// Remove from queue if queued (old system)
queueManager.LeaveQueue(player.userID);
// Remove from new queue system (Phase 3)
LeaveQueueInternal(player, false);
// Clean up private rooms (Phase 4)
CleanupPlayerRooms(player.userID);
// End match if in one
if (activeMatches.ContainsKey(player.userID))
{
var match = activeMatches[player.userID];
EndMatch(match, true);
}
// Clean up UIs
DestroyLobbyBrowser(player); // Phase 2: Use lobby browser
DestroyLeaderboardUI(player);
}
private void OnPlayerConnected(BasePlayer player)
{
if (player == null) return;
// Teleport to lobby and show UI after a small delay to ensure player is fully loaded
timer.Once(2f, () =>
{
if (player != null && player.IsConnected)
{
TeleportToLobby(player);
// Phase 2: Show lobby browser instead of simple button
ShowLobbyBrowser(player);
ShowLeaderboardUI(player); // Show persistent leaderboard (always)
// Send welcome and instructions
SendReply(player, "═══════════════════════════════════════");
SendReply(player, "Welcome to Hellis Duels!");
SendReply(player, "Use the lobby browser to join or create matches!");
SendReply(player, "Type /help to see all commands");
if (arenaManager.GetAllArenas().Count == 0)
{
if (permission.UserHasPermission(player.UserIDString, "hellisplugin.admin"))
{
SendReply(player, "No arenas found! Use /arena create to set up arenas");
}
else
{
SendReply(player, "No arenas available yet. Ask an admin to create some!");
}
}
SendReply(player, "═══════════════════════════════════════");
}
});
}
private void TeleportToLobby(BasePlayer player)
{
if (player == null || !player.IsConnected) return;
if (arenaManager.IsLobbySet())
{
player.Teleport(arenaManager.GetLobbyPosition());
SendReply(player, "Welcome to the lobby!");
}
else
{
SendReply(player, "⚠ Lobby not configured. Admin: use /lobby setpos to set lobby location.");
}
}
private object CanNetworkTo(BaseNetworkable entity, BasePlayer target)
{
// Player visibility isolation for multi-instance arenas
// Players in a match can only see their opponent
if (entity is BasePlayer player)
{
// Check if the player is in an active match
if (activeMatches.ContainsKey(player.userID))
{
var match = activeMatches[player.userID];
// Allow visibility to opponent in the same match
if (target.userID == match.Player1ID || target.userID == match.Player2ID)
{
return null; // Allow default behavior (visible)
}
// Hide from all other players
return false;
}
// Check if the target is in an active match
if (activeMatches.ContainsKey(target.userID))
{
var match = activeMatches[target.userID];
// Only show to their opponent
if (player.userID == match.Player1ID || player.userID == match.Player2ID)
{
return null; // Allow default behavior (visible)
}
// Hide from all other players
return false;
}
}
return null; // Default behavior
}
#endregion
#region Commands
// /duel command removed - use JOIN QUEUE button instead (always random mode)
[ChatCommand("leave")]
private void LeaveCommand(BasePlayer player, string command, string[] args)
{
if (queueManager.LeaveQueue(player.userID))
{
SendReply(player, "Left the queue.");
DestroyLeaveButton(player);
TeleportToLobby(player);
// ShowJoinButton(player); // Removed - lobby browser handles this
ShowLobbyBrowser(player); // Show lobby browser instead
}
else
{
SendReply(player, "You're not in queue.");
}
}
[ChatCommand("aimtrain")]
private void AimTrainCommand(BasePlayer player, string command, string[] args)
{
if (!config.EnableAimTraining)
{
SendReply(player, "Aim training is currently disabled.");
return;
}
if (!permission.UserHasPermission(player.UserIDString, "hellisplugin.use"))
{
SendReply(player, "You don't have permission to use this command.");
return;
}
StartAimTraining(player);
}
[ChatCommand("stats")]
private void StatsCommand(BasePlayer player, string command, string[] args)
{
if (!playerData.ContainsKey(player.userID))
{
SendReply(player, "No stats yet. Play some matches!");
return;
}
var data = playerData[player.userID];
SendReply(player, $"=== Your Stats ===\n" +
$"Matches: {data.TotalMatches}\n" +
$"Wins: {data.Wins}\n" +
$"Losses: {data.Losses}\n" +
$"Win Rate: {data.WinRate:F1}%\n" +
$"Rounds Won: {data.RoundsWon}\n" +
$"Rounds Lost: {data.RoundsLost}");
}
[ChatCommand("leaderboard")]
private void LeaderboardCommand(BasePlayer player, string command, string[] args)
{
if (playerData.Count == 0)
{
SendReply(player, "No stats yet. Be the first to duel!");
return;
}
// Sort by win rate, then by total wins - GLOBAL (no minimum matches)
var topPlayers = playerData
.OrderByDescending(p => p.Value.WinRate)
.ThenByDescending(p => p.Value.Wins)
.Take(10)
.ToList();
var message = "=== GLOBAL LEADERBOARD - TOP 10 ===\n";
int rank = 1;
foreach (var entry in topPlayers)
{
var playerName = covalence.Players.FindPlayerById(entry.Key.ToString())?.Name ?? "Unknown";
var stats = entry.Value;
message += $"{rank}. {playerName} - {stats.Wins}W/{stats.Losses}L ({stats.WinRate:F1}%)\n";
rank++;
}
// Show player's own rank if not in top 10
if (playerData.ContainsKey(player.userID))
{
var playerRank = playerData
.OrderByDescending(p => p.Value.WinRate)
.ThenByDescending(p => p.Value.Wins)
.ToList()
.FindIndex(p => p.Key == player.userID) + 1;
if (playerRank > 10)
{
var yourStats = playerData[player.userID];
message += $"\nYour Rank: #{playerRank} - {yourStats.Wins}W/{yourStats.Losses}L ({yourStats.WinRate:F1}%)";
}
}
SendReply(player, message);
}
[ChatCommand("top")]
private void TopCommand(BasePlayer player, string command, string[] args)
{
LeaderboardCommand(player, command, args);
}
[ChatCommand("toggleleaderboard")]
private void ToggleLeaderboardCommand(BasePlayer player, string command, string[] args)
{
if (activeLeaderboardUIs.Contains(player.userID))
{
DestroyLeaderboardUI(player);
SendReply(player, "Leaderboard UI hidden. Type /toggleleaderboard to show it again.");
}
else
{
ShowLeaderboardUI(player);
SendReply(player, "Leaderboard UI shown. Type /toggleleaderboard to hide it.");
}
}
[ChatCommand("showui")]
private void ShowUICommand(BasePlayer player, string command, string[] args)
{
ShowLobbyBrowser(player); // Show lobby browser instead of old button
ShowLeaderboardUI(player);
SendReply(player, "UI elements refreshed!");
SendReply(player, "Lobby Browser: Right side");
SendReply(player, "Leaderboard: Top left");
SendReply(player, "If you still don't see them, you may need to enable your cursor with F1 menu.");
}
// Helper method to refresh UI for all connected players (used after config reloads)
private void RefreshAllPlayerUI()
{
int refreshed = 0;
foreach (var player in BasePlayer.activePlayerList)
{
if (player == null || !player.IsConnected) continue;
// Skip players in active matches (they don't see lobby UI anyway)
if (activeMatches.Values.Any(d => d.Player1ID == player.userID || d.Player2ID == player.userID))
continue;
ShowLobbyBrowser(player); // Show lobby browser instead of old button
ShowLeaderboardUI(player);
refreshed++;
}
if (refreshed > 0)
{
Puts($"Refreshed UI for {refreshed} player(s) in lobby");
}
}
// Rolling stat calculation methods - calculate stats from events within time window
private int GetRecentKills(PlayerData data, int minutes)
{
var cutoff = DateTime.Now.AddMinutes(-minutes);
return data.StatEvents.Count(e => e.Type == "Kill" && e.Timestamp >= cutoff);
}
private int GetRecentDeaths(PlayerData data, int minutes)
{
var cutoff = DateTime.Now.AddMinutes(-minutes);
return data.StatEvents.Count(e => e.Type == "Death" && e.Timestamp >= cutoff);
}
private int GetRecentWins(PlayerData data, int minutes)
{
var cutoff = DateTime.Now.AddMinutes(-minutes);
return data.StatEvents.Count(e => e.Type == "Win" && e.Timestamp >= cutoff);
}
private int GetRecentLosses(PlayerData data, int minutes)
{
var cutoff = DateTime.Now.AddMinutes(-minutes);
return data.StatEvents.Count(e => e.Type == "Loss" && e.Timestamp >= cutoff);
}
// Cleanup old stat events to prevent data bloat
private void CleanupOldEvents()
{
var cutoff = DateTime.Now.AddMinutes(-config.LeaderboardTimeWindowMinutes);
int totalRemoved = 0;
foreach (var kvp in playerData)
{
int before = kvp.Value.StatEvents.Count;
kvp.Value.StatEvents.RemoveAll(e => e.Timestamp < cutoff);
totalRemoved += (before - kvp.Value.StatEvents.Count);
}
if (totalRemoved > 0)
{
Puts($"Cleaned up {totalRemoved} old stat event(s)");
SavePlayerData();
}
}
[ChatCommand("autorequeue")]
private void AutoRequeueCommand(BasePlayer player, string command, string[] args)
{
if (args.Length == 0)
{
// Show current status
bool enabled = !autoRequeueOptOut.Contains(player.userID);
SendReply(player, $"Auto-requeue is currently {(enabled ? "ENABLED" : "DISABLED")}");
SendReply(player, "Use '/autorequeue on' or '/autorequeue off' to change");
return;
}
string action = args[0].ToLower();
if (action == "on" || action == "enable")
{
autoRequeueOptOut.Remove(player.userID);
SendReply(player, "✓ Auto-requeue ENABLED - You'll automatically rejoin queue after matches");
}
else if (action == "off" || action == "disable")
{
autoRequeueOptOut.Add(player.userID);
SendReply(player, "✓ Auto-requeue DISABLED - Click JOIN QUEUE manually after matches");
}
else
{
SendReply(player, "Usage: /autorequeue [on/off]");
}
}
[ChatCommand("clearleaderboard")]
private void ClearLeaderboardCommand(BasePlayer player, string command, string[] args)
{
if (player == null) return;
// Admin check
if (!player.IsAdmin)
{
SendReply(player, "You must be an admin to use this command.");
return;
}
// Require confirmation
if (args == null || args.Length == 0 || args[0] != "confirm")
{
SendReply(player, "⚠️ WARNING: This will clear ALL player statistics!");
SendReply(player, "Type '/clearleaderboard confirm' to proceed.");
return;
}
// Clear all player data
int count = playerData.Count;
playerData.Clear();
SavePlayerData();
RefreshAllPlayerUI();
Puts($"{player.displayName} cleared all leaderboard data ({count} players)");
SendReply(player, $"✅ Leaderboard cleared! Removed {count} player records.");
}
[ChatCommand("help")]
private void HelpCommand(BasePlayer player, string command, string[] args)
{
SendReply(player, "═══════════════════════════════════════");
SendReply(player, "Hellis Duels - Available Commands");
SendReply(player, "═══════════════════════════════════════");
SendReply(player, "");
SendReply(player, "─── Queue Commands ───");
SendReply(player, "Click JOIN QUEUE button (top-right) to join!");
SendReply(player, "/leave - Leave the queue or match");
SendReply(player, "/autorequeue [on/off] - Toggle auto-requeue after matches");
SendReply(player, "");
SendReply(player, "─── Arena Commands ───");
SendReply(player, "/lobby - Teleport to lobby");
SendReply(player, "");
SendReply(player, "─── Stats Commands ───");
SendReply(player, "/stats - View your statistics");
SendReply(player, "/leaderboard or /top - View top players");
SendReply(player, "/toggleleaderboard - Show/hide leaderboard UI");
SendReply(player, "/showui - Refresh UI elements (if not visible)");
SendReply(player, "");
if (permission.UserHasPermission(player.UserIDString, "hellisplugin.admin"))
{
SendReply(player, "─── Admin Commands ───");
SendReply(player, "/arena create <name> - Start creating arena");
SendReply(player, "/arena setspawn1 - Set first spawn");
SendReply(player, "/arena setspawn2 - Set second spawn");
SendReply(player, "/arena save - Save the arena");
SendReply(player, "/arena list - List all arenas");
SendReply(player, "/arena delete <name> - Delete arena");
SendReply(player, "/arena setradius <name> <radius> - Set arena zone radius");
SendReply(player, "/lobby setpos - Set lobby position");
SendReply(player, "/lobby setradius <radius> - Set lobby zone radius");
SendReply(player, "/clearleaderboard - Clear all leaderboard data (requires confirm)");
SendReply(player, "");
}
SendReply(player, "═══════════════════════════════════════");
SendReply(player, "TIP: Click the green JOIN QUEUE button to start dueling!");
SendReply(player, "═══════════════════════════════════════");
}
[ChatCommand("forfeit")]
private void ForfeitCommand(BasePlayer player, string command, string[] args)
{
if (activeMatches.ContainsKey(player.userID))
{
var match = activeMatches[player.userID];
EndMatch(match, false, player.userID);
SendReply(player, "You forfeited the match.");
}
else
{
SendReply(player, "You're not in a match.");
}
}
[ChatCommand("lobby")]
private void LobbyCommand(BasePlayer player, string command, string[] args)
{
if (args.Length == 0)
{
TeleportToLobby(player);
return;
}
if (!permission.UserHasPermission(player.UserIDString, "hellisplugin.admin"))
{
SendReply(player, "You don't have permission to configure the lobby.");
return;
}
switch (args[0].ToLower())
{
case "setpos":
arenaManager.SetLobby(player.transform.position, arenaManager.GetLobbyRadius());
SaveArenas();
SendReply(player, $"Lobby position set to: {player.transform.position}");
break;
case "setradius":
if (args.Length < 2)
{
SendReply(player, $"Current lobby radius: {arenaManager.GetLobbyRadius()}m\nUsage: /lobby setradius <radius>");
return;
}
float lobbyRadius;
if (float.TryParse(args[1], out lobbyRadius) && lobbyRadius > 0)
{
arenaManager.SetLobby(arenaManager.GetLobbyPosition(), lobbyRadius);
SaveArenas();
SendReply(player, $"Lobby radius set to: {lobbyRadius}m");
}
else
{
SendReply(player, "Invalid radius. Please enter a positive number.");
}
break;
default:
SendReply(player, "Lobby Commands:\n" +
"/lobby - Teleport to lobby\n" +
"/lobby setpos - Set lobby position (Admin)\n" +
"/lobby setradius <radius> - Set lobby zone radius (Admin)");
break;
}
}
[ChatCommand("arena")]
private void ArenaCommand(BasePlayer player, string command, string[] args)
{
if (!permission.UserHasPermission(player.UserIDString, "hellisplugin.admin"))
{
SendReply(player, "You don't have permission to use this command.");
return;
}
if (args.Length == 0)
{
SendReply(player, "Arena Commands:\n" +
"/arena create <name> - Start creating a new arena\n" +
"/arena setspawn1 - Set first spawn point\n" +
"/arena setspawn2 - Set second spawn point\n" +
"/arena save - Save the arena\n" +
"/arena cancel - Cancel arena creation\n" +
"/arena list - List all arenas\n" +
"/arena delete <name> - Delete an arena\n" +
"/arena setradius <name> <radius> - Set arena zone radius\n" +
"/arena tp <name> [1|2] - Teleport to arena spawn");
return;
}
switch (args[0].ToLower())
{
case "create":
if (args.Length < 2)
{
SendReply(player, "Usage: /arena create <name>");
return;
}
CreateArena(player, string.Join(" ", args.Skip(1)));
break;
case "setspawn1":
SetSpawn1(player);
break;
case "setspawn2":
SetSpawn2(player);
break;
case "save":
SaveArena(player);
break;
case "cancel":
CancelArena(player);
break;
case "list":
ListArenas(player);
break;
case "delete":
if (args.Length < 2)
{
SendReply(player, "Usage: /arena delete <name>");
return;
}
DeleteArena(player, string.Join(" ", args.Skip(1)));
break;
case "tp":
if (args.Length < 2)
{
SendReply(player, "Usage: /arena tp <name> [1|2]");
return;
}
int spawnNum = 1;
string arenaName;
if (args.Length >= 3 && int.TryParse(args[args.Length - 1], out spawnNum))
{
// Last arg is spawn number
arenaName = string.Join(" ", args.Skip(1).Take(args.Length - 2));
}
else
{
// No spawn number, use all remaining args as name
arenaName = string.Join(" ", args.Skip(1));
}
TeleportToArena(player, arenaName, spawnNum);
break;
case "setradius":
if (args.Length < 3)
{
SendReply(player, "Usage: /arena setradius <name> <radius>");
return;
}
string arenaNameForRadius = string.Join(" ", args.Skip(1).Take(args.Length - 2));
float arenaRadius;
if (!float.TryParse(args[args.Length - 1], out arenaRadius) || arenaRadius <= 0)
{
SendReply(player, "Invalid radius. Please enter a positive number.");
return;
}
var arenaToUpdate = arenas.FirstOrDefault(a => a.Name.Equals(arenaNameForRadius, StringComparison.OrdinalIgnoreCase));
if (arenaToUpdate == null)
{
SendReply(player, $"Arena '{arenaNameForRadius}' not found.");
return;
}
arenaToUpdate.Radius = arenaRadius;
SaveArenas();
SendReply(player, $"Arena '{arenaNameForRadius}' radius set to: {arenaRadius}m");
break;
default:
SendReply(player, "Unknown command. Use /arena for help.");
break;
}
}
#endregion
#region Matchmaking
private void ProcessMatchmaking()
{
// Process old queue system
var matches = queueManager.TryMatchAll();
foreach (var match in matches)
{
var player1 = BasePlayer.FindByID(match.Player1ID);
var player2 = BasePlayer.FindByID(match.Player2ID);
if (player1 == null || player2 == null) continue;
StartDuel(player1, player2, match.Mode);
}
// Process new queue system (Phase 3)
ProcessAllQueues();
}
private void StartDuel(BasePlayer player1, BasePlayer player2, DuelMode mode)
{
var arena = arenaManager.GetAvailableArena();
if (arena == null)
{
SendReply(player1, "No arenas available. Please wait.");
SendReply(player2, "No arenas available. Please wait.");
queueManager.JoinQueue(player1.userID, player1.displayName, mode);
queueManager.JoinQueue(player2.userID, player2.displayName, mode);
return;
}
// Get instance ID for this match
int instanceId = arena.GetNextInstanceId();
if (instanceId == -1)
{
SendReply(player1, "No arena instances available. Please wait.");
SendReply(player2, "No arena instances available. Please wait.");
queueManager.JoinQueue(player1.userID, player1.displayName, mode);
queueManager.JoinQueue(player2.userID, player2.displayName, mode);
return;
}
var match = new ActiveMatch(player1.userID, player2.userID, mode, arena, instanceId, config.BestOfRounds);
activeMatches[player1.userID] = match;
activeMatches[player2.userID] = match;
// Clean up any arrows from previous matches
var arrows1 = new List<BaseEntity>();
foreach (var child in player1.children)
{
if (child != null && child.ShortPrefabName != null && child.ShortPrefabName.Contains("arrow"))
arrows1.Add(child);
}
foreach (var arrow in arrows1) arrow.Kill();
var arrows2 = new List<BaseEntity>();
foreach (var child in player2.children)
{
if (child != null && child.ShortPrefabName != null && child.ShortPrefabName.Contains("arrow"))
arrows2.Add(child);
}
foreach (var arrow in arrows2) arrow.Kill();
// Teleport players to arena
TeleportPlayer(player1, arena.Spawn1);
TeleportPlayer(player2, arena.Spawn2);
// Apply loadouts
GiveLoadout(player1, mode);
GiveLoadout(player2, mode);
// Hide join button and show leave button during match
DestroyJoinButton(player1);
DestroyJoinButton(player2);
ShowLeaveButton(player1);
ShowLeaveB
// ... [Code preview truncated due to length. Full source in attached file] ...