diff --git a/game/territory-autobattler/scenes/game/hex_grid_screen.gd b/game/territory-autobattler/scenes/game/hex_grid_screen.gd index 16d1c4e..e5a9893 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_screen.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_screen.gd @@ -46,7 +46,10 @@ var building_box: VBoxContainer var building_label: Label var building_bar: ProgressBar var progress_panel: PanelContainer +var progress_stack: VBoxContainer var action_buttons: HBoxContainer +var training_items: Array[Dictionary] = [] +var training_item_boxes: Array[VBoxContainer] = [] var training_started_unix := 0.0 var training_completes_unix := 0.0 var training_completion_refresh_requested := false @@ -433,12 +436,13 @@ func _build_map_overlays(map_layer: Control) -> void: progress_margin.add_theme_constant_override("margin_bottom", 10) progress_panel.add_child(progress_margin) - var progress_stack := VBoxContainer.new() + progress_stack = VBoxContainer.new() progress_stack.add_theme_constant_override("separation", 8) progress_margin.add_child(progress_stack) training_box = _progress_overlay_item("Training") progress_stack.add_child(training_box) + training_item_boxes.append(training_box) training_label = training_box.get_child(0) as Label training_bar = training_box.get_child(1) as ProgressBar @@ -683,7 +687,7 @@ func _confirm_scout_claim() -> void: selected_q = pending_move_q selected_r = pending_move_r selected_can_claim = true - _claim_selected_tile() + _move_selected_scouts(pending_move_q, pending_move_r, true) func _confirm_worker_assign() -> void: @@ -790,7 +794,7 @@ func _can_build_on_selected_tile(required_terrain: String) -> bool: 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) + return selected_can_claim and selected_scout_count > 0 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: @@ -1253,11 +1257,13 @@ func _on_units_request_completed(result: int, response_code: int, _headers: Pack _clear_removed_selected_unit() unit_label.text = "Scouts: %d | Peasants: %d | Archers: %d | Queue: %d" % [completed_scout_count, completed_peasant_count, completed_archer_count, training.size()] _update_action_buttons() + _load_visible_locations() if units_reload_queued: units_reload_queued = false call_deferred("_load_units") if training.is_empty(): + training_items.clear() training_started_unix = 0.0 training_completes_unix = 0.0 active_training_label = "Training" @@ -1265,13 +1271,25 @@ func _on_units_request_completed(result: int, response_code: int, _headers: Pack _update_training_bar() return - if typeof(training[0]) != TYPE_DICTIONARY: - return + training_items.clear() + for item in training: + if typeof(item) != TYPE_DICTIONARY: + continue + var training_item: Dictionary = item + training_items.append({ + "label": _unit_display_name(str(training_item.get("unitType", "Training"))), + "started": _parse_utc_timestamp(str(training_item.get("startedAtUtc", ""))), + "completes": _parse_utc_timestamp(str(training_item.get("completesAtUtc", ""))) + }) - var active_training: Dictionary = training[0] - active_training_label = _unit_display_name(str(active_training.get("unitType", "Training"))) - training_started_unix = _parse_utc_timestamp(str(active_training.get("startedAtUtc", ""))) - training_completes_unix = _parse_utc_timestamp(str(active_training.get("completesAtUtc", ""))) + if training_items.is_empty(): + training_started_unix = 0.0 + training_completes_unix = 0.0 + else: + var active_training: Dictionary = training_items[0] + active_training_label = str(active_training.get("label", "Training")) + training_started_unix = float(active_training.get("started", 0.0)) + training_completes_unix = float(active_training.get("completes", 0.0)) training_completion_refresh_requested = false _update_training_bar() @@ -1403,7 +1421,7 @@ func _move_selected_archers(to_q: int, to_r: int) -> void: coordinate_label.text = "Could not contact the unit move service." -func _move_selected_scouts(to_q: int, to_r: int) -> void: +func _move_selected_scouts(to_q: int, to_r: int, claim_on_arrival: bool = false) -> void: if selected_scout_count <= 0: return if to_q == selected_scout_q and to_r == selected_scout_r: @@ -1421,7 +1439,7 @@ func _move_selected_scouts(to_q: int, to_r: int) -> void: if move_units_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: return - coordinate_label.text = "Ordering %d Scouts to move to q:%d r:%d..." % [selected_scout_count, to_q, to_r] + coordinate_label.text = "Ordering Scout to claim q:%d r:%d..." % [to_q, to_r] if claim_on_arrival else "Ordering %d Scouts to move to q:%d r:%d..." % [selected_scout_count, to_q, to_r] var error: int = move_units_request.request( LOCATIONS_BASE_URL + "/api/units/move", PackedStringArray([ @@ -1434,7 +1452,8 @@ func _move_selected_scouts(to_q: int, to_r: int) -> void: "fromQ": selected_scout_q, "fromR": selected_scout_r, "toQ": to_q, - "toR": to_r + "toR": to_r, + "claimOnArrival": claim_on_arrival }) ) if error != OK: @@ -1582,6 +1601,9 @@ func _claim_selected_tile() -> void: if not selected_can_claim: coordinate_label.text = "Select a visible unclaimed tile to send a scout." return + if selected_scout_count <= 0: + coordinate_label.text = "Select a Scout, then choose Claim on the target tile." + return if completed_scout_count <= 0: coordinate_label.text = "Train a scout before claiming territory." return @@ -1594,23 +1616,8 @@ func _claim_selected_tile() -> void: 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: - _update_action_buttons() - coordinate_label.text = "Could not contact the locations service." + _move_selected_scouts(selected_q, selected_r, true) func _on_claim_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: @@ -1816,30 +1823,55 @@ func _update_tick_bar() -> void: func _update_training_bar() -> void: - if not training_bar or not training_label: + if not training_bar or not training_label or not progress_stack: return - if training_started_unix <= 0.0 or training_completes_unix <= 0.0: - training_box.visible = false + if training_items.is_empty(): + for box in training_item_boxes: + box.visible = false training_bar.value = 0.0 training_label.text = "Training: idle" _update_progress_panel_visibility() return - training_box.visible = true var now: float = Time.get_unix_time_from_system() - var duration: float = max(1.0, training_completes_unix - training_started_unix) - var elapsed: float = clamp(now - training_started_unix, 0.0, duration) - var remaining: int = int(max(0, int(ceil(training_completes_unix - now)))) - training_bar.value = elapsed / duration + while training_item_boxes.size() < training_items.size(): + var box := _progress_overlay_item("Training") + progress_stack.add_child(box) + progress_stack.move_child(building_box, progress_stack.get_child_count() - 1) + training_item_boxes.append(box) - if remaining <= 0: - training_label.text = "%s: ready" % active_training_label - if not training_completion_refresh_requested: - training_completion_refresh_requested = true - _load_units() - else: - training_label.text = "%s: %ds" % [active_training_label, remaining] + var any_ready := false + for index in range(training_item_boxes.size()): + var box: VBoxContainer = training_item_boxes[index] + if index >= training_items.size(): + box.visible = false + continue + + var item: Dictionary = training_items[index] + var label := box.get_child(0) as Label + var bar := box.get_child(1) as ProgressBar + var started := float(item.get("started", 0.0)) + var completes := float(item.get("completes", 0.0)) + var item_label := str(item.get("label", "Training")) + if started <= 0.0 or completes <= 0.0: + box.visible = false + continue + + box.visible = true + var duration: float = max(1.0, completes - started) + var elapsed: float = clamp(now - started, 0.0, duration) + var remaining: int = int(max(0, int(ceil(completes - now)))) + bar.value = elapsed / duration + if remaining <= 0: + label.text = "%s: ready" % item_label + any_ready = true + else: + label.text = "%s: %ds" % [item_label, remaining] + + if any_ready and not training_completion_refresh_requested: + training_completion_refresh_requested = true + _load_units() _update_progress_panel_visibility() @@ -1875,7 +1907,12 @@ func _update_building_timer() -> void: func _update_progress_panel_visibility() -> void: if progress_panel: - progress_panel.visible = (training_box and training_box.visible) or (building_box and building_box.visible) + var has_training := false + for box in training_item_boxes: + if box.visible: + has_training = true + break + progress_panel.visible = has_training or (building_box and building_box.visible) func _sync_active_building_timer(locations: Array, username: String) -> void: diff --git a/game/territory-autobattler/scenes/game/hex_grid_view.gd b/game/territory-autobattler/scenes/game/hex_grid_view.gd index f1cc309..27dacfe 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_view.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_view.gd @@ -181,6 +181,7 @@ func _draw_tile(tile: Dictionary) -> void: var visible_location: Dictionary = visible_tiles.get(key, {}) var building_type: String = _nullable_string(visible_location.get("buildingType", "")) var building_status: String = _nullable_string(visible_location.get("buildingStatus", "")) + var worker_unit_id: String = _nullable_string(visible_location.get("workerUnitId", "")) if coord == home_tile: _draw_town_hall_marker(center, height) elif building_type == "LogCamp" and (building_status == "Built" or building_status.is_empty()): @@ -193,6 +194,8 @@ func _draw_tile(tile: Dictionary) -> void: _draw_barracks_marker(center, height) elif not building_type.is_empty(): _draw_building_marker(center, height) + if _building_needs_worker(building_type, building_status, worker_unit_id): + _draw_missing_worker_marker(center, height) var archer_count := _archer_count_at(q, r) if archer_count > 0: @@ -585,6 +588,30 @@ func _draw_building_marker(center: Vector2, height: float) -> void: draw_line(left, right.lerp(top_right, 0.2), post_color.darkened(0.12), 1.5 * zoom) +func _building_needs_worker(building_type: String, building_status: String, worker_unit_id: String) -> bool: + if not worker_unit_id.is_empty(): + return false + if building_type != "LogCamp" and building_type != "Mine" and building_type != "Farm": + return false + return building_status == "Built" or building_status.is_empty() + + +func _draw_missing_worker_marker(center: Vector2, height: float) -> void: + var marker_center: Vector2 = center + Vector2(20.0 * zoom, -height * zoom - 27.0 * zoom) + var marker_scale: float = zoom + var badge_radius: float = 7.0 * marker_scale + var fill_color := Color(0.93, 0.66, 0.18, 0.96) + var edge_color := Color(0.18, 0.10, 0.02, 0.92) + var icon_color := Color(0.10, 0.07, 0.03, 0.96) + + draw_circle(marker_center + Vector2(1, 1) * marker_scale, badge_radius + 1.0 * marker_scale, Color(0.0, 0.0, 0.0, 0.35)) + draw_circle(marker_center, badge_radius, fill_color) + draw_arc(marker_center, badge_radius, 0.0, TAU, 24, edge_color, 1.4 * marker_scale) + draw_circle(marker_center + Vector2(0, -2.3) * marker_scale, 2.0 * marker_scale, icon_color) + draw_rect(Rect2(marker_center + Vector2(-2.2, 0.8) * marker_scale, Vector2(4.4, 4.8) * marker_scale), icon_color, true) + draw_line(marker_center + Vector2(-5.0, 5.8) * marker_scale, marker_center + Vector2(5.0, -5.8) * marker_scale, edge_color, 1.7 * marker_scale) + + func _draw_archer_unit_marker(center: Vector2, height: float, count: int) -> void: var marker_center: Vector2 = _archer_marker_center(center, height) var marker_scale: float = zoom diff --git a/microservices/LocationsApi/Controllers/LocationsController.cs b/microservices/LocationsApi/Controllers/LocationsController.cs index d57eba1..eaa85ec 100644 --- a/microservices/LocationsApi/Controllers/LocationsController.cs +++ b/microservices/LocationsApi/Controllers/LocationsController.cs @@ -87,6 +87,10 @@ public class LocationsController : ControllerBase { return NotFound(); } + if (!User.IsInRole("SUPER")) + { + return BadRequest("Use a Scout move order to claim territory on arrival."); + } if (!User.IsInRole("SUPER") && !await _locations.IsAdjacentToOwnedAsync(username, request.Q, request.R)) { diff --git a/microservices/LocationsApi/Models/UnitDto.cs b/microservices/LocationsApi/Models/UnitDto.cs index 2e1857f..7f1002a 100644 --- a/microservices/LocationsApi/Models/UnitDto.cs +++ b/microservices/LocationsApi/Models/UnitDto.cs @@ -15,6 +15,7 @@ public class MoveUnitSquadRequest public int FromR { get; set; } public int ToQ { get; set; } public int ToR { get; set; } + public bool ClaimOnArrival { get; set; } } public class DismissUnitSquadRequest @@ -75,6 +76,7 @@ public class UnitInstanceResponse public DateTime LastMovementRefillAtUtc { get; set; } public int? DestinationQ { get; set; } public int? DestinationR { get; set; } + public bool ClaimOnArrival { get; set; } public DateTime? MovementStartedAtUtc { get; set; } public DateTime? MovementCompletesAtUtc { get; set; } public DateTime CreatedAtUtc { get; set; } @@ -93,6 +95,7 @@ public class UnitInstanceResponse LastMovementRefillAtUtc = unit.LastMovementRefillAtUtc, DestinationQ = unit.DestinationQ, DestinationR = unit.DestinationR, + ClaimOnArrival = unit.ClaimOnArrival, MovementStartedAtUtc = unit.MovementStartedAtUtc, MovementCompletesAtUtc = unit.MovementCompletesAtUtc, CreatedAtUtc = unit.CreatedAtUtc, diff --git a/microservices/LocationsApi/Models/UnitInstance.cs b/microservices/LocationsApi/Models/UnitInstance.cs index 3d89f82..a407aa5 100644 --- a/microservices/LocationsApi/Models/UnitInstance.cs +++ b/microservices/LocationsApi/Models/UnitInstance.cs @@ -19,6 +19,7 @@ public class UnitInstance public DateTime LastMovementRefillAtUtc { get; set; } = DateTime.UtcNow; public int? DestinationQ { get; set; } public int? DestinationR { get; set; } + public bool ClaimOnArrival { get; set; } public DateTime? MovementStartedAtUtc { get; set; } public DateTime? MovementCompletesAtUtc { get; set; } public string? SourceTrainingId { get; set; } diff --git a/microservices/LocationsApi/Services/UnitTrainingService.cs b/microservices/LocationsApi/Services/UnitTrainingService.cs index fd198bf..0dd0837 100644 --- a/microservices/LocationsApi/Services/UnitTrainingService.cs +++ b/microservices/LocationsApi/Services/UnitTrainingService.cs @@ -190,6 +190,10 @@ public class UnitTrainingService { throw new ArgumentException("Only Scouts, Archers, and Peasants can be moved right now.", nameof(request.UnitType)); } + if (request.ClaimOnArrival && normalizedUnitType != ScoutUnitType) + { + throw new ArgumentException("Only Scouts can claim territory.", nameof(request.ClaimOnArrival)); + } if (request.FromQ == request.ToQ && request.FromR == request.ToR) { @@ -206,6 +210,10 @@ public class UnitTrainingService { throw new InvalidOperationException("Peasants can only move within your controlled territory."); } + if (request.ClaimOnArrival && !await IsAdjacentToOwnedAsync(username, request.ToQ, request.ToR)) + { + throw new InvalidOperationException("Scout claims must target territory adjacent to controlled territory."); + } var now = DateTime.UtcNow; var unitFilter = Builders.Filter.And( @@ -234,26 +242,24 @@ public class UnitTrainingService throw new InvalidOperationException("That squad has no movement remaining this tick."); } + var movingUnitIds = request.ClaimOnArrival + ? units.OrderBy(x => x.Id).Take(1).Select(x => x.Id).ToList() + : units.Select(x => x.Id).ToList(); var movementCompletesAt = now.AddMinutes(1); await _units.UpdateManyAsync( - unitFilter, + Builders.Filter.In(x => x.Id, movingUnitIds), Builders.Update .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.ClaimOnArrival, request.ClaimOnArrival) .Set(x => x.MovementStartedAtUtc, now) .Set(x => x.MovementCompletesAtUtc, movementCompletesAt) .Set(x => x.UpdatedAtUtc, now)); return await _units.Find(x => - x.Username == username && - x.UnitType == normalizedUnitType && - x.Status == "Moving" && - x.Q == request.FromQ && - x.R == request.FromR && - x.DestinationQ == request.ToQ && - x.DestinationR == request.ToR) + movingUnitIds.Contains(x.Id)) .ToListAsync(); } @@ -310,6 +316,7 @@ public class UnitTrainingService .Set(x => x.Status, "Idle") .Set(x => x.MovementPerTick, movementPerTick) .Set(x => x.MovementRemaining, movementPerTick) + .Set(x => x.ClaimOnArrival, false) .Unset(x => x.MovementStartedAtUtc) .Unset(x => x.MovementCompletesAtUtc) .Set(x => x.UpdatedAtUtc, now)); @@ -331,6 +338,7 @@ public class UnitTrainingService .Set(x => x.MovementRemaining, movementPerTick) .Unset(x => x.DestinationQ) .Unset(x => x.DestinationR) + .Set(x => x.ClaimOnArrival, false) .Unset(x => x.MovementStartedAtUtc) .Unset(x => x.MovementCompletesAtUtc) .Set(x => x.UpdatedAtUtc, now)); @@ -371,10 +379,16 @@ public class UnitTrainingService .Set(x => x.LastMovementRefillAtUtc, now) .Unset(x => x.DestinationQ) .Unset(x => x.DestinationR) + .Set(x => x.ClaimOnArrival, false) .Unset(x => x.MovementStartedAtUtc) .Unset(x => x.MovementCompletesAtUtc) .Set(x => x.UpdatedAtUtc, now)); + if (unit.UnitType == ScoutUnitType && unit.ClaimOnArrival) + { + await TryClaimWithArrivedScoutAsync(unit, destinationQ, destinationR, now); + } + } } @@ -409,6 +423,84 @@ public class UnitTrainingService } } + private async Task IsAdjacentToOwnedAsync(string username, int q, int r) + { + var owned = await _locations.Find(x => x.OwnerUsername == username).ToListAsync(); + return owned.Any(location => HexDistance(location.Q, location.R, q, r) == 1); + } + + private async Task TryClaimWithArrivedScoutAsync(UnitInstance scout, int q, int r, DateTime now) + { + var target = await _locations.Find(x => x.Q == q && x.R == r).FirstOrDefaultAsync(); + if (target is not null) + { + if (string.Equals(target.OwnerUsername, scout.Username, StringComparison.OrdinalIgnoreCase) || + string.Equals(target.Kind, "Home", StringComparison.OrdinalIgnoreCase) || + !string.IsNullOrWhiteSpace(target.BuildingType)) + { + return; + } + } + + var hasBlockingUnit = await _units.Find(x => + x.Id != scout.Id && + x.Q == q && + x.R == r) + .AnyAsync(); + if (hasBlockingUnit) + { + return; + } + + if (!await IsAdjacentToOwnedAsync(scout.Username, q, r)) + { + return; + } + + var claimed = false; + if (target is null) + { + var location = new Location + { + Q = q, + R = r, + Terrain = "Field", + Kind = "Claimed", + OwnerUsername = scout.Username, + Level = 1, + CreatedAtUtc = now, + UpdatedAtUtc = now + }; + + try + { + await _locations.InsertOneAsync(location); + claimed = true; + } + catch (MongoWriteException) + { + } + } + else + { + var result = await _locations.UpdateOneAsync( + x => x.Id == target.Id && + x.Kind != "Home" && + (x.BuildingType == null || x.BuildingType == "") && + x.OwnerUsername != scout.Username, + Builders.Update + .Set(x => x.OwnerUsername, scout.Username) + .Set(x => x.Kind, "Claimed") + .Set(x => x.UpdatedAtUtc, now)); + claimed = result.ModifiedCount > 0; + } + + if (claimed) + { + await _units.DeleteOneAsync(x => x.Id == scout.Id); + } + } + private async Task CompleteFinishedBuildingsAsync(string username) { var now = DateTime.UtcNow;