Adding upkeep

This commit is contained in:
hz
2026-05-20 04:25:24 -05:00
parent 194dae43da
commit dfbe1d63c0
10 changed files with 530 additions and 40 deletions
@@ -5,8 +5,14 @@ namespace LocationsApi.Services;
public class ResourceService
{
private const string ScoutUnitType = "Scout";
private const string ArcherUnitType = "Archer";
private const string PeasantUnitType = "Peasant";
private readonly IMongoCollection<PlayerResources> _resources;
private readonly IMongoCollection<Location> _locations;
private readonly IMongoCollection<UnitTraining> _training;
private readonly IMongoCollection<UnitInstance> _units;
private readonly GameSettingsService _settings;
private readonly int _startingGold;
private readonly int _headquartersGoldPerMinute;
@@ -15,6 +21,7 @@ public class ResourceService
private readonly int _townHallOreCapacity;
private readonly int _townHallFoodCapacity;
private readonly int _productionBuildingResourceCapacity;
private readonly int _unitFoodPerMinute;
public ResourceService(IConfiguration cfg, GameSettingsService settings)
{
@@ -24,6 +31,8 @@ public class ResourceService
var database = client.GetDatabase(databaseName);
_resources = database.GetCollection<PlayerResources>("PlayerResources");
_locations = database.GetCollection<Location>("Locations");
_training = database.GetCollection<UnitTraining>("UnitTraining");
_units = database.GetCollection<UnitInstance>("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)
@@ -44,6 +53,9 @@ public class ResourceService
_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<PlayerResources>(
Builders<PlayerResources>.IndexKeys.Ascending(x => x.Username),
@@ -92,7 +104,8 @@ public class ResourceService
public async Task<PlayerResources> GetAsync(string username)
{
var resources = await EnsureAsync(username);
return await ApplyProductionBuildingDeliveryAsync(resources);
resources = await ApplyProductionBuildingDeliveryAsync(resources);
return await ApplyFoodUpkeepAsync(resources);
}
public async Task<bool> TrySpendGoldAsync(string username, int amount)
@@ -300,6 +313,7 @@ public class ResourceService
private async Task<PlayerResources> ApplyProductionBuildingDeliveryAsync(PlayerResources resources)
{
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);
@@ -377,4 +391,239 @@ public class ResourceService
ReturnDocument = ReturnDocument.After
});
}
private async Task<PlayerResources> ApplyFoodUpkeepAsync(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 = upkeepPerMinute <= 0 || resources.LastFoodGeneratedAtUtc.Year < 2000
? 0
: (int)Math.Floor((now - resources.LastFoodGeneratedAtUtc).TotalMinutes);
var food = resources.Food;
var removedUnits = new List<FoodConsumingUnit>();
for (var minute = 0; minute < elapsedMinutes && units.Count > 0; minute++)
{
var neededFood = units.Count * unitFoodPerMinute;
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)
{
return resources;
}
await RemoveStarvedUnitsAsync(resources.Username, removedUnits, now);
var update = Builders<PlayerResources>.Update
.Set(x => x.FoodPerMinute, netFoodPerMinute)
.Set(x => x.Food, food)
.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<PlayerResources>
{
ReturnDocument = ReturnDocument.After
});
}
private async Task<List<FoodConsumingUnit>> 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 == 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<int> 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<UnitInstance>.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;
var currentQ = worker.Q;
var currentR = worker.R;
var nextTick = worker.MovementCompletesAtUtc!.Value;
while (nextTick <= now && HexDistance(currentQ, currentR, destinationQ, destinationR) > 1)
{
(currentQ, currentR) = NextStepToward(currentQ, currentR, destinationQ, destinationR);
nextTick = nextTick.AddMinutes(1);
}
if (HexDistance(currentQ, currentR, destinationQ, destinationR) > 1)
{
await _units.UpdateOneAsync(
x => x.Id == worker.Id,
Builders<UnitInstance>.Update
.Set(x => x.Q, currentQ)
.Set(x => x.R, currentR)
.Set(x => x.MovementCompletesAtUtc, nextTick)
.Set(x => x.UpdatedAtUtc, now));
continue;
}
var location = await _locations.FindOneAndUpdateAsync(
x => x.OwnerUsername == username &&
x.Q == destinationQ &&
x.R == destinationR &&
!string.IsNullOrWhiteSpace(x.BuildingType) &&
x.BuildingStatus != "Building" &&
(x.WorkerUnitId == null || x.WorkerUnitId == ""),
Builders<Location>.Update
.Set(x => x.WorkerUnitId, worker.Id)
.Set(x => x.WorkerUnitType, "Peasant")
.Set(x => x.LastResourceGeneratedAtUtc, now)
.Set(x => x.UpdatedAtUtc, now),
new FindOneAndUpdateOptions<Location> { ReturnDocument = ReturnDocument.After });
await _units.UpdateOneAsync(
x => x.Id == worker.Id,
Builders<UnitInstance>.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 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);
}
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<FoodConsumingUnit> 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<Location>.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);
}