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
@@ -22,6 +22,7 @@ var build_request: HTTPRequest
var refresh_request: HTTPRequest var refresh_request: HTTPRequest
var resources_timer: Timer var resources_timer: Timer
var auth_check_timer: Timer var auth_check_timer: Timer
var refresh_prompt_timer: Timer
var refresh_dialog: ConfirmationDialog var refresh_dialog: ConfirmationDialog
var session: Dictionary = {} var session: Dictionary = {}
var escape_overlay: Control var escape_overlay: Control
@@ -314,6 +315,12 @@ func _build_ui() -> void:
auth_check_timer.timeout.connect(_check_auth_expiry) auth_check_timer.timeout.connect(_check_auth_expiry)
add_child(auth_check_timer) add_child(auth_check_timer)
refresh_prompt_timer = Timer.new()
refresh_prompt_timer.one_shot = true
refresh_prompt_timer.autostart = false
refresh_prompt_timer.timeout.connect(_on_refresh_prompt_timeout)
add_child(refresh_prompt_timer)
resources_timer = Timer.new() resources_timer = Timer.new()
resources_timer.wait_time = RESOURCE_REFRESH_SECONDS resources_timer.wait_time = RESOURCE_REFRESH_SECONDS
resources_timer.autostart = false resources_timer.autostart = false
@@ -764,21 +771,29 @@ func _check_auth_expiry() -> void:
return return
if seconds_remaining <= TOKEN_REFRESH_PROMPT_SECONDS: if seconds_remaining <= TOKEN_REFRESH_PROMPT_SECONDS:
_prompt_refresh_or_logout("Your session expires in %d seconds. Refresh it now?" % int(ceil(seconds_remaining))) _prompt_refresh_or_logout(
"Your session expires in %d seconds. Refresh it now?" % int(ceil(seconds_remaining)),
seconds_remaining
)
func _prompt_refresh_or_logout(message: String) -> void: func _prompt_refresh_or_logout(message: String, timeout_seconds: float = TOKEN_REFRESH_PROMPT_SECONDS) -> void:
if refresh_prompt_active or refresh_request_active: if refresh_prompt_active or refresh_request_active:
return return
refresh_prompt_active = true refresh_prompt_active = true
if refresh_prompt_timer:
refresh_prompt_timer.wait_time = max(1.0, timeout_seconds)
refresh_prompt_timer.start()
if refresh_dialog: if refresh_dialog:
refresh_dialog.dialog_text = message refresh_dialog.dialog_text = "%s\n\nIf you do not respond before the session expires, you will be logged out." % message
refresh_dialog.popup_centered() refresh_dialog.popup_centered()
func _refresh_auth_token() -> void: func _refresh_auth_token() -> void:
refresh_prompt_active = false refresh_prompt_active = false
if refresh_prompt_timer:
refresh_prompt_timer.stop()
var username := str(session.get("username", "")) var username := str(session.get("username", ""))
var refresh_token := str(session.get("refreshToken", "")) var refresh_token := str(session.get("refreshToken", ""))
if username.is_empty() or refresh_token.is_empty(): if username.is_empty() or refresh_token.is_empty():
@@ -820,6 +835,14 @@ func _on_refresh_request_completed(result: int, response_code: int, _headers: Pa
_check_auth_expiry() _check_auth_expiry()
func _on_refresh_prompt_timeout() -> void:
if refresh_prompt_active and not refresh_request_active:
refresh_prompt_active = false
if refresh_dialog:
refresh_dialog.hide()
_logout()
func _save_session(next_session: Dictionary) -> void: func _save_session(next_session: Dictionary) -> void:
var file := FileAccess.open(SESSION_PATH, FileAccess.WRITE) var file := FileAccess.open(SESSION_PATH, FileAccess.WRITE)
if file: if file:
@@ -949,7 +972,7 @@ func _load_resources() -> void:
func _on_resources_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: func _on_resources_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
if response_code == 401: if response_code == 401:
_prompt_refresh_or_logout("Your session expired. Refresh it now?") _prompt_refresh_or_logout("Your session expired. Refresh it now?", 10.0)
return return
coordinate_label.text = "Resource request failed. result=%d status=%d body=%s" % [result, response_code, body.get_string_from_utf8()] coordinate_label.text = "Resource request failed. result=%d status=%d body=%s" % [result, response_code, body.get_string_from_utf8()]
return return
@@ -1162,7 +1185,7 @@ func _move_selected_archers(to_q: int, to_r: int) -> void:
if move_units_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: if move_units_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
return return
coordinate_label.text = "Moving %d Archers to q:%d r:%d..." % [selected_archer_count, to_q, to_r] coordinate_label.text = "Ordering %d Archers to move to q:%d r:%d..." % [selected_archer_count, to_q, to_r]
var error: int = move_units_request.request( var error: int = move_units_request.request(
LOCATIONS_BASE_URL + "/api/units/move", LOCATIONS_BASE_URL + "/api/units/move",
PackedStringArray([ PackedStringArray([
@@ -1194,8 +1217,9 @@ func _on_move_units_request_completed(result: int, response_code: int, _headers:
return return
_clear_selected_archers() _clear_selected_archers()
coordinate_label.text = "Archer squad moved." coordinate_label.text = "Archer squad movement ordered. It will arrive on the next tick."
_load_visible_locations() _load_visible_locations()
_load_units()
func _request_worker_action(endpoint: String, body: Dictionary) -> void: func _request_worker_action(endpoint: String, body: Dictionary) -> void:
@@ -1230,8 +1254,9 @@ func _on_worker_request_completed(result: int, response_code: int, _headers: Pac
return return
_clear_selected_archers() _clear_selected_archers()
coordinate_label.text = "Worker assignment updated." coordinate_label.text = "Worker assignment ordered. The peasant will travel one hex per tick."
_load_visible_locations() _load_visible_locations()
_load_units()
func _claim_selected_tile() -> void: func _claim_selected_tile() -> void:
@@ -1629,6 +1654,11 @@ func _toggle_escape_menu() -> void:
func _logout() -> void: func _logout() -> void:
refresh_prompt_active = false
if refresh_prompt_timer:
refresh_prompt_timer.stop()
if refresh_dialog:
refresh_dialog.hide()
if FileAccess.file_exists(SESSION_PATH): if FileAccess.file_exists(SESSION_PATH):
DirAccess.remove_absolute(ProjectSettings.globalize_path(SESSION_PATH)) DirAccess.remove_absolute(ProjectSettings.globalize_path(SESSION_PATH))
get_tree().remove_meta("auth_session") get_tree().remove_meta("auth_session")
@@ -821,7 +821,8 @@ func _archer_count_at(q: int, r: int) -> int:
func _peasant_count_at(q: int, r: int) -> int: func _peasant_count_at(q: int, r: int) -> int:
var count := 0 var count := 0
for unit in unit_instances: for unit in unit_instances:
if str(unit.get("unitType", "")) != "Peasant" or str(unit.get("status", "")) != "Idle": var status := str(unit.get("status", ""))
if str(unit.get("unitType", "")) != "Peasant" or (status != "Idle" and status != "MovingToWork"):
continue continue
if int(unit.get("q", 0)) == q and int(unit.get("r", 0)) == r: if int(unit.get("q", 0)) == q and int(unit.get("r", 0)) == r:
count += 1 count += 1
@@ -56,6 +56,13 @@ public class UnitInstanceResponse
public int Q { get; set; } public int Q { get; set; }
public int R { get; set; } public int R { get; set; }
public string Status { get; set; } = ""; public string Status { get; set; } = "";
public int MovementPerTick { get; set; }
public int MovementRemaining { get; set; }
public DateTime LastMovementRefillAtUtc { get; set; }
public int? DestinationQ { get; set; }
public int? DestinationR { get; set; }
public DateTime? MovementStartedAtUtc { get; set; }
public DateTime? MovementCompletesAtUtc { get; set; }
public DateTime CreatedAtUtc { get; set; } public DateTime CreatedAtUtc { get; set; }
public DateTime UpdatedAtUtc { get; set; } public DateTime UpdatedAtUtc { get; set; }
@@ -67,6 +74,13 @@ public class UnitInstanceResponse
Q = unit.Q, Q = unit.Q,
R = unit.R, R = unit.R,
Status = unit.Status, Status = unit.Status,
MovementPerTick = unit.MovementPerTick,
MovementRemaining = unit.MovementRemaining,
LastMovementRefillAtUtc = unit.LastMovementRefillAtUtc,
DestinationQ = unit.DestinationQ,
DestinationR = unit.DestinationR,
MovementStartedAtUtc = unit.MovementStartedAtUtc,
MovementCompletesAtUtc = unit.MovementCompletesAtUtc,
CreatedAtUtc = unit.CreatedAtUtc, CreatedAtUtc = unit.CreatedAtUtc,
UpdatedAtUtc = unit.UpdatedAtUtc UpdatedAtUtc = unit.UpdatedAtUtc
}; };
@@ -14,6 +14,13 @@ public class UnitInstance
public int Q { get; set; } public int Q { get; set; }
public int R { get; set; } public int R { get; set; }
public string Status { get; set; } = "Idle"; public string Status { get; set; } = "Idle";
public int MovementPerTick { get; set; } = 1;
public int MovementRemaining { get; set; } = 1;
public DateTime LastMovementRefillAtUtc { get; set; } = DateTime.UtcNow;
public int? DestinationQ { get; set; }
public int? DestinationR { get; set; }
public DateTime? MovementStartedAtUtc { get; set; }
public DateTime? MovementCompletesAtUtc { get; set; }
public string? SourceTrainingId { get; set; } public string? SourceTrainingId { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
@@ -12,6 +12,8 @@ public class GameSettingsService
public const string ArcherGoldCost = "ArcherGoldCost"; public const string ArcherGoldCost = "ArcherGoldCost";
public const string ArcherWoodCost = "ArcherWoodCost"; public const string ArcherWoodCost = "ArcherWoodCost";
public const string PeasantGoldCost = "PeasantGoldCost"; public const string PeasantGoldCost = "PeasantGoldCost";
public const string UnitFoodPerMinute = "UnitFoodPerMinute";
public const string UnitMovementPerTick = "UnitMovementPerTick";
public const string LogCampBuildSeconds = "LogCampBuildSeconds"; public const string LogCampBuildSeconds = "LogCampBuildSeconds";
public const string MineBuildSeconds = "MineBuildSeconds"; public const string MineBuildSeconds = "MineBuildSeconds";
public const string BarracksBuildSeconds = "BarracksBuildSeconds"; public const string BarracksBuildSeconds = "BarracksBuildSeconds";
@@ -284,6 +284,7 @@ public class LocationService
public async Task<Location?> AssignWorkerAsync(string username, AssignWorkerRequest request) public async Task<Location?> AssignWorkerAsync(string username, AssignWorkerRequest request)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
await CompletePendingWorkerAssignmentsAsync(username);
var location = await GetAsync(request.Q, request.R); var location = await GetAsync(request.Q, request.R);
if (location is null || if (location is null ||
!string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase) || !string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase) ||
@@ -308,36 +309,19 @@ public class LocationService
var claimed = await _units.UpdateOneAsync( var claimed = await _units.UpdateOneAsync(
x => x.Id == unit.Id && x.Status == "Idle", x => x.Id == unit.Id && x.Status == "Idle",
Builders<UnitInstance>.Update Builders<UnitInstance>.Update
.Set(x => x.Q, request.Q) .Set(x => x.Status, "MovingToWork")
.Set(x => x.R, request.R) .Set(x => x.MovementRemaining, 0)
.Set(x => x.Status, "Working") .Set(x => x.DestinationQ, request.Q)
.Set(x => x.DestinationR, request.R)
.Set(x => x.MovementStartedAtUtc, now)
.Set(x => x.MovementCompletesAtUtc, now.AddMinutes(Math.Max(1, HexDistance(unit.Q, unit.R, request.Q, request.R))))
.Set(x => x.UpdatedAtUtc, now)); .Set(x => x.UpdatedAtUtc, now));
if (claimed.ModifiedCount == 0) if (claimed.ModifiedCount == 0)
{ {
return null; return null;
} }
var updated = await _locations.FindOneAndUpdateAsync( return location;
x => x.Id == location.Id && (x.WorkerUnitId == null || x.WorkerUnitId == ""),
Builders<Location>.Update
.Set(x => x.WorkerUnitId, unit.Id)
.Set(x => x.WorkerUnitType, "Peasant")
.Set(x => x.LastResourceGeneratedAtUtc, now)
.Set(x => x.UpdatedAtUtc, now),
new FindOneAndUpdateOptions<Location> { ReturnDocument = ReturnDocument.After });
if (updated is null)
{
await _units.UpdateOneAsync(
x => x.Id == unit.Id,
Builders<UnitInstance>.Update
.Set(x => x.Status, "Idle")
.Set(x => x.UpdatedAtUtc, DateTime.UtcNow));
return null;
}
await RecalculateWorkerGenerationAsync(username);
return updated;
} }
public async Task<Location?> DeassignWorkerAsync(string username, DeassignWorkerRequest request) public async Task<Location?> DeassignWorkerAsync(string username, DeassignWorkerRequest request)
@@ -433,6 +417,7 @@ public class LocationService
public async Task<List<LocationResponse>> GetVisibleAsync(string username, int? visibilityDepth = null) public async Task<List<LocationResponse>> GetVisibleAsync(string username, int? visibilityDepth = null)
{ {
await CompleteFinishedBuildingsAsync(username); await CompleteFinishedBuildingsAsync(username);
await CompletePendingWorkerAssignmentsAsync(username);
await RecalculateWorkerGenerationAsync(username); await RecalculateWorkerGenerationAsync(username);
var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync(); var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync();
@@ -500,6 +485,7 @@ public class LocationService
public async Task<LocationResponse?> GetVisibleLocationAsync(string username, int q, int r) public async Task<LocationResponse?> GetVisibleLocationAsync(string username, int q, int r)
{ {
await CompletePendingWorkerAssignmentsAsync(username);
if (!await CanSeeAsync(username, q, r)) if (!await CanSeeAsync(username, q, r))
{ {
return null; return null;
@@ -522,6 +508,98 @@ public class LocationService
return generated.Count == 0 ? null : LocationResponse.FromLocation(generated[0]); return generated.Count == 0 ? null : LocationResponse.FromLocation(generated[0]);
} }
private async Task CompletePendingWorkerAssignmentsAsync(string username)
{
var now = DateTime.UtcNow;
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 });
var status = location is null ? "Idle" : "Working";
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, status)
.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 (movingWorkers.Count > 0)
{
await RecalculateWorkerGenerationAsync(username);
}
}
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();
}
public async Task<bool> CanSeeAsync(string username, int q, int r) public async Task<bool> CanSeeAsync(string username, int q, int r)
{ {
var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync(); var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync();
@@ -5,8 +5,14 @@ namespace LocationsApi.Services;
public class ResourceService 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<PlayerResources> _resources;
private readonly IMongoCollection<Location> _locations; private readonly IMongoCollection<Location> _locations;
private readonly IMongoCollection<UnitTraining> _training;
private readonly IMongoCollection<UnitInstance> _units;
private readonly GameSettingsService _settings; private readonly GameSettingsService _settings;
private readonly int _startingGold; private readonly int _startingGold;
private readonly int _headquartersGoldPerMinute; private readonly int _headquartersGoldPerMinute;
@@ -15,6 +21,7 @@ public class ResourceService
private readonly int _townHallOreCapacity; private readonly int _townHallOreCapacity;
private readonly int _townHallFoodCapacity; private readonly int _townHallFoodCapacity;
private readonly int _productionBuildingResourceCapacity; private readonly int _productionBuildingResourceCapacity;
private readonly int _unitFoodPerMinute;
public ResourceService(IConfiguration cfg, GameSettingsService settings) public ResourceService(IConfiguration cfg, GameSettingsService settings)
{ {
@@ -24,6 +31,8 @@ public class ResourceService
var database = client.GetDatabase(databaseName); var database = client.GetDatabase(databaseName);
_resources = database.GetCollection<PlayerResources>("PlayerResources"); _resources = database.GetCollection<PlayerResources>("PlayerResources");
_locations = database.GetCollection<Location>("Locations"); _locations = database.GetCollection<Location>("Locations");
_training = database.GetCollection<UnitTraining>("UnitTraining");
_units = database.GetCollection<UnitInstance>("Units");
_settings = settings; _settings = settings;
_startingGold = int.TryParse(cfg["Resources:StartingGold"], out var startingGold) ? Math.Max(0, startingGold) : 100; _startingGold = int.TryParse(cfg["Resources:StartingGold"], out var startingGold) ? Math.Max(0, startingGold) : 100;
_headquartersGoldPerMinute = int.TryParse(cfg["Resources:HeadquartersGoldPerMinute"], out var goldPerMinute) _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) _productionBuildingResourceCapacity = int.TryParse(cfg["Buildings:ProductionBuildingResourceCapacity"], out var productionBuildingResourceCapacity)
? Math.Max(0, productionBuildingResourceCapacity) ? Math.Max(0, productionBuildingResourceCapacity)
: 10; : 10;
_unitFoodPerMinute = int.TryParse(cfg["Units:UnitFoodPerMinute"], out var unitFoodPerMinute)
? Math.Max(0, unitFoodPerMinute)
: 1;
_resources.Indexes.CreateOne(new CreateIndexModel<PlayerResources>( _resources.Indexes.CreateOne(new CreateIndexModel<PlayerResources>(
Builders<PlayerResources>.IndexKeys.Ascending(x => x.Username), Builders<PlayerResources>.IndexKeys.Ascending(x => x.Username),
@@ -92,7 +104,8 @@ public class ResourceService
public async Task<PlayerResources> GetAsync(string username) public async Task<PlayerResources> GetAsync(string username)
{ {
var resources = await EnsureAsync(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) public async Task<bool> TrySpendGoldAsync(string username, int amount)
@@ -300,6 +313,7 @@ public class ResourceService
private async Task<PlayerResources> ApplyProductionBuildingDeliveryAsync(PlayerResources resources) private async Task<PlayerResources> ApplyProductionBuildingDeliveryAsync(PlayerResources resources)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
await CompletePendingWorkerAssignmentsAsync(resources.Username, now);
var woodCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallWoodCapacity, _townHallWoodCapacity); var woodCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallWoodCapacity, _townHallWoodCapacity);
var oreCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallOreCapacity, _townHallOreCapacity); var oreCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallOreCapacity, _townHallOreCapacity);
var foodCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallFoodCapacity, _townHallFoodCapacity); var foodCapacity = await _settings.GetNonNegativeIntAsync(GameSettingsService.TownHallFoodCapacity, _townHallFoodCapacity);
@@ -377,4 +391,239 @@ public class ResourceService
ReturnDocument = ReturnDocument.After 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);
} }
@@ -21,6 +21,7 @@ public class UnitTrainingService
private readonly int _defaultScoutTrainingSeconds; private readonly int _defaultScoutTrainingSeconds;
private readonly int _defaultArcherTrainingSeconds; private readonly int _defaultArcherTrainingSeconds;
private readonly int _defaultPeasantTrainingSeconds; private readonly int _defaultPeasantTrainingSeconds;
private readonly int _unitMovementPerTick;
private readonly int _visibilityDepth; private readonly int _visibilityDepth;
public UnitTrainingService(IConfiguration cfg, ResourceService resources, GameSettingsService settings) public UnitTrainingService(IConfiguration cfg, ResourceService resources, GameSettingsService settings)
@@ -47,6 +48,9 @@ public class UnitTrainingService
_defaultPeasantTrainingSeconds = int.TryParse(cfg["Units:PeasantTrainingSeconds"], out var peasantTrainingSeconds) _defaultPeasantTrainingSeconds = int.TryParse(cfg["Units:PeasantTrainingSeconds"], out var peasantTrainingSeconds)
? Math.Max(1, peasantTrainingSeconds) ? Math.Max(1, peasantTrainingSeconds)
: 60; : 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; _visibilityDepth = int.TryParse(cfg["World:VisibilityDepth"], out var visibilityDepth) ? Math.Max(0, visibilityDepth) : 3;
_training.Indexes.CreateOne(new CreateIndexModel<UnitTraining>( _training.Indexes.CreateOne(new CreateIndexModel<UnitTraining>(
@@ -65,6 +69,8 @@ public class UnitTrainingService
public async Task<PlayerUnitsResponse> GetMineAsync(string username) public async Task<PlayerUnitsResponse> GetMineAsync(string username)
{ {
await CompleteFinishedTrainingAsync(username); await CompleteFinishedTrainingAsync(username);
await CompletePendingMovementAsync(username);
await RefillMovementAsync(username);
await _training.DeleteManyAsync(x => x.Username == username && x.Status == "Consumed"); await _training.DeleteManyAsync(x => x.Username == username && x.Status == "Consumed");
var allTraining = await _training.Find(x => x.Username == username).ToListAsync(); var allTraining = await _training.Find(x => x.Username == username).ToListAsync();
@@ -167,6 +173,9 @@ public class UnitTrainingService
public async Task<List<UnitInstance>> MoveSquadAsync(string username, MoveUnitSquadRequest request) public async Task<List<UnitInstance>> MoveSquadAsync(string username, MoveUnitSquadRequest request)
{ {
await CompletePendingMovementAsync(username);
await RefillMovementAsync(username);
var normalizedUnitType = NormalizeUnitType(request.UnitType); var normalizedUnitType = NormalizeUnitType(request.UnitType);
if (normalizedUnitType != ArcherUnitType) if (normalizedUnitType != ArcherUnitType)
{ {
@@ -192,6 +201,7 @@ public class UnitTrainingService
var units = await _units.Find(x => var units = await _units.Find(x =>
x.Username == username && x.Username == username &&
x.UnitType == ArcherUnitType && x.UnitType == ArcherUnitType &&
x.Status == "Idle" &&
x.Q == request.FromQ && x.Q == request.FromQ &&
x.R == request.FromR) x.R == request.FromR)
.ToListAsync(); .ToListAsync();
@@ -201,24 +211,35 @@ public class UnitTrainingService
return []; 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( await _units.UpdateManyAsync(
x => x.Username == username && x => x.Username == username &&
x.UnitType == ArcherUnitType && x.UnitType == ArcherUnitType &&
x.Status == "Idle" &&
x.Q == request.FromQ && x.Q == request.FromQ &&
x.R == request.FromR, x.R == request.FromR,
Builders<UnitInstance>.Update Builders<UnitInstance>.Update
.Set(x => x.Q, request.ToQ) .Set(x => x.Status, "Moving")
.Set(x => x.R, request.ToR) .Inc(x => x.MovementRemaining, -1)
.Set(x => x.Status, "Idle") .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)); .Set(x => x.UpdatedAtUtc, now));
await ClaimTargetIfEmptyAsync(username, request.ToQ, request.ToR, now);
return await _units.Find(x => return await _units.Find(x =>
x.Username == username && x.Username == username &&
x.UnitType == ArcherUnitType && x.UnitType == ArcherUnitType &&
x.Q == request.ToQ && x.Status == "Moving" &&
x.R == request.ToR) x.Q == request.FromQ &&
x.R == request.FromR &&
x.DestinationQ == request.ToQ &&
x.DestinationR == request.ToR)
.ToListAsync(); .ToListAsync();
} }
@@ -234,6 +255,86 @@ public class UnitTrainingService
.FirstOrDefaultAsync(); .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) private async Task CompleteFinishedBuildingsAsync(string username)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
@@ -376,6 +477,7 @@ public class UnitTrainingService
continue; continue;
} }
var movementPerTick = await _settings.GetIntAsync(GameSettingsService.UnitMovementPerTick, _unitMovementPerTick);
var unit = new UnitInstance var unit = new UnitInstance
{ {
Username = completed.Username, Username = completed.Username,
@@ -383,6 +485,9 @@ public class UnitTrainingService
Q = completed.OriginQ ?? 0, Q = completed.OriginQ ?? 0,
R = completed.OriginR ?? 0, R = completed.OriginR ?? 0,
Status = "Idle", Status = "Idle",
MovementPerTick = movementPerTick,
MovementRemaining = movementPerTick,
LastMovementRefillAtUtc = now,
SourceTrainingId = completed.Id, SourceTrainingId = completed.Id,
CreatedAtUtc = now, CreatedAtUtc = now,
UpdatedAtUtc = now UpdatedAtUtc = now
@@ -32,6 +32,8 @@
"ScoutTrainingSeconds": 60, "ScoutTrainingSeconds": 60,
"PeasantGoldCost": 25, "PeasantGoldCost": 25,
"PeasantTrainingSeconds": 60, "PeasantTrainingSeconds": 60,
"UnitFoodPerMinute": 1,
"UnitMovementPerTick": 1,
"ArcherGoldCost": 100, "ArcherGoldCost": 100,
"ArcherWoodCost": 100, "ArcherWoodCost": 100,
"ArcherTrainingSeconds": 60 "ArcherTrainingSeconds": 60
@@ -21,6 +21,8 @@ data:
Units__ScoutTrainingSeconds: "60" Units__ScoutTrainingSeconds: "60"
Units__PeasantGoldCost: "25" Units__PeasantGoldCost: "25"
Units__PeasantTrainingSeconds: "60" Units__PeasantTrainingSeconds: "60"
Units__UnitFoodPerMinute: "1"
Units__UnitMovementPerTick: "1"
Units__ArcherGoldCost: "100" Units__ArcherGoldCost: "100"
Units__ArcherWoodCost: "100" Units__ArcherWoodCost: "100"
Units__ArcherTrainingSeconds: "60" Units__ArcherTrainingSeconds: "60"