From 3167558e04d596be99acc963ddfb1893c14c20b2 Mon Sep 17 00:00:00 2001 From: hz Date: Mon, 18 May 2026 15:40:15 -0500 Subject: [PATCH] territory claiming --- .../scenes/game/hex_grid_screen.gd | 75 +++++++++++++++++++ .../scenes/game/hex_grid_view.gd | 4 +- .../Controllers/LocationsController.cs | 20 ++++- microservices/LocationsApi/README.md | 5 +- .../Services/UnitTrainingService.cs | 27 +++++++ 5 files changed, 127 insertions(+), 4 deletions(-) diff --git a/game/territory-autobattler/scenes/game/hex_grid_screen.gd b/game/territory-autobattler/scenes/game/hex_grid_screen.gd index 2268cdb..94a1c8f 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_screen.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_screen.gd @@ -13,6 +13,7 @@ var visible_request: HTTPRequest var resources_request: HTTPRequest var units_request: HTTPRequest var train_unit_request: HTTPRequest +var claim_request: HTTPRequest var resources_timer: Timer var session: Dictionary = {} var escape_overlay: Control @@ -28,6 +29,11 @@ var training_label: Label var training_bar: ProgressBar var training_started_unix := 0.0 var training_completes_unix := 0.0 +var training_completion_refresh_requested := false +var claim_button: Button +var selected_q := 0 +var selected_r := 0 +var selected_can_claim := false func _ready() -> void: @@ -206,6 +212,11 @@ func _build_ui() -> void: train_button.pressed.connect(_train_scout) bottom_content.add_child(train_button) + claim_button = _small_button("Claim 1 Scout") + claim_button.disabled = true + claim_button.pressed.connect(_claim_selected_tile) + bottom_content.add_child(claim_button) + http_request = HTTPRequest.new() http_request.request_completed.connect(_on_home_request_completed) add_child(http_request) @@ -226,6 +237,10 @@ func _build_ui() -> void: train_unit_request.request_completed.connect(_on_train_unit_request_completed) add_child(train_unit_request) + claim_request = HTTPRequest.new() + claim_request.request_completed.connect(_on_claim_request_completed) + add_child(claim_request) + resources_timer = Timer.new() resources_timer.wait_time = RESOURCE_REFRESH_SECONDS resources_timer.autostart = false @@ -241,6 +256,11 @@ func _on_tile_hovered(q: int, r: int, terrain: String) -> void: func _on_tile_selected(q: int, r: int, terrain: String) -> void: + selected_q = q + selected_r = r + selected_can_claim = terrain.contains("Unclaimed") + if claim_button: + claim_button.disabled = not selected_can_claim if coordinate_label: coordinate_label.text = "Selected q:%d r:%d | %s territory" % [q, r, terrain] @@ -441,6 +461,7 @@ func _on_units_request_completed(result: int, response_code: int, _headers: Pack if training.is_empty(): training_started_unix = 0.0 training_completes_unix = 0.0 + training_completion_refresh_requested = false _update_training_bar() return @@ -450,6 +471,7 @@ func _on_units_request_completed(result: int, response_code: int, _headers: Pack var active_training: Dictionary = training[0] training_started_unix = _parse_utc_timestamp(str(active_training.get("startedAtUtc", ""))) training_completes_unix = _parse_utc_timestamp(str(active_training.get("completesAtUtc", ""))) + training_completion_refresh_requested = false _update_training_bar() @@ -495,11 +517,61 @@ func _on_train_unit_request_completed(result: int, response_code: int, _headers: var training: Dictionary = parsed training_started_unix = _parse_utc_timestamp(str(training.get("startedAtUtc", ""))) training_completes_unix = _parse_utc_timestamp(str(training.get("completesAtUtc", ""))) + training_completion_refresh_requested = false coordinate_label.text = "Scout training started. Completion takes 1 minute." _refresh_player_state() +func _claim_selected_tile() -> void: + if not selected_can_claim: + coordinate_label.text = "Select a visible unclaimed tile to send a scout." + return + + var access_token: String = str(session.get("accessToken", "")) + if access_token.is_empty(): + coordinate_label.text = "No auth token available. Territory was not claimed." + return + + if claim_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + return + + claim_button.disabled = true + coordinate_label.text = "Sending scout to claim q:%d r:%d..." % [selected_q, selected_r] + var error: int = claim_request.request( + LOCATIONS_BASE_URL + "/api/locations/claim", + PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % access_token + ]), + HTTPClient.METHOD_POST, + JSON.stringify({ "q": selected_q, "r": selected_r }) + ) + if error != OK: + claim_button.disabled = false + coordinate_label.text = "Could not contact the locations service." + + +func _on_claim_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 = "Scout claim request failed." + claim_button.disabled = not selected_can_claim + return + + if response_code < 200 or response_code >= 300: + coordinate_label.text = "Scout claim failed: %s" % text + claim_button.disabled = not selected_can_claim + _refresh_player_state() + return + + selected_can_claim = false + claim_button.disabled = true + coordinate_label.text = "Scout claimed q:%d r:%d. Territory expanded." % [selected_q, selected_r] + _load_visible_locations() + _refresh_player_state() + + func _update_tick_bar() -> void: if not tick_bar or not tick_label: return @@ -536,6 +608,9 @@ func _update_training_bar() -> void: if remaining <= 0: training_label.text = "Training: ready" + if not training_completion_refresh_requested: + training_completion_refresh_requested = true + _load_units() else: training_label.text = "Training: %ds" % remaining diff --git a/game/territory-autobattler/scenes/game/hex_grid_view.gd b/game/territory-autobattler/scenes/game/hex_grid_view.gd index e07c04e..50f89a5 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_view.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_view.gd @@ -70,7 +70,9 @@ func _gui_input(event: InputEvent) -> void: if not event.pressed and drag_distance < 6.0 and hovered_tile.x != 9999: selected_tile = hovered_tile var data: Dictionary = _tile_data(selected_tile.x, selected_tile.y) - tile_selected.emit(selected_tile.x, selected_tile.y, str(data.get("terrain", "Open"))) + var key: String = _tile_key(selected_tile.x, selected_tile.y) + var label: String = _tile_display_label(key, data) if visible_tiles.has(key) else "Unexplored" + tile_selected.emit(selected_tile.x, selected_tile.y, label) queue_redraw() elif event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed: zoom = clampf(zoom + 0.08, 0.65, 1.6) diff --git a/microservices/LocationsApi/Controllers/LocationsController.cs b/microservices/LocationsApi/Controllers/LocationsController.cs index 6803d83..53a25e1 100644 --- a/microservices/LocationsApi/Controllers/LocationsController.cs +++ b/microservices/LocationsApi/Controllers/LocationsController.cs @@ -11,10 +11,12 @@ namespace LocationsApi.Controllers; public class LocationsController : ControllerBase { private readonly LocationService _locations; + private readonly UnitTrainingService _units; - public LocationsController(LocationService locations) + public LocationsController(LocationService locations, UnitTrainingService units) { _locations = locations; + _units = units; } [Authorize(Roles = "USER,SUPER")] @@ -86,6 +88,22 @@ public class LocationsController : ControllerBase return NotFound(); } + var existingLocation = await _locations.GetAsync(request.Q, request.R); + if (existingLocation is not null && !string.IsNullOrWhiteSpace(existingLocation.OwnerUsername)) + { + if (string.Equals(existingLocation.OwnerUsername, username, StringComparison.OrdinalIgnoreCase)) + { + return Ok(LocationResponse.FromLocation(existingLocation)); + } + + return Conflict("Location is already controlled by another player."); + } + + if (!User.IsInRole("SUPER") && !await _units.TryConsumeCompletedScoutAsync(username)) + { + return BadRequest("A completed scout is required to claim unclaimed territory."); + } + var location = await _locations.ClaimAsync(request.Q, request.R, username); if (location is null) { diff --git a/microservices/LocationsApi/README.md b/microservices/LocationsApi/README.md index c7c4ff9..c4dc033 100644 --- a/microservices/LocationsApi/README.md +++ b/microservices/LocationsApi/README.md @@ -7,7 +7,7 @@ Tracks hex locations for the territory map. - `GET /api/locations` List visible locations for USER/SUPER, all known locations for SUPER. - `GET /api/locations?minQ=-7&maxQ=7&minR=-7&maxR=7` List a coordinate range, constrained by visibility for USER. - `GET /api/locations/{q}/{r}` Fetch one visible location for USER, any known location for SUPER. -- `POST /api/locations/claim` Claim a location for the authenticated user. +- `POST /api/locations/claim` Claim visible unclaimed territory for the authenticated user. Normal players spend one completed Scout. - `GET /api/locations/home` Get the authenticated user's home location. - `POST /api/locations/home` Assign or return the authenticated user's home location. - `GET /api/locations/visible` Get locations visible to the authenticated user. @@ -33,4 +33,5 @@ minute. Generation is applied lazily when the resource record is read. Unit training is stored server-side in the `UnitTraining` collection. The first unit type is `Scout`, which costs 50 gold and takes 60 seconds to train. -Finished training is completed lazily when unit state is read. +Finished training is completed lazily when unit state is read. Claiming an +unclaimed visible location consumes one completed Scout. diff --git a/microservices/LocationsApi/Services/UnitTrainingService.cs b/microservices/LocationsApi/Services/UnitTrainingService.cs index 3f51398..e0f0b5b 100644 --- a/microservices/LocationsApi/Services/UnitTrainingService.cs +++ b/microservices/LocationsApi/Services/UnitTrainingService.cs @@ -78,6 +78,33 @@ public class UnitTrainingService return training; } + public async Task TryConsumeCompletedScoutAsync(string username) + { + await CompleteFinishedTrainingAsync(username); + + var now = DateTime.UtcNow; + var update = Builders.Update + .Set(x => x.Status, "Consumed") + .Set(x => x.UpdatedAtUtc, now); + + var consumed = await _training.FindOneAndUpdateAsync( + x => x.Username == username && x.UnitType == ScoutUnitType && x.Status == "Completed", + update, + new FindOneAndUpdateOptions + { + Sort = Builders.Sort.Ascending(x => x.CompletedAtUtc).Ascending(x => x.CreatedAtUtc), + ReturnDocument = ReturnDocument.After + }); + + return consumed is not null; + } + + public async Task HasCompletedScoutAsync(string username) + { + await CompleteFinishedTrainingAsync(username); + return await _training.Find(x => x.Username == username && x.UnitType == ScoutUnitType && x.Status == "Completed").AnyAsync(); + } + private async Task CompleteFinishedTrainingAsync(string username) { var now = DateTime.UtcNow;