diff --git a/game/territory-autobattler/scenes/game/hex_grid_screen.gd b/game/territory-autobattler/scenes/game/hex_grid_screen.gd index 245a1b3..3c16b20 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_screen.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_screen.gd @@ -77,6 +77,7 @@ var completed_peasant_count := 0 var selected_peasant_id := "" var selected_peasant_q := 9999 var selected_peasant_r := 9999 +var selected_peasant_count := 0 var selected_archer_q := 9999 var selected_archer_r := 9999 var selected_archer_count := 0 @@ -514,16 +515,18 @@ func _handle_unit_selection_or_move(q: int, r: int) -> bool: var archer_count: int = grid_view.get_archer_count_at(q, r) var peasant_id: String = grid_view.get_peasant_id_at(q, r) - if not peasant_id.is_empty(): + var peasant_count: int = grid_view.get_peasant_count_at(q, r) + if peasant_count > 0: if unit_action_popup: unit_action_popup.hide() selected_peasant_id = peasant_id selected_peasant_q = q selected_peasant_r = r + selected_peasant_count = peasant_count selected_archer_count = 0 grid_view.set_selected_unit_tile(q, r) if coordinate_label: - coordinate_label.text = "Selected Peasant at q:%d r:%d. Right-click a building to assign work." % [q, r] + coordinate_label.text = "Selected Peasant squad: %d at q:%d r:%d. Right-click controlled territory to move or a building to assign work." % [peasant_count, q, r] return true if archer_count > 0: @@ -533,6 +536,7 @@ func _handle_unit_selection_or_move(q: int, r: int) -> bool: selected_archer_r = r selected_archer_count = archer_count selected_peasant_id = "" + 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] @@ -547,7 +551,8 @@ func _on_tile_action_requested(q: int, r: int, _terrain: String, screen_position var location: Dictionary = grid_view.get_visible_location(q, r) var has_building := _location_has_completed_building(location) var has_worker := not _nullable_string(location.get("workerUnitId", "")).is_empty() - if selected_archer_count <= 0 and selected_peasant_id.is_empty() and not has_worker: + var selected_peasants := selected_peasant_count > 0 + if selected_archer_count <= 0 and not selected_peasants and not has_worker: if unit_action_popup: unit_action_popup.hide() if coordinate_label: @@ -561,6 +566,10 @@ func _on_tile_action_requested(q: int, r: int, _terrain: String, screen_position 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." + return if not grid_view.is_visible_tile(q, r): if coordinate_label: coordinate_label.text = "Archers can only move to visible tiles." @@ -571,7 +580,7 @@ func _on_tile_action_requested(q: int, r: int, _terrain: String, screen_position if coordinate_label: coordinate_label.text = "Orders for q:%d r:%d." % [q, r] if unit_action_popup: - unit_action_move_button.visible = selected_archer_count > 0 + unit_action_move_button.visible = selected_archer_count > 0 or selected_peasants unit_action_assign_button.visible = not selected_peasant_id.is_empty() and has_building and not has_worker unit_action_deassign_button.visible = has_worker var popup_size := Vector2i(116, 132) @@ -587,7 +596,10 @@ func _confirm_unit_move() -> void: unit_action_popup.hide() if pending_move_q == 9999: return - _move_selected_archers(pending_move_q, pending_move_r) + if selected_archer_count > 0: + _move_selected_archers(pending_move_q, pending_move_r) + elif selected_peasant_count > 0: + _move_selected_peasants(pending_move_q, pending_move_r) func _confirm_worker_assign() -> void: @@ -689,6 +701,7 @@ func _clear_selected_archers() -> void: selected_peasant_id = "" selected_peasant_q = 9999 selected_peasant_r = 9999 + selected_peasant_count = 0 pending_move_q = 9999 pending_move_r = 9999 if unit_action_popup: @@ -1205,19 +1218,57 @@ func _move_selected_archers(to_q: int, to_r: int) -> void: coordinate_label.text = "Could not contact the unit move service." +func _move_selected_peasants(to_q: int, to_r: int) -> void: + if selected_peasant_count <= 0: + return + if to_q == selected_peasant_q and to_r == selected_peasant_r: + coordinate_label.text = "Peasant squad is already on that tile." + return + if not grid_view.is_owned_tile(to_q, to_r): + coordinate_label.text = "Peasants can only move within controlled territory." + return + + var access_token: String = str(session.get("accessToken", "")) + if access_token.is_empty(): + coordinate_label.text = "No auth token available. Peasant squad was not moved." + return + + if move_units_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + return + + coordinate_label.text = "Ordering %d Peasants to move to q:%d r:%d..." % [selected_peasant_count, to_q, to_r] + var error: int = move_units_request.request( + LOCATIONS_BASE_URL + "/api/units/move", + PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % access_token + ]), + HTTPClient.METHOD_POST, + JSON.stringify({ + "unitType": "Peasant", + "fromQ": selected_peasant_q, + "fromR": selected_peasant_r, + "toQ": to_q, + "toR": to_r + }) + ) + if error != OK: + coordinate_label.text = "Could not contact the unit move service." + + func _on_move_units_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: var text: String = body.get_string_from_utf8() if result != HTTPRequest.RESULT_SUCCESS: - coordinate_label.text = "Archer move request failed." + coordinate_label.text = "Unit move request failed." return if response_code < 200 or response_code >= 300: - coordinate_label.text = "Archer move failed: %s" % text + coordinate_label.text = "Unit move failed: %s" % text _refresh_player_state() return _clear_selected_archers() - coordinate_label.text = "Archer squad movement ordered. It will arrive on the next tick." + coordinate_label.text = "Unit movement ordered." _load_visible_locations() _load_units() diff --git a/game/territory-autobattler/scenes/game/hex_grid_view.gd b/game/territory-autobattler/scenes/game/hex_grid_view.gd index cc083d9..de1a483 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_view.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_view.gd @@ -122,6 +122,9 @@ func _draw() -> void: for tile in sorted_tiles: _draw_tile(tile) + _draw_movement_indicators() + + func _draw_grid_lines() -> void: var line_color: Color = Color(0.12, 0.17, 0.17, 0.35) for x in range(-8, 9): @@ -243,6 +246,30 @@ func _draw_control_layer(control_tiles: Array, control: String, layer_scale: flo ) +func _draw_movement_indicators() -> void: + for unit in unit_instances: + var status := str(unit.get("status", "")) + if status != "Moving" and status != "MovingToWork": + continue + if unit.get("destinationQ", null) == null or unit.get("destinationR", null) == null: + continue + + var from_q := int(unit.get("q", 0)) + var from_r := int(unit.get("r", 0)) + var to_q := int(unit.get("destinationQ", 0)) + var to_r := int(unit.get("destinationR", 0)) + var from_center := _tile_center(from_q, from_r) + var to_center := _tile_center(to_q, to_r) + var target_tile: Dictionary = _tile_data(to_q, to_r) + var target_height := float(target_tile.get("height", _tile_height(to_q, to_r))) + var marker_color := Color(0.98, 0.82, 0.28, 0.92) if str(unit.get("unitType", "")) == "Peasant" else Color(0.38, 0.86, 1.0, 0.92) + + draw_line(from_center, to_center, Color(marker_color.r, marker_color.g, marker_color.b, 0.72), 3.0) + draw_circle(from_center, 4.0, Color(marker_color.r, marker_color.g, marker_color.b, 0.88)) + draw_polyline(_closed_points(_hex_points(to_center, target_height + 3.0)), marker_color, 4.0) + draw_circle(to_center, 7.0, Color(marker_color.r, marker_color.g, marker_color.b, 0.76)) + + func _update_hover(mouse_position: Vector2) -> void: var closest := _closest_tile_at(mouse_position) @@ -695,6 +722,14 @@ func get_peasant_id_at(q: int, r: int) -> String: return "" +func get_peasant_count_at(q: int, r: int) -> int: + return _idle_peasant_count_at(q, r) + + +func is_owned_tile(q: int, r: int) -> bool: + return owned_tiles.has(_tile_key(q, r)) + + func is_visible_non_rival_tile(q: int, r: int) -> bool: var key := _tile_key(q, r) return visible_tiles.has(key) and not rival_tiles.has(key) @@ -829,6 +864,16 @@ func _peasant_count_at(q: int, r: int) -> int: return count +func _idle_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": + continue + if int(unit.get("q", 0)) == q and int(unit.get("r", 0)) == r: + count += 1 + return count + + func _owner_username(location: Dictionary) -> String: var owner_value: Variant = location.get("ownerUsername", "") if owner_value == null: diff --git a/microservices/LocationsApi/Models/UnitDto.cs b/microservices/LocationsApi/Models/UnitDto.cs index 8f4ada7..18f8b43 100644 --- a/microservices/LocationsApi/Models/UnitDto.cs +++ b/microservices/LocationsApi/Models/UnitDto.cs @@ -9,6 +9,7 @@ public class TrainUnitRequest public class MoveUnitSquadRequest { + public string? UnitId { get; set; } public string UnitType { get; set; } = "Archer"; public int FromQ { get; set; } public int FromR { get; set; } diff --git a/microservices/LocationsApi/Services/UnitTrainingService.cs b/microservices/LocationsApi/Services/UnitTrainingService.cs index 05d0017..6d395d8 100644 --- a/microservices/LocationsApi/Services/UnitTrainingService.cs +++ b/microservices/LocationsApi/Services/UnitTrainingService.cs @@ -177,9 +177,9 @@ public class UnitTrainingService await RefillMovementAsync(username); var normalizedUnitType = NormalizeUnitType(request.UnitType); - if (normalizedUnitType != ArcherUnitType) + if (normalizedUnitType != ArcherUnitType && normalizedUnitType != PeasantUnitType) { - throw new ArgumentException("Only Archer squads can be moved right now.", nameof(request.UnitType)); + throw new ArgumentException("Only Archers and Peasants can be moved right now.", nameof(request.UnitType)); } if (request.FromQ == request.ToQ && request.FromR == request.ToR) @@ -187,24 +187,38 @@ public class UnitTrainingService throw new InvalidOperationException("Select a different destination tile."); } - if (HexDistance(request.FromQ, request.FromR, request.ToQ, request.ToR) != 1) + 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 (!await CanMoveToAsync(username, request.ToQ, request.ToR)) + if (normalizedUnitType == ArcherUnitType && !await CanMoveToAsync(username, request.ToQ, request.ToR)) { throw new InvalidOperationException("Archers can only move to visible tiles."); } + if (normalizedUnitType == PeasantUnitType && !await CanPeasantMoveToAsync(username, request.ToQ, request.ToR)) + { + throw new InvalidOperationException("Peasants can only move within your controlled territory."); + } + var now = DateTime.UtcNow; - var units = await _units.Find(x => - x.Username == username && - x.UnitType == ArcherUnitType && - x.Status == "Idle" && - x.Q == request.FromQ && - x.R == request.FromR) - .ToListAsync(); + var unitFilter = Builders.Filter.And( + Builders.Filter.Eq(x => x.Username, username), + Builders.Filter.Eq(x => x.UnitType, normalizedUnitType), + Builders.Filter.Eq(x => x.Status, "Idle"), + Builders.Filter.Eq(x => x.Q, request.FromQ), + Builders.Filter.Eq(x => x.R, request.FromR)); + if (normalizedUnitType == PeasantUnitType) + { + if (!string.IsNullOrWhiteSpace(request.UnitId)) + { + unitFilter &= Builders.Filter.Eq(x => x.Id, request.UnitId); + } + } + + var units = await _units.Find(unitFilter).ToListAsync(); if (units.Count == 0) { @@ -216,13 +230,9 @@ public class UnitTrainingService throw new InvalidOperationException("That squad has no movement remaining this tick."); } - var movementCompletesAt = now.AddMinutes(1); + var movementCompletesAt = now.AddMinutes(Math.Max(1, movementDistance)); await _units.UpdateManyAsync( - x => x.Username == username && - x.UnitType == ArcherUnitType && - x.Status == "Idle" && - x.Q == request.FromQ && - x.R == request.FromR, + unitFilter, Builders.Update .Set(x => x.Status, "Moving") .Inc(x => x.MovementRemaining, -1) @@ -234,7 +244,7 @@ public class UnitTrainingService return await _units.Find(x => x.Username == username && - x.UnitType == ArcherUnitType && + x.UnitType == normalizedUnitType && x.Status == "Moving" && x.Q == request.FromQ && x.R == request.FromR && @@ -282,11 +292,34 @@ public class UnitTrainingService continue; } + 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) + { + (currentQ, currentR) = NextStepToward(currentQ, currentR, destinationQ, destinationR); + nextTick = nextTick.AddMinutes(1); + } + + 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.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.Q, destinationQ) + .Set(x => x.R, destinationR) .Set(x => x.Status, "Idle") .Set(x => x.MovementPerTick, movementPerTick) .Set(x => x.MovementRemaining, movementPerTick) @@ -299,7 +332,7 @@ public class UnitTrainingService if (unit.UnitType == ArcherUnitType) { - await ClaimTargetIfEmptyAsync(username, unit.DestinationQ.Value, unit.DestinationR.Value, now); + await ClaimTargetIfEmptyAsync(username, destinationQ, destinationR, now); } } } @@ -363,6 +396,15 @@ public class UnitTrainingService return playerUnits.Any(unit => HexDistance(unit.Q, unit.R, q, r) <= 1); } + private async Task CanPeasantMoveToAsync(string username, int q, int r) + { + return await _locations.Find(x => + x.OwnerUsername == username && + x.Q == q && + x.R == r) + .AnyAsync(); + } + 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(); @@ -428,6 +470,25 @@ public class UnitTrainingService return Math.Max(Math.Max(Math.Abs(q1 - q2), Math.Abs(r1 - r2)), Math.Abs(s1 - s2)); } + 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); + } + public async Task TryConsumeCompletedScoutAsync(string username) { await CompleteFinishedTrainingAsync(username);