From a98144ecdf236b22d0bb49fa1366234cc9d1bb58 Mon Sep 17 00:00:00 2001 From: Killer Date: Fri, 20 Feb 2026 23:16:51 +0100 Subject: [PATCH 1/2] Update UserData service --- TBot/Services/UserData.cs | 54 ++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/TBot/Services/UserData.cs b/TBot/Services/UserData.cs index 228a821d..2330f71f 100644 --- a/TBot/Services/UserData.cs +++ b/TBot/Services/UserData.cs @@ -9,31 +9,33 @@ namespace Tbot.Services { - // Data required by TBotMain instances - public class UserData { - public Server serverInfo = new(); - public ServerData serverData; - public UserInfo userInfo; - public AllianceClass allianceClass; - public List celestials; - public List fleets; - public List attacks; - public Slots slots; - public Researches researches; + public class UserData { + public Server serverInfo = new(); + public ServerData serverData; + public UserInfo userInfo; + public AllianceClass allianceClass; + public List celestials; + public List fleets; + public List attacks; + public Slots slots; + public Researches researches; - public List scheduledFleets; - public List farmTargets; - public Dictionary discoveryBlackList; - public float lastDOIR; - public float nextDOIR; - public Staff staff; - public bool isSleeping = false; - } + public List scheduledFleets; + public List farmTargets; + public Dictionary discoveryBlackList; + public float lastDOIR; + public float nextDOIR; + public Staff staff; + public bool isSleeping = false; - // Data used by TelegramMessenger binded to TBotMain - public class TelegramUserData { - public Celestial CurrentCelestial; // Willingly left to null - public Celestial CurrentCelestialToSave; // Willingly left to null - public Missions Mission = Missions.None; - } -} + public int autoFarmLastGalaxy = 0; + public int autoFarmLastSystem = 0; + public int autoFarmLastRangeIndex = 0; + } + + public class TelegramUserData { + public Celestial CurrentCelestial; + public Celestial CurrentCelestialToSave; + public Missions Mission = Missions.None; + } +} \ No newline at end of file From deeee4b93e13cc08012d4dac884f64c34633d13f Mon Sep 17 00:00:00 2001 From: Killer Date: Fri, 20 Feb 2026 23:27:24 +0100 Subject: [PATCH 2/2] Add Model folder with AutoFarm state models --- TBot/Model/AutoFarmBlacklist.cs | 168 +++++++ TBot/Model/AutoFarmSuccessfulTargets.cs | 306 ++++++++++++ TBot/Model/SharedFarmState.cs | 601 ++++++++++++++++++++++++ 3 files changed, 1075 insertions(+) create mode 100644 TBot/Model/AutoFarmBlacklist.cs create mode 100644 TBot/Model/AutoFarmSuccessfulTargets.cs create mode 100644 TBot/Model/SharedFarmState.cs diff --git a/TBot/Model/AutoFarmBlacklist.cs b/TBot/Model/AutoFarmBlacklist.cs new file mode 100644 index 00000000..2a17ff72 --- /dev/null +++ b/TBot/Model/AutoFarmBlacklist.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using TBot.Ogame.Infrastructure.Models; + +namespace TBot.Model { + public enum BlacklistReason { + HasFleet, + HasDefense, + LowResources, + ManuallyAdded + } + + public class BlacklistedTarget { + public Coordinate Coordinate { get; set; } + public BlacklistReason Reason { get; set; } + public DateTime BlacklistedAt { get; set; } + public DateTime ExpiresAt { get; set; } + + public BlacklistedTarget() { + } + + public BlacklistedTarget(Coordinate coordinate, BlacklistReason reason, DateTime expiresAt) { + Coordinate = coordinate; + Reason = reason; + BlacklistedAt = DateTime.UtcNow; + ExpiresAt = expiresAt; + } + + public bool IsExpired() { + return DateTime.UtcNow >= ExpiresAt; + } + } + + public class AutoFarmBlacklist { + private List _blacklistedTargets; + private readonly object _lock = new object(); + private string _filePath; + + public AutoFarmBlacklist() { + _blacklistedTargets = new List(); + } + + public AutoFarmBlacklist(string filePath) { + string dataFolder = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data"); + if (!Directory.Exists(dataFolder)) { + Directory.CreateDirectory(dataFolder); + } + _filePath = Path.Combine(dataFolder, filePath); + _blacklistedTargets = new List(); + LoadFromFile(); + } + + public void AddTarget(Coordinate coordinate, BlacklistReason reason, int hoursUntilReset) { + lock (_lock) { + _blacklistedTargets.RemoveAll(t => t.Coordinate.Galaxy == coordinate.Galaxy + && t.Coordinate.System == coordinate.System + && t.Coordinate.Position == coordinate.Position); + + DateTime expiresAt = DateTime.UtcNow.AddHours(hoursUntilReset); + _blacklistedTargets.Add(new BlacklistedTarget(coordinate, reason, expiresAt)); + SaveToFile(); + } + } + + public bool IsBlacklisted(Coordinate coordinate) { + lock (_lock) { + CleanupExpiredTargets(); + + return _blacklistedTargets.Any(t => + t.Coordinate.Galaxy == coordinate.Galaxy + && t.Coordinate.System == coordinate.System + && t.Coordinate.Position == coordinate.Position); + } + } + + public BlacklistedTarget GetBlacklistedTarget(Coordinate coordinate) { + lock (_lock) { + CleanupExpiredTargets(); + + return _blacklistedTargets.FirstOrDefault(t => + t.Coordinate.Galaxy == coordinate.Galaxy + && t.Coordinate.System == coordinate.System + && t.Coordinate.Position == coordinate.Position); + } + } + + public void RemoveTarget(Coordinate coordinate) { + lock (_lock) { + _blacklistedTargets.RemoveAll(t => + t.Coordinate.Galaxy == coordinate.Galaxy + && t.Coordinate.System == coordinate.System + && t.Coordinate.Position == coordinate.Position); + } + SaveToFile(); + } + + public void ClearAll() { + lock (_lock) { + _blacklistedTargets.Clear(); + } + SaveToFile(); + } + + public int GetBlacklistedCount() { + lock (_lock) { + CleanupExpiredTargets(); + return _blacklistedTargets.Count; + } + } + + public List GetAllBlacklisted() { + lock (_lock) { + CleanupExpiredTargets(); + return new List(_blacklistedTargets); + } + } + + private void CleanupExpiredTargets() { + _blacklistedTargets.RemoveAll(t => t.IsExpired()); + } + + public void ManualCleanup() { + lock (_lock) { + CleanupExpiredTargets(); + } + } + private void LoadFromFile() { + if (string.IsNullOrEmpty(_filePath) || !File.Exists(_filePath)) { + return; + } + + try { + string json = File.ReadAllText(_filePath); + var loaded = JsonConvert.DeserializeObject>(json); + if (loaded != null) { + lock (_lock) { + _blacklistedTargets = loaded; + CleanupExpiredTargets(); + } + } + } catch (Exception) { + lock (_lock) { + _blacklistedTargets = new List(); + } +} + } + + private void SaveToFile() { + if (string.IsNullOrEmpty(_filePath)) { + return; + } + + List snapshot; + lock (_lock) { + snapshot = _blacklistedTargets.ToList(); + } + + try { + string json = JsonConvert.SerializeObject(snapshot, Formatting.Indented); + File.WriteAllText(_filePath, json); + } catch (Exception) { + } + } + } +} diff --git a/TBot/Model/AutoFarmSuccessfulTargets.cs b/TBot/Model/AutoFarmSuccessfulTargets.cs new file mode 100644 index 00000000..eef9351b --- /dev/null +++ b/TBot/Model/AutoFarmSuccessfulTargets.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using TBot.Ogame.Infrastructure.Models; + +namespace TBot.Model +{ + public class SuccessfulTarget + { + public string Coordinate { get; set; } + public DateTime FirstAttacked { get; set; } + public DateTime LastAttacked { get; set; } + public int TimesAttacked { get; set; } + public Resources TotalLootCollected { get; set; } + public Resources AverageLoot { get; set; } + + public SuccessfulTarget() + { + TotalLootCollected = new Resources(); + AverageLoot = new Resources(); + } + + public SuccessfulTarget(string coordinate, Resources loot) + { + Coordinate = coordinate; + FirstAttacked = DateTime.UtcNow; + LastAttacked = DateTime.UtcNow; + TimesAttacked = 1; + + TotalLootCollected = new Resources + { + Metal = loot.Metal, + Crystal = loot.Crystal, + Deuterium = loot.Deuterium + }; + + AverageLoot = new Resources + { + Metal = loot.Metal, + Crystal = loot.Crystal, + Deuterium = loot.Deuterium + }; + } + + public void AddAttack(Resources loot) + { + LastAttacked = DateTime.UtcNow; + TimesAttacked++; + + TotalLootCollected.Metal += loot.Metal; + TotalLootCollected.Crystal += loot.Crystal; + TotalLootCollected.Deuterium += loot.Deuterium; + + AverageLoot.Metal = TotalLootCollected.Metal / TimesAttacked; + AverageLoot.Crystal = TotalLootCollected.Crystal / TimesAttacked; + AverageLoot.Deuterium = TotalLootCollected.Deuterium / TimesAttacked; + } + } + + public class AutoFarmSuccessfulTargets + { + private List _successfulTargets; + private readonly object _lock = new object(); + + private string _filePath; + + private DateTime _lastSaveUtc = DateTime.MinValue; + private int _pendingChanges = 0; + + private readonly TimeSpan _saveInterval = TimeSpan.FromSeconds(60); + private const int SaveAfterChanges = 20; + + private const int MaxTargets = 5000; + private readonly TimeSpan _staleCutoff = TimeSpan.FromDays(30); + + public AutoFarmSuccessfulTargets() + { + _successfulTargets = new List(); + } + + public AutoFarmSuccessfulTargets(string fileName) + { + string dataFolder = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data"); + if (!Directory.Exists(dataFolder)) + { + Directory.CreateDirectory(dataFolder); + } + + string safeFileName = MakeSafeFileName(fileName); + _filePath = Path.Combine(dataFolder, safeFileName); + + _successfulTargets = new List(); + LoadFromFile(); + } + + public void RecordAttack(Coordinate coordinate, Resources loot) + { + lock (_lock) + { + string coordStr = $"{coordinate.Galaxy}:{coordinate.System}:{coordinate.Position}"; + + var existing = _successfulTargets.FirstOrDefault(t => t.Coordinate == coordStr); + if (existing != null) + { + existing.AddAttack(loot); + } + else + { + _successfulTargets.Add(new SuccessfulTarget(coordStr, loot)); + } + + _pendingChanges++; + + CleanupUnsafeGrowth_NoThrow(); + + SaveIfNeeded_NoThrow(); + } + } + + public SuccessfulTarget GetTarget(Coordinate coordinate) + { + lock (_lock) + { + string coordStr = $"{coordinate.Galaxy}:{coordinate.System}:{coordinate.Position}"; + return _successfulTargets.FirstOrDefault(t => t.Coordinate == coordStr); + } + } + + public List GetAllTargets() + { + lock (_lock) + { + return new List(_successfulTargets); + } + } + + public int GetTotalCount() + { + lock (_lock) + { + return _successfulTargets.Count; + } + } + + public long GetTotalLootCollected() + { + lock (_lock) + { + return _successfulTargets.Sum(t => + t.TotalLootCollected.Metal + + t.TotalLootCollected.Crystal + + t.TotalLootCollected.Deuterium + ); + } + } + + + public void ForceSave() + { + lock (_lock) + { + SaveToFile_NoThrow(force: true); + } + } + + private void LoadFromFile() + { + if (string.IsNullOrEmpty(_filePath) || !File.Exists(_filePath)) + { + return; + } + + try + { + string json = File.ReadAllText(_filePath); + var loaded = JsonConvert.DeserializeObject>(json); + if (loaded != null) + { + _successfulTargets = loaded; + } + } + catch (Exception ex) + { + _successfulTargets = new List(); + WriteError_NoThrow($"LoadFromFile failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + private void SaveIfNeeded_NoThrow() + { + if (string.IsNullOrEmpty(_filePath)) + return; + + var now = DateTime.UtcNow; + + bool timeDue = (now - _lastSaveUtc) >= _saveInterval; + bool changesDue = _pendingChanges >= SaveAfterChanges; + + if (timeDue || changesDue) + { + SaveToFile_NoThrow(force: false); + } + } + + private void SaveToFile_NoThrow(bool force) + { + if (string.IsNullOrEmpty(_filePath)) + return; + + try + { + if (!force && _pendingChanges <= 0) + return; + + string json = JsonConvert.SerializeObject(_successfulTargets, Formatting.Indented); + + string tmpPath = _filePath + ".tmp"; + + File.WriteAllText(tmpPath, json); + + if (File.Exists(_filePath)) + { + try + { + File.Replace(tmpPath, _filePath, null); + } + catch + { + File.Delete(_filePath); + File.Move(tmpPath, _filePath); + } + } + else + { + File.Move(tmpPath, _filePath); + } + + _lastSaveUtc = DateTime.UtcNow; + _pendingChanges = 0; + } + catch (Exception ex) + { + WriteError_NoThrow($"SaveToFile failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + private void CleanupUnsafeGrowth_NoThrow() + { + try + { + var cutoff = DateTime.UtcNow - _staleCutoff; + _successfulTargets.RemoveAll(t => t.LastAttacked < cutoff); + + if (_successfulTargets.Count > MaxTargets) + { + _successfulTargets = _successfulTargets + .OrderByDescending(t => t.LastAttacked) + .Take(MaxTargets) + .ToList(); + } + } + catch (Exception ex) + { + WriteError_NoThrow($"Cleanup failed: {ex.GetType().Name}: {ex.Message}"); + } + } + + private static string MakeSafeFileName(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + return "autofarm_successful_targets.json"; + + string name = Path.GetFileName(fileName); + + if (!name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + name += ".json"; + + foreach (char c in Path.GetInvalidFileNameChars()) + { + name = name.Replace(c, '_'); + } + + return name; + } + + private void WriteError_NoThrow(string message) + { + try + { + string baseDir = Path.GetDirectoryName(_filePath); + if (string.IsNullOrEmpty(baseDir)) + return; + + string errPath = Path.Combine(baseDir, "autofarm_successful_targets.errors.log"); + string line = $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC] {message}{Environment.NewLine}"; + File.AppendAllText(errPath, line); + } + catch + { + + } + } + } +} diff --git a/TBot/Model/SharedFarmState.cs b/TBot/Model/SharedFarmState.cs new file mode 100644 index 00000000..299b993c --- /dev/null +++ b/TBot/Model/SharedFarmState.cs @@ -0,0 +1,601 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using Newtonsoft.Json; +using TBot.Ogame.Infrastructure.Models; +using TBot.Ogame.Infrastructure.Enums; + +namespace TBot.Model +{ + + public class FarmBotInstance + { + public string Name { get; set; } + public DateTime LastHeartbeat { get; set; } + public bool IsActive { get; set; } + + public bool IsAlive(TimeSpan timeout) + { + return (DateTime.UtcNow - LastHeartbeat) < timeout; + } + } + + public class ScannedTarget + { + public string Coord { get; set; } + public string ScannedBy { get; set; } + public DateTime ScannedAt { get; set; } + public DateTime ExpiresAt { get; set; } + public long TotalResources { get; set; } + public bool HasFleet { get; set; } + public bool HasDefense { get; set; } + + public bool IsExpired() + { + return DateTime.UtcNow >= ExpiresAt; + } + + public Coordinate GetCoordinate() + { + if (string.IsNullOrWhiteSpace(Coord)) + return null; + + var parts = Coord.Split(':'); + if (parts.Length != 3) + return null; + + if (!int.TryParse(parts[0], out var g)) return null; + if (!int.TryParse(parts[1], out var s)) return null; + if (!int.TryParse(parts[2], out var p)) return null; + + return new Coordinate + { + Galaxy = g, + System = s, + Position = p, + Type = Celestials.Planet + }; + } + } + + public class ClaimedAttack + { + public string Coord { get; set; } + public string ClaimedBy { get; set; } + public DateTime ClaimedAt { get; set; } + public DateTime? AttackSentAt { get; set; } + public DateTime ReturnsAt { get; set; } + + public bool IsExpired() + { + if (DateTime.UtcNow >= ReturnsAt) + return true; + + if (!AttackSentAt.HasValue && (DateTime.UtcNow - ClaimedAt).TotalHours > 2) + return true; + + return false; + } + + public Coordinate GetCoordinate() + { + if (string.IsNullOrWhiteSpace(Coord)) + return null; + + var parts = Coord.Split(':'); + if (parts.Length != 3) + return null; + + if (!int.TryParse(parts[0], out var g)) return null; + if (!int.TryParse(parts[1], out var s)) return null; + if (!int.TryParse(parts[2], out var p)) return null; + + return new Coordinate + { + Galaxy = g, + System = s, + Position = p, + Type = Celestials.Planet + }; + } + } + + public class SharedFarmState + { + private const string DEFAULT_FILENAME = "shared_farm_state.json"; + private const int MAX_RETRY_ATTEMPTS = 5; + private const int RETRY_DELAY_MS = 100; + private const string MUTEX_GLOBAL_NAME = @"Global\TBot_SharedFarmState_Mutex"; + private const string MUTEX_LOCAL_NAME = @"Local\TBot_SharedFarmState_Mutex"; + + public string Version { get; set; } = "1.1"; + public DateTime LastUpdated { get; set; } + public List Instances { get; set; } + public List ScannedTargets { get; set; } + public List ClaimedAttacks { get; set; } + + private static readonly object _globalLock = new object(); + private string _filePath; + + public SharedFarmState() + { + Instances = new List(); + ScannedTargets = new List(); + ClaimedAttacks = new List(); + LastUpdated = DateTime.UtcNow; + } + + public static SharedFarmState Initialize(string instanceName = null) + { + string dataFolder = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data"); + if (!Directory.Exists(dataFolder)) + Directory.CreateDirectory(dataFolder); + + string filePath = Path.Combine(dataFolder, DEFAULT_FILENAME); + var state = LoadFromFile(filePath); + state._filePath = filePath; + + if (!string.IsNullOrEmpty(instanceName)) + { + state.UpdateInstanceHeartbeat(instanceName); + state.Save(); + } + + return state; + } + + private static SharedFarmState LoadFromFile(string filePath) + { + lock (_globalLock) + { + if (!File.Exists(filePath)) + return new SharedFarmState(); + + for (int attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) + { + try + { + string json = File.ReadAllText(filePath); + var state = JsonConvert.DeserializeObject(json); + + if (state != null) + { + state._filePath = filePath; + state.CleanupExpiredEntries(); + return state; + } + } + catch (IOException) when (attempt < MAX_RETRY_ATTEMPTS - 1) + { + Thread.Sleep(RETRY_DELAY_MS * (attempt + 1)); + } + catch (JsonException) + { + try + { + string backupPath = filePath + ".corrupt." + DateTime.UtcNow.Ticks; + File.Move(filePath, backupPath); + } + catch {} + return new SharedFarmState(); + } + catch + { + return new SharedFarmState(); + } + } + + return new SharedFarmState(); + } + } + + private static Mutex CreateNamedMutex() + { + try + { + return new Mutex(false, MUTEX_GLOBAL_NAME); + } + catch + { + return new Mutex(false, MUTEX_LOCAL_NAME); + } + } + + private static bool WithCrossProcessMutex(TimeSpan timeout, Action action) + { + using (var mutex = CreateNamedMutex()) + { + try + { + if (!mutex.WaitOne(timeout)) + return false; + + try + { + action(); + return true; + } + finally + { + try { mutex.ReleaseMutex(); } catch { } + } + } + catch (AbandonedMutexException) + { + try + { + action(); + return true; + } + finally + { + try { mutex.ReleaseMutex(); } catch { } + } + } + catch + { + return false; + } + } + } + + public void Save() + { + if (string.IsNullOrEmpty(_filePath)) + return; + + WithCrossProcessMutex(TimeSpan.FromSeconds(5), () => + { + SaveNoMutex(); + }); + } + + private void SaveNoMutex() + { + lock (_globalLock) + { + LastUpdated = DateTime.UtcNow; + CleanupExpiredEntries(); + + string tempPath = _filePath + ".tmp"; + string backupPath = _filePath + ".bak"; + + try + { + string json = JsonConvert.SerializeObject(this, Formatting.Indented); + File.WriteAllText(tempPath, json); + + try + { + if (File.Exists(_filePath)) + File.Replace(tempPath, _filePath, backupPath, ignoreMetadataErrors: true); + else + File.Move(tempPath, _filePath); + } + catch + { + if (File.Exists(_filePath)) + File.Delete(_filePath); + + File.Move(tempPath, _filePath); + } + } + catch + { + if (File.Exists(tempPath)) + { + try { File.Delete(tempPath); } catch { } + } + } + } + } + + public void Reload() + { + if (string.IsNullOrEmpty(_filePath)) + return; + + var fresh = LoadFromFile(_filePath); + fresh._filePath = _filePath; + + Version = fresh.Version; + LastUpdated = fresh.LastUpdated; + Instances = fresh.Instances; + ScannedTargets = fresh.ScannedTargets; + ClaimedAttacks = fresh.ClaimedAttacks; + } + + public void UpdateInstanceHeartbeat(string instanceName) + { + var instance = Instances.FirstOrDefault(i => i.Name == instanceName); + if (instance == null) + { + instance = new FarmBotInstance + { + Name = instanceName, + IsActive = true + }; + Instances.Add(instance); + } + + instance.LastHeartbeat = DateTime.UtcNow; + instance.IsActive = true; + } + + public List GetActiveBots() + { + return Instances.Where(i => i.IsAlive(TimeSpan.FromMinutes(5))).ToList(); + } + + public ScannedTarget GetRecentScan(Coordinate coord, int withinHours = 8) + { + string coordStr = $"{coord.Galaxy}:{coord.System}:{coord.Position}"; + return ScannedTargets.FirstOrDefault(s => + s.Coord == coordStr && + (DateTime.UtcNow - s.ScannedAt).TotalHours < withinHours); + } + + public void AddScannedTarget(Coordinate coord, string scannedBy, long totalResources, + bool hasFleet, bool hasDefense, int expiresInHours = 8) + { + string coordStr = $"{coord.Galaxy}:{coord.System}:{coord.Position}"; + + ScannedTargets.RemoveAll(s => s.Coord == coordStr); + + ScannedTargets.Add(new ScannedTarget + { + Coord = coordStr, + ScannedBy = scannedBy, + ScannedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddHours(expiresInHours), + TotalResources = totalResources, + HasFleet = hasFleet, + HasDefense = hasDefense + }); + } + + public void UpdateScannedTarget(Coordinate coord, long totalResources, bool hasFleet, bool hasDefense) + { + string coordStr = $"{coord.Galaxy}:{coord.System}:{coord.Position}"; + var existing = ScannedTargets.FirstOrDefault(s => s.Coord == coordStr); + + if (existing != null) + { + existing.TotalResources = totalResources; + existing.HasFleet = hasFleet; + existing.HasDefense = hasDefense; + } + } + + public List GetGoodTargets(long minResources, int maxAgeMinutes = 30) + { + return ScannedTargets + .Where(s => !s.IsExpired()) + .Where(s => (DateTime.UtcNow - s.ScannedAt).TotalMinutes <= maxAgeMinutes) + .Where(s => s.TotalResources > 0) + .Where(s => s.TotalResources >= minResources) + .Where(s => !s.HasFleet) + .Where(s => !s.HasDefense) + .Where(s => !IsTargetClaimed(s.GetCoordinate())) + .OrderByDescending(s => s.TotalResources) + .ToList(); + } + + public List GetTargetsWithoutReports(string scannedByInstance = null) + { + var query = ScannedTargets + .Where(s => !s.IsExpired()) + .Where(s => s.TotalResources == 0); + + if (!string.IsNullOrEmpty(scannedByInstance)) + query = query.Where(s => s.ScannedBy == scannedByInstance); + + return query.OrderBy(s => s.ScannedAt).ToList(); + } + + public bool ShouldRescan(Coordinate coord, int maxAgeMinutes = 30) + { + string coordStr = $"{coord.Galaxy}:{coord.System}:{coord.Position}"; + var scan = ScannedTargets.FirstOrDefault(s => s.Coord == coordStr && !s.IsExpired()); + + if (scan == null) return true; + if (scan.TotalResources == 0) return true; + + return (DateTime.UtcNow - scan.ScannedAt).TotalMinutes > maxAgeMinutes; + } + + public bool TryClaimAttack(Coordinate coord, string claimedBy, DateTime returnsAt) + { + Reload(); + return TryClaimAttack_NoReload(coord, claimedBy, returnsAt); + } + + private bool TryClaimAttack_NoReload(Coordinate coord, string claimedBy, DateTime returnsAt) + { + string coordStr = $"{coord.Galaxy}:{coord.System}:{coord.Position}"; + + var existingClaim = ClaimedAttacks.FirstOrDefault(c => c.Coord == coordStr && !c.IsExpired()); + if (existingClaim != null && existingClaim.ClaimedBy != claimedBy) + return false; + + ClaimedAttacks.RemoveAll(c => c.Coord == coordStr); + ClaimedAttacks.Add(new ClaimedAttack + { + Coord = coordStr, + ClaimedBy = claimedBy, + ClaimedAt = DateTime.UtcNow, + AttackSentAt = null, + ReturnsAt = returnsAt + }); + + return true; + } + + public void MarkAttackSent(Coordinate coord, string claimedBy) + { + string coordStr = $"{coord.Galaxy}:{coord.System}:{coord.Position}"; + var claim = ClaimedAttacks.FirstOrDefault(c => c.Coord == coordStr && c.ClaimedBy == claimedBy); + if (claim != null) + { + claim.AttackSentAt = DateTime.UtcNow; + } + } + + public bool IsTargetClaimed(Coordinate coord) + { + if (coord == null) return false; + string coordStr = $"{coord.Galaxy}:{coord.System}:{coord.Position}"; + return ClaimedAttacks.Any(c => c.Coord == coordStr && !c.IsExpired()); + } + + public string GetClaimOwner(Coordinate coord) + { + if (coord == null) return null; + string coordStr = $"{coord.Galaxy}:{coord.System}:{coord.Position}"; + return ClaimedAttacks.FirstOrDefault(c => c.Coord == coordStr && !c.IsExpired())?.ClaimedBy; + } + + private void CleanupExpiredEntries() + { + ScannedTargets.RemoveAll(s => s.IsExpired()); + ClaimedAttacks.RemoveAll(c => c.IsExpired()); + + foreach (var instance in Instances) + { + if (!instance.IsAlive(TimeSpan.FromMinutes(5))) + instance.IsActive = false; + } + } + + public string GetStats() + { + CleanupExpiredEntries(); + return $"Instances: {Instances.Count(i => i.IsActive)} active, " + + $"Scanned: {ScannedTargets.Count}, " + + $"Claims: {ClaimedAttacks.Count}"; + } + + public static void AddScannedTargetAtomic(string filePath, Coordinate coord, string scannedBy, + long totalResources, bool hasFleet, bool hasDefense) + { + WithCrossProcessMutex(TimeSpan.FromSeconds(5), () => + { + var state = LoadFromFile(filePath); + state._filePath = filePath; + + state.AddScannedTarget(coord, scannedBy, totalResources, hasFleet, hasDefense); + state.SaveNoMutex(); + }); + } + + public static void UpdateScannedTargetAtomic(string filePath, Coordinate coord, long totalResources, bool hasFleet, bool hasDefense) + { + WithCrossProcessMutex(TimeSpan.FromSeconds(5), () => + { + var state = LoadFromFile(filePath); + state._filePath = filePath; + + state.UpdateScannedTarget(coord, totalResources, hasFleet, hasDefense); + state.SaveNoMutex(); + }); + } + + public static void MarkAttackSentAtomic(string filePath, Coordinate coord, string claimedBy) + { + WithCrossProcessMutex(TimeSpan.FromSeconds(5), () => + { + var state = LoadFromFile(filePath); + state._filePath = filePath; + + state.MarkAttackSent(coord, claimedBy); + state.SaveNoMutex(); + }); + } + + public static bool TryReserveScan(string filePath, Coordinate coord, string scannedBy, + int withinHours = 8, int maxAgeMinutes = 30) + { + bool result = false; + + bool acquired = WithCrossProcessMutex(TimeSpan.FromSeconds(5), () => + { + var state = LoadFromFile(filePath); + state._filePath = filePath; + + if (state.ShouldRescan(coord, maxAgeMinutes)) + { + state.AddScannedTarget(coord, scannedBy, 0, false, false); + state.SaveNoMutex(); + result = true; + return; + } + + string coordStr = $"{coord.Galaxy}:{coord.System}:{coord.Position}"; + var recentScan = state.ScannedTargets.FirstOrDefault(s => + s.Coord == coordStr && + (DateTime.UtcNow - s.ScannedAt).TotalHours < withinHours); + + if (recentScan != null) + { + if (recentScan.ScannedBy == scannedBy) + { + if (state.ShouldRescan(coord, maxAgeMinutes)) + { + state.AddScannedTarget(coord, scannedBy, 0, false, false); + state.SaveNoMutex(); + result = true; + return; + } + result = false; + return; + } + else + { + if ((DateTime.UtcNow - recentScan.ScannedAt).TotalMinutes <= maxAgeMinutes) + { + result = false; + return; + } + + state.AddScannedTarget(coord, scannedBy, 0, false, false); + state.SaveNoMutex(); + result = true; + return; + } + } + + state.AddScannedTarget(coord, scannedBy, 0, false, false); + state.SaveNoMutex(); + result = true; + }); + + if (!acquired) + return false; + + return result; + } + + public static bool TryClaimAttackAtomic(string filePath, Coordinate coord, string claimedBy, DateTime returnsAt) + { + bool claimed = false; + + bool acquired = WithCrossProcessMutex(TimeSpan.FromSeconds(5), () => + { + var state = LoadFromFile(filePath); + state._filePath = filePath; + + claimed = state.TryClaimAttack_NoReload(coord, claimedBy, returnsAt); + + if (claimed) + state.SaveNoMutex(); + }); + + if (!acquired) + return false; + + return claimed; + } + } +}