From dfbe1d63c025dd118b8a1f5e5d4266975ef30b57 Mon Sep 17 00:00:00 2001 From: hz Date: Wed, 20 May 2026 04:25:24 -0500 Subject: [PATCH] Adding upkeep --- .../scenes/game/hex_grid_screen.gd | 44 ++- .../scenes/game/hex_grid_view.gd | 3 +- microservices/LocationsApi/Models/UnitDto.cs | 14 + .../LocationsApi/Models/UnitInstance.cs | 7 + .../Services/GameSettingsService.cs | 2 + .../LocationsApi/Services/LocationService.cs | 126 +++++++-- .../LocationsApi/Services/ResourceService.cs | 251 +++++++++++++++++- .../Services/UnitTrainingService.cs | 119 ++++++++- microservices/LocationsApi/appsettings.json | 2 + microservices/LocationsApi/k8s/configmap.yaml | 2 + 10 files changed, 530 insertions(+), 40 deletions(-) diff --git a/game/territory-autobattler/scenes/game/hex_grid_screen.gd b/game/territory-autobattler/scenes/game/hex_grid_screen.gd index 42e8738..245a1b3 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_screen.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_screen.gd @@ -22,6 +22,7 @@ var build_request: HTTPRequest var refresh_request: HTTPRequest var resources_timer: Timer var auth_check_timer: Timer +var refresh_prompt_timer: Timer var refresh_dialog: ConfirmationDialog var session: Dictionary = {} var escape_overlay: Control @@ -314,6 +315,12 @@ func _build_ui() -> void: auth_check_timer.timeout.connect(_check_auth_expiry) 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.wait_time = RESOURCE_REFRESH_SECONDS resources_timer.autostart = false @@ -764,21 +771,29 @@ func _check_auth_expiry() -> void: return 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: return 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: - 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() func _refresh_auth_token() -> void: refresh_prompt_active = false + if refresh_prompt_timer: + refresh_prompt_timer.stop() var username := str(session.get("username", "")) var refresh_token := str(session.get("refreshToken", "")) 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() +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: var file := FileAccess.open(SESSION_PATH, FileAccess.WRITE) 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: if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: 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 coordinate_label.text = "Resource request failed. result=%d status=%d body=%s" % [result, response_code, body.get_string_from_utf8()] 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: 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( LOCATIONS_BASE_URL + "/api/units/move", PackedStringArray([ @@ -1194,8 +1217,9 @@ func _on_move_units_request_completed(result: int, response_code: int, _headers: return _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_units() 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 _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_units() func _claim_selected_tile() -> void: @@ -1629,6 +1654,11 @@ func _toggle_escape_menu() -> 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): DirAccess.remove_absolute(ProjectSettings.globalize_path(SESSION_PATH)) get_tree().remove_meta("auth_session") diff --git a/game/territory-autobattler/scenes/game/hex_grid_view.gd b/game/territory-autobattler/scenes/game/hex_grid_view.gd index 5dc910a..cc083d9 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_view.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_view.gd @@ -821,7 +821,8 @@ func _archer_count_at(q: int, r: int) -> int: func _peasant_count_at(q: int, r: int) -> int: var count := 0 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 if int(unit.get("q", 0)) == q and int(unit.get("r", 0)) == r: count += 1 diff --git a/microservices/LocationsApi/Models/UnitDto.cs b/microservices/LocationsApi/Models/UnitDto.cs index 52e3f0e..8f4ada7 100644 --- a/microservices/LocationsApi/Models/UnitDto.cs +++ b/microservices/LocationsApi/Models/UnitDto.cs @@ -56,6 +56,13 @@ public class UnitInstanceResponse public int Q { get; set; } public int R { 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 UpdatedAtUtc { get; set; } @@ -67,6 +74,13 @@ public class UnitInstanceResponse Q = unit.Q, R = unit.R, 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, UpdatedAtUtc = unit.UpdatedAtUtc }; diff --git a/microservices/LocationsApi/Models/UnitInstance.cs b/microservices/LocationsApi/Models/UnitInstance.cs index d41accd..3d89f82 100644 --- a/microservices/LocationsApi/Models/UnitInstance.cs +++ b/microservices/LocationsApi/Models/UnitInstance.cs @@ -14,6 +14,13 @@ public class UnitInstance public int Q { get; set; } public int R { get; set; } 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 DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; diff --git a/microservices/LocationsApi/Services/GameSettingsService.cs b/microservices/LocationsApi/Services/GameSettingsService.cs index f4c9dd7..c8feefd 100644 --- a/microservices/LocationsApi/Services/GameSettingsService.cs +++ b/microservices/LocationsApi/Services/GameSettingsService.cs @@ -12,6 +12,8 @@ public class GameSettingsService public const string ArcherGoldCost = "ArcherGoldCost"; public const string ArcherWoodCost = "ArcherWoodCost"; public const string PeasantGoldCost = "PeasantGoldCost"; + public const string UnitFoodPerMinute = "UnitFoodPerMinute"; + public const string UnitMovementPerTick = "UnitMovementPerTick"; public const string LogCampBuildSeconds = "LogCampBuildSeconds"; public const string MineBuildSeconds = "MineBuildSeconds"; public const string BarracksBuildSeconds = "BarracksBuildSeconds"; diff --git a/microservices/LocationsApi/Services/LocationService.cs b/microservices/LocationsApi/Services/LocationService.cs index 74e30f9..3f5a453 100644 --- a/microservices/LocationsApi/Services/LocationService.cs +++ b/microservices/LocationsApi/Services/LocationService.cs @@ -284,6 +284,7 @@ public class LocationService public async Task AssignWorkerAsync(string username, AssignWorkerRequest request) { var now = DateTime.UtcNow; + await CompletePendingWorkerAssignmentsAsync(username); var location = await GetAsync(request.Q, request.R); if (location is null || !string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase) || @@ -308,36 +309,19 @@ public class LocationService var claimed = await _units.UpdateOneAsync( x => x.Id == unit.Id && x.Status == "Idle", Builders.Update - .Set(x => x.Q, request.Q) - .Set(x => x.R, request.R) - .Set(x => x.Status, "Working") + .Set(x => x.Status, "MovingToWork") + .Set(x => x.MovementRemaining, 0) + .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)); if (claimed.ModifiedCount == 0) { return null; } - var updated = await _locations.FindOneAndUpdateAsync( - x => x.Id == location.Id && (x.WorkerUnitId == null || x.WorkerUnitId == ""), - Builders.Update - .Set(x => x.WorkerUnitId, unit.Id) - .Set(x => x.WorkerUnitType, "Peasant") - .Set(x => x.LastResourceGeneratedAtUtc, now) - .Set(x => x.UpdatedAtUtc, now), - new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); - - if (updated is null) - { - await _units.UpdateOneAsync( - x => x.Id == unit.Id, - Builders.Update - .Set(x => x.Status, "Idle") - .Set(x => x.UpdatedAtUtc, DateTime.UtcNow)); - return null; - } - - await RecalculateWorkerGenerationAsync(username); - return updated; + return location; } public async Task DeassignWorkerAsync(string username, DeassignWorkerRequest request) @@ -433,6 +417,7 @@ public class LocationService public async Task> GetVisibleAsync(string username, int? visibilityDepth = null) { await CompleteFinishedBuildingsAsync(username); + await CompletePendingWorkerAssignmentsAsync(username); await RecalculateWorkerGenerationAsync(username); var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync(); @@ -500,6 +485,7 @@ public class LocationService public async Task GetVisibleLocationAsync(string username, int q, int r) { + await CompletePendingWorkerAssignmentsAsync(username); if (!await CanSeeAsync(username, q, r)) { return null; @@ -522,6 +508,98 @@ public class LocationService 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.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.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.Update + .Set(x => x.WorkerUnitId, worker.Id) + .Set(x => x.WorkerUnitType, "Peasant") + .Set(x => x.LastResourceGeneratedAtUtc, now) + .Set(x => x.UpdatedAtUtc, now), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + + var status = location is null ? "Idle" : "Working"; + await _units.UpdateOneAsync( + x => x.Id == worker.Id, + Builders.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 CanSeeAsync(string username, int q, int r) { var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync(); diff --git a/microservices/LocationsApi/Services/ResourceService.cs b/microservices/LocationsApi/Services/ResourceService.cs index 56cb2db..947d422 100644 --- a/microservices/LocationsApi/Services/ResourceService.cs +++ b/microservices/LocationsApi/Services/ResourceService.cs @@ -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 _resources; private readonly IMongoCollection _locations; + private readonly IMongoCollection _training; + private readonly IMongoCollection _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"); _locations = database.GetCollection("Locations"); + _training = database.GetCollection("UnitTraining"); + _units = database.GetCollection("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( Builders.IndexKeys.Ascending(x => x.Username), @@ -92,7 +104,8 @@ public class ResourceService public async Task GetAsync(string username) { var resources = await EnsureAsync(username); - return await ApplyProductionBuildingDeliveryAsync(resources); + resources = await ApplyProductionBuildingDeliveryAsync(resources); + return await ApplyFoodUpkeepAsync(resources); } public async Task TrySpendGoldAsync(string username, int amount) @@ -300,6 +313,7 @@ public class ResourceService private async Task 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 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(); + + 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.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 + { + ReturnDocument = ReturnDocument.After + }); + } + + private async Task> 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 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.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.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.Update + .Set(x => x.WorkerUnitId, worker.Id) + .Set(x => x.WorkerUnitType, "Peasant") + .Set(x => x.LastResourceGeneratedAtUtc, now) + .Set(x => x.UpdatedAtUtc, now), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + + await _units.UpdateOneAsync( + x => x.Id == worker.Id, + Builders.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 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.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); } diff --git a/microservices/LocationsApi/Services/UnitTrainingService.cs b/microservices/LocationsApi/Services/UnitTrainingService.cs index 32d31df..05d0017 100644 --- a/microservices/LocationsApi/Services/UnitTrainingService.cs +++ b/microservices/LocationsApi/Services/UnitTrainingService.cs @@ -21,6 +21,7 @@ public class UnitTrainingService 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) @@ -47,6 +48,9 @@ public class UnitTrainingService _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( @@ -65,6 +69,8 @@ public class UnitTrainingService public async Task 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(); @@ -167,6 +173,9 @@ public class UnitTrainingService public async Task> MoveSquadAsync(string username, MoveUnitSquadRequest request) { + await CompletePendingMovementAsync(username); + await RefillMovementAsync(username); + var normalizedUnitType = NormalizeUnitType(request.UnitType); if (normalizedUnitType != ArcherUnitType) { @@ -192,6 +201,7 @@ public class UnitTrainingService var units = await _units.Find(x => x.Username == username && x.UnitType == ArcherUnitType && + x.Status == "Idle" && x.Q == request.FromQ && x.R == request.FromR) .ToListAsync(); @@ -201,24 +211,35 @@ public class UnitTrainingService 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.Update - .Set(x => x.Q, request.ToQ) - .Set(x => x.R, request.ToR) - .Set(x => x.Status, "Idle") + .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)); - await ClaimTargetIfEmptyAsync(username, request.ToQ, request.ToR, now); - return await _units.Find(x => x.Username == username && x.UnitType == ArcherUnitType && - x.Q == request.ToQ && - x.R == request.ToR) + x.Status == "Moving" && + x.Q == request.FromQ && + x.R == request.FromR && + x.DestinationQ == request.ToQ && + x.DestinationR == request.ToR) .ToListAsync(); } @@ -234,6 +255,86 @@ public class UnitTrainingService .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; + } + + await _units.UpdateOneAsync( + x => x.Id == unit.Id, + Builders.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.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; @@ -376,6 +477,7 @@ public class UnitTrainingService continue; } + var movementPerTick = await _settings.GetIntAsync(GameSettingsService.UnitMovementPerTick, _unitMovementPerTick); var unit = new UnitInstance { Username = completed.Username, @@ -383,6 +485,9 @@ public class UnitTrainingService Q = completed.OriginQ ?? 0, R = completed.OriginR ?? 0, Status = "Idle", + MovementPerTick = movementPerTick, + MovementRemaining = movementPerTick, + LastMovementRefillAtUtc = now, SourceTrainingId = completed.Id, CreatedAtUtc = now, UpdatedAtUtc = now diff --git a/microservices/LocationsApi/appsettings.json b/microservices/LocationsApi/appsettings.json index a599125..5d27db9 100644 --- a/microservices/LocationsApi/appsettings.json +++ b/microservices/LocationsApi/appsettings.json @@ -32,6 +32,8 @@ "ScoutTrainingSeconds": 60, "PeasantGoldCost": 25, "PeasantTrainingSeconds": 60, + "UnitFoodPerMinute": 1, + "UnitMovementPerTick": 1, "ArcherGoldCost": 100, "ArcherWoodCost": 100, "ArcherTrainingSeconds": 60 diff --git a/microservices/LocationsApi/k8s/configmap.yaml b/microservices/LocationsApi/k8s/configmap.yaml index 4257b07..c057b00 100644 --- a/microservices/LocationsApi/k8s/configmap.yaml +++ b/microservices/LocationsApi/k8s/configmap.yaml @@ -21,6 +21,8 @@ data: Units__ScoutTrainingSeconds: "60" Units__PeasantGoldCost: "25" Units__PeasantTrainingSeconds: "60" + Units__UnitFoodPerMinute: "1" + Units__UnitMovementPerTick: "1" Units__ArcherGoldCost: "100" Units__ArcherWoodCost: "100" Units__ArcherTrainingSeconds: "60"