using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using RainOpsMini.Helpers.SquadGameLog; using SqlSugar; namespace RainOpsMini.Helpers.SquadGameLog { public class GameLogEntry { public DateTime? Timestamp { get; set; } public string LogType { get; set; } public object Data { get; set; } public string RawContent { get; set; } } public class PlayerGameStats { public int Kills { get; set; } public int Deaths { get; set; } public int Wounds { get; set; } public int Revives { get; set; } } public class LogSummary { public DateTime? LogTime { get; set; } public string LogType { get; set; } public string RawContent { get; set; } } public class LogStorage { private static readonly Lazy _instance = new Lazy(() => new LogStorage()); public static LogStorage Instance => _instance.Value; private LogStorage() { } public PlayerGameStats GetPlayerStats(string steamId, DateTime startTime) { if (string.IsNullOrEmpty(steamId)) return new PlayerGameStats(); var stats = new PlayerGameStats(); using (var db = DbHelper.GetInstance()) { // Kills: Assuming SteamID in Log_Die is the Killer's SteamID // Exclude Team Kills stats.Kills = db.Queryable() .Where(x => x.SteamID == steamId && x.LogTime >= startTime && x.IsTK != true) .Count(); // Deaths: Use VictimSteamID to count deaths stats.Deaths = db.Queryable() .Where(x => x.VictimSteamID == steamId && x.LogTime >= startTime) .Count(); // Wounds (Inflicted?): Log_Wound has SteamID (Attacker?) // If Log_Wound SteamID is Attacker: // stats.Wounds = db.Queryable() // .Where(x => x.SteamID == steamId && x.LogTime >= startTime) // .Count(); // Revives (Performed): Log_Revived has ReviverSteamID stats.Revives = db.Queryable() .Where(x => x.ReviverSteamID == steamId && x.LogTime >= startTime) .Count(); } return stats; } public void RegisterPlayer(string name, string steamId) { // Memory optimization: Do not store player mapping in memory } public void RegisterDieEvent(DateTime timestamp, string killerSteamId, string victimName) { // Memory optimization: Do not store die events in memory } public void RegisterKill(string killerSteamId) { } public void RegisterDeath(string victimName) { } public void AddLog(string logType, object data, string rawContent, DateTime? timestamp = null) { if (string.IsNullOrEmpty(rawContent)) return; // Generate Hash ID from RawContent string hashId = SquadLogHelper.GetMD5Hash(rawContent); // Memory optimization: Removed in-memory deduplication (_processedLogs) // We rely on database unique constraints (if any) or accept duplicates if file is re-processed. // Usually FileTimestampAndSizeWatcher avoids re-processing. // Set the Hash ID to the entity's Id property via Reflection var idProperty = data.GetType().GetProperty("Id"); if (idProperty != null && idProperty.PropertyType == typeof(string)) { idProperty.SetValue(data, hashId); } // Save to DB using (var db = DbHelper.GetInstance()) { try { switch (logType) { case "Disconnect": db.Insertable((Log_Disconnect)data).ExecuteCommand(); break; case "MatchLost": db.Insertable((Log_MatchLost)data).ExecuteCommand(); break; case "CreateSquad": db.Insertable((Log_CreateSquad)data).ExecuteCommand(); break; case "NewPlayerConnect": db.Insertable((Log_NewPlayerConnect)data).ExecuteCommand(); break; case "Wound": db.Insertable((Log_Wound)data).ExecuteCommand(); break; case "Die": db.Insertable((Log_Die)data).ExecuteCommand(); break; case "Revived": db.Insertable((Log_Revived)data).ExecuteCommand(); break; case "Attack": db.Insertable((Log_Attack)data).ExecuteCommand(); break; case "Tick": db.Insertable((Log_Tick)data).ExecuteCommand(); break; case "BattleEnd": db.Insertable((Log_BattleEnd)data).ExecuteCommand(); break; case "RemovedPlayer": db.Insertable((Log_RemovedPlayer)data).ExecuteCommand(); break; case "SetNextLayer": db.Insertable((Log_SetNextLayer)data).ExecuteCommand(); break; case "ChangeLayer": db.Insertable((Log_ChangeLayer)data).ExecuteCommand(); break; } } catch (Exception ex) { // Ignore duplicate key errors (SQLite error code 19 or generic message containing "constraint") if (!ex.Message.Contains("constraint") && !ex.Message.Contains("UNIQUE")) { RainOpsMini.Helpers.RainOpsLog.Log($"[Error] Failed to insert log {logType}: {ex.Message}"); } } } } private List GetLogsForType(ISqlSugarClient db, string typeName, string search, int page, int pageSize, out int totalCount) where T : class, new() { var query = db.Queryable(); if (!string.IsNullOrEmpty(search)) { query = query.Where("RawContent like @search", new { search = "%" + search + "%" }); } totalCount = query.Count(); var list = query.OrderBy("LogTime DESC").ToPageList(page, pageSize); return list.Select(x => { dynamic d = x; return new GameLogEntry { Timestamp = d.LogTime, LogType = typeName, Data = x, RawContent = d.RawContent }; }).ToList(); } public IEnumerable GetLogs(string type, string search, int page, int pageSize, out int totalCount) { using (var db = DbHelper.GetInstance()) { if (!string.IsNullOrEmpty(type)) { switch (type) { case "Disconnect": return GetLogsForType(db, type, search, page, pageSize, out totalCount); case "MatchLost": return GetLogsForType(db, type, search, page, pageSize, out totalCount); case "CreateSquad": return GetLogsForType(db, type, search, page, pageSize, out totalCount); case "NewPlayerConnect": return GetLogsForType(db, type, search, page, pageSize, out totalCount); case "Wound": return GetLogsForType(db, type, search, page, pageSize, out totalCount); case "Die": return GetLogsForType(db, type, search, page, pageSize, out totalCount); case "Revived": return GetLogsForType(db, type, search, page, pageSize, out totalCount); case "Attack": return GetLogsForType(db, type, search, page, pageSize, out totalCount); case "Tick": return GetLogsForType(db, type, search, page, pageSize, out totalCount); case "BattleEnd": return GetLogsForType(db, type, search, page, pageSize, out totalCount); case "RemovedPlayer": return GetLogsForType(db, type, search, page, pageSize, out totalCount); case "SetNextLayer": return GetLogsForType(db, type, search, page, pageSize, out totalCount); case "ChangeLayer": return GetLogsForType(db, type, search, page, pageSize, out totalCount); default: totalCount = 0; return new List(); } } else { var q1 = db.Queryable().Select(x => new LogSummary { LogTime = x.LogTime, LogType = "Disconnect", RawContent = x.RawContent }); var q2 = db.Queryable().Select(x => new LogSummary { LogTime = x.LogTime, LogType = "MatchLost", RawContent = x.RawContent }); var q3 = db.Queryable().Select(x => new LogSummary { LogTime = x.LogTime, LogType = "CreateSquad", RawContent = x.RawContent }); var q4 = db.Queryable().Select(x => new LogSummary { LogTime = x.LogTime, LogType = "NewPlayerConnect", RawContent = x.RawContent }); var q5 = db.Queryable().Select(x => new LogSummary { LogTime = x.LogTime, LogType = "Wound", RawContent = x.RawContent }); var q6 = db.Queryable().Select(x => new LogSummary { LogTime = x.LogTime, LogType = "Die", RawContent = x.RawContent }); var q7 = db.Queryable().Select(x => new LogSummary { LogTime = x.LogTime, LogType = "Revived", RawContent = x.RawContent }); var q8 = db.Queryable().Select(x => new LogSummary { LogTime = x.LogTime, LogType = "Attack", RawContent = x.RawContent }); var q9 = db.Queryable().Select(x => new LogSummary { LogTime = x.LogTime, LogType = "Tick", RawContent = x.RawContent }); var q10 = db.Queryable().Select(x => new LogSummary { LogTime = x.LogTime, LogType = "BattleEnd", RawContent = x.RawContent }); var q11 = db.Queryable().Select(x => new LogSummary { LogTime = x.LogTime, LogType = "RemovedPlayer", RawContent = x.RawContent }); var q12 = db.Queryable().Select(x => new LogSummary { LogTime = x.LogTime, LogType = "SetNextLayer", RawContent = x.RawContent }); var q13 = db.Queryable().Select(x => new LogSummary { LogTime = x.LogTime, LogType = "ChangeLayer", RawContent = x.RawContent }); var all = db.UnionAll(q1, q2, q3, q4, q5, q6, q7, q8, q9, q10, q11, q12, q13); if (!string.IsNullOrEmpty(search)) { all = all.Where(x => x.RawContent.Contains(search)); } totalCount = all.Count(); var res = all.OrderByDescending(x => x.LogTime).ToPageList(page, pageSize); return res.Select(x => new GameLogEntry { Timestamp = x.LogTime, LogType = x.LogType, RawContent = x.RawContent, Data = null }).ToList(); } } } public Dictionary GetLogStats() { using (var db = DbHelper.GetInstance()) { var stats = new Dictionary(); stats["Disconnect"] = db.Queryable().Count(); stats["MatchLost"] = db.Queryable().Count(); stats["CreateSquad"] = db.Queryable().Count(); stats["NewPlayerConnect"] = db.Queryable().Count(); stats["Wound"] = db.Queryable().Count(); stats["Die"] = db.Queryable().Count(); stats["Revived"] = db.Queryable().Count(); stats["Attack"] = db.Queryable().Count(); stats["Tick"] = db.Queryable().Count(); stats["BattleEnd"] = db.Queryable().Count(); stats["RemovedPlayer"] = db.Queryable().Count(); stats["SetNextLayer"] = db.Queryable().Count(); stats["ChangeLayer"] = db.Queryable().Count(); return stats; } } public DateTime? GetLastSquadCreationTime(string steamId) { if (string.IsNullOrEmpty(steamId)) return null; using (var db = DbHelper.GetInstance()) { var log = db.Queryable() .Where(x => x.SteamID == steamId) .OrderByDescending(x => x.LogTime) .First(); return log?.LogTime; } } public void SaveMatchStats(DateTime matchEndTime, string mapName) { try { using (var db = DbHelper.GetInstance()) { // 1. Determine Match Start Time (Time of previous BattleEnd) var lastMatchEnd = db.Queryable() .Where(x => x.LogTime < matchEndTime) .OrderBy(x => x.LogTime, OrderByType.Desc) .First(); var startTime = lastMatchEnd?.LogTime ?? matchEndTime.AddHours(-2); // Default to 2 hours if no previous match // 2. Aggregate Stats from Logs // Kills var kills = db.Queryable() .Where(x => x.LogTime > startTime && x.LogTime <= matchEndTime && x.IsTK != true) .GroupBy(x => x.AttackerSteamID) .Select(x => new { SteamID = x.AttackerSteamID, Count = SqlFunc.AggregateCount(x.Id) }) .ToList(); // Deaths var deaths = db.Queryable() .Where(x => x.LogTime > startTime && x.LogTime <= matchEndTime) .GroupBy(x => x.VictimSteamID) .Select(x => new { SteamID = x.VictimSteamID, Count = SqlFunc.AggregateCount(x.Id) }) .ToList(); // TeamKills var teamKills = db.Queryable() .Where(x => x.LogTime > startTime && x.LogTime <= matchEndTime && x.IsTK == true) .GroupBy(x => x.AttackerSteamID) .Select(x => new { SteamID = x.AttackerSteamID, Count = SqlFunc.AggregateCount(x.Id) }) .ToList(); // Wounds (Knocks) var wounds = db.Queryable() .Where(x => x.LogTime > startTime && x.LogTime <= matchEndTime && x.IsTK != true) .GroupBy(x => x.AttackerSteamID) .Select(x => new { SteamID = x.AttackerSteamID, Count = SqlFunc.AggregateCount(x.Id) }) .ToList(); // Revives var revives = db.Queryable() .Where(x => x.LogTime > startTime && x.LogTime <= matchEndTime) .GroupBy(x => x.ReviverSteamID) .Select(x => new { SteamID = x.ReviverSteamID, Count = SqlFunc.AggregateCount(x.Id) }) .ToList(); // 3. Get Player Info (Name, Team) - Try to get from RconCache first, then fallback to logs var playerStatsDict = new Dictionary(); var currentPlayers = RainOpsMini.Program.RconCache?.Players?.ToList() ?? new List(); // Helper to get or create stats entry MatchPlayerStats GetStats(string steamId) { if (string.IsNullOrEmpty(steamId)) return null; if (!playerStatsDict.ContainsKey(steamId)) { var pInfo = currentPlayers.FirstOrDefault(p => p.SteamId == steamId); playerStatsDict[steamId] = new MatchPlayerStats { MatchId = $"{mapName}_{matchEndTime:yyyyMMddHHmmss}", MatchDate = matchEndTime, MapName = mapName, SteamId = steamId, PlayerName = pInfo?.Name ?? "Unknown", TeamId = pInfo?.TeamId ?? 0, Role = pInfo?.Role ?? "", EosId = pInfo?.EosId ?? "" }; } return playerStatsDict[steamId]; } // Populate Stats foreach (var k in kills) { var s = GetStats(k.SteamID); if (s != null) s.Kills = k.Count; } foreach (var d in deaths) { var s = GetStats(d.SteamID); if (s != null) s.Deaths = d.Count; } foreach (var tk in teamKills) { var s = GetStats(tk.SteamID); if (s != null) s.TeamKills = tk.Count; } foreach (var w in wounds) { var s = GetStats(w.SteamID); if (s != null) s.Wounds = w.Count; } foreach (var r in revives) { var s = GetStats(r.SteamID); if (s != null) s.Revives = r.Count; } // Fix missing names from logs if not in RconCache var allSteamIds = playerStatsDict.Keys.ToList(); foreach (var steamId in allSteamIds) { var stats = playerStatsDict[steamId]; if (stats.PlayerName == "Unknown") { // Try to find name in logs var nameLog = db.Queryable() .Where(x => x.SteamID == steamId) .OrderBy(x => x.LogTime, OrderByType.Desc) .First(); if (nameLog == null) { // Try Die/Wound logs var dieLog = db.Queryable() .Where(x => x.AttackerSteamID == steamId || x.VictimSteamID == steamId) .OrderBy(x => x.LogTime, OrderByType.Desc) .First(); if (dieLog != null) { stats.PlayerName = dieLog.AttackerSteamID == steamId ? dieLog.AttackerName : dieLog.VictimName; } } else { // Log_NewPlayerConnect doesn't have Name usually (based on entity definition earlier)? // Wait, Log_NewPlayerConnect definition: PlayerController, IP, EOSID, SteamID. No Name. // Log_CreateSquad has UserName. // Log_Die has PlayerName/AttackerName. } } } // Save to DB if (playerStatsDict.Any()) { db.Insertable(playerStatsDict.Values.ToList()).ExecuteCommand(); } } } catch (Exception ex) { RainOpsMini.Helpers.RainOpsLog.Log($"Error saving match stats: {ex.Message}"); } } private ConcurrentDictionary _playerConnectTimes = new ConcurrentDictionary(); public void UpdatePlayerConnectTime(string steamId, DateTime time) { _playerConnectTimes.AddOrUpdate(steamId, time, (k, v) => time); } public DateTime? GetPlayerConnectTime(string steamId) { if (_playerConnectTimes.TryGetValue(steamId, out var time)) { return time; } return null; } public void UpdatePlayerConnect(string steamId, string eosId, string name, DateTime time) { try { UpdatePlayerConnectTime(steamId, time); using (var db = DbHelper.GetInstance()) { var profile = db.Queryable().Single(p => p.SteamID == steamId); if (profile == null) { profile = new PlayerProfile { SteamID = steamId, EOSID = eosId, LastName = name ?? "Unknown", FirstSeen = time, LastSeen = time, TotalPlaytimeSeconds = 0, PlayStickiness = 0 }; db.Insertable(profile).ExecuteCommand(); } else { profile.LastSeen = time; profile.EOSID = eosId; if (!string.IsNullOrEmpty(name)) profile.LastName = name; db.Updateable(profile).ExecuteCommand(); } // Start Session var openSession = db.Queryable() .Where(s => s.SteamID == steamId && s.EndTime == null) .OrderBy(s => s.StartTime, OrderByType.Desc) .First(); if (openSession != null) { openSession.EndTime = time; openSession.DurationSeconds = (time - openSession.StartTime).TotalSeconds; db.Updateable(openSession).ExecuteCommand(); } var newSession = new PlayerSession { SteamID = steamId, StartTime = time, EndTime = null }; db.Insertable(newSession).ExecuteCommand(); } Task.Run(() => { try { var hours = RainOpsMini.Helpers.SteamHelper.GetSquadRunTime(steamId); if (hours > 0) UpdateSquadPlaytime(steamId, hours); } catch {} }); } catch (Exception ex) { RainOpsMini.Helpers.RainOpsLog.Log($"Error updating player connect: {ex.Message}"); } } public void UpdatePlayerDisconnect(string eosId, DateTime time) { try { using (var db = DbHelper.GetInstance()) { var profile = db.Queryable().First(p => p.EOSID == eosId); if (profile == null) return; var openSession = db.Queryable() .Where(s => s.SteamID == profile.SteamID && s.EndTime == null) .OrderBy(s => s.StartTime, OrderByType.Desc) .First(); if (openSession != null) { openSession.EndTime = time; openSession.DurationSeconds = (time - openSession.StartTime).TotalSeconds; db.Updateable(openSession).ExecuteCommand(); profile.TotalPlaytimeSeconds += openSession.DurationSeconds; double days = (time - profile.FirstSeen).TotalDays; if (days < 1) days = 1; profile.PlayStickiness = (profile.TotalPlaytimeSeconds / 3600.0) / days; db.Updateable(profile).ExecuteCommand(); } } } catch (Exception ex) { RainOpsMini.Helpers.RainOpsLog.Log($"Error updating player disconnect: {ex.Message}"); } } public void UpdateSquadPlaytime(string steamId, int hours) { using (var db = DbHelper.GetInstance()) { db.Updateable() .SetColumns(p => p.SquadPlaytimeHours == hours) .Where(p => p.SteamID == steamId) .ExecuteCommand(); } } public void UpdatePlayerGameTimeInfo(string steamId, int hours, string name) { using (var db = DbHelper.GetInstance()) { var existing = db.Queryable().First(p => p.SteamID == steamId); if (existing != null) { existing.SquadPlaytimeHours = hours; if (!string.IsNullOrWhiteSpace(name)) { existing.LastName = name; } db.Updateable(existing).ExecuteCommand(); } else { var newProfile = new PlayerProfile { SteamID = steamId, SquadPlaytimeHours = hours, LastName = !string.IsNullOrWhiteSpace(name) ? name : "Unknown", FirstSeen = DateTime.Now, LastSeen = DateTime.Now, TotalPlaytimeSeconds = 0, PlayStickiness = 0, EOSID = "" }; db.Insertable(newProfile).ExecuteCommand(); } } } public void UpdatePlayerName(string steamId, string name) { using (var db = DbHelper.GetInstance()) { db.Updateable() .SetColumns(p => p.LastName == name) .Where(p => p.SteamID == steamId) .ExecuteCommand(); } } } }