518 lines
21 KiB
C#
518 lines
21 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 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 = allTraining.Count(x => x.UnitType == ScoutUnitType && x.Status == "Completed");
|
|
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.");
|
|
}
|
|
}
|
|
|
|
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 != ArcherUnitType)
|
|
{
|
|
throw new ArgumentException("Only Archer squads 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.");
|
|
}
|
|
|
|
if (HexDistance(request.FromQ, request.FromR, request.ToQ, request.ToR) != 1)
|
|
{
|
|
throw new InvalidOperationException("Archers can only move to adjacent tiles.");
|
|
}
|
|
|
|
if (!await CanMoveToAsync(username, request.ToQ, request.ToR))
|
|
{
|
|
throw new InvalidOperationException("Archers can only move to visible tiles.");
|
|
}
|
|
|
|
var now = DateTime.UtcNow;
|
|
var units = await _units.Find(x =>
|
|
x.Username == username &&
|
|
x.UnitType == ArcherUnitType &&
|
|
x.Status == "Idle" &&
|
|
x.Q == request.FromQ &&
|
|
x.R == request.FromR)
|
|
.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(
|
|
x => x.Username == username &&
|
|
x.UnitType == ArcherUnitType &&
|
|
x.Status == "Idle" &&
|
|
x.Q == request.FromQ &&
|
|
x.R == request.FromR,
|
|
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.MovementStartedAtUtc, now)
|
|
.Set(x => x.MovementCompletesAtUtc, movementCompletesAt)
|
|
.Set(x => x.UpdatedAtUtc, now));
|
|
|
|
return await _units.Find(x =>
|
|
x.Username == username &&
|
|
x.UnitType == ArcherUnitType &&
|
|
x.Status == "Moving" &&
|
|
x.Q == request.FromQ &&
|
|
x.R == request.FromR &&
|
|
x.DestinationQ == request.ToQ &&
|
|
x.DestinationR == request.ToR)
|
|
.ToListAsync();
|
|
}
|
|
|
|
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)
|
|
.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, unit.DestinationQ.Value)
|
|
.Set(x => x.R, unit.DestinationR.Value)
|
|
.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, unit.DestinationQ.Value, unit.DestinationR.Value, 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 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 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<Location>.Filter.And(
|
|
Builders<Location>.Filter.Eq(x => x.Id, target.Id),
|
|
Builders<Location>.Filter.Ne(x => x.Kind, "Home"),
|
|
Builders<Location>.Filter.Or(
|
|
Builders<Location>.Filter.Eq(x => x.BuildingType, null),
|
|
Builders<Location>.Filter.Eq(x => x.BuildingType, "")));
|
|
|
|
await _locations.UpdateOneAsync(
|
|
capturableFilter,
|
|
Builders<Location>.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));
|
|
}
|
|
|
|
public async Task<bool> TryConsumeCompletedScoutAsync(string username)
|
|
{
|
|
await CompleteFinishedTrainingAsync(username);
|
|
|
|
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);
|
|
return await _training.Find(x => x.Username == username && x.UnitType == ScoutUnitType && x.Status == "Completed").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))
|
|
{
|
|
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);
|
|
}
|
|
catch (MongoWriteException)
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string? NormalizeUnitType(string unitType)
|
|
{
|
|
var trimmed = unitType.Trim();
|
|
return trimmed.ToLowerInvariant() switch
|
|
{
|
|
"scout" => ScoutUnitType,
|
|
"archer" => ArcherUnitType,
|
|
"peasant" => PeasantUnitType,
|
|
_ => null
|
|
};
|
|
}
|
|
}
|