Иконка ресурса

FREE MStats : 2.0.0 2.0.0

Нет прав для скачивания
C#:
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using ConVar;
using Oxide.Core;
using Oxide.Core.Database;
using Oxide.Core.Plugins;
using Rust;
using UnityEngine;
// Alias instead of `using Oxide.Core.MySql.Libraries;`: inside namespace Oxide.Plugins the bare name `MySql`
// resolves to Carbon's `Oxide.MySql` namespace before any using directive is considered (CS0118 on Carbon).
using MySqlLibrary = Oxide.Core.MySql.Libraries.MySql;
namespace Oxide.Plugins {
    [Info("MStats", "Limmek", "2.0.0")]
    [Description("Logs player statistics and server events to MySQL")]
    public class MStats : RustPlugin {
#region Constants
        /// <summary>
        /// Version of the MySQL schema this build expects. Independent of the plugin version.
        /// Bump it and register a migration in <see cref="Migrations"/> whenever a table changes.
        /// </summary>
        public const int DatabaseSchemaVersion = 1;
        private const string SchemaTable = "mstats_schema";
        private const string WipeDataFile = "MStats";
        private const string PendingDataFile = "MStats_pending";
        private const string PermissionAdmin = "mstats.admin";
        private const ulong SteamIdOffset = 76561197960265728UL;
        private const int MaxReconnectDelaySeconds = 900;
        private const int ViolationThrottleSeconds = 30;
        /// <summary>Console commands the client sends automatically during normal play; not worth a row each.</summary>
        private static readonly string[] IgnoredCommandPrefixes = {
            "chat.", "inventory.", "craft.", "client.", "global.respawn", "global.respawn_sleepingbag", "note.", "gametip.", "demo.", "mission."
        }
        ;
        private const string TableOptions = " ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
#endregion
#region Fields
        private ConfigData _config;
        private MySqlLibrary _mySql;
        private Connection _db;
        private DbState _state = DbState.Disabled;
        private bool _schemaReady;
        private bool _schemaInitRunning;
        private int _schemaVersionInDb = -1;
        private string _schemaError;
        private DateTime _connectedSince;
        private DateTime _nextReconnectAt = DateTime.MinValue;
        private int _reconnectDelaySeconds;
        private Timer _healthTimer;
        private Timer _statusTimer;
        private readonly Queue<PendingStatement> _pending = new Queue<PendingStatement>();
        private long _sentStatements;
        private long _failedStatements;
        private long _droppedStatements;
        private DateTime _lastOfflineLog = DateTime.MinValue;
        private DateTime? _lastErrorAt;
        private string _lastError;
        private readonly Dictionary<ulong, Session> _sessions = new Dictionary<ulong, Session>();
        private readonly Dictionary<string, long> _counters = new Dictionary<string, long>();
        private readonly Dictionary<string, DateTime> _violationSeen = new Dictionary<string, DateTime>();
        /// <summary>Quarry / excavator arm net id -> Steam ID of the player who last started it / set its target.</summary>
        private readonly Dictionary<ulong, ulong> _extractorOperators = new Dictionary<ulong, ulong>();
        private bool _newSaveDetected;
        private bool _truncatePending;
        private string _truncateReason;
        private WipeInfo _wipe;
        private enum DbState {
            Disabled, Connecting, Online, Offline
        }
        private sealed class PendingStatement {
            public string Text;
            public object[] Args;
            public int Attempts;
            public Sql ToSql() => Sql.Builder.Append(Text, Args ?? new object[0]);
        }
        private sealed class Session {
            public DateTime StartedUtc;
            public string StartedStamp;
        }
        private sealed class ItemRef {
            public string Shortname;
            public string Name;
            public static readonly ItemRef Unknown = new ItemRef {
                Shortname = "unknown", Name = "Unknown"
            }
            ;
        }
#endregion
#region Configuration
        private sealed class ConfigData {
            public bool Enabled = false;
            public DatabaseSettings Database = new DatabaseSettings();
            public WipeSettings Wipe = new WipeSettings();
            public LoggingSettings Logging = new LoggingSettings();
            public ServerStatusSettings ServerStatus = new ServerStatusSettings();
            public AdminLogSettings AdminLog = new AdminLogSettings();
        }
        private sealed class DatabaseSettings {
            public string Host = "127.0.0.1";
            public int Port = 3306;
            public string Database = "mstats";
            public string Username = "mstats";
            public string Password = "change-me";
            public bool CreateDatabaseIfMissing = true;
            public bool UseUtcTimestamps = true;
            public int HealthCheckIntervalSeconds = 30;
            public int ReconnectIntervalSeconds = 60;
            public int MaxBufferedStatements = 20000;
        }
        private sealed class WipeSettings {
            public bool TruncateOnMapWipe = false;
            public bool TruncateOnForcedWipe = false;
        }
        private sealed class LoggingSettings {
            public bool Connections = true;
            public bool Gathering = true;
            public bool QuarryAndExcavator = true;
            public bool WorldItemPickups = true;
            public bool Crafting = true;
            public bool Items = true;
            public bool WeaponsFired = true;
            public bool Hits = true;
            public bool PvpKills = true;
            public bool PveKills = true;
            public bool Deaths = true;
            public bool Building = true;
            public bool BuildingDestroyed = true;
            public bool Deployables = true;
            public bool Cupboards = true;
            public bool Chat = true;
            public bool TeamChat = true;
            public bool Commands = true;
            public bool RconCommands = true;
            public bool ServerMessages = true;
            public bool ServerEvents = true;
            public bool Teams = true;
            public bool Missions = true;
            public bool Activity = true;
            public bool Moderation = true;
        }
        /// <summary>Periodic server snapshots (population, sleepers, queue, entities, FPS, memory) for population graphs.</summary>
        private sealed class ServerStatusSettings {
            public bool Enabled = true;
            public int IntervalSeconds = 300;
        }
        private sealed class AdminLogSettings {
            public bool Enabled = true;
            // Array, not List<T>: with a pre-populated List Json.NET (ObjectCreationHandling.Auto) reuses the
            // instance and appends the file's values, duplicating the defaults on every load. Arrays are always replaced.
            public string[] Keywords = {
                "admin", "mod", "cheat", "hack"
            }
            ;
        }
        protected override void LoadDefaultConfig() {
            PrintWarning("Creating a new configuration file");
            _config = new ConfigData();
            SaveConfigData();
        }
        protected override void LoadConfig() {
            base.LoadConfig();
            try {
                _config = Config.ReadObject<ConfigData>();
                if (_config == null) {
                    throw new Exception("configuration file is empty");
                }
                _config.Database = _config.Database ?? new DatabaseSettings();
                _config.Wipe = _config.Wipe ?? new WipeSettings();
                _config.Logging = _config.Logging ?? new LoggingSettings();
                _config.ServerStatus = _config.ServerStatus ?? new ServerStatusSettings();
                _config.AdminLog = _config.AdminLog ?? new AdminLogSettings();
                _config.AdminLog.Keywords = (_config.AdminLog.Keywords ?? new string[0]) .Where(k => !string.IsNullOrWhiteSpace(k)) .Select(k => k.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray();
                ValidateConfig(_config);
                SaveConfigData();
            }
            catch (Exception ex) {
                PrintError($"Failed to load configuration: {ex.Message}. Using defaults (the file is NOT overwritten).");
                _config = new ConfigData();
            }
        }
        private void SaveConfigData() => Config.WriteObject(_config, true);
        private void ValidateConfig(ConfigData cfg) {
            var db = cfg.Database;
            if (db.Port <= 0 || db.Port > 65535) {
                PrintWarning($"Invalid Database.Port '{db.Port}', using 3306");
                db.Port = 3306;
            }
            db.HealthCheckIntervalSeconds = Math.Max(5, db.HealthCheckIntervalSeconds);
            db.ReconnectIntervalSeconds = Math.Max(10, db.ReconnectIntervalSeconds);
            db.MaxBufferedStatements = Math.Max(100, db.MaxBufferedStatements);
            cfg.ServerStatus.IntervalSeconds = Math.Max(30, cfg.ServerStatus.IntervalSeconds);
            if (string.IsNullOrEmpty(db.Database) || !Regex.IsMatch(db.Database, @"^[A-Za-z0-9_$]+$")) {
                PrintWarning($"Database.Database '{db.Database}' is not a valid identifier; CreateDatabaseIfMissing is disabled.");
                db.CreateDatabaseIfMissing = false;
            }
        }
#endregion
#region Lifecycle
        private void Init() {
            permission.RegisterPermission(PermissionAdmin, this);
        }
        private void OnNewSave(string filename) {
            // Oxide.Rust raises this when SaveRestore has to start from a fresh save, i.e. the map was wiped.
            _newSaveDetected = true;
            Puts($"New save detected ({filename}).");
        }
        private void OnServerInitialized() {
            if (!_config.Enabled) {
                PrintWarning($"MStats is disabled (\"Enabled\": false). Configure the Database section in {ConfigFilePath()} and set Enabled to true.");
                return;
            }
            _mySql = Interface.Oxide.GetLibrary<MySqlLibrary>();
            if (_mySql == null) {
                PrintError("The MySQL library (Oxide.Core.MySql) is not available in this modding framework. MStats cannot run without it.");
                return;
            }
            _state = DbState.Connecting;
            _reconnectDelaySeconds = _config.Database.ReconnectIntervalSeconds;
            // Statements that could not be written before the last unload/shutdown are replayed first.
            LoadPendingFromDisk();
            DetectWipe();
            // Crash recovery: nothing can be online before we have seen a connect.
            Write("UPDATE mstats_players SET is_online = 0 WHERE is_online = 1");
            Write("UPDATE mstats_sessions SET disconnected_at = COALESCE(last_seen_at, connected_at), duration_seconds = TIMESTAMPDIFF(SECOND, connected_at, COALESCE(last_seen_at, connected_at)), disconnect_reason = 'server restart' WHERE disconnected_at IS NULL");
            foreach (var player in BasePlayer.activePlayerList) {
                if (IsRealPlayer(player)) {
                    OpenSession(player);
                }
            }
            ServerEvent("server_started", null, Vector3.zero, null, null, $"protocol {Protocol.printable}");
            Puts($"MStats v{Version} | schema v{DatabaseSchemaVersion} | Rust {Protocol.printable} | {FrameworkDescription()}");
            if (_config.Database.CreateDatabaseIfMissing) {
                CreateDatabaseThenConnect();
            }
            else {
                Connect();
            }
            _healthTimer = timer.Every(_config.Database.HealthCheckIntervalSeconds, HealthCheck);
            if (_config.ServerStatus.Enabled) {
                _statusTimer = timer.Every(_config.ServerStatus.IntervalSeconds, RecordServerStatus);
                RecordServerStatus();
            }
        }
        /// <summary>Periodic population/performance snapshot. Cheap: only reads counters Rust already maintains.</summary>
        private void RecordServerStatus() {
            if (_state == DbState.Disabled) return;
            try {
                var queue = ServerMgr.Instance != null ? ServerMgr.Instance.connectionQueue : null;
                var report = Performance.report;
                Count("status");
                Write("INSERT INTO mstats_server_status (players_online, players_max, sleepers, joining, queued, entities, fps, fps_average, memory_mb, recorded_at) VALUES (@0, @1, @2, @3, @4, @5, @6, @7, @8, @9)", BasePlayer.activePlayerList.Count, ConVar.Server.maxplayers, BasePlayer.sleepingPlayerList.Count, queue != null ? queue.Joining : 0, queue != null ? queue.Queued : 0, BaseNetworkable.serverEntities.Count, report.frameRate, Math.Round(report.frameRateAverage, 1), report.memoryUsageSystem, Stamp(Now));
            }
            catch (Exception ex) {
                PrintError($"RecordServerStatus: {ex.Message}");
            }
        }
        private void OnServerSave() {
            // Checkpoint online time so a crash loses at most one save interval.
            if (_state == DbState.Disabled) return;
            var nowUtc = DateTime.UtcNow;
            var stamp = Stamp(Now);
            foreach (var id in _sessions.Keys.ToList()) {
                var session = _sessions[id];
                var seconds = (long)(nowUtc - session.StartedUtc).TotalSeconds;
                if (seconds <= 0) continue;
                Write("UPDATE mstats_players SET online_seconds = online_seconds + @0, last_seen = @1 WHERE steam_id = @2", seconds, stamp, id);
                Write("UPDATE mstats_sessions SET last_seen_at = @0 WHERE steam_id = @1 AND connected_at = @2 AND disconnected_at IS NULL", stamp, id, session.StartedStamp);
                session.StartedUtc = nowUtc;
            }
            // While MySQL is unreachable, mirror the in-memory buffer to disk so a hard kill loses nothing.
            if (_state != DbState.Online || _pending.Count > 0) {
                SavePendingToDisk(false);
            }
        }
        private void OnServerShutdown() {
            ServerEvent("server_shutdown", null, Vector3.zero, null, null, null);
        }
        private void Unload() {
            _healthTimer?.Destroy();
            _healthTimer = null;
            _statusTimer?.Destroy();
            _statusTimer = null;
            if (_state == DbState.Disabled || _mySql == null) return;
            foreach (var id in _sessions.Keys.ToList()) {
                var player = BasePlayer.FindByID(id);
                CloseSession(id, player != null ? DisplayName(player) : null, "plugin unloaded");
            }
            _sessions.Clear();
            _violationSeen.Clear();
            _extractorOperators.Clear();
            // Anything still buffered is kept on disk and replayed by the next load.
            SavePendingToDisk(true);
            _pending.Clear();
            var connection = _db;
            var library = _mySql;
            _db = null;
            _state = DbState.Disabled;
            if (connection == null) return;
            // Unregister the connection now (otherwise Oxide warns about an unclosed connection right after
            // Unload). The worker thread re-opens a closed connection for the statements queued above; the
            // trailing no-op query closes the socket again once they have been executed.
            try {
                library.CloseDb(connection);
            }
            catch {
            }
            library.Query(Sql.Builder.Append("SELECT 1"), connection, _ => {
                try {
                    library.CloseDb(connection);
                }
                catch {
                }
            }
            );
        }
#endregion
#region Database connection
        private string BuildConnectionString(bool includeDatabase) {
            var db = _config.Database;
            var sb = new StringBuilder();
            sb.Append("Server=").Append(Quote(db.Host)).Append(';');
            sb.Append("Port=").Append(db.Port).Append(';');
            if (includeDatabase) {
                sb.Append("Database=").Append(Quote(db.Database)).Append(';');
            }
            sb.Append("User=").Append(Quote(db.Username)).Append(';');
            sb.Append("Password=").Append(Quote(db.Password)).Append(';');
            // Same options Oxide.MySql applies in its own OpenDb(host, port, ...) overload, plus utf8mb4.
            sb.Append("Pooling=false;default command timeout=120;Allow Zero Datetime=true;SslMode=Disabled;AllowPublicKeyRetrieval=true;CharSet=utf8mb4;");
            return sb.ToString();
        }
        private static string Quote(string value) => "'" + (value ?? string.Empty).Replace("'", "''") + "'";
        private void CreateDatabaseThenConnect() {
            Connection bootstrap;
            try {
                bootstrap = _mySql.OpenDb(BuildConnectionString(false), this, false);
            }
            catch (Exception ex) {
                PrintError($"Could not open bootstrap connection: {ex.Message}");
                Connect();
                return;
            }
            var sql = Sql.Builder.Append($"CREATE DATABASE IF NOT EXISTS `{_config.Database.Database}` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
            _mySql.Query(sql, bootstrap, rows => {
                if (rows == null) {
                    PrintWarning("CreateDatabaseIfMissing: CREATE DATABASE failed (see the exception above). Continuing with a normal connection attempt.");
                }
                else {
                    Puts($"Database `{_config.Database.Database}` exists or was created.");
                }
                try {
                    _mySql.CloseDb(bootstrap);
                }
                catch {
                }
                Connect();
            }
            );
        }
        private void Connect() {
            if (_mySql == null || !_config.Enabled || _state == DbState.Disabled) return;
            if (_db != null) {
                try {
                    _mySql.CloseDb(_db);
                }
                catch {
                }
                _db = null;
            }
            _state = DbState.Connecting;
            _schemaReady = false;
            try {
                _db = _mySql.OpenDb(BuildConnectionString(true), this, true);
            }
            catch (Exception ex) {
                RecordError(ex.Message);
                GoOffline($"could not create MySQL connection: {ex.Message}");
                return;
            }
            var connection = _db;
            Ping(connection, ok => {
                if (connection != _db) return;
                // superseded by a newer attempt
                if (!ok) {
                    GoOffline("MySQL is unavailable");
                    return;
                }
                _connectedSince = DateTime.UtcNow;
                _reconnectDelaySeconds = _config.Database.ReconnectIntervalSeconds;
                _state = DbState.Online;
                Puts($"Connected to MySQL {_config.Database.Host}:{_config.Database.Port}/{_config.Database.Database}");
                EnsureSchema();
            }
            );
        }
        private void Ping(Connection connection, Action<bool> callback) {
            try {
                _mySql.Query(Sql.Builder.Append("SELECT 1"), connection, rows => callback(rows != null));
            }
            catch (Exception ex) {
                RecordError(ex.Message);
                callback(false);
            }
        }
        private void HealthCheck() {
            if (_mySql == null || _state == DbState.Disabled) return;
            switch (_state) {
                case DbState.Online : var connection = _db;
                Ping(connection, ok => {
                    if (connection != _db) return;
                    if (!ok) {
                        GoOffline("health check failed");
                    }
                    else if (!_schemaReady && !_schemaInitRunning) {
                        EnsureSchema();
                    }
                }
                );
                break;
                case DbState.Offline : if (DateTime.UtcNow >= _nextReconnectAt) {
                    Connect();
                }
                break;
                case DbState.Connecting :  // A ping is still in flight; the worker thread is busy or the server is slow.
                break;
            }
        }
        /// <summary>
        /// Switches to the offline state and schedules the next reconnect with exponential back-off,
        /// so an unreachable server does not spam the Oxide log with one exception per health check.
        /// </summary>
        private void GoOffline(string why) {
            var wasOnline = _state == DbState.Online;
            _state = DbState.Offline;
            _schemaReady = false;
            _nextReconnectAt = DateTime.UtcNow.AddSeconds(_reconnectDelaySeconds);
            var message = wasOnline ? $"MySQL connection lost ({why}). Buffering statistics in memory; next reconnect in {_reconnectDelaySeconds}s." : $"MySQL connection failed ({why}). Buffering statistics in memory; next reconnect in {_reconnectDelaySeconds}s.";
            _reconnectDelaySeconds = Math.Min(MaxReconnectDelaySeconds, _reconnectDelaySeconds * 2);
            if (wasOnline || (DateTime.UtcNow - _lastOfflineLog).TotalMinutes >= 5) {
                _lastOfflineLog = DateTime.UtcNow;
                PrintWarning($"{message} (buffered: {_pending.Count}, dropped: {_droppedStatements})");
            }
        }
        private void RecordError(string message) {
            _lastError = message;
            _lastErrorAt = DateTime.UtcNow;
        }
#endregion
#region Statement queue
        /// <summary>
        /// Queues a write. Statements run on the Oxide MySQL worker thread and never block the server.
        /// While the database is unavailable they are buffered (bounded) and replayed on reconnect.
        /// </summary>
        private void Write(string sql, params object[] args) {
            if (_state == DbState.Disabled || _mySql == null) return;
            var statement = new PendingStatement {
                Text = sql, Args = args ?? new object[0]
            }
            ;
            if (_state == DbState.Online && _schemaReady) {
                Send(statement);
            }
            else {
                Buffer(statement);
            }
        }
        private void Buffer(PendingStatement statement) {
            var max = _config.Database.MaxBufferedStatements;
            while (_pending.Count >= max) {
                _pending.Dequeue();
                _droppedStatements++;
                if (_droppedStatements == 1 || _droppedStatements % 1000 == 0) {
                    PrintWarning($"Statement buffer full ({max}); oldest statistics are being dropped (total dropped: {_droppedStatements}).");
                }
            }
            _pending.Enqueue(statement);
        }
        private void Send(PendingStatement statement) {
            var connection = _db;
            statement.Attempts++;
            try {
                // Query (ExecuteReader) is used for writes too: it is the only path where Oxide.MySql
                // reports failure (null result) instead of silently returning 0 affected rows.
                _mySql.Query(statement.ToSql(), connection, rows => {
                    if (rows != null) {
                        _sentStatements++;
                        return;
                    }
                    StatementFailed(statement, connection);
                }
                );
            }
            catch (Exception ex) {
                RecordError(ex.Message);
                StatementFailed(statement, connection);
            }
        }
        private void StatementFailed(PendingStatement statement, Connection connection) {
            _failedStatements++;
            RecordError("statement failed (see the server log for the MySQL exception)");
            if (statement.Attempts < 2) {
                Buffer(statement);
            }
            else {
                PrintWarning($"Statement dropped after {statement.Attempts} attempts: {Truncate(statement.Text, 160)}");
            }
            if (connection == _db && _state == DbState.Online) {
                GoOffline("write failed");
            }
        }
        private void FlushPending() {
            if (_state != DbState.Online || !_schemaReady) return;
            var count = _pending.Count;
            while (_pending.Count > 0 && _state == DbState.Online) {
                Send(_pending.Dequeue());
            }
            if (count > 0) {
                Puts($"Replayed {count} buffered statement(s).");
                SavePendingToDisk(false);
            }
        }
        /// <summary>
        /// Mirrors the in-memory buffer to oxide/data/MStats_pending.json (or clears the file when the
        /// buffer is empty) so statements survive a plugin unload, server restart or hard kill.
        /// </summary>
        private void SavePendingToDisk(bool announce) {
            try {
                var list = _pending.ToList();
                Interface.Oxide.DataFileSystem.WriteObject(PendingDataFile, list);
                if (announce && list.Count > 0) {
                    PrintWarning($"{list.Count} buffered statement(s) could not be written because the database is unavailable; saved to {PendingFilePath()} for replay on the next load.");
                }
            }
            catch (Exception ex) {
                PrintError($"Could not save the statement buffer to disk: {ex.Message}");
            }
        }
        private void LoadPendingFromDisk() {
            if (!Interface.Oxide.DataFileSystem.ExistsDatafile(PendingDataFile)) return;
            try {
                var list = Interface.Oxide.DataFileSystem.ReadObject<List<PendingStatement>>(PendingDataFile);
                var restored = 0;
                if (list != null) {
                    foreach (var statement in list) {
                        if (statement == null || string.IsNullOrEmpty(statement.Text)) continue;
                        statement.Attempts = 0;
                        Buffer(statement);
                        restored++;
                    }
                }
                if (restored > 0) {
                    Puts($"Restored {restored} statement(s) from {PendingFilePath()}; they are written once the database is ready.");
                }
                // Keep the file in sync with the (bounded) in-memory buffer; FlushPending clears it once written.
                SavePendingToDisk(false);
            }
            catch (Exception ex) {
                PrintError($"Could not read the saved statement buffer: {ex.Message}. The file is left untouched.");
            }
        }
        /// <summary>Runs a maintenance statement (DDL etc.) directly and reports success.</summary>
        private void Exec(string sql, Action<bool> done, params object[] args) {
            var connection = _db;
            if (connection == null || _mySql == null) {
                done(false);
                return;
            }
            try {
                _mySql.Query(Sql.Builder.Append(sql, args), connection, rows => done(rows != null));
            }
            catch (Exception ex) {
                RecordError(ex.Message);
                done(false);
            }
        }
        private void Select(string sql, Action<List<Dictionary<string, object>>> done, params object[] args) {
            var connection = _db;
            if (connection == null || _mySql == null) {
                done(null);
                return;
            }
            try {
                _mySql.Query(Sql.Builder.Append(sql, args), connection, done);
            }
            catch (Exception ex) {
                RecordError(ex.Message);
                done(null);
            }
        }
        private static void RunSteps(List<Action<Action<bool>>> steps, int index, Action<bool> done) {
            if (index >= steps.Count) {
                done(true);
                return;
            }
            steps[index](ok => {
                if (!ok) {
                    done(false);
                    return;
                }
                RunSteps(steps, index + 1, done);
            }
            );
        }
#endregion
#region Schema
        private const string Position = "pos_x FLOAT NULL, pos_y FLOAT NULL, pos_z FLOAT NULL, grid VARCHAR(8) NULL";
        /// <summary>All tables of the current schema. Statements are idempotent (CREATE TABLE IF NOT EXISTS).</summary>
        private static readonly Dictionary<string, string> Tables = new Dictionary<string, string> {
            [SchemaTable]
            = "CREATE TABLE IF NOT EXISTS mstats_schema (id TINYINT UNSIGNED NOT NULL, schema_version INT UNSIGNED NOT NULL, plugin_version VARCHAR(32) NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id))" + TableOptions, ["mstats_players"] = "CREATE TABLE IF NOT EXISTS mstats_players (steam_id BIGINT UNSIGNED NOT NULL, name VARCHAR(128) NOT NULL, last_ip VARCHAR(45) NULL, is_admin TINYINT(1) NOT NULL DEFAULT 0, is_online TINYINT(1) NOT NULL DEFAULT 0, first_seen DATETIME NOT NULL, last_seen DATETIME NOT NULL, connections INT UNSIGNED NOT NULL DEFAULT 0, online_seconds BIGINT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (steam_id), KEY idx_name (name), KEY idx_last_seen (last_seen))" + TableOptions, ["mstats_player_names"] = "CREATE TABLE IF NOT EXISTS mstats_player_names (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, steam_id BIGINT UNSIGNED NOT NULL, name VARCHAR(128) NOT NULL, first_used DATETIME NOT NULL, last_used DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_player_name (steam_id, name))" + TableOptions, ["mstats_sessions"] = "CREATE TABLE IF NOT EXISTS mstats_sessions (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, steam_id BIGINT UNSIGNED NOT NULL, name VARCHAR(128) NOT NULL, ip VARCHAR(45) NULL, connected_at DATETIME NOT NULL, last_seen_at DATETIME NULL, disconnected_at DATETIME NULL, duration_seconds INT UNSIGNED NULL, disconnect_reason VARCHAR(255) NULL, PRIMARY KEY (id), KEY idx_player (steam_id, connected_at), KEY idx_connected (connected_at))" + TableOptions, ["mstats_gather"] = "CREATE TABLE IF NOT EXISTS mstats_gather (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, steam_id BIGINT UNSIGNED NOT NULL, item_shortname VARCHAR(64) NOT NULL, item_name VARCHAR(128) NOT NULL, source VARCHAR(16) NOT NULL, amount BIGINT UNSIGNED NOT NULL DEFAULT 0, day DATE NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_gather (steam_id, item_shortname, source, day), KEY idx_day (day))" + TableOptions, ["mstats_crafting"] = "CREATE TABLE IF NOT EXISTS mstats_crafting (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, steam_id BIGINT UNSIGNED NOT NULL, item_shortname VARCHAR(64) NOT NULL, item_name VARCHAR(128) NOT NULL, amount BIGINT UNSIGNED NOT NULL DEFAULT 0, day DATE NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_craft (steam_id, item_shortname, day), KEY idx_day (day))" + TableOptions, ["mstats_items"] = "CREATE TABLE IF NOT EXISTS mstats_items (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, steam_id BIGINT UNSIGNED NOT NULL, action VARCHAR(24) NOT NULL, item_shortname VARCHAR(64) NOT NULL, item_name VARCHAR(128) NOT NULL, amount BIGINT UNSIGNED NOT NULL DEFAULT 0, day DATE NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_item (steam_id, action, item_shortname, day), KEY idx_day (day))" + TableOptions, ["mstats_shots"] = "CREATE TABLE IF NOT EXISTS mstats_shots (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, steam_id BIGINT UNSIGNED NOT NULL, weapon_shortname VARCHAR(64) NOT NULL, weapon_name VARCHAR(128) NOT NULL, ammo_shortname VARCHAR(64) NOT NULL, ammo_name VARCHAR(128) NOT NULL, shots BIGINT UNSIGNED NOT NULL DEFAULT 0, day DATE NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_shots (steam_id, weapon_shortname, ammo_shortname, day), KEY idx_day (day))" + TableOptions, ["mstats_hits"] = "CREATE TABLE IF NOT EXISTS mstats_hits (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, steam_id BIGINT UNSIGNED NOT NULL, weapon_shortname VARCHAR(64) NOT NULL, target_type VARCHAR(16) NOT NULL, bodypart VARCHAR(32) NOT NULL, hits BIGINT UNSIGNED NOT NULL DEFAULT 0, headshots BIGINT UNSIGNED NOT NULL DEFAULT 0, damage DECIMAL(14,1) NOT NULL DEFAULT 0, day DATE NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_hits (steam_id, weapon_shortname, target_type, bodypart, day), KEY idx_day (day))" + TableOptions, ["mstats_pvp_kills"] = "CREATE TABLE IF NOT EXISTS mstats_pvp_kills (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, killer_id BIGINT UNSIGNED NOT NULL, killer_name VARCHAR(128) NOT NULL, victim_id BIGINT UNSIGNED NOT NULL, victim_name VARCHAR(128) NOT NULL, weapon_shortname VARCHAR(64) NOT NULL, weapon_name VARCHAR(128) NOT NULL, bodypart VARCHAR(32) NOT NULL, is_headshot TINYINT(1) NOT NULL DEFAULT 0, distance DECIMAL(8,1) NOT NULL DEFAULT 0, victim_sleeping TINYINT(1) NOT NULL DEFAULT 0, victim_wounded TINYINT(1) NOT NULL DEFAULT 0, killer_x FLOAT NULL, killer_y FLOAT NULL, killer_z FLOAT NULL, " + Position + ", occurred_at DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_killer (killer_id, occurred_at), KEY idx_victim (victim_id, occurred_at), KEY idx_occurred (occurred_at))" + TableOptions, ["mstats_pve_kills"] = "CREATE TABLE IF NOT EXISTS mstats_pve_kills (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, steam_id BIGINT UNSIGNED NOT NULL, victim_type VARCHAR(16) NOT NULL, victim_prefab VARCHAR(64) NOT NULL, weapon_shortname VARCHAR(64) NOT NULL, weapon_name VARCHAR(128) NOT NULL, bodypart VARCHAR(32) NOT NULL, distance DECIMAL(8,1) NOT NULL DEFAULT 0, " + Position + ", occurred_at DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_player (steam_id, occurred_at), KEY idx_type (victim_type, victim_prefab), KEY idx_occurred (occurred_at))" + TableOptions, ["mstats_deaths"] = "CREATE TABLE IF NOT EXISTS mstats_deaths (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, steam_id BIGINT UNSIGNED NOT NULL, name VARCHAR(128) NOT NULL, cause VARCHAR(32) NOT NULL, killer_type VARCHAR(24) NOT NULL, killer_id BIGINT UNSIGNED NULL, killer_name VARCHAR(128) NULL, weapon_shortname VARCHAR(64) NULL, weapon_name VARCHAR(128) NULL, " + Position + ", occurred_at DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_player (steam_id, occurred_at), KEY idx_killer_type (killer_type), KEY idx_occurred (occurred_at))" + TableOptions, ["mstats_building"] = "CREATE TABLE IF NOT EXISTS mstats_building (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, steam_id BIGINT UNSIGNED NOT NULL, action VARCHAR(16) NOT NULL, block VARCHAR(64) NOT NULL, grade VARCHAR(16) NOT NULL, amount BIGINT UNSIGNED NOT NULL DEFAULT 0, day DATE NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_building (steam_id, action, block, grade, day), KEY idx_day (day))" + TableOptions, ["mstats_deployables"] = "CREATE TABLE IF NOT EXISTS mstats_deployables (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, steam_id BIGINT UNSIGNED NOT NULL, item_shortname VARCHAR(64) NOT NULL, item_name VARCHAR(128) NOT NULL, amount BIGINT UNSIGNED NOT NULL DEFAULT 0, day DATE NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_deployable (steam_id, item_shortname, day), KEY idx_day (day))" + TableOptions, ["mstats_building_destroyed"] = "CREATE TABLE IF NOT EXISTS mstats_building_destroyed (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, steam_id BIGINT UNSIGNED NOT NULL, owner_id BIGINT UNSIGNED NOT NULL DEFAULT 0, entity_type VARCHAR(16) NOT NULL, entity VARCHAR(64) NOT NULL, grade VARCHAR(16) NULL, weapon_shortname VARCHAR(64) NOT NULL, weapon_name VARCHAR(128) NOT NULL, " + Position + ", occurred_at DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_player (steam_id, occurred_at), KEY idx_owner (owner_id), KEY idx_occurred (occurred_at))" + TableOptions, ["mstats_cupboards"] = "CREATE TABLE IF NOT EXISTS mstats_cupboards (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, cupboard_id BIGINT UNSIGNED NOT NULL, steam_id BIGINT UNSIGNED NOT NULL, name VARCHAR(128) NOT NULL, is_authorized TINYINT(1) NOT NULL DEFAULT 1, " + Position + ", authorized_at DATETIME NOT NULL, deauthorized_at DATETIME NULL, PRIMARY KEY (id), UNIQUE KEY uq_cupboard_player (cupboard_id, steam_id), KEY idx_player (steam_id))" + TableOptions, ["mstats_chat"] = "CREATE TABLE IF NOT EXISTS mstats_chat (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, steam_id BIGINT UNSIGNED NOT NULL, name VARCHAR(128) NOT NULL, channel VARCHAR(16) NOT NULL, message TEXT NOT NULL, is_admin TINYINT(1) NOT NULL DEFAULT 0, is_flagged TINYINT(1) NOT NULL DEFAULT 0, occurred_at DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_player (steam_id, occurred_at), KEY idx_flagged (is_flagged, occurred_at), KEY idx_occurred (occurred_at))" + TableOptions, ["mstats_commands"] = "CREATE TABLE IF NOT EXISTS mstats_commands (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, steam_id BIGINT UNSIGNED NULL, name VARCHAR(128) NOT NULL, source VARCHAR(8) NOT NULL, command VARCHAR(128) NOT NULL, arguments TEXT NULL, occurred_at DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_player (steam_id, occurred_at), KEY idx_command (command), KEY idx_occurred (occurred_at))" + TableOptions, ["mstats_server_messages"] = "CREATE TABLE IF NOT EXISTS mstats_server_messages (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, message TEXT NOT NULL, occurred_at DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_occurred (occurred_at))" + TableOptions, ["mstats_server_events"] = "CREATE TABLE IF NOT EXISTS mstats_server_events (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, event VARCHAR(48) NOT NULL, entity VARCHAR(64) NULL, entity_id BIGINT UNSIGNED NULL, steam_id BIGINT UNSIGNED NULL, name VARCHAR(128) NULL, " + Position + ", details VARCHAR(255) NULL, occurred_at DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_event (event, occurred_at), KEY idx_occurred (occurred_at))" + TableOptions, ["mstats_wipes"] = "CREATE TABLE IF NOT EXISTS mstats_wipes (id INT UNSIGNED NOT NULL AUTO_INCREMENT, kind VARCHAR(16) NOT NULL, wipe_id VARCHAR(64) NULL, level VARCHAR(64) NULL, seed INT UNSIGNED NOT NULL, world_size INT UNSIGNED NOT NULL, protocol_network INT UNSIGNED NOT NULL, protocol_save INT UNSIGNED NOT NULL, save_created_at DATETIME NULL, detected_at DATETIME NOT NULL, PRIMARY KEY (id))" + TableOptions, ["mstats_activity"] = "CREATE TABLE IF NOT EXISTS mstats_activity (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, steam_id BIGINT UNSIGNED NOT NULL, action VARCHAR(32) NOT NULL, subject VARCHAR(64) NOT NULL, amount BIGINT UNSIGNED NOT NULL DEFAULT 0, day DATE NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_activity (steam_id, action, subject, day), KEY idx_day (day))" + TableOptions, ["mstats_moderation"] = "CREATE TABLE IF NOT EXISTS mstats_moderation (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, steam_id BIGINT UNSIGNED NOT NULL, name VARCHAR(128) NOT NULL, event VARCHAR(24) NOT NULL, details TEXT NULL, actor_id BIGINT UNSIGNED NULL, actor_name VARCHAR(128) NULL, occurred_at DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_player (steam_id, occurred_at), KEY idx_event (event, occurred_at))" + TableOptions, ["mstats_teams"] = "CREATE TABLE IF NOT EXISTS mstats_teams (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, team_id BIGINT UNSIGNED NOT NULL, event VARCHAR(16) NOT NULL, steam_id BIGINT UNSIGNED NULL, name VARCHAR(128) NULL, actor_id BIGINT UNSIGNED NULL, member_count INT UNSIGNED NULL, occurred_at DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_team (team_id, occurred_at), KEY idx_player (steam_id))" + TableOptions, ["mstats_missions"] = "CREATE TABLE IF NOT EXISTS mstats_missions (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, steam_id BIGINT UNSIGNED NOT NULL, mission VARCHAR(64) NOT NULL, result VARCHAR(16) NOT NULL, reason VARCHAR(32) NULL, occurred_at DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_player (steam_id, occurred_at))" + TableOptions, ["mstats_server_status"] = "CREATE TABLE IF NOT EXISTS mstats_server_status (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, players_online INT UNSIGNED NOT NULL, players_max INT UNSIGNED NOT NULL, sleepers INT UNSIGNED NOT NULL, joining INT UNSIGNED NOT NULL, queued INT UNSIGNED NOT NULL, entities INT UNSIGNED NOT NULL, fps INT UNSIGNED NOT NULL, fps_average DECIMAL(6,1) NOT NULL, memory_mb BIGINT UNSIGNED NOT NULL, recorded_at DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_recorded (recorded_at))" + TableOptions,
        }
        ;
        /// <summary>Tables that are never truncated by wipe handling or mstats.empty.</summary>
        private static readonly HashSet<string> PersistentTables = new HashSet<string> {
            SchemaTable, "mstats_wipes"
        }
        ;
        /// <summary>
        /// Forward-only migrations keyed by the schema version they upgrade FROM. Each step must be
        /// idempotent and must not destroy data (check information_schema before ALTER TABLE; MySQL has no
        /// ADD COLUMN IF NOT EXISTS). New tables need no migration: <see cref="Tables"/> is always applied
        /// first with CREATE TABLE IF NOT EXISTS. Empty for the first schema version.
        /// </summary>
        private static readonly Dictionary<int, Action<List<Action<Action<bool>>>, MStats>> Migrations = new Dictionary<int, Action<List<Action<Action<bool>>>, MStats>>();
        private void EnsureSchema() {
            if (_schemaInitRunning) return;
            _schemaInitRunning = true;
            _schemaError = null;
            Select("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()", rows => {
                if (rows == null) {
                    _schemaInitRunning = false;
                    _schemaError = "could not read information_schema";
                    GoOffline("schema inspection failed");
                    return;
                }
                var existing = new HashSet<string>(rows.Select(r => Convert.ToString(r["TABLE_NAME"])), StringComparer.OrdinalIgnoreCase);
                if (!existing.Contains(SchemaTable)) {
                    ApplySchema(0);
                    return;
                }
                Select("SELECT schema_version FROM " + SchemaTable + " WHERE id = 1", versionRows => {
                    var version = versionRows != null && versionRows.Count > 0 ? Convert.ToInt32(versionRows[0]["schema_version"]) : 0;
                    ApplySchema(version);
                }
                );
            }
            );
        }
        private void ApplySchema(int fromVersion) {
            _schemaVersionInDb = fromVersion;
            if (fromVersion > DatabaseSchemaVersion) {
                _schemaInitRunning = false;
                _schemaError = $"database schema v{fromVersion} is newer than this plugin supports (v{DatabaseSchemaVersion})";
                PrintError($"{_schemaError}. Update the plugin. Statistics are not written.");
                return;
            }
            var steps = new List<Action<Action<bool>>>();
            foreach (var table in Tables) {
                var sql = table.Value;
                steps.Add(done => Exec(sql, done));
            }
            for (var v = fromVersion; v < DatabaseSchemaVersion && v > 0; v++) {
                Action<List<Action<Action<bool>>>, MStats> migration;
                if (Migrations.TryGetValue(v, out migration)) {
                    var from = v;
                    steps.Add(done => {
                        Puts($"Migrating schema v{from} -> v{from + 1}");
                        done(true);
                    }
                    );
                    migration(steps, this);
                }
            }
            steps.Add(done => Exec("INSERT INTO " + SchemaTable + " (id, schema_version, plugin_version, updated_at) VALUES (1, @0, @1, @2) ON DUPLICATE KEY UPDATE schema_version = @0, plugin_version = @1, updated_at = @2", done, DatabaseSchemaVersion, Version.ToString(), Stamp(Now)));
            RunSteps(steps, 0, ok => {
                _schemaInitRunning = false;
                if (!ok) {
                    _schemaError = "schema creation/migration failed (see the MySQL exception above)";
                    PrintError(_schemaError + ". Retrying on the next health check.");
                    return;
                }
                _schemaVersionInDb = DatabaseSchemaVersion;
                Puts(fromVersion == 0 ? $"Schema v{DatabaseSchemaVersion} created." : fromVersion < DatabaseSchemaVersion ? $"Schema migrated v{fromVersion} -> v{DatabaseSchemaVersion}." : $"Schema v{DatabaseSchemaVersion} verified.");
                SchemaReady();
            }
            );
        }
        private void SchemaReady() {
            _schemaReady = true;
            if (_truncatePending) {
                _truncatePending = false;
                TruncateStatistics(_truncateReason, FlushPending);
            }
            else {
                FlushPending();
            }
        }
        private void TruncateStatistics(string reason, Action done) {
            PrintWarning($"Truncating all MStats statistics tables ({reason}).");
            var steps = new List<Action<Action<bool>>>();
            foreach (var table in Tables.Keys.Where(t => !PersistentTables.Contains(t))) {
                var name = table;
                steps.Add(ok => Exec($"TRUNCATE TABLE `{name}`", ok));
            }
            RunSteps(steps, 0, ok => {
                if (ok) Puts("Statistics tables truncated.");
                else PrintError("Truncating tables failed part-way; see the MySQL exception above.");
                done?.Invoke();
            }
            );
        }
#endregion
#region Wipe detection
        private sealed class WipeInfo {
            public string WipeId;
            public string SaveCreated;
            public int Seed;
            public int WorldSize;
            public string Level;
            public int Network;
            public int Save;
        }
        private void DetectWipe() {
            _wipe = new WipeInfo {
                WipeId = SaveRestore.WipeId, SaveCreated = SaveRestore.SaveCreatedTime.ToString("yyyy-MM-dd HH:mm:ss"), Seed = ConVar.Server.seed, WorldSize = ConVar.Server.worldsize, Level = ConVar.Server.level, Network = Protocol.network, Save = Protocol.save
            }
            ;
            WipeInfo stored = null;
            if (Interface.Oxide.DataFileSystem.ExistsDatafile(WipeDataFile)) {
                try {
                    stored = Interface.Oxide.DataFileSystem.ReadObject<WipeInfo>(WipeDataFile);
                    if (stored != null && stored.Seed == 0 && string.IsNullOrEmpty(stored.WipeId)) stored = null;
                }
                catch (Exception ex) {
                    PrintWarning($"Could not read wipe data file: {ex.Message}");
                }
            }
            Puts($"World: {_wipe.Level} seed {_wipe.Seed} size {_wi
// ... [Code preview truncated due to length. Full source in attached file] ...

Автор
15FPS
Скачиваний
0
Просмотры
1
Первый выпуск
Обновление
Оценка
0.00 звёзд 0 оценок
Сверху