From 94f3b1e2584728fb079d5275222ffd9e97d48bbd Mon Sep 17 00:00:00 2001 From: hz Date: Wed, 20 May 2026 19:11:35 -0500 Subject: [PATCH] Updating movement --- .../scenes/game/hex_grid_screen.gd | 67 +++--- .../scenes/game/hex_grid_view.gd | 55 ++++- .../LocationsApi/Services/LocationService.cs | 98 +++++++-- .../LocationsApi/Services/ResourceService.cs | 90 ++++++-- .../Services/UnitTrainingService.cs | 193 ++++++++++++++++-- 5 files changed, 414 insertions(+), 89 deletions(-) diff --git a/game/territory-autobattler/scenes/game/hex_grid_screen.gd b/game/territory-autobattler/scenes/game/hex_grid_screen.gd index 3c16b20..b60b8e2 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_screen.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_screen.gd @@ -27,7 +27,7 @@ var refresh_dialog: ConfirmationDialog var session: Dictionary = {} var escape_overlay: Control var escape_panel: PanelContainer -var gold_label: Label +var gold_label: RichTextLabel var tick_bar: ProgressBar var tick_label: Label var resource_text := "Gold: --\nWood: --\nOre: --\nFood: --" @@ -357,10 +357,14 @@ func _build_map_overlays(map_layer: Control) -> void: resource_stack.add_theme_constant_override("separation", 6) resource_margin.add_child(resource_stack) - gold_label = Label.new() + gold_label = RichTextLabel.new() gold_label.text = resource_text + gold_label.bbcode_enabled = true + gold_label.fit_content = true + gold_label.scroll_active = false + gold_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + gold_label.custom_minimum_size = Vector2(190, 74) gold_label.add_theme_font_size_override("font_size", 14) - gold_label.add_theme_color_override("font_color", Color(0.95, 0.78, 0.32)) resource_stack.add_child(gold_label) tick_label = Label.new() @@ -539,7 +543,7 @@ func _handle_unit_selection_or_move(q: int, r: int) -> bool: selected_peasant_count = 0 grid_view.set_selected_unit_tile(q, r) if coordinate_label: - coordinate_label.text = "Selected Archer squad: %d at q:%d r:%d. Right-click an adjacent visible tile for orders." % [archer_count, q, r] + coordinate_label.text = "Selected Archer squad: %d at q:%d r:%d. Right-click a visible tile for orders." % [archer_count, q, r] return true return false @@ -562,10 +566,6 @@ func _on_tile_action_requested(q: int, r: int, _terrain: String, screen_position if coordinate_label: coordinate_label.text = "Archer squad is already on that tile." return - if selected_archer_count > 0 and not grid_view.is_neighbor(selected_archer_q, selected_archer_r, q, r): - if coordinate_label: - coordinate_label.text = "Archers can only move to an adjacent tile." - return if selected_peasants and not grid_view.is_owned_tile(q, r): if coordinate_label: coordinate_label.text = "Peasants can only move within controlled territory." @@ -623,7 +623,8 @@ func _location_has_completed_building(location: Dictionary) -> bool: return false var building_type: String = _nullable_string(location.get("buildingType", "")) var building_status: String = _nullable_string(location.get("buildingStatus", "")) - return not building_type.is_empty() and (building_status == "Built" or building_status.is_empty()) + var can_have_worker := building_type == "LogCamp" or building_type == "Mine" or building_type == "Farm" + return can_have_worker and (building_status == "Built" or building_status.is_empty()) func _can_build_log_camp_on_selected_tile() -> bool: @@ -1008,20 +1009,12 @@ func _on_resources_request_completed(result: int, response_code: int, _headers: var food: int = int(resources.get("food", 0)) var food_per_minute: int = int(resources.get("foodPerMinute", 0)) var food_capacity: int = int(resources.get("foodCapacity", 100)) - resource_text = "Gold: %d/%d (+%d/min)\nWood: %d/%d (+%d/min)\nOre: %d/%d (+%d/min)\nFood: %d/%d (+%d/min)" % [ - gold, - gold_capacity, - gold_per_minute, - wood, - wood_capacity, - wood_per_minute, - ore, - ore_capacity, - ore_per_minute, - food, - food_capacity, - food_per_minute - ] + resource_text = "\n".join([ + _resource_line("Gold", gold, gold_capacity, gold_per_minute), + _resource_line("Wood", wood, wood_capacity, wood_per_minute), + _resource_line("Ore", ore, ore_capacity, ore_per_minute), + _resource_line("Food", food, food_capacity, food_per_minute) + ]) gold_label.text = resource_text var anchor_text: String = str(resources.get("lastGoldGeneratedAtUtc", "")) @@ -1031,6 +1024,27 @@ func _on_resources_request_completed(result: int, response_code: int, _headers: _update_tick_bar() +func _resource_line(resource_name: String, amount: int, capacity: int, rate: int) -> String: + var value_color := "#f2c752" + var rate_color := "#d9574f" if rate < 0 else "#f2c752" + return "[color=%s]%s: %d/%d[/color] [color=%s](%s)[/color]" % [ + value_color, + resource_name, + amount, + capacity, + rate_color, + _resource_rate_text(rate) + ] + + +func _resource_rate_text(rate: int) -> String: + if rate > 0: + return "+%d/min" % rate + if rate < 0: + return "%d/min" % rate + return "0/min" + + func _load_units() -> void: var access_token: String = str(session.get("accessToken", "")) if access_token.is_empty(): @@ -1183,9 +1197,6 @@ func _move_selected_archers(to_q: int, to_r: int) -> void: if to_q == selected_archer_q and to_r == selected_archer_r: coordinate_label.text = "Archer squad is already on that tile." return - if not grid_view.is_neighbor(selected_archer_q, selected_archer_r, to_q, to_r): - coordinate_label.text = "Archers can only move to an adjacent tile." - return if not grid_view.is_visible_tile(to_q, to_r): coordinate_label.text = "Archers can only move to visible tiles." return @@ -1267,6 +1278,10 @@ func _on_move_units_request_completed(result: int, response_code: int, _headers: _refresh_player_state() return + var parsed = JSON.parse_string(text) + if typeof(parsed) == TYPE_ARRAY and grid_view: + grid_view.upsert_unit_instances(parsed) + _clear_selected_archers() coordinate_label.text = "Unit movement ordered." _load_visible_locations() diff --git a/game/territory-autobattler/scenes/game/hex_grid_view.gd b/game/territory-autobattler/scenes/game/hex_grid_view.gd index de1a483..301144a 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_view.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_view.gd @@ -197,6 +197,9 @@ func _draw_tile(tile: Dictionary) -> void: var archer_count := _archer_count_at(q, r) if archer_count > 0: _draw_archer_unit_marker(center, height, archer_count) + var scout_count := _scout_count_at(q, r) + if scout_count > 0: + _draw_scout_unit_marker(center, height, scout_count) var peasant_count := _peasant_count_at(q, r) if peasant_count > 0: _draw_peasant_unit_marker(center, height, peasant_count) @@ -622,6 +625,29 @@ func _draw_peasant_unit_marker(center: Vector2, height: float, count: int) -> vo draw_string(ThemeDB.fallback_font, marker_center + Vector2(7, 10) * marker_scale, str(count), HORIZONTAL_ALIGNMENT_LEFT, -1, int(9 * marker_scale), Color(0.94, 0.86, 0.6)) +func _draw_scout_unit_marker(center: Vector2, height: float, count: int) -> void: + var marker_center: Vector2 = center + Vector2(-18, -height * zoom - 12.0) * zoom + var marker_scale: float = zoom + var cloak_color := Color(0.46, 0.38, 0.20) + var hood_color := Color(0.68, 0.52, 0.24) + var outline_color := Color(0.08, 0.06, 0.03) + var glass_color := Color(0.92, 0.78, 0.34) + + draw_circle(marker_center + Vector2(1, 2) * marker_scale, 8.5 * marker_scale, Color(0.01, 0.02, 0.02, 0.45)) + var body := PackedVector2Array([ + marker_center + Vector2(-6, 8) * marker_scale, + marker_center + Vector2(0, -8) * marker_scale, + marker_center + Vector2(6, 8) * marker_scale + ]) + draw_colored_polygon(body, cloak_color) + draw_polyline(_closed_points(body), outline_color, 1.1 * marker_scale) + draw_circle(marker_center + Vector2(0, -6) * marker_scale, 4.0 * marker_scale, hood_color) + draw_line(marker_center + Vector2(5, -5) * marker_scale, marker_center + Vector2(12, -11) * marker_scale, glass_color, 2.0 * marker_scale) + draw_circle(marker_center + Vector2(13, -12) * marker_scale, 3.0 * marker_scale, glass_color) + if count > 1: + draw_string(ThemeDB.fallback_font, marker_center + Vector2(7, 10) * marker_scale, str(count), HORIZONTAL_ALIGNMENT_LEFT, -1, int(9 * marker_scale), Color(0.94, 0.86, 0.6)) + + func _tile_data(q: int, r: int) -> Dictionary: for tile in tiles: if int(tile["q"]) == q and int(tile["r"]) == r: @@ -699,6 +725,23 @@ func set_unit_instances(units: Array) -> void: queue_redraw() +func upsert_unit_instances(units: Array) -> void: + for item in units: + if typeof(item) != TYPE_DICTIONARY: + continue + var unit: Dictionary = item + var unit_id := str(unit.get("id", "")) + var replaced := false + for index in range(unit_instances.size()): + if str(unit_instances[index].get("id", "")) == unit_id: + unit_instances[index] = unit + replaced = true + break + if not replaced: + unit_instances.append(unit) + queue_redraw() + + func set_selected_unit_tile(q: int, r: int) -> void: selected_unit_tile = Vector2i(q, r) queue_redraw() @@ -853,11 +896,21 @@ func _archer_count_at(q: int, r: int) -> int: return count +func _scout_count_at(q: int, r: int) -> int: + var count := 0 + for unit in unit_instances: + if str(unit.get("unitType", "")) != "Scout": + continue + if int(unit.get("q", 0)) == q and int(unit.get("r", 0)) == r: + count += 1 + return count + + func _peasant_count_at(q: int, r: int) -> int: var count := 0 for unit in unit_instances: var status := str(unit.get("status", "")) - if str(unit.get("unitType", "")) != "Peasant" or (status != "Idle" and status != "MovingToWork"): + if str(unit.get("unitType", "")) != "Peasant" or (status != "Idle" and status != "Moving" 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/Services/LocationService.cs b/microservices/LocationsApi/Services/LocationService.cs index 3f5a453..18e8cd1 100644 --- a/microservices/LocationsApi/Services/LocationService.cs +++ b/microservices/LocationsApi/Services/LocationService.cs @@ -288,7 +288,7 @@ public class LocationService var location = await GetAsync(request.Q, request.R); if (location is null || !string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase) || - string.IsNullOrWhiteSpace(location.BuildingType) || + !CanAssignWorkerToBuilding(location.BuildingType) || location.BuildingStatus == "Building" || !string.IsNullOrWhiteSpace(location.WorkerUnitId)) { @@ -314,7 +314,7 @@ public class LocationService .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.MovementCompletesAtUtc, now.AddMinutes(1)) .Set(x => x.UpdatedAtUtc, now)); if (claimed.ModifiedCount == 0) { @@ -535,23 +535,29 @@ public class LocationService 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) + if (HexDistance(worker.Q, worker.R, destinationQ, destinationR) > 1) { - (currentQ, currentR) = NextStepToward(currentQ, currentR, destinationQ, destinationR); - nextTick = nextTick.AddMinutes(1); - } + var nextStep = await NextPeasantStepTowardAsync(username, worker.Q, worker.R, destinationQ, destinationR); + if (nextStep is null) + { + await _units.UpdateOneAsync( + x => x.Id == worker.Id, + Builders.Update + .Set(x => x.Status, "Idle") + .Unset(x => x.DestinationQ) + .Unset(x => x.DestinationR) + .Unset(x => x.MovementStartedAtUtc) + .Unset(x => x.MovementCompletesAtUtc) + .Set(x => x.UpdatedAtUtc, now)); + continue; + } - 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.Q, nextStep.Value.q) + .Set(x => x.R, nextStep.Value.r) + .Set(x => x.MovementCompletesAtUtc, now.AddMinutes(1)) .Set(x => x.UpdatedAtUtc, now)); continue; } @@ -560,7 +566,7 @@ public class LocationService x => x.OwnerUsername == username && x.Q == destinationQ && x.R == destinationR && - !string.IsNullOrWhiteSpace(x.BuildingType) && + (x.BuildingType == "LogCamp" || x.BuildingType == "Mine" || x.BuildingType == "Farm") && x.BuildingStatus != "Building" && (x.WorkerUnitId == null || x.WorkerUnitId == ""), Builders.Update @@ -591,13 +597,63 @@ public class LocationService } } - private static (int q, int r) NextStepToward(int fromQ, int fromR, int toQ, int toR) + private async Task<(int q, int r)?> NextPeasantStepTowardAsync(string username, 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(); + var owned = await _locations.Find(x => x.OwnerUsername == username).ToListAsync(); + var ownedCoordinates = owned.Select(x => (x.Q, x.R)).ToHashSet(); + if (!ownedCoordinates.Contains((toQ, toR))) + { + return null; + } + + var start = (fromQ, fromR); + var target = (toQ, toR); + var queue = new Queue<(int q, int r)>(); + var previous = new Dictionary<(int q, int r), (int q, int r)>(); + var visited = new HashSet<(int q, int r)> { start }; + queue.Enqueue(start); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (current == target) + { + break; + } + + foreach (var neighbor in NeighborCoordinates(current.q, current.r) + .Select(x => (x.Q, x.R)) + .OrderBy(x => HexDistance(x.Q, x.R, toQ, toR)) + .ThenBy(x => x.Q) + .ThenBy(x => x.R)) + { + if (!ownedCoordinates.Contains(neighbor) || !visited.Add(neighbor)) + { + continue; + } + + previous[neighbor] = current; + queue.Enqueue(neighbor); + } + } + + if (!visited.Contains(target)) + { + return null; + } + + var step = target; + while (previous.TryGetValue(step, out var before) && before != start) + { + step = before; + } + + return step == start ? target : step; + } + + private static bool CanAssignWorkerToBuilding(string? buildingType) + { + return buildingType == "LogCamp" || buildingType == "Mine" || buildingType == "Farm"; } public async Task CanSeeAsync(string username, int q, int r) diff --git a/microservices/LocationsApi/Services/ResourceService.cs b/microservices/LocationsApi/Services/ResourceService.cs index 947d422..6405d71 100644 --- a/microservices/LocationsApi/Services/ResourceService.cs +++ b/microservices/LocationsApi/Services/ResourceService.cs @@ -462,7 +462,7 @@ public class ResourceService .ToListAsync(); var persistedUnits = await _units.Find(x => x.Username == username && - (x.UnitType == ArcherUnitType || x.UnitType == PeasantUnitType)) + (x.UnitType == ScoutUnitType || x.UnitType == ArcherUnitType || x.UnitType == PeasantUnitType)) .ToListAsync(); var units = completedScouts @@ -511,23 +511,29 @@ public class ResourceService 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) + if (HexDistance(worker.Q, worker.R, destinationQ, destinationR) > 1) { - (currentQ, currentR) = NextStepToward(currentQ, currentR, destinationQ, destinationR); - nextTick = nextTick.AddMinutes(1); - } + var nextStep = await NextPeasantStepTowardAsync(username, worker.Q, worker.R, destinationQ, destinationR); + if (nextStep is null) + { + await _units.UpdateOneAsync( + x => x.Id == worker.Id, + Builders.Update + .Set(x => x.Status, "Idle") + .Unset(x => x.DestinationQ) + .Unset(x => x.DestinationR) + .Unset(x => x.MovementStartedAtUtc) + .Unset(x => x.MovementCompletesAtUtc) + .Set(x => x.UpdatedAtUtc, now)); + continue; + } - 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.Q, nextStep.Value.q) + .Set(x => x.R, nextStep.Value.r) + .Set(x => x.MovementCompletesAtUtc, now.AddMinutes(1)) .Set(x => x.UpdatedAtUtc, now)); continue; } @@ -536,7 +542,7 @@ public class ResourceService x => x.OwnerUsername == username && x.Q == destinationQ && x.R == destinationR && - !string.IsNullOrWhiteSpace(x.BuildingType) && + (x.BuildingType == "LogCamp" || x.BuildingType == "Mine" || x.BuildingType == "Farm") && x.BuildingStatus != "Building" && (x.WorkerUnitId == null || x.WorkerUnitId == ""), Builders.Update @@ -561,13 +567,57 @@ public class ResourceService } } - private static (int q, int r) NextStepToward(int fromQ, int fromR, int toQ, int toR) + private async Task<(int q, int r)?> NextPeasantStepTowardAsync(string username, 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(); + var owned = await _locations.Find(x => x.OwnerUsername == username).ToListAsync(); + var ownedCoordinates = owned.Select(x => (x.Q, x.R)).ToHashSet(); + if (!ownedCoordinates.Contains((toQ, toR))) + { + return null; + } + + var start = (fromQ, fromR); + var target = (toQ, toR); + var queue = new Queue<(int q, int r)>(); + var previous = new Dictionary<(int q, int r), (int q, int r)>(); + var visited = new HashSet<(int q, int r)> { start }; + queue.Enqueue(start); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (current == target) + { + break; + } + + foreach (var neighbor in NeighborCoordinates(current.q, current.r) + .OrderBy(x => HexDistance(x.q, x.r, toQ, toR)) + .ThenBy(x => x.q) + .ThenBy(x => x.r)) + { + if (!ownedCoordinates.Contains(neighbor) || !visited.Add(neighbor)) + { + continue; + } + + previous[neighbor] = current; + queue.Enqueue(neighbor); + } + } + + if (!visited.Contains(target)) + { + return null; + } + + var step = target; + while (previous.TryGetValue(step, out var before) && before != start) + { + step = before; + } + + return step == start ? target : step; } private static IEnumerable<(int q, int r)> NeighborCoordinates(int q, int r) diff --git a/microservices/LocationsApi/Services/UnitTrainingService.cs b/microservices/LocationsApi/Services/UnitTrainingService.cs index 6d395d8..1cd5863 100644 --- a/microservices/LocationsApi/Services/UnitTrainingService.cs +++ b/microservices/LocationsApi/Services/UnitTrainingService.cs @@ -69,13 +69,14 @@ public class UnitTrainingService public async Task GetMineAsync(string username) { await CompleteFinishedTrainingAsync(username); + await PromoteCompletedScoutsAsync(username); await CompletePendingMovementAsync(username); await RefillMovementAsync(username); await _training.DeleteManyAsync(x => x.Username == username && x.Status == "Consumed"); var allTraining = await _training.Find(x => x.Username == username).ToListAsync(); var allUnits = await _units.Find(x => x.Username == username).ToListAsync(); - var completedScouts = allTraining.Count(x => x.UnitType == ScoutUnitType && x.Status == "Completed"); + var completedScouts = allUnits.Count(x => x.UnitType == ScoutUnitType && x.Status == "Idle"); var completedArchers = allUnits.Count(x => x.UnitType == ArcherUnitType); var completedPeasants = allUnits.Count(x => x.UnitType == PeasantUnitType && x.Status == "Idle"); var activeTraining = allTraining @@ -129,6 +130,14 @@ public class UnitTrainingService throw new InvalidOperationException("A town hall is required to train Peasants."); } } + else if (normalizedUnitType == ScoutUnitType) + { + origin = await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync(); + if (origin is null) + { + throw new InvalidOperationException("A town hall is required to train Scouts."); + } + } var goldCost = normalizedUnitType == ArcherUnitType ? await _settings.GetNonNegativeIntAsync(GameSettingsService.ArcherGoldCost, _archerGoldCost) @@ -188,11 +197,6 @@ public class UnitTrainingService } var movementDistance = HexDistance(request.FromQ, request.FromR, request.ToQ, request.ToR); - if (normalizedUnitType == ArcherUnitType && movementDistance != 1) - { - throw new InvalidOperationException("Archers can only move to adjacent tiles."); - } - if (normalizedUnitType == ArcherUnitType && !await CanMoveToAsync(username, request.ToQ, request.ToR)) { throw new InvalidOperationException("Archers can only move to visible tiles."); @@ -230,7 +234,7 @@ public class UnitTrainingService throw new InvalidOperationException("That squad has no movement remaining this tick."); } - var movementCompletesAt = now.AddMinutes(Math.Max(1, movementDistance)); + var movementCompletesAt = now.AddMinutes(1); await _units.UpdateManyAsync( unitFilter, Builders.Update @@ -294,23 +298,44 @@ public class UnitTrainingService var destinationQ = unit.DestinationQ.Value; var destinationR = unit.DestinationR.Value; - var currentQ = unit.Q; - var currentR = unit.R; - var nextTick = unit.MovementCompletesAtUtc!.Value; - while (nextTick <= now && HexDistance(currentQ, currentR, destinationQ, destinationR) > 1) + if (unit.UnitType == PeasantUnitType && HexDistance(unit.Q, unit.R, destinationQ, destinationR) > 1) { - (currentQ, currentR) = NextStepToward(currentQ, currentR, destinationQ, destinationR); - nextTick = nextTick.AddMinutes(1); - } + var nextStep = await NextPeasantStepTowardAsync(username, unit.Q, unit.R, destinationQ, destinationR); + if (nextStep is null) + { + await _units.UpdateOneAsync( + x => x.Id == unit.Id, + Builders.Update + .Set(x => x.Status, "Idle") + .Set(x => x.MovementPerTick, movementPerTick) + .Set(x => x.MovementRemaining, movementPerTick) + .Unset(x => x.DestinationQ) + .Unset(x => x.DestinationR) + .Unset(x => x.MovementStartedAtUtc) + .Unset(x => x.MovementCompletesAtUtc) + .Set(x => x.UpdatedAtUtc, now)); + continue; + } - if (HexDistance(currentQ, currentR, destinationQ, destinationR) > 1) - { await _units.UpdateOneAsync( x => x.Id == unit.Id, Builders.Update - .Set(x => x.Q, currentQ) - .Set(x => x.R, currentR) - .Set(x => x.MovementCompletesAtUtc, nextTick) + .Set(x => x.Q, nextStep.Value.q) + .Set(x => x.R, nextStep.Value.r) + .Set(x => x.MovementCompletesAtUtc, now.AddMinutes(1)) + .Set(x => x.UpdatedAtUtc, now)); + continue; + } + + if (unit.UnitType != PeasantUnitType && HexDistance(unit.Q, unit.R, destinationQ, destinationR) > 1) + { + var nextStep = NextStepToward(unit.Q, unit.R, destinationQ, destinationR); + await _units.UpdateOneAsync( + x => x.Id == unit.Id, + Builders.Update + .Set(x => x.Q, nextStep.q) + .Set(x => x.R, nextStep.r) + .Set(x => x.MovementCompletesAtUtc, now.AddMinutes(1)) .Set(x => x.UpdatedAtUtc, now)); continue; } @@ -405,6 +430,59 @@ public class UnitTrainingService .AnyAsync(); } + private async Task<(int q, int r)?> NextPeasantStepTowardAsync(string username, int fromQ, int fromR, int toQ, int toR) + { + var owned = await _locations.Find(x => x.OwnerUsername == username).ToListAsync(); + var ownedCoordinates = owned.Select(x => (x.Q, x.R)).ToHashSet(); + if (!ownedCoordinates.Contains((toQ, toR))) + { + return null; + } + + var start = (fromQ, fromR); + var target = (toQ, toR); + var queue = new Queue<(int q, int r)>(); + var previous = new Dictionary<(int q, int r), (int q, int r)>(); + var visited = new HashSet<(int q, int r)> { start }; + queue.Enqueue(start); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (current == target) + { + break; + } + + foreach (var neighbor in NeighborCoordinates(current.q, current.r) + .OrderBy(x => HexDistance(x.q, x.r, toQ, toR)) + .ThenBy(x => x.q) + .ThenBy(x => x.r)) + { + if (!ownedCoordinates.Contains(neighbor) || !visited.Add(neighbor)) + { + continue; + } + + previous[neighbor] = current; + queue.Enqueue(neighbor); + } + } + + if (!visited.Contains(target)) + { + return null; + } + + var step = target; + while (previous.TryGetValue(step, out var before) && before != start) + { + step = before; + } + + return step == start ? target : step; + } + private 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(); @@ -492,6 +570,18 @@ public class UnitTrainingService public async Task TryConsumeCompletedScoutAsync(string username) { await CompleteFinishedTrainingAsync(username); + await PromoteCompletedScoutsAsync(username); + + var consumedUnit = await _units.FindOneAndDeleteAsync( + x => x.Username == username && x.UnitType == ScoutUnitType && x.Status == "Idle", + new FindOneAndDeleteOptions + { + Sort = Builders.Sort.Ascending(x => x.CreatedAtUtc) + }); + if (consumedUnit is not null) + { + return true; + } var consumed = await _training.FindOneAndDeleteAsync( x => x.Username == username && x.UnitType == ScoutUnitType && x.Status == "Completed", @@ -506,7 +596,8 @@ public class UnitTrainingService public async Task HasCompletedScoutAsync(string username) { await CompleteFinishedTrainingAsync(username); - return await _training.Find(x => x.Username == username && x.UnitType == ScoutUnitType && x.Status == "Completed").AnyAsync(); + await PromoteCompletedScoutsAsync(username); + return await _units.Find(x => x.Username == username && x.UnitType == ScoutUnitType && x.Status == "Idle").AnyAsync(); } private async Task CompleteFinishedTrainingAsync(string username) @@ -533,7 +624,7 @@ public class UnitTrainingService ReturnDocument = ReturnDocument.After }); - if (completed is null || (completed.UnitType != ArcherUnitType && completed.UnitType != PeasantUnitType)) + if (completed is null || (completed.UnitType != ArcherUnitType && completed.UnitType != PeasantUnitType && completed.UnitType != ScoutUnitType)) { continue; } @@ -557,6 +648,14 @@ public class UnitTrainingService try { await _units.InsertOneAsync(unit); + if (completed.UnitType == ScoutUnitType) + { + await _training.UpdateOneAsync( + x => x.Id == completed.Id, + Builders.Update + .Set(x => x.Status, "Consumed") + .Set(x => x.UpdatedAtUtc, now)); + } } catch (MongoWriteException) { @@ -564,6 +663,58 @@ public class UnitTrainingService } } + private async Task PromoteCompletedScoutsAsync(string username) + { + var now = DateTime.UtcNow; + var completedScouts = await _training.Find(x => + x.Username == username && + x.UnitType == ScoutUnitType && + x.Status == "Completed") + .ToListAsync(); + if (completedScouts.Count == 0) + { + return; + } + + var home = await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync(); + var movementPerTick = await _settings.GetIntAsync(GameSettingsService.UnitMovementPerTick, _unitMovementPerTick); + foreach (var scout in completedScouts) + { + var existing = await _units.Find(x => x.SourceTrainingId == scout.Id).AnyAsync(); + if (!existing) + { + var unit = new UnitInstance + { + Username = scout.Username, + UnitType = ScoutUnitType, + Q = scout.OriginQ ?? home?.Q ?? 0, + R = scout.OriginR ?? home?.R ?? 0, + Status = "Idle", + MovementPerTick = movementPerTick, + MovementRemaining = movementPerTick, + LastMovementRefillAtUtc = now, + SourceTrainingId = scout.Id, + CreatedAtUtc = now, + UpdatedAtUtc = now + }; + + try + { + await _units.InsertOneAsync(unit); + } + catch (MongoWriteException) + { + } + } + + await _training.UpdateOneAsync( + x => x.Id == scout.Id, + Builders.Update + .Set(x => x.Status, "Consumed") + .Set(x => x.UpdatedAtUtc, now)); + } + } + private static string? NormalizeUnitType(string unitType) { var trimmed = unitType.Trim();