using LocationsApi.Models; using MongoDB.Driver; namespace LocationsApi.Services; public class UnitTrainingService { private const string ScoutUnitType = "Scout"; private const string ArcherUnitType = "Archer"; private const string PeasantUnitType = "Peasant"; private readonly IMongoCollection _training; private readonly IMongoCollection _units; private readonly IMongoCollection _locations; private readonly ResourceService _resources; private readonly GameSettingsService _settings; private readonly int _scoutGoldCost; private readonly int _archerGoldCost; private readonly int _archerWoodCost; private readonly int _peasantGoldCost; private readonly int _defaultScoutTrainingSeconds; private readonly int _defaultArcherTrainingSeconds; private readonly int _defaultPeasantTrainingSeconds; private readonly int _unitMovementPerTick; private readonly int _visibilityDepth; public UnitTrainingService(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); _training = database.GetCollection("UnitTraining"); _units = database.GetCollection("Units"); _locations = database.GetCollection("Locations"); _resources = resources; _settings = settings; _scoutGoldCost = int.TryParse(cfg["Units:ScoutGoldCost"], out var scoutGoldCost) ? Math.Max(0, scoutGoldCost) : 50; _archerGoldCost = int.TryParse(cfg["Units:ArcherGoldCost"], out var archerGoldCost) ? Math.Max(0, archerGoldCost) : 100; _archerWoodCost = int.TryParse(cfg["Units:ArcherWoodCost"], out var archerWoodCost) ? Math.Max(0, archerWoodCost) : 100; _peasantGoldCost = int.TryParse(cfg["Units:PeasantGoldCost"], out var peasantGoldCost) ? Math.Max(0, peasantGoldCost) : 25; _defaultScoutTrainingSeconds = int.TryParse(cfg["Units:ScoutTrainingSeconds"], out var scoutTrainingSeconds) ? Math.Max(1, scoutTrainingSeconds) : 60; _defaultArcherTrainingSeconds = int.TryParse(cfg["Units:ArcherTrainingSeconds"], out var archerTrainingSeconds) ? Math.Max(1, archerTrainingSeconds) : 60; _defaultPeasantTrainingSeconds = int.TryParse(cfg["Units:PeasantTrainingSeconds"], out var peasantTrainingSeconds) ? Math.Max(1, peasantTrainingSeconds) : 60; _unitMovementPerTick = int.TryParse(cfg["Units:UnitMovementPerTick"], out var unitMovementPerTick) ? Math.Max(1, unitMovementPerTick) : 1; _visibilityDepth = int.TryParse(cfg["World:VisibilityDepth"], out var visibilityDepth) ? Math.Max(0, visibilityDepth) : 3; _training.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.Username).Ascending(x => x.Status))); _units.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.Username).Ascending(x => x.UnitType))); _units.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.SourceTrainingId), new CreateIndexOptions { Unique = true, Sparse = true })); } public async Task GetMineAsync(string username) { await CompleteFinishedTrainingAsync(username); await PromoteCompletedScoutsAsync(username); await CompletePendingMovementAsync(username); await RefillMovementAsync(username); await _training.DeleteManyAsync(x => x.Username == username && x.Status == "Consumed"); var allTraining = await _training.Find(x => x.Username == username).ToListAsync(); var allUnits = await _units.Find(x => x.Username == username).ToListAsync(); var completedScouts = allUnits.Count(x => x.UnitType == ScoutUnitType && x.Status == "Idle"); var completedArchers = allUnits.Count(x => x.UnitType == ArcherUnitType); var completedPeasants = allUnits.Count(x => x.UnitType == PeasantUnitType && x.Status == "Idle"); var activeTraining = allTraining .Where(x => x.Status == "Training") .OrderBy(x => x.CompletesAtUtc) .Select(UnitTrainingResponse.FromTraining) .ToList(); return new PlayerUnitsResponse { CompletedScouts = completedScouts, CompletedArchers = completedArchers, CompletedPeasants = completedPeasants, Training = activeTraining, Units = allUnits .OrderBy(x => x.Q) .ThenBy(x => x.R) .ThenBy(x => x.CreatedAtUtc) .Select(UnitInstanceResponse.FromUnit) .ToList() }; } public async Task TrainAsync(string username, TrainUnitRequest request) { var normalizedUnitType = NormalizeUnitType(request.UnitType); if (normalizedUnitType is null) { throw new ArgumentException("Unsupported unit type.", nameof(request.UnitType)); } Location? origin = null; if (normalizedUnitType == ArcherUnitType) { if (request.Q is null || request.R is null) { throw new InvalidOperationException("Select a completed Barracks to train Archers."); } origin = await GetBuiltBarracksAsync(username, request.Q.Value, request.R.Value); if (origin is null) { throw new InvalidOperationException("A completed Barracks is required to train Archers."); } } else if (normalizedUnitType == PeasantUnitType) { origin = await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync(); if (origin is null) { throw new InvalidOperationException("A town hall is required to train Peasants."); } } else if (normalizedUnitType == ScoutUnitType) { origin = await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync(); if (origin is null) { throw new InvalidOperationException("A town hall is required to train Scouts."); } } var goldCost = normalizedUnitType == ArcherUnitType ? await _settings.GetNonNegativeIntAsync(GameSettingsService.ArcherGoldCost, _archerGoldCost) : normalizedUnitType == PeasantUnitType ? await _settings.GetNonNegativeIntAsync(GameSettingsService.PeasantGoldCost, _peasantGoldCost) : await _settings.GetNonNegativeIntAsync(GameSettingsService.ScoutGoldCost, _scoutGoldCost); var woodCost = normalizedUnitType == ArcherUnitType ? await _settings.GetNonNegativeIntAsync(GameSettingsService.ArcherWoodCost, _archerWoodCost) : 0; var spent = normalizedUnitType == ArcherUnitType ? await _resources.TrySpendResourcesAsync(username, goldCost, woodCost, 0) : await _resources.TrySpendGoldAsync(username, goldCost); if (!spent) { return null; } var now = DateTime.UtcNow; var trainingSeconds = normalizedUnitType == ArcherUnitType ? await _settings.GetIntAsync(GameSettingsService.ArcherTrainingSeconds, _defaultArcherTrainingSeconds) : normalizedUnitType == PeasantUnitType ? await _settings.GetIntAsync(GameSettingsService.PeasantTrainingSeconds, _defaultPeasantTrainingSeconds) : await _settings.GetIntAsync(GameSettingsService.ScoutTrainingSeconds, _defaultScoutTrainingSeconds); var training = new UnitTraining { Username = username, UnitType = normalizedUnitType, GoldCost = goldCost, WoodCost = woodCost, OriginQ = origin?.Q, OriginR = origin?.R, Status = "Training", StartedAtUtc = now, CompletesAtUtc = now.AddSeconds(trainingSeconds), CreatedAtUtc = now, UpdatedAtUtc = now }; await _training.InsertOneAsync(training); return training; } public async Task> MoveSquadAsync(string username, MoveUnitSquadRequest request) { await CompletePendingMovementAsync(username); await RefillMovementAsync(username); var normalizedUnitType = NormalizeUnitType(request.UnitType); if (normalizedUnitType != ScoutUnitType && normalizedUnitType != ArcherUnitType && normalizedUnitType != PeasantUnitType) { throw new ArgumentException("Only Scouts, Archers, and Peasants can be moved right now.", nameof(request.UnitType)); } if (request.FromQ == request.ToQ && request.FromR == request.ToR) { throw new InvalidOperationException("Select a different destination tile."); } var movementDistance = HexDistance(request.FromQ, request.FromR, request.ToQ, request.ToR); if (normalizedUnitType == ArcherUnitType && !await CanMoveToAsync(username, request.ToQ, request.ToR)) { throw new InvalidOperationException("Archers can only move to visible tiles."); } if (normalizedUnitType == PeasantUnitType && !await CanPeasantMoveToAsync(username, request.ToQ, request.ToR)) { throw new InvalidOperationException("Peasants can only move within your controlled territory."); } var now = DateTime.UtcNow; var unitFilter = Builders.Filter.And( Builders.Filter.Eq(x => x.Username, username), Builders.Filter.Eq(x => x.UnitType, normalizedUnitType), Builders.Filter.Eq(x => x.Status, "Idle"), Builders.Filter.Eq(x => x.Q, request.FromQ), Builders.Filter.Eq(x => x.R, request.FromR)); if (normalizedUnitType == PeasantUnitType) { if (!string.IsNullOrWhiteSpace(request.UnitId)) { unitFilter &= Builders.Filter.Eq(x => x.Id, request.UnitId); } } var units = await _units.Find(unitFilter).ToListAsync(); if (units.Count == 0) { return []; } if (units.Any(x => x.MovementRemaining <= 0)) { throw new InvalidOperationException("That squad has no movement remaining this tick."); } var movementCompletesAt = now.AddMinutes(1); await _units.UpdateManyAsync( unitFilter, Builders.Update .Set(x => x.Status, "Moving") .Inc(x => x.MovementRemaining, -1) .Set(x => x.DestinationQ, request.ToQ) .Set(x => x.DestinationR, request.ToR) .Set(x => x.MovementStartedAtUtc, now) .Set(x => x.MovementCompletesAtUtc, movementCompletesAt) .Set(x => x.UpdatedAtUtc, now)); return await _units.Find(x => x.Username == username && x.UnitType == normalizedUnitType && x.Status == "Moving" && x.Q == request.FromQ && x.R == request.FromR && x.DestinationQ == request.ToQ && x.DestinationR == request.ToR) .ToListAsync(); } private async Task GetBuiltBarracksAsync(string username, int q, int r) { await CompleteFinishedBuildingsAsync(username); return await _locations.Find(x => x.OwnerUsername == username && x.Q == q && x.R == r && x.BuildingType == "Barracks" && (x.BuildingStatus == "Built" || x.BuildingStatus == null || x.BuildingStatus == "")) .FirstOrDefaultAsync(); } private async Task CompletePendingMovementAsync(string username) { var now = DateTime.UtcNow; var movementPerTick = await _settings.GetIntAsync(GameSettingsService.UnitMovementPerTick, _unitMovementPerTick); var movingUnits = await _units.Find(x => x.Username == username && x.Status == "Moving" && x.MovementCompletesAtUtc != null && x.MovementCompletesAtUtc <= now) .ToListAsync(); foreach (var unit in movingUnits) { if (unit.DestinationQ is null || unit.DestinationR is null) { await _units.UpdateOneAsync( x => x.Id == unit.Id, Builders.Update .Set(x => x.Status, "Idle") .Set(x => x.MovementPerTick, movementPerTick) .Set(x => x.MovementRemaining, movementPerTick) .Unset(x => x.MovementStartedAtUtc) .Unset(x => x.MovementCompletesAtUtc) .Set(x => x.UpdatedAtUtc, now)); continue; } var destinationQ = unit.DestinationQ.Value; var destinationR = unit.DestinationR.Value; if (unit.UnitType == PeasantUnitType && HexDistance(unit.Q, unit.R, destinationQ, destinationR) > 1) { var nextStep = await NextPeasantStepTowardAsync(username, unit.Q, unit.R, destinationQ, destinationR); if (nextStep is null) { await _units.UpdateOneAsync( x => x.Id == unit.Id, Builders.Update .Set(x => x.Status, "Idle") .Set(x => x.MovementPerTick, movementPerTick) .Set(x => x.MovementRemaining, movementPerTick) .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 == unit.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; } if (unit.UnitType != PeasantUnitType && HexDistance(unit.Q, unit.R, destinationQ, destinationR) > 1) { var nextStep = NextStepToward(unit.Q, unit.R, destinationQ, destinationR); await _units.UpdateOneAsync( x => x.Id == unit.Id, Builders.Update .Set(x => x.Q, nextStep.q) .Set(x => x.R, nextStep.r) .Set(x => x.MovementCompletesAtUtc, now.AddMinutes(1)) .Set(x => x.UpdatedAtUtc, now)); continue; } await _units.UpdateOneAsync( x => x.Id == unit.Id, Builders.Update .Set(x => x.Q, destinationQ) .Set(x => x.R, destinationR) .Set(x => x.Status, "Idle") .Set(x => x.MovementPerTick, movementPerTick) .Set(x => x.MovementRemaining, movementPerTick) .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 (unit.UnitType == ArcherUnitType) { await ClaimTargetIfEmptyAsync(username, destinationQ, destinationR, now); } } } private async Task RefillMovementAsync(string username) { var now = DateTime.UtcNow; var movementPerTick = await _settings.GetIntAsync(GameSettingsService.UnitMovementPerTick, _unitMovementPerTick); var units = await _units.Find(x => x.Username == username && x.Status != "Moving" && x.Status != "MovingToWork").ToListAsync(); foreach (var unit in units) { var lastRefill = unit.LastMovementRefillAtUtc.Year < 2000 ? now : unit.LastMovementRefillAtUtc; var elapsedTicks = (int)Math.Floor((now - lastRefill).TotalMinutes); var movementRemaining = elapsedTicks > 0 ? movementPerTick : Math.Min(unit.MovementRemaining, movementPerTick); if (elapsedTicks <= 0 && unit.MovementPerTick == movementPerTick && unit.MovementRemaining == movementRemaining) { continue; } var update = Builders.Update .Set(x => x.MovementPerTick, movementPerTick) .Set(x => x.MovementRemaining, movementRemaining) .Set(x => x.UpdatedAtUtc, now); if (elapsedTicks > 0) { update = update.Set(x => x.LastMovementRefillAtUtc, lastRefill.AddMinutes(elapsedTicks)); } await _units.UpdateOneAsync(x => x.Id == unit.Id, update); } } private async Task CompleteFinishedBuildingsAsync(string username) { var now = DateTime.UtcNow; var update = Builders.Update .Set(x => x.BuildingStatus, "Built") .Set(x => x.BuildingBuiltAtUtc, now) .Set(x => x.UpdatedAtUtc, now); await _locations.UpdateManyAsync( x => x.OwnerUsername == username && x.BuildingType == "Barracks" && x.BuildingStatus == "Building" && x.BuildingCompletesAtUtc <= now, update); } private async Task CanMoveToAsync(string username, int q, int r) { var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync(); 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); } private async Task CanPeasantMoveToAsync(string username, int q, int r) { return await _locations.Find(x => x.OwnerUsername == username && x.Q == q && x.R == r) .AnyAsync(); } 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 async Task ClaimTargetIfEmptyAsync(string username, int q, int r, DateTime now) { var hasOtherUnits = await _units.Find(x => x.Username != username && x.Q == q && x.R == r).AnyAsync(); var target = await _locations.Find(x => x.Q == q && x.R == r).FirstOrDefaultAsync(); var hasBuilding = target is not null && (!string.IsNullOrWhiteSpace(target.BuildingType) || string.Equals(target.Kind, "Home", StringComparison.OrdinalIgnoreCase)); if (hasOtherUnits || hasBuilding) { return; } if (target is null) { var location = new Location { Q = q, R = r, Terrain = "Field", Kind = "Claimed", OwnerUsername = username, Level = 1, CreatedAtUtc = now, UpdatedAtUtc = now }; try { await _locations.InsertOneAsync(location); } catch (MongoWriteException) { } return; } if (string.Equals(target.OwnerUsername, username, StringComparison.OrdinalIgnoreCase)) { return; } var capturableFilter = Builders.Filter.And( Builders.Filter.Eq(x => x.Id, target.Id), Builders.Filter.Ne(x => x.Kind, "Home"), Builders.Filter.Or( Builders.Filter.Eq(x => x.BuildingType, null), Builders.Filter.Eq(x => x.BuildingType, ""))); await _locations.UpdateOneAsync( capturableFilter, Builders.Update .Set(x => x.OwnerUsername, username) .Set(x => x.Kind, "Claimed") .Set(x => x.UpdatedAtUtc, now)); } 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 (int q, int r) NextStepToward(int fromQ, int fromR, int toQ, int toR) { return NeighborCoordinates(fromQ, fromR) .OrderBy(x => HexDistance(x.q, x.r, toQ, toR)) .ThenBy(x => x.q) .ThenBy(x => x.r) .First(); } 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); } public async Task TryConsumeCompletedScoutAsync(string username) { await CompleteFinishedTrainingAsync(username); await PromoteCompletedScoutsAsync(username); var consumedUnit = await _units.FindOneAndDeleteAsync( x => x.Username == username && x.UnitType == ScoutUnitType && x.Status == "Idle", new FindOneAndDeleteOptions { Sort = Builders.Sort.Ascending(x => x.CreatedAtUtc) }); if (consumedUnit is not null) { return true; } var consumed = await _training.FindOneAndDeleteAsync( x => x.Username == username && x.UnitType == ScoutUnitType && x.Status == "Completed", new FindOneAndDeleteOptions { Sort = Builders.Sort.Ascending(x => x.CompletedAtUtc).Ascending(x => x.CreatedAtUtc) }); return consumed is not null; } public async Task HasCompletedScoutAsync(string username) { await CompleteFinishedTrainingAsync(username); await PromoteCompletedScoutsAsync(username); return await _units.Find(x => x.Username == username && x.UnitType == ScoutUnitType && x.Status == "Idle").AnyAsync(); } private async Task CompleteFinishedTrainingAsync(string username) { var now = DateTime.UtcNow; var readyTraining = await _training.Find(x => x.Username == username && x.Status == "Training" && x.CompletesAtUtc <= now) .ToListAsync(); foreach (var training in readyTraining) { var update = Builders.Update .Set(x => x.Status, "Completed") .Set(x => x.CompletedAtUtc, now) .Set(x => x.UpdatedAtUtc, now); var completed = await _training.FindOneAndUpdateAsync( x => x.Id == training.Id && x.Status == "Training", update, new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); if (completed is null || (completed.UnitType != ArcherUnitType && completed.UnitType != PeasantUnitType && completed.UnitType != ScoutUnitType)) { continue; } var movementPerTick = await _settings.GetIntAsync(GameSettingsService.UnitMovementPerTick, _unitMovementPerTick); var unit = new UnitInstance { Username = completed.Username, UnitType = completed.UnitType, Q = completed.OriginQ ?? 0, R = completed.OriginR ?? 0, Status = "Idle", MovementPerTick = movementPerTick, MovementRemaining = movementPerTick, LastMovementRefillAtUtc = now, SourceTrainingId = completed.Id, CreatedAtUtc = now, UpdatedAtUtc = now }; try { await _units.InsertOneAsync(unit); if (completed.UnitType == ScoutUnitType) { await _training.UpdateOneAsync( x => x.Id == completed.Id, Builders.Update .Set(x => x.Status, "Consumed") .Set(x => x.UpdatedAtUtc, now)); } } catch (MongoWriteException) { } } } private async Task PromoteCompletedScoutsAsync(string username) { var now = DateTime.UtcNow; var completedScouts = await _training.Find(x => x.Username == username && x.UnitType == ScoutUnitType && x.Status == "Completed") .ToListAsync(); if (completedScouts.Count == 0) { return; } var home = await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync(); var movementPerTick = await _settings.GetIntAsync(GameSettingsService.UnitMovementPerTick, _unitMovementPerTick); foreach (var scout in completedScouts) { var existing = await _units.Find(x => x.SourceTrainingId == scout.Id).AnyAsync(); if (!existing) { var unit = new UnitInstance { Username = scout.Username, UnitType = ScoutUnitType, Q = scout.OriginQ ?? home?.Q ?? 0, R = scout.OriginR ?? home?.R ?? 0, Status = "Idle", MovementPerTick = movementPerTick, MovementRemaining = movementPerTick, LastMovementRefillAtUtc = now, SourceTrainingId = scout.Id, CreatedAtUtc = now, UpdatedAtUtc = now }; try { await _units.InsertOneAsync(unit); } catch (MongoWriteException) { } } await _training.UpdateOneAsync( x => x.Id == scout.Id, Builders.Update .Set(x => x.Status, "Consumed") .Set(x => x.UpdatedAtUtc, now)); } } private static string? NormalizeUnitType(string unitType) { var trimmed = unitType.Trim(); return trimmed.ToLowerInvariant() switch { "scout" => ScoutUnitType, "archer" => ArcherUnitType, "peasant" => PeasantUnitType, _ => null }; } }