using LocationsApi.Models; using MongoDB.Driver; namespace LocationsApi.Services; public class LocationService { private readonly IMongoCollection _locations; private readonly ResourceService _resources; private readonly GameSettingsService _settings; private readonly int _homeMinDistance; private readonly int _homeSearchRadius; private readonly int _visibilityDepth; private readonly int _logCampWoodPerMinute; private readonly int _mineOrePerMinute; private readonly int _defaultLogCampBuildSeconds; private readonly int _defaultMineBuildSeconds; public LocationService(IConfiguration cfg, ResourceService resources, GameSettingsService settings) { var connectionString = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; var databaseName = cfg["MongoDB:DatabaseName"] ?? "territory-autobattler"; var client = new MongoClient(connectionString); var database = client.GetDatabase(databaseName); _locations = database.GetCollection("Locations"); _resources = resources; _settings = settings; _homeMinDistance = int.TryParse(cfg["World:HomeMinDistance"], out var minDistance) ? Math.Max(1, minDistance) : 8; _homeSearchRadius = int.TryParse(cfg["World:HomeSearchRadius"], out var searchRadius) ? Math.Max(_homeMinDistance, searchRadius) : 80; _visibilityDepth = int.TryParse(cfg["World:VisibilityDepth"], out var visibilityDepth) ? Math.Max(0, visibilityDepth) : 3; _logCampWoodPerMinute = int.TryParse(cfg["Buildings:LogCampWoodPerMinute"], out var logCampWoodPerMinute) ? Math.Max(1, logCampWoodPerMinute) : 5; _mineOrePerMinute = int.TryParse(cfg["Buildings:MineOrePerMinute"], out var mineOrePerMinute) ? Math.Max(1, mineOrePerMinute) : 5; _defaultLogCampBuildSeconds = int.TryParse(cfg["Buildings:LogCampBuildSeconds"], out var logCampBuildSeconds) ? Math.Max(1, logCampBuildSeconds) : 60; _defaultMineBuildSeconds = int.TryParse(cfg["Buildings:MineBuildSeconds"], out var mineBuildSeconds) ? Math.Max(1, mineBuildSeconds) : 60; var coordinateIndex = Builders.IndexKeys .Ascending(x => x.Q) .Ascending(x => x.R); _locations.Indexes.CreateOne(new CreateIndexModel( coordinateIndex, new CreateIndexOptions { Unique = true })); var ownerHomeIndex = Builders.IndexKeys .Ascending(x => x.OwnerUsername) .Ascending(x => x.Kind); _locations.Indexes.CreateOne(new CreateIndexModel( ownerHomeIndex, new CreateIndexOptions { Unique = true, PartialFilterExpression = Builders.Filter.Eq(x => x.Kind, "Home") })); } public Task> GetAllAsync() => _locations.Find(FilterDefinition.Empty) .SortBy(x => x.Q) .ThenBy(x => x.R) .ToListAsync(); public Task> GetRangeAsync(int minQ, int maxQ, int minR, int maxR) => _locations.Find(x => x.Q >= minQ && x.Q <= maxQ && x.R >= minR && x.R <= maxR) .SortBy(x => x.Q) .ThenBy(x => x.R) .ToListAsync(); public async Task GetAsync(int q, int r) => await _locations.Find(x => x.Q == q && x.R == r).FirstOrDefaultAsync(); public async Task UpsertAsync(UpsertLocationRequest request, string? ownerUsername = null) { var now = DateTime.UtcNow; var update = Builders.Update .SetOnInsert(x => x.CreatedAtUtc, now) .Set(x => x.Terrain, NormalizeTerrain(request.Terrain)) .Set(x => x.Kind, "Open") .Set(x => x.Label, request.Label) .Set(x => x.Level, Math.Max(1, request.Level)) .Set(x => x.UpdatedAtUtc, now); if (!string.IsNullOrWhiteSpace(ownerUsername)) { update = update .Set(x => x.OwnerUsername, ownerUsername) .Set(x => x.Kind, "Claimed"); } var options = new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }; return await _locations.FindOneAndUpdateAsync( x => x.Q == request.Q && x.R == request.R, update, options); } public async Task ClaimAsync(int q, int r, string username) { var existing = await GetAsync(q, r); if (existing is not null) { if (!string.IsNullOrWhiteSpace(existing.OwnerUsername) && !string.Equals(existing.OwnerUsername, username, StringComparison.OrdinalIgnoreCase)) { return null; } existing.OwnerUsername = username; if (string.IsNullOrWhiteSpace(existing.Kind) || existing.Kind == "Open") { existing.Kind = "Claimed"; } existing.UpdatedAtUtc = DateTime.UtcNow; await _locations.ReplaceOneAsync(x => x.Id == existing.Id, existing); return existing; } return await UpsertAsync(new UpsertLocationRequest { Q = q, R = r, Terrain = "Field" }, username); } public async Task BuildAsync(BuildLocationRequest request, string username) { var buildingType = NormalizeBuildingType(request.BuildingType); if (buildingType is null) { throw new ArgumentException("Unsupported building type.", nameof(request.BuildingType)); } var location = await GetAsync(request.Q, request.R); if (location is null || !string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase)) { return null; } if (!string.IsNullOrWhiteSpace(location.BuildingType)) { throw new InvalidOperationException("This location already has a building or construction in progress."); } var requiredTerrain = buildingType == "LogCamp" ? "Forest" : "Ore"; if (!string.Equals(location.Terrain, requiredTerrain, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException($"{buildingType} can only be built on {requiredTerrain} territory."); } await CompleteFinishedBuildingsAsync(username); var now = DateTime.UtcNow; var settingKey = buildingType == "LogCamp" ? GameSettingsService.LogCampBuildSeconds : GameSettingsService.MineBuildSeconds; var defaultBuildSeconds = buildingType == "LogCamp" ? _defaultLogCampBuildSeconds : _defaultMineBuildSeconds; var buildSeconds = await _settings.GetIntAsync(settingKey, defaultBuildSeconds); var update = Builders.Update .Set(x => x.BuildingType, buildingType) .Set(x => x.BuildingStatus, "Building") .Set(x => x.BuildingStartedAtUtc, now) .Set(x => x.BuildingCompletesAtUtc, now.AddSeconds(buildSeconds)) .Set(x => x.UpdatedAtUtc, now); var updated = await _locations.FindOneAndUpdateAsync( x => x.Id == location.Id && x.OwnerUsername == username && x.BuildingType == null, update, new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); if (updated is null) { throw new InvalidOperationException("This location already has a building or construction in progress."); } return updated; } public async Task CompleteFinishedBuildingsAsync(string username) { var now = DateTime.UtcNow; var readyBuildings = await _locations.Find(x => x.OwnerUsername == username && (x.BuildingType == "LogCamp" || x.BuildingType == "Mine") && x.BuildingStatus == "Building" && x.BuildingCompletesAtUtc <= now) .ToListAsync(); foreach (var building in readyBuildings) { var update = Builders.Update .Set(x => x.BuildingStatus, "Built") .Set(x => x.BuildingBuiltAtUtc, now) .Set(x => x.UpdatedAtUtc, now); var updated = await _locations.FindOneAndUpdateAsync( x => x.Id == building.Id && x.BuildingStatus == "Building", update, new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); if (updated is not null) { if (updated.BuildingType == "LogCamp") { await _resources.AddWoodGenerationAsync(username, _logCampWoodPerMinute); } else if (updated.BuildingType == "Mine") { await _resources.AddOreGenerationAsync(username, _mineOrePerMinute); } } } } public async Task GetHomeAsync(string username) => await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync(); public async Task AssignHomeAsync(string username) { var existingHome = await GetHomeAsync(username); if (existingHome is not null) { await _resources.EnsureAsync(username); return existingHome; } var homeLocations = await _locations.Find(x => x.Kind == "Home").ToListAsync(); foreach (var candidate in HomeCandidates(username)) { if (!IsFarEnoughFromHomes(candidate.q, candidate.r, homeLocations)) { continue; } var existingLocation = await GetAsync(candidate.q, candidate.r); if (existingLocation is not null && !string.IsNullOrWhiteSpace(existingLocation.OwnerUsername)) { continue; } var home = await TryCreateHomeAsync(username, candidate.q, candidate.r); if (home is not null) { await _resources.EnsureAsync(username); return home; } homeLocations = await _locations.Find(x => x.Kind == "Home").ToListAsync(); } throw new InvalidOperationException("No valid home location is available in the configured search radius."); } public async Task> GetVisibleAsync(string username, int? visibilityDepth = null) { await CompleteFinishedBuildingsAsync(username); var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync(); if (ownedLocations.Count == 0) { ownedLocations.Add(await AssignHomeAsync(username)); } var depth = Math.Max(0, visibilityDepth ?? _visibilityDepth); var visibleCoordinates = new HashSet<(int q, int r)>(); foreach (var owned in ownedLocations) { foreach (var coordinate in HexRange(owned.Q, owned.R, depth)) { visibleCoordinates.Add(coordinate); } } var minQ = visibleCoordinates.Min(x => x.q); var maxQ = visibleCoordinates.Max(x => x.q); var minR = visibleCoordinates.Min(x => x.r); var maxR = visibleCoordinates.Max(x => x.r); var existingLocations = await GetRangeAsync(minQ, maxQ, minR, maxR); var existingByCoordinate = existingLocations.ToDictionary(x => (x.Q, x.R), x => x); var missingCoordinates = visibleCoordinates .Where(coordinate => !existingByCoordinate.ContainsKey(coordinate)) .OrderBy(coordinate => HexDistance(0, 0, coordinate.q, coordinate.r)) .ThenBy(coordinate => coordinate.q) .ThenBy(coordinate => coordinate.r) .ToList(); if (missingCoordinates.Count > 0) { var generatedLocations = await GenerateVisibleLocationsAsync(missingCoordinates, existingByCoordinate); foreach (var generated in generatedLocations) { existingByCoordinate[(generated.Q, generated.R)] = generated; } } return visibleCoordinates .OrderBy(x => x.q) .ThenBy(x => x.r) .Select(coordinate => existingByCoordinate.TryGetValue(coordinate, out var existing) ? LocationResponse.FromLocation(existing) : throw new InvalidOperationException($"Visible location {coordinate.q},{coordinate.r} was not generated.")) .ToList(); } public async Task GetVisibleLocationAsync(string username, int q, int r) { if (!await CanSeeAsync(username, q, r)) { return null; } var existing = await GetAsync(q, r); if (existing is not null) { return LocationResponse.FromLocation(existing); } var nearbyLocations = await GetRangeAsync(q - 1, q + 1, r - 1, r + 1); var nearbyByCoordinate = nearbyLocations.ToDictionary(x => (x.Q, x.R), x => x); var generated = await GenerateVisibleLocationsAsync( new List<(int q, int r)> { (q, r) }, nearbyByCoordinate); return generated.Count == 0 ? null : LocationResponse.FromLocation(generated[0]); } public async Task CanSeeAsync(string username, int q, int r) { var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync(); if (ownedLocations.Count == 0) { return false; } return ownedLocations.Any(owned => HexDistance(owned.Q, owned.R, q, r) <= _visibilityDepth); } private async Task TryCreateHomeAsync(string username, int q, int r) { var now = DateTime.UtcNow; var update = Builders.Update .SetOnInsert(x => x.CreatedAtUtc, now) .Set(x => x.Terrain, "Command") .Set(x => x.Kind, "Home") .Set(x => x.OwnerUsername, username) .Set(x => x.Label, $"{username}'s Home") .Set(x => x.Level, 1) .Set(x => x.HomeAssignedAtUtc, now) .Set(x => x.UpdatedAtUtc, now); try { return await _locations.FindOneAndUpdateAsync( x => x.Q == q && x.R == r && (x.OwnerUsername == null || x.OwnerUsername == username), update, new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }); } catch (MongoWriteException) { return await GetHomeAsync(username); } catch (MongoCommandException) { return await GetHomeAsync(username); } } private async Task> GenerateVisibleLocationsAsync( List<(int q, int r)> missingCoordinates, Dictionary<(int Q, int R), Location> existingByCoordinate) { var generated = new List(); var now = DateTime.UtcNow; foreach (var coordinate in missingCoordinates) { var terrain = GenerateTerrain(coordinate.q, coordinate.r, existingByCoordinate); var location = new Location { Q = coordinate.q, R = coordinate.r, Terrain = terrain, Kind = "Open", Level = 1, CreatedAtUtc = now, UpdatedAtUtc = now }; try { await _locations.InsertOneAsync(location); generated.Add(location); existingByCoordinate[(location.Q, location.R)] = location; } catch (MongoWriteException) { var existing = await GetAsync(coordinate.q, coordinate.r); if (existing is not null) { generated.Add(existing); existingByCoordinate[(existing.Q, existing.R)] = existing; } } } return generated; } private static string GenerateTerrain( int q, int r, Dictionary<(int Q, int R), Location> existingByCoordinate) { var neighborTerrains = NeighborCoordinates(q, r) .Select(coordinate => existingByCoordinate.TryGetValue(coordinate, out var location) ? location.Terrain : "") .Where(terrain => !string.IsNullOrWhiteSpace(terrain) && terrain != "Command") .ToList(); var weights = new Dictionary { ["Field"] = 48, ["Forest"] = 24, ["Highland"] = 16, ["Ore"] = 12 }; foreach (var terrain in neighborTerrains) { if (!weights.ContainsKey(terrain)) { continue; } weights[terrain] += terrain switch { "Forest" => 18, "Highland" => 14, "Ore" => 8, _ => 10 }; } var hash = StableCoordinateHash(q, r); var totalWeight = weights.Values.Sum(); var roll = hash % totalWeight; foreach (var option in weights) { if (roll < option.Value) { return option.Key; } roll -= option.Value; } return "Field"; } private static IEnumerable<(int Q, int R)> NeighborCoordinates(int q, int r) { yield return (q + 1, r); yield return (q + 1, r - 1); yield return (q, r - 1); yield return (q - 1, r); yield return (q - 1, r + 1); yield return (q, r + 1); } private static int StableCoordinateHash(int q, int r) { unchecked { var hash = 17; hash = hash * 31 + q; hash = hash * 31 + r; hash = hash * 31 + 7919; return Math.Abs(hash); } } private IEnumerable<(int q, int r)> HomeCandidates(string username) { var preferredDirection = Math.Abs(username.GetHashCode()) % 6; yield return (0, 0); for (var radius = _homeMinDistance; radius <= _homeSearchRadius; radius++) { foreach (var candidate in HexRing(radius, preferredDirection)) { yield return candidate; } } } private static IEnumerable<(int q, int r)> HexRing(int radius, int directionOffset) { var directions = new (int q, int r)[] { (1, 0), (1, -1), (0, -1), (-1, 0), (-1, 1), (0, 1) }; var q = directions[(4 + directionOffset) % 6].q * radius; var r = directions[(4 + directionOffset) % 6].r * radius; for (var side = 0; side < 6; side++) { var direction = directions[(side + directionOffset) % 6]; for (var step = 0; step < radius; step++) { yield return (q, r); q += direction.q; r += direction.r; } } } private static IEnumerable<(int q, int r)> HexRange(int centerQ, int centerR, int radius) { for (var dq = -radius; dq <= radius; dq++) { var minDr = Math.Max(-radius, -dq - radius); var maxDr = Math.Min(radius, -dq + radius); for (var dr = minDr; dr <= maxDr; dr++) { yield return (centerQ + dq, centerR + dr); } } } private bool IsFarEnoughFromHomes(int q, int r, List homeLocations) => homeLocations.All(home => HexDistance(q, r, home.Q, home.R) >= _homeMinDistance); private static int HexDistance(int q1, int r1, int q2, int r2) { var s1 = -q1 - r1; var s2 = -q2 - r2; return Math.Max(Math.Max(Math.Abs(q1 - q2), Math.Abs(r1 - r2)), Math.Abs(s1 - s2)); } private static string NormalizeTerrain(string terrain) { var trimmed = terrain.Trim(); return trimmed switch { "Command" or "Field" or "Forest" or "Ore" or "Highland" => trimmed, _ => "Field" }; } private static string? NormalizeBuildingType(string buildingType) { var trimmed = buildingType.Trim(); return trimmed.ToLowerInvariant() switch { "logcamp" or "log camp" => "LogCamp", "mine" => "Mine", _ => null }; } }