From f142440aca5c43bbc5879bb85f7a58c63a71ad1c Mon Sep 17 00:00:00 2001 From: hz Date: Wed, 20 May 2026 19:38:33 -0500 Subject: [PATCH] allowing territory to be abandoned, units to be dismissed, and buildings to be destroyed --- .../scenes/game/hex_grid_screen.gd | 255 +++++++++++++++++- .../Controllers/LocationsController.cs | 46 ++++ .../Controllers/UnitsController.cs | 30 +++ microservices/LocationsApi/Models/Dto.cs | 6 + microservices/LocationsApi/Models/UnitDto.cs | 13 + .../LocationsApi/Services/LocationService.cs | 87 ++++++ .../Services/UnitTrainingService.cs | 24 +- 7 files changed, 445 insertions(+), 16 deletions(-) diff --git a/game/territory-autobattler/scenes/game/hex_grid_screen.gd b/game/territory-autobattler/scenes/game/hex_grid_screen.gd index c7da298..085a795 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_screen.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_screen.gd @@ -16,9 +16,11 @@ var resources_request: HTTPRequest var units_request: HTTPRequest var train_unit_request: HTTPRequest var move_units_request: HTTPRequest +var dismiss_units_request: HTTPRequest var worker_request: HTTPRequest var claim_request: HTTPRequest var build_request: HTTPRequest +var territory_action_request: HTTPRequest var refresh_request: HTTPRequest var resources_timer: Timer var auth_check_timer: Timer @@ -58,10 +60,14 @@ var build_log_camp_button: Button var build_mine_button: Button var build_farm_button: Button var build_barracks_button: Button +var destroy_building_button: Button +var abandon_territory_button: Button var unit_action_popup: PopupPanel var unit_action_move_button: Button +var unit_action_claim_button: Button var unit_action_assign_button: Button var unit_action_deassign_button: Button +var unit_action_dismiss_button: Button var selected_q := 0 var selected_r := 0 var selected_can_claim := false @@ -69,6 +75,8 @@ var selected_can_build_log_camp := false var selected_can_build_mine := false var selected_can_build_farm := false var selected_can_build_barracks := false +var selected_can_destroy_building := false +var selected_can_abandon_territory := false var selected_can_train_archer := false var selected_is_home := false var completed_scout_count := 0 @@ -266,6 +274,16 @@ func _build_ui() -> void: build_barracks_button.pressed.connect(_build_barracks) action_buttons.add_child(build_barracks_button) + destroy_building_button = _small_button("Destroy Building") + destroy_building_button.visible = false + destroy_building_button.pressed.connect(_destroy_selected_building) + action_buttons.add_child(destroy_building_button) + + abandon_territory_button = _small_button("Give Up") + abandon_territory_button.visible = false + abandon_territory_button.pressed.connect(_abandon_selected_territory) + action_buttons.add_child(abandon_territory_button) + unit_label = Label.new() unit_label.text = "Scouts: -- | Archers: -- | Queue: --" unit_label.custom_minimum_size = Vector2(180, 0) @@ -297,6 +315,10 @@ func _build_ui() -> void: move_units_request.request_completed.connect(_on_move_units_request_completed) add_child(move_units_request) + dismiss_units_request = HTTPRequest.new() + dismiss_units_request.request_completed.connect(_on_dismiss_units_request_completed) + add_child(dismiss_units_request) + worker_request = HTTPRequest.new() worker_request.request_completed.connect(_on_worker_request_completed) add_child(worker_request) @@ -309,6 +331,10 @@ func _build_ui() -> void: build_request.request_completed.connect(_on_build_request_completed) add_child(build_request) + territory_action_request = HTTPRequest.new() + territory_action_request.request_completed.connect(_on_territory_action_request_completed) + add_child(territory_action_request) + refresh_request = HTTPRequest.new() refresh_request.request_completed.connect(_on_refresh_request_completed) add_child(refresh_request) @@ -482,6 +508,11 @@ func _build_unit_action_menu() -> void: unit_action_move_button.pressed.connect(_confirm_unit_move) actions.add_child(unit_action_move_button) + unit_action_claim_button = _small_button("Claim") + unit_action_claim_button.custom_minimum_size = Vector2(92, 34) + unit_action_claim_button.pressed.connect(_confirm_scout_claim) + actions.add_child(unit_action_claim_button) + unit_action_assign_button = _small_button("Assign") unit_action_assign_button.custom_minimum_size = Vector2(92, 34) unit_action_assign_button.pressed.connect(_confirm_worker_assign) @@ -492,6 +523,11 @@ func _build_unit_action_menu() -> void: unit_action_deassign_button.pressed.connect(_confirm_worker_deassign) actions.add_child(unit_action_deassign_button) + unit_action_dismiss_button = _small_button("Dismiss") + unit_action_dismiss_button.custom_minimum_size = Vector2(92, 34) + unit_action_dismiss_button.pressed.connect(_confirm_unit_dismiss) + actions.add_child(unit_action_dismiss_button) + func _on_tile_hovered(q: int, r: int, terrain: String) -> void: if coordinate_label: @@ -510,6 +546,8 @@ func _on_tile_selected(q: int, r: int, terrain: String) -> void: selected_can_build_mine = _can_build_mine_on_selected_tile() selected_can_build_farm = _can_build_farm_on_selected_tile() selected_can_build_barracks = _can_build_barracks_on_selected_tile() + selected_can_destroy_building = _can_destroy_building_on_selected_tile() + selected_can_abandon_territory = _can_abandon_selected_tile() selected_can_train_archer = _selected_tile_has_built_barracks() _update_action_buttons() if coordinate_label: @@ -577,27 +615,22 @@ func _on_tile_action_requested(q: int, r: int, _terrain: String, screen_position var has_worker := not _nullable_string(location.get("workerUnitId", "")).is_empty() var selected_peasants := selected_peasant_count > 0 var selected_scouts := selected_scout_count > 0 - if selected_archer_count <= 0 and not selected_scouts and not selected_peasants and not has_worker: + var selected_archers := selected_archer_count > 0 + var can_scout_claim := selected_scouts and _can_claim_tile(q, r) + var is_selected_unit_tile := (selected_archers and q == selected_archer_q and r == selected_archer_r) or (selected_scouts and q == selected_scout_q and r == selected_scout_r) or (selected_peasants and q == selected_peasant_q and r == selected_peasant_r) + if not selected_archers and not selected_scouts and not selected_peasants and not has_worker: if unit_action_popup: unit_action_popup.hide() if coordinate_label: coordinate_label.text = "Select a unit or right-click a staffed building." return - if selected_archer_count > 0 and q == selected_archer_q and r == selected_archer_r: - if coordinate_label: - coordinate_label.text = "Archer squad is already on that tile." - return - if selected_scouts and q == selected_scout_q and r == selected_scout_r: - if coordinate_label: - coordinate_label.text = "Scout squad is already on that tile." - return - if selected_peasants and not grid_view.is_owned_tile(q, r): + if selected_peasants and not is_selected_unit_tile 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 not is_selected_unit_tile and not grid_view.is_visible_tile(q, r): if coordinate_label: - coordinate_label.text = "Archers can only move to visible tiles." + coordinate_label.text = "Units can only move to visible tiles." return pending_move_q = q @@ -605,10 +638,19 @@ 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 or selected_scouts or selected_peasants + unit_action_move_button.visible = not is_selected_unit_tile and (selected_archers or selected_scouts or selected_peasants) + unit_action_claim_button.visible = can_scout_claim 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) + unit_action_dismiss_button.visible = is_selected_unit_tile and (selected_archers or selected_scouts or selected_peasants) + var visible_action_count := 0 + for button in [unit_action_move_button, unit_action_claim_button, unit_action_assign_button, unit_action_deassign_button, unit_action_dismiss_button]: + if button.visible: + visible_action_count += 1 + if visible_action_count == 0: + unit_action_popup.hide() + return + var popup_size := Vector2i(116, 16 + visible_action_count * 40) var visible_rect := get_viewport().get_visible_rect() var max_x := int(max(0.0, visible_rect.size.x - popup_size.x - 8.0)) var max_y := int(max(0.0, visible_rect.size.y - popup_size.y - 8.0)) @@ -629,6 +671,20 @@ func _confirm_unit_move() -> void: _move_selected_peasants(pending_move_q, pending_move_r) +func _confirm_scout_claim() -> void: + if unit_action_popup: + unit_action_popup.hide() + if pending_move_q == 9999: + return + if not _can_claim_tile(pending_move_q, pending_move_r): + coordinate_label.text = "Scouts can only claim visible unclaimed territory adjacent to controlled territory." + return + selected_q = pending_move_q + selected_r = pending_move_r + selected_can_claim = true + _claim_selected_tile() + + func _confirm_worker_assign() -> void: if unit_action_popup: unit_action_popup.hide() @@ -645,6 +701,17 @@ func _confirm_worker_deassign() -> void: _request_worker_action("deassign-worker", { "q": pending_move_q, "r": pending_move_r }) +func _confirm_unit_dismiss() -> void: + if unit_action_popup: + unit_action_popup.hide() + if selected_archer_count > 0: + _request_unit_dismiss("Archer", selected_archer_q, selected_archer_r) + elif selected_scout_count > 0: + _request_unit_dismiss("Scout", selected_scout_q, selected_scout_r) + elif selected_peasant_count > 0: + _request_unit_dismiss("Peasant", selected_peasant_q, selected_peasant_r) + + func _location_has_completed_building(location: Dictionary) -> bool: if location.is_empty(): return false @@ -675,6 +742,36 @@ func _can_build_barracks_on_selected_tile() -> bool: return kind != "Home" +func _can_destroy_building_on_selected_tile() -> bool: + if not grid_view: + return false + + var location: Dictionary = grid_view.get_visible_location(selected_q, selected_r) + if location.is_empty(): + return false + + var username: String = str(session.get("username", "")) + var owner_username: String = str(location.get("ownerUsername", "")) + var kind: String = str(location.get("kind", "")) + var building_type: String = _nullable_string(location.get("buildingType", "")) + return owner_username == username and kind != "Home" and not building_type.is_empty() + + +func _can_abandon_selected_tile() -> bool: + if not grid_view: + return false + + var location: Dictionary = grid_view.get_visible_location(selected_q, selected_r) + if location.is_empty(): + return false + + var username: String = str(session.get("username", "")) + var owner_username: String = str(location.get("ownerUsername", "")) + var kind: String = str(location.get("kind", "")) + var building_type: String = _nullable_string(location.get("buildingType", "")) + return owner_username == username and kind != "Home" and building_type.is_empty() + + func _can_build_on_selected_tile(required_terrain: String) -> bool: if not grid_view: return false @@ -695,6 +792,22 @@ func _can_claim_selected_tile() -> bool: return selected_can_claim and completed_scout_count > 0 and grid_view and grid_view.has_owned_neighbor(selected_q, selected_r) +func _can_claim_tile(q: int, r: int) -> bool: + if not grid_view: + return false + if completed_scout_count <= 0: + return false + if not grid_view.is_visible_tile(q, r): + return false + + var location: Dictionary = grid_view.get_visible_location(q, r) + if location.is_empty(): + return false + + var owner_username: String = _nullable_string(location.get("ownerUsername", "")) + return owner_username.is_empty() and grid_view.has_owned_neighbor(q, r) + + func _update_action_buttons() -> void: if train_button: train_button.visible = selected_is_home @@ -720,6 +833,12 @@ func _update_action_buttons() -> void: if build_barracks_button: build_barracks_button.visible = selected_can_build_barracks build_barracks_button.disabled = false + if destroy_building_button: + destroy_building_button.visible = selected_can_destroy_building + destroy_building_button.disabled = false + if abandon_territory_button: + abandon_territory_button.visible = selected_can_abandon_territory + abandon_territory_button.disabled = false func _clear_selected_archers() -> void: @@ -1356,6 +1475,47 @@ func _on_move_units_request_completed(result: int, response_code: int, _headers: _load_units() +func _request_unit_dismiss(unit_type: String, q: int, r: int) -> void: + var access_token: String = str(session.get("accessToken", "")) + if access_token.is_empty(): + coordinate_label.text = "No auth token available. Unit squad was not dismissed." + return + + if dismiss_units_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + return + + coordinate_label.text = "Dismissing %s squad at q:%d r:%d..." % [unit_type, q, r] + var error: int = dismiss_units_request.request( + LOCATIONS_BASE_URL + "/api/units/dismiss", + PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % access_token + ]), + HTTPClient.METHOD_POST, + JSON.stringify({ "unitType": unit_type, "q": q, "r": r }) + ) + if error != OK: + coordinate_label.text = "Could not contact the unit dismiss service." + + +func _on_dismiss_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 = "Unit dismiss request failed." + return + + if response_code < 200 or response_code >= 300: + coordinate_label.text = "Unit dismiss failed: %s" % text + _refresh_player_state() + return + + _clear_selected_archers() + coordinate_label.text = "Unit squad dismissed." + _load_visible_locations() + _load_units() + _refresh_player_state() + + func _request_worker_action(endpoint: String, body: Dictionary) -> void: var access_token: String = str(session.get("accessToken", "")) if access_token.is_empty(): @@ -1480,6 +1640,70 @@ func _build_barracks() -> void: _request_building("Barracks") +func _destroy_selected_building() -> void: + if not selected_can_destroy_building: + coordinate_label.text = "Select controlled territory with a non-town-hall building." + return + + _request_territory_action("destroy-building", "Destroying building") + + +func _abandon_selected_territory() -> void: + if not selected_can_abandon_territory: + coordinate_label.text = "Select controlled territory without a building." + return + + _request_territory_action("abandon", "Giving up territory") + + +func _request_territory_action(endpoint: String, message: String) -> void: + var access_token: String = str(session.get("accessToken", "")) + if access_token.is_empty(): + coordinate_label.text = "No auth token available. Territory action failed." + return + + if territory_action_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + return + + destroy_building_button.disabled = true + abandon_territory_button.disabled = true + coordinate_label.text = "%s at q:%d r:%d..." % [message, selected_q, selected_r] + var error: int = territory_action_request.request( + LOCATIONS_BASE_URL + "/api/locations/" + endpoint, + PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % access_token + ]), + HTTPClient.METHOD_POST, + JSON.stringify({ "q": selected_q, "r": selected_r }) + ) + if error != OK: + _update_action_buttons() + coordinate_label.text = "Could not contact the locations service." + + +func _on_territory_action_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 = "Territory action request failed." + _update_action_buttons() + return + + if response_code < 200 or response_code >= 300: + coordinate_label.text = "Territory action failed: %s" % text + _update_action_buttons() + _refresh_player_state() + return + + selected_can_destroy_building = false + selected_can_abandon_territory = false + _update_action_buttons() + coordinate_label.text = "Territory updated." + _load_visible_locations() + _load_units() + _refresh_player_state() + + func _request_building(building_type: String) -> void: var access_token: String = str(session.get("accessToken", "")) if access_token.is_empty(): @@ -1529,7 +1753,10 @@ func _on_build_request_completed(result: int, response_code: int, _headers: Pack selected_can_build_log_camp = false selected_can_build_mine = false + selected_can_build_farm = false selected_can_build_barracks = false + selected_can_destroy_building = true + selected_can_abandon_territory = false _update_action_buttons() var parsed = JSON.parse_string(text) if typeof(parsed) == TYPE_DICTIONARY: diff --git a/microservices/LocationsApi/Controllers/LocationsController.cs b/microservices/LocationsApi/Controllers/LocationsController.cs index dfc0467..b3a9bd2 100644 --- a/microservices/LocationsApi/Controllers/LocationsController.cs +++ b/microservices/LocationsApi/Controllers/LocationsController.cs @@ -180,6 +180,52 @@ public class LocationsController : ControllerBase : Ok(LocationResponse.FromLocation(location)); } + [Authorize(Roles = "USER,SUPER")] + [HttpPost("destroy-building")] + public async Task> DestroyBuilding([FromBody] TerritoryLocationRequest request) + { + var username = User.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrWhiteSpace(username)) + { + return Unauthorized(); + } + + try + { + var location = await _locations.DestroyBuildingAsync(username, request.Q, request.R); + return location is null + ? BadRequest("Select controlled territory with a non-town-hall building.") + : Ok(LocationResponse.FromLocation(location)); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + [Authorize(Roles = "USER,SUPER")] + [HttpPost("abandon")] + public async Task> Abandon([FromBody] TerritoryLocationRequest request) + { + var username = User.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrWhiteSpace(username)) + { + return Unauthorized(); + } + + try + { + var location = await _locations.AbandonAsync(username, request.Q, request.R); + return location is null + ? BadRequest("Select controlled non-home territory to give up.") + : Ok(LocationResponse.FromLocation(location)); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + [Authorize(Roles = "USER,SUPER")] [HttpGet("home")] public async Task> GetHome() diff --git a/microservices/LocationsApi/Controllers/UnitsController.cs b/microservices/LocationsApi/Controllers/UnitsController.cs index b4c9e49..d6b763c 100644 --- a/microservices/LocationsApi/Controllers/UnitsController.cs +++ b/microservices/LocationsApi/Controllers/UnitsController.cs @@ -89,4 +89,34 @@ public class UnitsController : ControllerBase return BadRequest(new { error = ex.Message }); } } + + [Authorize(Roles = "USER,SUPER")] + [HttpPost("dismiss")] + public async Task> Dismiss([FromBody] DismissUnitSquadRequest request) + { + var username = User.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrWhiteSpace(username)) + { + return Unauthorized(); + } + + try + { + var dismissedCount = await _units.DismissSquadAsync(username, request); + if (dismissedCount == 0) + { + return BadRequest(new { error = "No matching idle units are available to dismiss." }); + } + + return Ok(new DismissUnitSquadResponse + { + UnitType = request.UnitType, + DismissedCount = dismissedCount + }); + } + catch (ArgumentException ex) + { + return BadRequest(new { error = ex.Message }); + } + } } diff --git a/microservices/LocationsApi/Models/Dto.cs b/microservices/LocationsApi/Models/Dto.cs index 9fb5eec..c73ec8d 100644 --- a/microservices/LocationsApi/Models/Dto.cs +++ b/microservices/LocationsApi/Models/Dto.cs @@ -35,6 +35,12 @@ public class DeassignWorkerRequest public int R { get; set; } } +public class TerritoryLocationRequest +{ + public int Q { get; set; } + public int R { get; set; } +} + public class LocationResponse { public string Id { get; set; } = ""; diff --git a/microservices/LocationsApi/Models/UnitDto.cs b/microservices/LocationsApi/Models/UnitDto.cs index 18f8b43..2e1857f 100644 --- a/microservices/LocationsApi/Models/UnitDto.cs +++ b/microservices/LocationsApi/Models/UnitDto.cs @@ -17,6 +17,19 @@ public class MoveUnitSquadRequest public int ToR { get; set; } } +public class DismissUnitSquadRequest +{ + public string UnitType { get; set; } = "Archer"; + public int Q { get; set; } + public int R { get; set; } +} + +public class DismissUnitSquadResponse +{ + public string UnitType { get; set; } = ""; + public int DismissedCount { get; set; } +} + public class UnitTrainingResponse { public string Id { get; set; } = ""; diff --git a/microservices/LocationsApi/Services/LocationService.cs b/microservices/LocationsApi/Services/LocationService.cs index 18e8cd1..f2f77d5 100644 --- a/microservices/LocationsApi/Services/LocationService.cs +++ b/microservices/LocationsApi/Services/LocationService.cs @@ -355,6 +355,93 @@ public class LocationService return updated; } + public async Task DestroyBuildingAsync(string username, int q, int r) + { + var now = DateTime.UtcNow; + var location = await GetAsync(q, r); + if (location is null || + !string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase) || + string.IsNullOrWhiteSpace(location.BuildingType)) + { + return null; + } + + if (string.Equals(location.Kind, "Home", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("The town hall cannot be destroyed."); + } + + if (!string.IsNullOrWhiteSpace(location.WorkerUnitId)) + { + await _units.UpdateOneAsync( + x => x.Id == location.WorkerUnitId && x.Username == username, + Builders.Update + .Set(x => x.Q, q) + .Set(x => x.R, r) + .Set(x => x.Status, "Idle") + .Set(x => x.MovementRemaining, 1) + .Set(x => x.UpdatedAtUtc, now)); + } + + var updated = await _locations.FindOneAndUpdateAsync( + x => x.Id == location.Id && x.OwnerUsername == username && x.BuildingType != null && x.BuildingType != "", + Builders.Update + .Unset(x => x.BuildingType) + .Unset(x => x.BuildingStatus) + .Unset(x => x.BuildingStartedAtUtc) + .Unset(x => x.BuildingCompletesAtUtc) + .Unset(x => x.BuildingBuiltAtUtc) + .Unset(x => x.WorkerUnitId) + .Unset(x => x.WorkerUnitType) + .Set(x => x.StoredResourceAmount, 0) + .Set(x => x.StoredResourceCapacity, 10) + .Unset(x => x.LastResourceGeneratedAtUtc) + .Set(x => x.UpdatedAtUtc, now), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + + await RecalculateWorkerGenerationAsync(username); + return updated; + } + + public async Task AbandonAsync(string username, int q, int r) + { + var location = await GetAsync(q, r); + if (location is null || + !string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (string.Equals(location.Kind, "Home", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("The town hall cannot be given up."); + } + + if (!string.IsNullOrWhiteSpace(location.BuildingType) || !string.IsNullOrWhiteSpace(location.WorkerUnitId)) + { + throw new InvalidOperationException("Destroy the building before giving up this territory."); + } + + var hasUnits = await _units.Find(x => + x.Username == username && + x.Q == q && + x.R == r) + .AnyAsync(); + if (hasUnits) + { + throw new InvalidOperationException("Move or dismiss units on this tile before giving up the territory."); + } + + return await _locations.FindOneAndUpdateAsync( + x => x.Id == location.Id && x.OwnerUsername == username, + Builders.Update + .Set(x => x.Kind, "Open") + .Unset(x => x.OwnerUsername) + .Unset(x => x.Label) + .Set(x => x.UpdatedAtUtc, DateTime.UtcNow), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + } + private async Task RecalculateWorkerGenerationAsync(string username) { var built = await _locations.Find(x => diff --git a/microservices/LocationsApi/Services/UnitTrainingService.cs b/microservices/LocationsApi/Services/UnitTrainingService.cs index cddaba2..259aab2 100644 --- a/microservices/LocationsApi/Services/UnitTrainingService.cs +++ b/microservices/LocationsApi/Services/UnitTrainingService.cs @@ -197,9 +197,9 @@ public class UnitTrainingService } var movementDistance = HexDistance(request.FromQ, request.FromR, request.ToQ, request.ToR); - if (normalizedUnitType == ArcherUnitType && !await CanMoveToAsync(username, request.ToQ, request.ToR)) + if ((normalizedUnitType == ArcherUnitType || normalizedUnitType == ScoutUnitType) && !await CanMoveToAsync(username, request.ToQ, request.ToR)) { - throw new InvalidOperationException("Archers can only move to visible tiles."); + throw new InvalidOperationException($"{normalizedUnitType}s can only move to visible tiles."); } if (normalizedUnitType == PeasantUnitType && !await CanPeasantMoveToAsync(username, request.ToQ, request.ToR)) @@ -257,6 +257,26 @@ public class UnitTrainingService .ToListAsync(); } + public async Task DismissSquadAsync(string username, DismissUnitSquadRequest request) + { + var normalizedUnitType = NormalizeUnitType(request.UnitType); + if (normalizedUnitType != ScoutUnitType && normalizedUnitType != ArcherUnitType && normalizedUnitType != PeasantUnitType) + { + throw new ArgumentException("Only Scouts, Archers, and Peasants can be dismissed right now.", nameof(request.UnitType)); + } + + await CompletePendingMovementAsync(username); + + var result = await _units.DeleteManyAsync(x => + x.Username == username && + x.UnitType == normalizedUnitType && + x.Status == "Idle" && + x.Q == request.Q && + x.R == request.R); + + return (int)result.DeletedCount; + } + private async Task GetBuiltBarracksAsync(string username, int q, int r) { await CompleteFinishedBuildingsAsync(username);