// Fix 10: Полное переписание PlayerMapManager. // // До этого: плоский JSON-файл, целиком читаемый при старте в AllMaps (Dictionary в RAM) // и полностью перезаписываемый при каждом SavePlayerMap / DeletePlayerMap. // Кеш не имел вытеснения — все когда-либо загруженные карты жили до рестарта сервера. // // Теперь: Postgres-таблица player_maps + TTL-кеш с ограничением размера (аналог AccountCache). // Каждое сохранение — один UPSERT по id, а не сериализация всей коллекции. // Карты, к которым не обращались больше CacheTTL, вытесняются фоновым таймером. // // Требуется миграция (выполнить один раз в psql): // CREATE TABLE IF NOT EXISTS player_maps ( // id BIGINT PRIMARY KEY, // account_id BIGINT NOT NULL, // state INT NOT NULL DEFAULT 0, // data JSONB NOT NULL // ); // CREATE INDEX IF NOT EXISTS idx_player_maps_account_id ON player_maps (account_id); // CREATE INDEX IF NOT EXISTS idx_player_maps_state ON player_maps (state) WHERE state = 3; // // Для однократного импорта существующего player_maps.json запустите Init() — он автоматически // вызовет MigrateFromJsonIfNeeded(), которая прочитает файл, зальёт данные в БД // и переименует файл в player_maps.json.migrated, чтобы миграция не повторялась. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using Npgsql; using NpgsqlTypes; using Supercell.Laser.Logic.Home.Structures; using Supercell.Laser.Titan.Json; using Supercell.Laser.Titan.Math; namespace Supercell.Laser.Server.PlayerMapUtils { public static class PlayerMapManager { // ─── Настройки кеша ─────────────────────────────────────────────────────── // Карта вытесняется, если к ней не обращались дольше CacheTTL. private static readonly TimeSpan CacheTTL = TimeSpan.FromMinutes(10); // При превышении MaxCacheSize вытесняются самые старые (LRU) сверх лимита. private const int MaxCacheSize = 500; // Интервал фонового обхода для вытеснения устаревших записей. private static readonly TimeSpan EvictInterval = TimeSpan.FromMinutes(5); // ─── Путь к устаревшему JSON-файлу (только для однократной миграции) ───── private static readonly string LegacyJsonPath = "/root/36.218/Supercell.Laser.Server/player_maps.json"; // ─── Внутреннее состояние ───────────────────────────────────────────────── private static string _connectionString; private sealed class CachedMap { public LogicPlayerMap Map; public DateTime LastAccess; } private static ConcurrentDictionary _cache; private static Timer _evictionTimer; // ─── Инициализация ──────────────────────────────────────────────────────── // Вызвать из точки старта сервера рядом с Accounts.Init() и Alliances.Init(). // connectionString — та же строка подключения к Postgres, что у Accounts. public static void Init(string connectionString) { _connectionString = connectionString; _cache = new ConcurrentDictionary(); MigrateFromJsonIfNeeded(); _evictionTimer = new Timer( EvictStaleMaps, null, EvictInterval, EvictInterval); } // ─── Публичный API (сигнатуры не изменились) ───────────────────────────── public static void SavePlayerMap(LogicPlayerMap map) { if (map == null) return; // Обновляем кеш _cache[map.MapId] = new CachedMap { Map = map, LastAccess = DateTime.UtcNow }; // Пишем только одну строку в БД — не всю коллекцию UpsertToDB(map); Console.WriteLine( $"[PlayerMap] Created/Updated MapId={map.MapId}, " + $"AccountId={map.AccountId}, Name={map.MapName}, State={map.State}"); } public static void DeletePlayerMap(long mapId) { _cache.TryRemove(mapId, out var cached); try { using var connection = new NpgsqlConnection(_connectionString); connection.Open(); using var cmd = new NpgsqlCommand( "DELETE FROM player_maps WHERE id = @id", connection); cmd.Parameters.Add(new NpgsqlParameter("@id", NpgsqlDbType.Bigint) { Value = mapId }); cmd.ExecuteNonQuery(); } catch (Exception ex) { Logger.Error($"[PlayerMap] DeletePlayerMap error for {mapId}: {ex.Message}"); } Console.WriteLine( $"[PlayerMap] Deleted MapId={mapId}" + (cached?.Map != null ? $", AccountId={cached.Map.AccountId}, Name={cached.Map.MapName}" : "")); } public static LogicPlayerMap GetPlayerMap(long mapId) { if (_cache.TryGetValue(mapId, out var cached)) { cached.LastAccess = DateTime.UtcNow; return cached.Map; } var map = LoadFromDB(mapId); if (map != null) _cache[mapId] = new CachedMap { Map = map, LastAccess = DateTime.UtcNow }; return map; } public static List GetAllPublishedMaps() { // Запрашиваем БД напрямую — не итерируем весь кеш и не держим все карты в памяти. var maps = new List(); try { using var connection = new NpgsqlConnection(_connectionString); connection.Open(); using var cmd = new NpgsqlCommand( "SELECT data FROM player_maps WHERE state = 3", connection); using var reader = cmd.ExecuteReader(); while (reader.Read()) { var map = DeserializeMap(reader.GetString(0)); if (map != null) { maps.Add(map); // Добавляем в кеш попутно — если понадобится снова, не пойдём в БД _cache.TryAdd(map.MapId, new CachedMap { Map = map, LastAccess = DateTime.UtcNow }); } } } catch (Exception ex) { Logger.Error($"[PlayerMap] GetAllPublishedMaps error: {ex.Message}"); } return maps; } public static List GetPlayerMapsByAccount(long accountId) { var maps = new List(); try { using var connection = new NpgsqlConnection(_connectionString); connection.Open(); using var cmd = new NpgsqlCommand( "SELECT data FROM player_maps WHERE account_id = @account_id", connection); cmd.Parameters.Add(new NpgsqlParameter("@account_id", NpgsqlDbType.Bigint) { Value = accountId }); using var reader = cmd.ExecuteReader(); while (reader.Read()) { var map = DeserializeMap(reader.GetString(0)); if (map != null) { maps.Add(map); _cache.TryAdd(map.MapId, new CachedMap { Map = map, LastAccess = DateTime.UtcNow }); } } } catch (Exception ex) { Logger.Error($"[PlayerMap] GetPlayerMapsByAccount error for {accountId}: {ex.Message}"); } return maps; } public static void SyncPlayerMap(LogicPlayerMap map, bool isDeleted = false) { if (map == null) return; if (isDeleted) DeletePlayerMap(map.MapId); else SavePlayerMap(map); } // ─── Вытеснение из кеша ─────────────────────────────────────────────────── private static void EvictStaleMaps(object state) { try { var now = DateTime.UtcNow; // Вытесняем устаревшие по TTL var stale = _cache .Where(kv => (now - kv.Value.LastAccess) > CacheTTL) .Select(kv => kv.Key) .ToList(); foreach (var id in stale) _cache.TryRemove(id, out _); // Если кеш всё равно переполнен — вытесняем LRU сверх лимита if (_cache.Count > MaxCacheSize) { var oldest = _cache .OrderBy(kv => kv.Value.LastAccess) .Take(_cache.Count - MaxCacheSize) .Select(kv => kv.Key) .ToList(); foreach (var id in oldest) _cache.TryRemove(id, out _); } if (stale.Count > 0 || _cache.Count < MaxCacheSize) { Console.WriteLine( $"[PlayerMap] Cache eviction: removed {stale.Count} stale entries, " + $"cache size now {_cache.Count}"); } } catch (Exception ex) { Logger.Error($"[PlayerMap] EvictStaleMaps error: {ex.Message}"); } } // ─── Работа с БД ───────────────────────────────────────────────────────── private static void UpsertToDB(LogicPlayerMap map) { try { string json = SerializeMap(map); using var connection = new NpgsqlConnection(_connectionString); connection.Open(); using var cmd = new NpgsqlCommand( "INSERT INTO player_maps (id, account_id, state, data) " + "VALUES (@id, @account_id, @state, @data) " + "ON CONFLICT (id) DO UPDATE " + "SET account_id = EXCLUDED.account_id, " + " state = EXCLUDED.state, " + " data = EXCLUDED.data", connection); cmd.Parameters.Add(new NpgsqlParameter("@id", NpgsqlDbType.Bigint) { Value = map.MapId }); cmd.Parameters.Add(new NpgsqlParameter("@account_id", NpgsqlDbType.Bigint) { Value = map.AccountId }); cmd.Parameters.Add(new NpgsqlParameter("@state", NpgsqlDbType.Integer) { Value = map.State }); cmd.Parameters.Add(new NpgsqlParameter("@data", NpgsqlDbType.Jsonb) { Value = json }); cmd.ExecuteNonQuery(); } catch (Exception ex) { Logger.Error($"[PlayerMap] UpsertToDB error for {map.MapId}: {ex.Message}"); } } private static LogicPlayerMap LoadFromDB(long mapId) { try { using var connection = new NpgsqlConnection(_connectionString); connection.Open(); using var cmd = new NpgsqlCommand( "SELECT data FROM player_maps WHERE id = @id", connection); cmd.Parameters.Add(new NpgsqlParameter("@id", NpgsqlDbType.Bigint) { Value = mapId }); using var reader = cmd.ExecuteReader(); if (reader.Read()) return DeserializeMap(reader.GetString(0)); } catch (Exception ex) { Logger.Error($"[PlayerMap] LoadFromDB error for {mapId}: {ex.Message}"); } return null; } // ─── Сериализация (через LogicJSON — как было раньше) ───────────────────── private static string SerializeMap(LogicPlayerMap map) { if (map == null) return null; LogicJSONObject obj = new LogicJSONObject(); obj.Put("MapId", new LogicJSONString(map.MapId.ToString())); obj.Put("AccountId", new LogicJSONString(map.AccountId.ToString())); obj.Put("MapName", new LogicJSONString(map.MapName ?? "")); obj.Put("AvatarName", new LogicJSONString(map.AvatarName ?? "")); obj.Put("GameModeVariation", new LogicJSONNumber(map.GameModeVariation)); obj.Put("MapEnvironmentData", new LogicJSONNumber(map.MapEnvironmentData)); obj.Put("CompressedMapData", new LogicJSONString(map.CompressedMapData?.GetString() ?? "")); obj.Put("State", new LogicJSONNumber(map.State)); obj.Put("LastUpdateTime", new LogicJSONNumber((int)map.LastUpdateTimeSecondsSinceEpoch)); obj.Put("FriendlyParticipantCount",new LogicJSONNumber(map.FriendlyParticipantCount)); obj.Put("FriendlySignoffCount", new LogicJSONNumber(map.FriendlySignoffCount)); obj.Put("Likes", new LogicJSONNumber(map.Likes)); obj.Put("Dislikes", new LogicJSONNumber(map.Dislikes)); obj.Put("Battles", new LogicJSONNumber(map.Battles)); return LogicJSONParser.CreateJSONString(obj, 250); } private static LogicPlayerMap DeserializeMap(string json) { try { LogicJSONObject obj = LogicJSONParser.ParseObject(json); if (obj == null) return null; return new LogicPlayerMap { MapId = long.Parse(obj.GetJSONString("MapId").GetStringValue()), AccountId = long.Parse(obj.GetJSONString("AccountId").GetStringValue()), MapName = obj.GetJSONString("MapName").GetStringValue(), AvatarName = obj.GetJSONString("AvatarName").GetStringValue(), GameModeVariation = obj.GetJSONNumber("GameModeVariation").GetIntValue(), MapEnvironmentData = obj.GetJSONNumber("MapEnvironmentData").GetIntValue(), CompressedMapData = new LogicCompressedString(obj.GetJSONString("CompressedMapData")?.GetStringValue() ?? ""), State = obj.GetJSONNumber("State").GetIntValue(), LastUpdateTimeSecondsSinceEpoch = obj.GetJSONNumber("LastUpdateTime").GetIntValue(), FriendlyParticipantCount = obj.GetJSONNumber("FriendlyParticipantCount").GetIntValue(), FriendlySignoffCount = obj.GetJSONNumber("FriendlySignoffCount").GetIntValue(), Likes = obj.GetJSONNumber("Likes").GetIntValue(), Dislikes = obj.GetJSONNumber("Dislikes").GetIntValue(), Battles = obj.GetJSONNumber("Battles").GetIntValue() }; } catch (Exception ex) { Logger.Error($"[PlayerMap] DeserializeMap error: {ex.Message}"); return null; } } // ─── Однократная миграция из legacy JSON-файла ──────────────────────────── private static void MigrateFromJsonIfNeeded() { if (!File.Exists(LegacyJsonPath)) return; Console.WriteLine("[PlayerMap] Найден legacy JSON-файл, запускаем миграцию в Postgres..."); int count = 0; try { string json = File.ReadAllText(LegacyJsonPath); LogicJSONObject root = LogicJSONParser.ParseObject(json); if (root == null) return; LogicJSONArray mapsArray = root.GetJSONArray("maps"); if (mapsArray == null) return; for (int i = 0; i < mapsArray.Size(); i++) { LogicJSONObject mapObj = mapsArray.GetJSONObject(i); var map = DeserializeMap(LogicJSONParser.CreateJSONString(mapObj, 250)); if (map == null) continue; UpsertToDB(map); count++; } // Переименовываем файл, чтобы миграция не повторялась File.Move(LegacyJsonPath, LegacyJsonPath + ".migrated"); Console.WriteLine($"[PlayerMap] Миграция завершена: {count} карт импортировано. " + $"Файл переименован в player_maps.json.migrated."); } catch (Exception ex) { Logger.Error($"[PlayerMap] MigrateFromJsonIfNeeded error: {ex.Message}"); } } } }