using LocationsApi.Models; using MongoDB.Driver; namespace LocationsApi.Services; public class LocationService { private readonly IMongoCollection _locations; private readonly IMongoCollection _units; private readonly IMongoCollection _intel; 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 _farmFoodPerMinute; private readonly int _defaultLogCampBuildSeconds; private readonly int _defaultMineBuildSeconds; private readonly int _defaultBarracksBuildSeconds; private readonly int _defaultFarmBuildSeconds; private readonly int _barracksGoldCost; private readonly int _barracksWoodCost; private readonly int _barracksOreCost; 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"); _units = database.GetCollection("Units"); _intel = database.GetCollection("LocationIntel"); _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; _farmFoodPerMinute = int.TryParse(cfg["Buildings:FarmFoodPerMinute"], out var farmFoodPerMinute) ? Math.Max(1, farmFoodPerMinute) : 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; _defaultBarracksBuildSeconds = int.TryParse(cfg["Buildings:BarracksBuildSeconds"], out var barracksBuildSeconds) ? Math.Max(1, barracksBuildSeconds) : 60; _defaultFarmBuildSeconds = int.TryParse(cfg["Buildings:FarmBuildSeconds"], out var farmBuildSeconds) ? Math.Max(1, farmBuildSeconds) : 60; _barracksGoldCost = int.TryParse(cfg["Buildings:BarracksGoldCost"], out var barracksGoldCost) ? Math.Max(0, barracksGoldCost) : 500; _barracksWoodCost = int.TryParse(cfg["Buildings:BarracksWoodCost"], out var barracksWoodCost) ? Math.Max(0, barracksWoodCost) : 100; _barracksOreCost = int.TryParse(cfg["Buildings:BarracksOreCost"], out var barracksOreCost) ? Math.Max(0, barracksOreCost) : 100; 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") })); _intel.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys .Ascending(x => x.Username) .Ascending(x => x.Q) .Ascending(x => x.R), new CreateIndexOptions { Unique = true })); } 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."); } if (buildingType == "Barracks" && string.Equals(location.Kind, "Home", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException("Barracks cannot be built on the town hall."); } var requiredTerrain = RequiredTerrainForBuilding(buildingType); if (requiredTerrain is not null && !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 = BuildSecondsSettingKey(buildingType); var defaultBuildSeconds = DefaultBuildSeconds(buildingType); 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 || x.BuildingType == ""), update, new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); if (updated is null) { throw new InvalidOperationException("This location already has a building or construction in progress."); } if (buildingType == "Barracks") { var barracksGoldCost = await _settings.GetNonNegativeIntAsync(GameSettingsService.BarracksGoldCost, _barracksGoldCost); var barracksWoodCost = await _settings.GetNonNegativeIntAsync(GameSettingsService.BarracksWoodCost, _barracksWoodCost); var barracksOreCost = await _settings.GetNonNegativeIntAsync(GameSettingsService.BarracksOreCost, _barracksOreCost); var spent = await _resources.TrySpendResourcesAsync(username, barracksGoldCost, barracksWoodCost, barracksOreCost); if (!spent) { var rollback = Builders.Update .Unset(x => x.BuildingType) .Unset(x => x.BuildingStatus) .Unset(x => x.BuildingStartedAtUtc) .Unset(x => x.BuildingCompletesAtUtc) .Unset(x => x.BuildingBuiltAtUtc) .Set(x => x.UpdatedAtUtc, DateTime.UtcNow); await _locations.UpdateOneAsync( x => x.Id == updated.Id && x.BuildingType == "Barracks" && x.BuildingStatus == "Building" && x.BuildingStartedAtUtc == now, rollback); throw new InvalidOperationException($"Not enough resources to build Barracks. Required: {barracksGoldCost} gold, {barracksWoodCost} wood, {barracksOreCost} ore."); } } 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.BuildingType == "Barracks" || x.BuildingType == "Farm") && 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.StoredResourceCapacity, 10) .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) { await RecalculateWorkerGenerationAsync(username); } } } public async Task AssignWorkerAsync(string username, AssignWorkerRequest request) { var now = DateTime.UtcNow; await CompletePendingWorkerAssignmentsAsync(username); var location = await GetAsync(request.Q, request.R); if (location is null || !string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase) || !CanAssignWorkerToBuilding(location.BuildingType) || location.BuildingStatus == "Building" || !string.IsNullOrWhiteSpace(location.WorkerUnitId)) { return null; } var unit = await _units.Find(x => x.Id == request.UnitId && x.Username == username && x.UnitType == "Peasant" && x.Status == "Idle") .FirstOrDefaultAsync(); if (unit is null) { return null; } var claimed = await _units.UpdateOneAsync( x => x.Id == unit.Id && x.Status == "Idle", Builders.Update .Set(x => x.Status, "MovingToWork") .Set(x => x.MovementRemaining, 0) .Set(x => x.DestinationQ, request.Q) .Set(x => x.DestinationR, request.R) .Set(x => x.MovementStartedAtUtc, now) .Set(x => x.MovementCompletesAtUtc, now.AddMinutes(1)) .Set(x => x.UpdatedAtUtc, now)); if (claimed.ModifiedCount == 0) { return null; } return location; } public async Task DeassignWorkerAsync(string username, DeassignWorkerRequest request) { var now = DateTime.UtcNow; var location = await GetAsync(request.Q, request.R); if (location is null || !string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(location.WorkerUnitId)) { return null; } await _units.UpdateOneAsync( x => x.Id == location.WorkerUnitId && x.Username == username && x.Status == "Working", Builders.Update .Set(x => x.Q, request.Q) .Set(x => x.R, request.R) .Set(x => x.Status, "Idle") .Set(x => x.UpdatedAtUtc, now)); var updated = await _locations.FindOneAndUpdateAsync( x => x.Id == location.Id, Builders.Update .Unset(x => x.WorkerUnitId) .Unset(x => x.WorkerUnitType) .Set(x => x.UpdatedAtUtc, now), new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); await RecalculateWorkerGenerationAsync(username); return updated; } private async Task RecalculateWorkerGenerationAsync(string username) { var built = await _locations.Find(x => x.OwnerUsername == username && !string.IsNullOrWhiteSpace(x.WorkerUnitId) && (x.BuildingStatus == "Built" || x.BuildingStatus == null || x.BuildingStatus == "") && (x.BuildingType == "LogCamp" || x.BuildingType == "Mine" || x.BuildingType == "Farm")) .ToListAsync(); var woodPerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.LogCampWoodPerMinute, _logCampWoodPerMinute); var orePerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.MineOrePerMinute, _mineOrePerMinute); var foodPerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.FarmFoodPerMinute, _farmFoodPerMinute); await _resources.SetBuildingGenerationAsync( username, built.Count(x => x.BuildingType == "LogCamp") * woodPerMinute, built.Count(x => x.BuildingType == "Mine") * orePerMinute, built.Count(x => x.BuildingType == "Farm") * foodPerMinute); } 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); await CompletePendingWorkerAssignmentsAsync(username); await RecalculateWorkerGenerationAsync(username); var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync(); if (ownedLocations.Count == 0) { ownedLocations.Add(await AssignHomeAsync(username)); } var playerUnits = await _units.Find(x => x.Username == username).ToListAsync(); var depth = Math.Max(0, visibilityDepth ?? _visibilityDepth); var visibleCoordinates = new HashSet<(int q, int r)>(); var occupiedCoordinates = playerUnits.Select(x => (x.Q, x.R)).ToHashSet(); foreach (var owned in ownedLocations) { foreach (var coordinate in HexRange(owned.Q, owned.R, depth)) { visibleCoordinates.Add(coordinate); } } foreach (var unit in playerUnits) { foreach (var coordinate in HexRange(unit.Q, unit.R, 1)) { 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; } } await RefreshIntelAsync(username, occupiedCoordinates, existingByCoordinate); var intelByCoordinate = await GetIntelByCoordinateAsync(username, visibleCoordinates); return visibleCoordinates .OrderBy(x => x.q) .ThenBy(x => x.r) .Select(coordinate => existingByCoordinate.TryGetValue(coordinate, out var existing) ? VisibleResponseFor(username, existing, occupiedCoordinates, intelByCoordinate) : throw new InvalidOperationException($"Visible location {coordinate.q},{coordinate.r} was not generated.")) .ToList(); } public async Task GetVisibleLocationAsync(string username, int q, int r) { await CompletePendingWorkerAssignmentsAsync(username); if (!await CanSeeAsync(username, q, r)) { return null; } var existing = await GetAsync(q, r); if (existing is not null) { var occupiedCoordinates = await GetOccupiedCoordinatesAsync(username); await RefreshIntelAsync(username, occupiedCoordinates, new Dictionary<(int Q, int R), Location> { [(existing.Q, existing.R)] = existing }); var intelByCoordinate = await GetIntelByCoordinateAsync(username, new[] { (q, r) }); return VisibleResponseFor(username, existing, occupiedCoordinates, intelByCoordinate); } 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]); } private async Task CompletePendingWorkerAssignmentsAsync(string username) { var now = DateTime.UtcNow; var movingWorkers = await _units.Find(x => x.Username == username && x.UnitType == "Peasant" && x.Status == "MovingToWork" && x.MovementCompletesAtUtc != null && x.MovementCompletesAtUtc <= now) .ToListAsync(); foreach (var worker in movingWorkers) { if (worker.DestinationQ is null || worker.DestinationR is null) { await _units.UpdateOneAsync( x => x.Id == worker.Id, Builders.Update .Set(x => x.Status, "Idle") .Unset(x => x.MovementStartedAtUtc) .Unset(x => x.MovementCompletesAtUtc) .Set(x => x.UpdatedAtUtc, now)); continue; } var destinationQ = worker.DestinationQ.Value; var destinationR = worker.DestinationR.Value; if (HexDistance(worker.Q, worker.R, destinationQ, destinationR) > 1) { var nextStep = await NextPeasantStepTowardAsync(username, worker.Q, worker.R, destinationQ, destinationR); if (nextStep is null) { await _units.UpdateOneAsync( x => x.Id == worker.Id, Builders.Update .Set(x => x.Status, "Idle") .Unset(x => x.DestinationQ) .Unset(x => x.DestinationR) .Unset(x => x.MovementStartedAtUtc) .Unset(x => x.MovementCompletesAtUtc) .Set(x => x.UpdatedAtUtc, now)); continue; } await _units.UpdateOneAsync( x => x.Id == worker.Id, Builders.Update .Set(x => x.Q, nextStep.Value.q) .Set(x => x.R, nextStep.Value.r) .Set(x => x.MovementCompletesAtUtc, now.AddMinutes(1)) .Set(x => x.UpdatedAtUtc, now)); continue; } var location = await _locations.FindOneAndUpdateAsync( x => x.OwnerUsername == username && x.Q == destinationQ && x.R == destinationR && (x.BuildingType == "LogCamp" || x.BuildingType == "Mine" || x.BuildingType == "Farm") && x.BuildingStatus != "Building" && (x.WorkerUnitId == null || x.WorkerUnitId == ""), Builders.Update .Set(x => x.WorkerUnitId, worker.Id) .Set(x => x.WorkerUnitType, "Peasant") .Set(x => x.LastResourceGeneratedAtUtc, now) .Set(x => x.UpdatedAtUtc, now), new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); var status = location is null ? "Idle" : "Working"; await _units.UpdateOneAsync( x => x.Id == worker.Id, Builders.Update .Set(x => x.Q, destinationQ) .Set(x => x.R, destinationR) .Set(x => x.Status, status) .Set(x => x.LastMovementRefillAtUtc, now) .Unset(x => x.DestinationQ) .Unset(x => x.DestinationR) .Unset(x => x.MovementStartedAtUtc) .Unset(x => x.MovementCompletesAtUtc) .Set(x => x.UpdatedAtUtc, now)); } if (movingWorkers.Count > 0) { await RecalculateWorkerGenerationAsync(username); } } private async Task<(int q, int r)?> NextPeasantStepTowardAsync(string username, int fromQ, int fromR, int toQ, int toR) { var owned = await _locations.Find(x => x.OwnerUsername == username).ToListAsync(); var ownedCoordinates = owned.Select(x => (x.Q, x.R)).ToHashSet(); if (!ownedCoordinates.Contains((toQ, toR))) { return null; } var start = (fromQ, fromR); var target = (toQ, toR); var queue = new Queue<(int q, int r)>(); var previous = new Dictionary<(int q, int r), (int q, int r)>(); var visited = new HashSet<(int q, int r)> { start }; queue.Enqueue(start); while (queue.Count > 0) { var current = queue.Dequeue(); if (current == target) { break; } foreach (var neighbor in NeighborCoordinates(current.q, current.r) .Select(x => (x.Q, x.R)) .OrderBy(x => HexDistance(x.Q, x.R, toQ, toR)) .ThenBy(x => x.Q) .ThenBy(x => x.R)) { if (!ownedCoordinates.Contains(neighbor) || !visited.Add(neighbor)) { continue; } previous[neighbor] = current; queue.Enqueue(neighbor); } } if (!visited.Contains(target)) { return null; } var step = target; while (previous.TryGetValue(step, out var before) && before != start) { step = before; } return step == start ? target : step; } private static bool CanAssignWorkerToBuilding(string? buildingType) { return buildingType == "LogCamp" || buildingType == "Mine" || buildingType == "Farm"; } 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; } if (ownedLocations.Any(owned => HexDistance(owned.Q, owned.R, q, r) <= _visibilityDepth)) { return true; } var playerUnits = await _units.Find(x => x.Username == username).ToListAsync(); return playerUnits.Any(unit => HexDistance(unit.Q, unit.R, q, r) <= 1); } public async Task IsAdjacentToOwnedAsync(string username, int q, int r) { var neighbors = NeighborCoordinates(q, r).ToList(); return await _locations.Find(x => x.OwnerUsername == username && neighbors.Any(n => n.Q == x.Q && n.R == x.R)) .AnyAsync(); } 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> GetOccupiedCoordinatesAsync(string username) { var units = await _units.Find(x => x.Username == username).ToListAsync(); return units.Select(x => (x.Q, x.R)).ToHashSet(); } private async Task> GetIntelByCoordinateAsync( string username, IEnumerable<(int q, int r)> coordinates) { var coordinateList = coordinates.Select(x => (x.q, x.r)).ToList(); if (coordinateList.Count == 0) { return new Dictionary<(int Q, int R), LocationIntel>(); } var minQ = coordinateList.Min(x => x.q); var maxQ = coordinateList.Max(x => x.q); var minR = coordinateList.Min(x => x.r); var maxR = coordinateList.Max(x => x.r); var coordinateSet = coordinateList.ToHashSet(); var records = await _intel.Find(x => x.Username == username && x.Q >= minQ && x.Q <= maxQ && x.R >= minR && x.R <= maxR) .ToListAsync(); return records .Where(x => coordinateSet.Contains((x.Q, x.R))) .ToDictionary(x => (x.Q, x.R), x => x); } private async Task RefreshIntelAsync( string username, HashSet<(int Q, int R)> occupiedCoordinates, Dictionary<(int Q, int R), Location> existingByCoordinate) { var now = DateTime.UtcNow; foreach (var coordinate in occupiedCoordinates) { if (!existingByCoordinate.TryGetValue(coordinate, out var location)) { location = await GetAsync(coordinate.Q, coordinate.R); if (location is null) { continue; } } if (string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase)) { continue; } var update = Builders.Update .SetOnInsert(x => x.CreatedAtUtc, now) .Set(x => x.Terrain, location.Terrain) .Set(x => x.Kind, location.Kind) .Set(x => x.OwnerUsername, location.OwnerUsername) .Set(x => x.Label, location.Label) .Set(x => x.Level, location.Level) .Set(x => x.BuildingType, location.BuildingType) .Set(x => x.BuildingStatus, location.BuildingStatus) .Set(x => x.BuildingStartedAtUtc, location.BuildingStartedAtUtc) .Set(x => x.BuildingCompletesAtUtc, location.BuildingCompletesAtUtc) .Set(x => x.BuildingBuiltAtUtc, location.BuildingBuiltAtUtc) .Set(x => x.WorkerUnitId, location.WorkerUnitId) .Set(x => x.WorkerUnitType, location.WorkerUnitType) .Set(x => x.StoredResourceAmount, location.StoredResourceAmount) .Set(x => x.StoredResourceCapacity, location.StoredResourceCapacity) .Set(x => x.LastResourceGeneratedAtUtc, location.LastResourceGeneratedAtUtc) .Set(x => x.HomeAssignedAtUtc, location.HomeAssignedAtUtc) .Set(x => x.ObservedAtUtc, now) .Set(x => x.UpdatedAtUtc, now); await _intel.UpdateOneAsync( x => x.Username == username && x.Q == coordinate.Q && x.R == coordinate.R, update, new UpdateOptions { IsUpsert = true }); } } private static LocationResponse VisibleResponseFor( string username, Location location, HashSet<(int Q, int R)> occupiedCoordinates, Dictionary<(int Q, int R), LocationIntel> intelByCoordinate) { if (string.IsNullOrWhiteSpace(location.OwnerUsername) || string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase) || occupiedCoordinates.Contains((location.Q, location.R))) { return LocationResponse.FromLocation(location); } return intelByCoordinate.TryGetValue((location.Q, location.R), out var intel) ? LocationResponse.FromIntel(intel) : LocationResponse.FromLocation(location, revealDetails: false); } 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", "barracks" => "Barracks", "farm" => "Farm", _ => null }; } private static string? RequiredTerrainForBuilding(string buildingType) => buildingType switch { "LogCamp" => "Forest", "Mine" => "Ore", "Farm" => "Field", "Barracks" => null, _ => null }; private static string BuildSecondsSettingKey(string buildingType) => buildingType switch { "LogCamp" => GameSettingsService.LogCampBuildSeconds, "Mine" => GameSettingsService.MineBuildSeconds, "Barracks" => GameSettingsService.BarracksBuildSeconds, "Farm" => GameSettingsService.FarmBuildSeconds, _ => GameSettingsService.LogCampBuildSeconds }; private int DefaultBuildSeconds(string buildingType) => buildingType switch { "LogCamp" => _defaultLogCampBuildSeconds, "Mine" => _defaultMineBuildSeconds, "Barracks" => _defaultBarracksBuildSeconds, "Farm" => _defaultFarmBuildSeconds, _ => 60 }; }