using LocationsApi.Models; using MongoDB.Driver; namespace LocationsApi.Services; public class ResourceService { private const string ScoutUnitType = "Scout"; private const string ArcherUnitType = "Archer"; private const string PeasantUnitType = "Peasant"; private readonly IMongoCollection _resources; private readonly IMongoCollection _locations; private readonly IMongoCollection _training; private readonly IMongoCollection _units; private readonly GameSettingsService _settings; private readonly int _startingGold; private readonly int _headquartersGoldPerMinute; private readonly int _townHallGoldCapacity; private readonly int _townHallWoodCapacity; private readonly int _townHallOreCapacity; private readonly int _townHallFoodCapacity; private readonly int _productionBuildingResourceCapacity; private readonly int _unitFoodPerMinute; public ResourceService(IConfiguration cfg, 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); _resources = database.GetCollection("PlayerResources"); _locations = database.GetCollection("Locations"); _training = database.GetCollection("UnitTraining"); _units = database.GetCollection("Units"); _settings = settings; _startingGold = int.TryParse(cfg["Resources:StartingGold"], out var startingGold) ? Math.Max(0, startingGold) : 100; _headquartersGoldPerMinute = int.TryParse(cfg["Resources:HeadquartersGoldPerMinute"], out var goldPerMinute) ? Math.Max(0, goldPerMinute) : 10; _townHallGoldCapacity = int.TryParse(cfg["Resources:TownHallGoldCapacity"], out var goldCapacity) ? Math.Max(0, goldCapacity) : 500; _townHallWoodCapacity = int.TryParse(cfg["Resources:TownHallWoodCapacity"], out var woodCapacity) ? Math.Max(0, woodCapacity) : 100; _townHallOreCapacity = int.TryParse(cfg["Resources:TownHallOreCapacity"], out var oreCapacity) ? Math.Max(0, oreCapacity) : 100; _townHallFoodCapacity = int.TryParse(cfg["Resources:TownHallFoodCapacity"], out var foodCapacity) ? Math.Max(0, foodCapacity) : 100; _productionBuildingResourceCapacity = int.TryParse(cfg["Buildings:ProductionBuildingResourceCapacity"], out var productionBuildingResourceCapacity) ? Math.Max(0, productionBuildingResourceCapacity) : 10; _unitFoodPerMinute = int.TryParse(cfg["Units:UnitFoodPerMinute"], out var unitFoodPerMinute) ? Math.Max(0, unitFoodPerMinute) : 1; _resources.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.Username), new CreateIndexOptions { Unique = true })); } public async Task EnsureAsync(string username) { var now = DateTime.UtcNow; var startingGold = await _settings.GetNonNegativeIntAsync(GameSettingsService.StartingGold, _startingGold); var goldPerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.HeadquartersGoldPerMinute, _headquartersGoldPerMinute); var goldCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallGoldCapacity, _townHallGoldCapacity); var woodCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallWoodCapacity, _townHallWoodCapacity); var oreCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallOreCapacity, _townHallOreCapacity); var foodCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallFoodCapacity, _townHallFoodCapacity); var update = Builders.Update .SetOnInsert(x => x.Username, username) .SetOnInsert(x => x.Gold, Math.Min(startingGold, goldCapacity)) .SetOnInsert(x => x.Wood, 0) .SetOnInsert(x => x.Ore, 0) .SetOnInsert(x => x.Food, 0) .SetOnInsert(x => x.CreatedAtUtc, now) .SetOnInsert(x => x.LastGoldGeneratedAtUtc, now) .SetOnInsert(x => x.LastWoodGeneratedAtUtc, now) .SetOnInsert(x => x.LastOreGeneratedAtUtc, now) .SetOnInsert(x => x.LastFoodGeneratedAtUtc, now) .Set(x => x.GoldPerMinute, goldPerMinute) .Set(x => x.GoldCapacity, goldCapacity) .Set(x => x.WoodCapacity, woodCapacity) .Set(x => x.OreCapacity, oreCapacity) .Set(x => x.FoodCapacity, foodCapacity) .Set(x => x.UpdatedAtUtc, now); var resources = await _resources.FindOneAndUpdateAsync( x => x.Username == username, update, new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }); return await ApplyResourceGenerationAsync(resources); } public async Task GetAsync(string username) { var resources = await EnsureAsync(username); resources = await ApplyFoodTickAsync(resources); return await ApplyProductionBuildingDeliveryAsync(resources, includeFood: false); } public async Task TrySpendGoldAsync(string username, int amount) { if (amount <= 0) { return true; } await EnsureAsync(username); var update = Builders.Update .Inc(x => x.Gold, -amount) .Set(x => x.UpdatedAtUtc, DateTime.UtcNow); var updated = await _resources.FindOneAndUpdateAsync( x => x.Username == username && x.Gold >= amount, update, new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); return updated is not null; } public async Task TrySpendResourcesAsync(string username, int gold, int wood, int ore) { gold = Math.Max(0, gold); wood = Math.Max(0, wood); ore = Math.Max(0, ore); if (gold == 0 && wood == 0 && ore == 0) { return true; } await EnsureAsync(username); var update = Builders.Update .Inc(x => x.Gold, -gold) .Inc(x => x.Wood, -wood) .Inc(x => x.Ore, -ore) .Set(x => x.UpdatedAtUtc, DateTime.UtcNow); var updated = await _resources.FindOneAndUpdateAsync( x => x.Username == username && x.Gold >= gold && x.Wood >= wood && x.Ore >= ore, update, new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); return updated is not null; } public async Task AddWoodGenerationAsync(string username, int woodPerMinute) { if (woodPerMinute <= 0) { return; } var resources = await EnsureAsync(username); var now = DateTime.UtcNow; var update = Builders.Update .Inc(x => x.WoodPerMinute, woodPerMinute) .Set(x => x.UpdatedAtUtc, now); if (resources.WoodPerMinute <= 0 || resources.LastWoodGeneratedAtUtc.Year < 2000) { update = update.Set(x => x.LastWoodGeneratedAtUtc, now); } await _resources.UpdateOneAsync(x => x.Username == username, update); } public async Task AddOreGenerationAsync(string username, int orePerMinute) { if (orePerMinute <= 0) { return; } var resources = await EnsureAsync(username); var now = DateTime.UtcNow; var update = Builders.Update .Inc(x => x.OrePerMinute, orePerMinute) .Set(x => x.UpdatedAtUtc, now); if (resources.OrePerMinute <= 0 || resources.LastOreGeneratedAtUtc.Year < 2000) { update = update.Set(x => x.LastOreGeneratedAtUtc, now); } await _resources.UpdateOneAsync(x => x.Username == username, update); } public async Task AddFoodGenerationAsync(string username, int foodPerMinute) { if (foodPerMinute <= 0) { return; } var resources = await EnsureAsync(username); var now = DateTime.UtcNow; var update = Builders.Update .Inc(x => x.FoodPerMinute, foodPerMinute) .Set(x => x.UpdatedAtUtc, now); if (resources.FoodPerMinute <= 0 || resources.LastFoodGeneratedAtUtc.Year < 2000) { update = update.Set(x => x.LastFoodGeneratedAtUtc, now); } await _resources.UpdateOneAsync(x => x.Username == username, update); } public async Task SetBuildingGenerationAsync(string username, int woodPerMinute, int orePerMinute, int foodPerMinute) { await EnsureAsync(username); var now = DateTime.UtcNow; var update = Builders.Update .Set(x => x.WoodPerMinute, Math.Max(0, woodPerMinute)) .Set(x => x.OrePerMinute, Math.Max(0, orePerMinute)) .Set(x => x.FoodPerMinute, Math.Max(0, foodPerMinute)) .Set(x => x.UpdatedAtUtc, now); if (woodPerMinute > 0) { update = update.Set(x => x.LastWoodGeneratedAtUtc, now); } if (orePerMinute > 0) { update = update.Set(x => x.LastOreGeneratedAtUtc, now); } if (foodPerMinute > 0) { update = update.Set(x => x.LastFoodGeneratedAtUtc, now); } await _resources.UpdateOneAsync(x => x.Username == username, update); } private async Task ApplyResourceGenerationAsync(PlayerResources resources) { var now = DateTime.UtcNow; var goldPerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.HeadquartersGoldPerMinute, _headquartersGoldPerMinute); var goldCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallGoldCapacity, _townHallGoldCapacity); var woodCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallWoodCapacity, _townHallWoodCapacity); var oreCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallOreCapacity, _townHallOreCapacity); var foodCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallFoodCapacity, _townHallFoodCapacity); var goldElapsedMinutes = goldPerMinute <= 0 ? 0 : (int)Math.Floor((now - resources.LastGoldGeneratedAtUtc).TotalMinutes); var cappedGold = Math.Min(resources.Gold, goldCapacity); var cappedWood = Math.Min(resources.Wood, woodCapacity); var cappedOre = Math.Min(resources.Ore, oreCapacity); var cappedFood = Math.Min(resources.Food, foodCapacity); var isOverCapacity = resources.Gold != cappedGold || resources.Wood != cappedWood || resources.Ore != cappedOre || resources.Food != cappedFood; if (goldElapsedMinutes <= 0 && !isOverCapacity) { return resources; } var update = Builders.Update .Set(x => x.GoldPerMinute, goldPerMinute) .Set(x => x.GoldCapacity, goldCapacity) .Set(x => x.WoodCapacity, woodCapacity) .Set(x => x.OreCapacity, oreCapacity) .Set(x => x.FoodCapacity, foodCapacity) .Set(x => x.UpdatedAtUtc, now); if (isOverCapacity) { update = update .Set(x => x.Gold, cappedGold) .Set(x => x.Wood, cappedWood) .Set(x => x.Ore, cappedOre) .Set(x => x.Food, cappedFood); } if (goldElapsedMinutes > 0) { var generatedGold = Math.Min(goldCapacity, cappedGold + (goldElapsedMinutes * goldPerMinute)); update = update .Set(x => x.Gold, generatedGold) .Set(x => x.LastGoldGeneratedAtUtc, resources.LastGoldGeneratedAtUtc.AddMinutes(goldElapsedMinutes)); } return await _resources.FindOneAndUpdateAsync( x => x.Username == resources.Username, update, new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); } private async Task ApplyProductionBuildingDeliveryAsync(PlayerResources resources, bool includeFood = true) { var now = DateTime.UtcNow; await CompletePendingWorkerAssignmentsAsync(resources.Username, now); var woodCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallWoodCapacity, _townHallWoodCapacity); var oreCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallOreCapacity, _townHallOreCapacity); var foodCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallFoodCapacity, _townHallFoodCapacity); var buildingCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.ProductionBuildingResourceCapacity, _productionBuildingResourceCapacity); var logCampWoodPerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.LogCampWoodPerMinute, 5); var mineOrePerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.MineOrePerMinute, 5); var farmFoodPerMinute = includeFood ? await _settings.GetNonNegativeIntAsync(GameSettingsService.FarmFoodPerMinute, 5) : 0; var buildings = await _locations.Find(x => x.OwnerUsername == resources.Username && !string.IsNullOrWhiteSpace(x.WorkerUnitId) && (x.BuildingStatus == "Built" || x.BuildingStatus == null || x.BuildingStatus == "") && (x.BuildingType == "LogCamp" || x.BuildingType == "Mine" || (includeFood && x.BuildingType == "Farm"))) .ToListAsync(); var wood = Math.Min(resources.Wood, woodCapacity); var ore = Math.Min(resources.Ore, oreCapacity); var food = Math.Min(resources.Food, foodCapacity); foreach (var building in buildings) { var rate = building.BuildingType switch { "LogCamp" => logCampWoodPerMinute, "Mine" => mineOrePerMinute, "Farm" => farmFoodPerMinute, _ => 0 }; var lastGenerated = building.LastResourceGeneratedAtUtc ?? building.BuildingBuiltAtUtc ?? now; var elapsedMinutes = rate <= 0 ? 0 : (int)Math.Floor((now - lastGenerated).TotalMinutes); var stored = Math.Min(buildingCapacity, Math.Max(0, building.StoredResourceAmount) + (elapsedMinutes * rate)); var delivered = 0; if (building.BuildingType == "LogCamp") { delivered = Math.Min(stored, Math.Max(0, woodCapacity - wood)); wood += delivered; } else if (building.BuildingType == "Mine") { delivered = Math.Min(stored, Math.Max(0, oreCapacity - ore)); ore += delivered; } else if (building.BuildingType == "Farm") { delivered = Math.Min(stored, Math.Max(0, foodCapacity - food)); food += delivered; } var update = Builders.Update .Set(x => x.StoredResourceAmount, stored - delivered) .Set(x => x.StoredResourceCapacity, buildingCapacity) .Set(x => x.UpdatedAtUtc, now); if (elapsedMinutes > 0 || building.LastResourceGeneratedAtUtc is null) { update = update.Set(x => x.LastResourceGeneratedAtUtc, lastGenerated.AddMinutes(elapsedMinutes)); } await _locations.UpdateOneAsync(x => x.Id == building.Id, update); } return await _resources.FindOneAndUpdateAsync( x => x.Username == resources.Username, Builders.Update .Set(x => x.Wood, wood) .Set(x => x.Ore, ore) .Set(x => x.Food, food) .Set(x => x.WoodCapacity, woodCapacity) .Set(x => x.OreCapacity, oreCapacity) .Set(x => x.FoodCapacity, foodCapacity) .Set(x => x.UpdatedAtUtc, now), new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); } private async Task ApplyFoodTickAsync(PlayerResources resources) { var now = DateTime.UtcNow; var unitFoodPerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.UnitFoodPerMinute, _unitFoodPerMinute); var foodProductionPerMinute = await CalculateFoodProductionPerMinuteAsync(resources.Username); var units = await GetFoodConsumingUnitsAsync(resources.Username); var upkeepPerMinute = units.Count * unitFoodPerMinute; var netFoodPerMinute = foodProductionPerMinute - upkeepPerMinute; var elapsedMinutes = resources.LastFoodGeneratedAtUtc.Year < 2000 ? 0 : (int)Math.Floor((now - resources.LastFoodGeneratedAtUtc).TotalMinutes); var foodCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallFoodCapacity, _townHallFoodCapacity); var food = Math.Min(resources.Food, foodCapacity); var removedUnits = new List(); for (var minute = 0; minute < elapsedMinutes; minute++) { if (foodProductionPerMinute > 0) { food = Math.Min(foodCapacity, food + foodProductionPerMinute); } var neededFood = units.Count * unitFoodPerMinute; if (neededFood <= 0) { continue; } if (food >= neededFood) { food -= neededFood; continue; } var shortage = neededFood - food; food = 0; var unitsToRemove = Math.Min(shortage, units.Count); for (var i = 0; i < unitsToRemove; i++) { var index = Random.Shared.Next(units.Count); removedUnits.Add(units[index]); units.RemoveAt(index); } } netFoodPerMinute = foodProductionPerMinute - (units.Count * unitFoodPerMinute); if (elapsedMinutes <= 0 && removedUnits.Count == 0 && resources.Food == food && resources.FoodPerMinute == netFoodPerMinute && resources.FoodCapacity == foodCapacity) { return resources; } await RemoveStarvedUnitsAsync(resources.Username, removedUnits, now); var update = Builders.Update .Set(x => x.FoodPerMinute, netFoodPerMinute) .Set(x => x.Food, food) .Set(x => x.FoodCapacity, foodCapacity) .Set(x => x.UpdatedAtUtc, now); if (elapsedMinutes > 0) { update = update.Set(x => x.LastFoodGeneratedAtUtc, resources.LastFoodGeneratedAtUtc.AddMinutes(elapsedMinutes)); } return await _resources.FindOneAndUpdateAsync( x => x.Username == resources.Username, update, new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); } private async Task> GetFoodConsumingUnitsAsync(string username) { var completedScouts = await _training.Find(x => x.Username == username && x.UnitType == ScoutUnitType && x.Status == "Completed") .ToListAsync(); var persistedUnits = await _units.Find(x => x.Username == username && (x.UnitType == ScoutUnitType || x.UnitType == ArcherUnitType || x.UnitType == PeasantUnitType)) .ToListAsync(); var units = completedScouts .Select(x => new FoodConsumingUnit(x.Id, x.UnitType, true)) .ToList(); units.AddRange(persistedUnits.Select(x => new FoodConsumingUnit(x.Id, x.UnitType, false))); return units; } private async Task CalculateFoodProductionPerMinuteAsync(string username) { var farmFoodPerMinute = await _settings.GetNonNegativeIntAsync(GameSettingsService.FarmFoodPerMinute, 5); var farms = await _locations.CountDocumentsAsync(x => x.OwnerUsername == username && !string.IsNullOrWhiteSpace(x.WorkerUnitId) && (x.BuildingStatus == "Built" || x.BuildingStatus == null || x.BuildingStatus == "") && x.BuildingType == "Farm"); return checked((int)farms) * farmFoodPerMinute; } private async Task CompletePendingWorkerAssignmentsAsync(string username, DateTime now) { 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 }); await _units.UpdateOneAsync( x => x.Id == worker.Id, Builders.Update .Set(x => x.Q, destinationQ) .Set(x => x.R, destinationR) .Set(x => x.Status, location is null ? "Idle" : "Working") .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)); } } 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) .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 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 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 async Task RemoveStarvedUnitsAsync(string username, List units, DateTime now) { if (units.Count == 0) { return; } var scoutIds = units .Where(x => x.IsTrainingRecord) .Select(x => x.Id) .ToList(); var instanceIds = units .Where(x => !x.IsTrainingRecord) .Select(x => x.Id) .ToList(); if (scoutIds.Count > 0) { await _training.DeleteManyAsync(x => x.Username == username && x.UnitType == ScoutUnitType && scoutIds.Contains(x.Id)); } if (instanceIds.Count > 0) { await _locations.UpdateManyAsync( x => x.OwnerUsername == username && x.WorkerUnitId != null && instanceIds.Contains(x.WorkerUnitId), Builders.Update .Unset(x => x.WorkerUnitId) .Unset(x => x.WorkerUnitType) .Set(x => x.UpdatedAtUtc, now)); await _units.DeleteManyAsync(x => x.Username == username && instanceIds.Contains(x.Id)); } } private sealed record FoodConsumingUnit(string Id, string UnitType, bool IsTrainingRecord); }