Files
territory-autobattler/microservices/LocationsApi/Services/UnitTrainingService.cs
T
2026-05-21 02:21:54 -05:00

780 lines
31 KiB
C#

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<UnitTraining> _training;
private readonly IMongoCollection<UnitInstance> _units;
private readonly IMongoCollection<Location> _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>("UnitTraining");
_units = database.GetCollection<UnitInstance>("Units");
_locations = database.GetCollection<Location>("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<UnitTraining>(
Builders<UnitTraining>.IndexKeys.Ascending(x => x.Username).Ascending(x => x.Status)));
_units.Indexes.CreateOne(new CreateIndexModel<UnitInstance>(
Builders<UnitInstance>.IndexKeys.Ascending(x => x.Username).Ascending(x => x.UnitType)));
_units.Indexes.CreateOne(new CreateIndexModel<UnitInstance>(
Builders<UnitInstance>.IndexKeys.Ascending(x => x.SourceTrainingId),
new CreateIndexOptions<UnitInstance>
{
Unique = true,
Sparse = true
}));
}
public async Task<PlayerUnitsResponse> 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<UnitTraining?> 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<List<UnitInstance>> 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.ClaimOnArrival && normalizedUnitType != ScoutUnitType)
{
throw new ArgumentException("Only Scouts can claim territory.", nameof(request.ClaimOnArrival));
}
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 || normalizedUnitType == ScoutUnitType) && !await CanMoveToAsync(username, request.ToQ, request.ToR))
{
throw new InvalidOperationException($"{normalizedUnitType}s 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.");
}
if (request.ClaimOnArrival && !await IsAdjacentToOwnedAsync(username, request.ToQ, request.ToR))
{
throw new InvalidOperationException("Scout claims must target territory adjacent to controlled territory.");
}
var now = DateTime.UtcNow;
var unitFilter = Builders<UnitInstance>.Filter.And(
Builders<UnitInstance>.Filter.Eq(x => x.Username, username),
Builders<UnitInstance>.Filter.Eq(x => x.UnitType, normalizedUnitType),
Builders<UnitInstance>.Filter.Eq(x => x.Status, "Idle"),
Builders<UnitInstance>.Filter.Eq(x => x.Q, request.FromQ),
Builders<UnitInstance>.Filter.Eq(x => x.R, request.FromR));
if (normalizedUnitType == PeasantUnitType)
{
if (!string.IsNullOrWhiteSpace(request.UnitId))
{
unitFilter &= Builders<UnitInstance>.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 movingUnitIds = request.ClaimOnArrival
? units.OrderBy(x => x.Id).Take(1).Select(x => x.Id).ToList()
: units.Select(x => x.Id).ToList();
var movementCompletesAt = now.AddMinutes(1);
await _units.UpdateManyAsync(
Builders<UnitInstance>.Filter.In(x => x.Id, movingUnitIds),
Builders<UnitInstance>.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.ClaimOnArrival, request.ClaimOnArrival)
.Set(x => x.MovementStartedAtUtc, now)
.Set(x => x.MovementCompletesAtUtc, movementCompletesAt)
.Set(x => x.UpdatedAtUtc, now));
return await _units.Find(x =>
movingUnitIds.Contains(x.Id))
.ToListAsync();
}
public async Task<int> DismissSquadAsync(string username, DismissUnitSquadRequest request)
{
var normalizedUnitType = NormalizeUnitType(request.UnitType);
if (normalizedUnitType != ScoutUnitType && normalizedUnitType != ArcherUnitType && normalizedUnitType != PeasantUnitType)
{
throw new ArgumentException("Only Scouts, Archers, and Peasants can be dismissed right now.", nameof(request.UnitType));
}
await CompletePendingMovementAsync(username);
var result = await _units.DeleteManyAsync(x =>
x.Username == username &&
x.UnitType == normalizedUnitType &&
x.Status == "Idle" &&
x.Q == request.Q &&
x.R == request.R);
return (int)result.DeletedCount;
}
private async Task<Location?> 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<UnitInstance>.Update
.Set(x => x.Status, "Idle")
.Set(x => x.MovementPerTick, movementPerTick)
.Set(x => x.MovementRemaining, movementPerTick)
.Set(x => x.ClaimOnArrival, false)
.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<UnitInstance>.Update
.Set(x => x.Status, "Idle")
.Set(x => x.MovementPerTick, movementPerTick)
.Set(x => x.MovementRemaining, movementPerTick)
.Unset(x => x.DestinationQ)
.Unset(x => x.DestinationR)
.Set(x => x.ClaimOnArrival, false)
.Unset(x => x.MovementStartedAtUtc)
.Unset(x => x.MovementCompletesAtUtc)
.Set(x => x.UpdatedAtUtc, now));
continue;
}
await _units.UpdateOneAsync(
x => x.Id == unit.Id,
Builders<UnitInstance>.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<UnitInstance>.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<UnitInstance>.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)
.Set(x => x.ClaimOnArrival, false)
.Unset(x => x.MovementStartedAtUtc)
.Unset(x => x.MovementCompletesAtUtc)
.Set(x => x.UpdatedAtUtc, now));
if (unit.UnitType == ScoutUnitType && unit.ClaimOnArrival)
{
await TryClaimWithArrivedScoutAsync(unit, 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<UnitInstance>.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<bool> IsAdjacentToOwnedAsync(string username, int q, int r)
{
var owned = await _locations.Find(x => x.OwnerUsername == username).ToListAsync();
return owned.Any(location => HexDistance(location.Q, location.R, q, r) == 1);
}
private async Task TryClaimWithArrivedScoutAsync(UnitInstance scout, int q, int r, DateTime now)
{
var target = await _locations.Find(x => x.Q == q && x.R == r).FirstOrDefaultAsync();
if (target is not null)
{
if (string.Equals(target.OwnerUsername, scout.Username, StringComparison.OrdinalIgnoreCase) ||
string.Equals(target.Kind, "Home", StringComparison.OrdinalIgnoreCase) ||
!string.IsNullOrWhiteSpace(target.BuildingType))
{
return;
}
}
var hasBlockingUnit = await _units.Find(x =>
x.Id != scout.Id &&
x.Q == q &&
x.R == r)
.AnyAsync();
if (hasBlockingUnit)
{
return;
}
if (!await IsAdjacentToOwnedAsync(scout.Username, q, r))
{
return;
}
var claimed = false;
if (target is null)
{
var location = new Location
{
Q = q,
R = r,
Terrain = "Field",
Kind = "Claimed",
OwnerUsername = scout.Username,
Level = 1,
CreatedAtUtc = now,
UpdatedAtUtc = now
};
try
{
await _locations.InsertOneAsync(location);
claimed = true;
}
catch (MongoWriteException)
{
}
}
else
{
var result = await _locations.UpdateOneAsync(
x => x.Id == target.Id &&
x.Kind != "Home" &&
(x.BuildingType == null || x.BuildingType == "") &&
x.OwnerUsername != scout.Username,
Builders<Location>.Update
.Set(x => x.OwnerUsername, scout.Username)
.Set(x => x.Kind, "Claimed")
.Set(x => x.UpdatedAtUtc, now));
claimed = result.ModifiedCount > 0;
}
if (claimed)
{
await _units.DeleteOneAsync(x => x.Id == scout.Id);
}
}
private async Task CompleteFinishedBuildingsAsync(string username)
{
var now = DateTime.UtcNow;
var update = Builders<Location>.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<bool> 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<bool> 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 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<bool> 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<UnitInstance>
{
Sort = Builders<UnitInstance>.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<UnitTraining>
{
Sort = Builders<UnitTraining>.Sort.Ascending(x => x.CompletedAtUtc).Ascending(x => x.CreatedAtUtc)
});
return consumed is not null;
}
public async Task<bool> 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<UnitTraining>.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<UnitTraining>
{
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<UnitTraining>.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<UnitTraining>.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
};
}
}