From b0b270f4d88bd94fdd3e403930ad07d6fed97f07 Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 19 May 2026 05:20:28 -0500 Subject: [PATCH] tracking units --- .../scenes/game/hex_grid_screen.gd | 20 +++- .../scenes/game/hex_grid_view.gd | 92 +++++++++++++++ .../Controllers/UnitsController.cs | 2 +- microservices/LocationsApi/Models/UnitDto.cs | 31 +++++ .../LocationsApi/Models/UnitInstance.cs | 20 ++++ .../LocationsApi/Models/UnitTraining.cs | 2 + .../LocationsApi/Services/LocationService.cs | 5 + .../Services/UnitTrainingService.cs | 108 +++++++++++++++--- 8 files changed, 258 insertions(+), 22 deletions(-) create mode 100644 microservices/LocationsApi/Models/UnitInstance.cs diff --git a/game/territory-autobattler/scenes/game/hex_grid_screen.gd b/game/territory-autobattler/scenes/game/hex_grid_screen.gd index b050db4..85b4401 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_screen.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_screen.gd @@ -432,7 +432,12 @@ func _can_build_mine_on_selected_tile() -> bool: func _can_build_barracks_on_selected_tile() -> bool: - return _can_build_on_selected_tile("") + if not _can_build_on_selected_tile(""): + return false + + var location: Dictionary = grid_view.get_visible_location(selected_q, selected_r) + var kind: String = str(location.get("kind", "")) + return kind != "Home" func _can_build_on_selected_tile(required_terrain: String) -> bool: @@ -813,6 +818,13 @@ func _on_units_request_completed(result: int, response_code: int, _headers: Pack completed_scout_count = int(units.get("completedScouts", 0)) completed_archer_count = int(units.get("completedArchers", 0)) + if grid_view: + grid_view.set_completed_archers(completed_archer_count) + var unit_instances: Array = [] + var raw_units = units.get("units", []) + if typeof(raw_units) == TYPE_ARRAY: + unit_instances = raw_units + grid_view.set_unit_instances(unit_instances) unit_label.text = "Scouts: %d | Archers: %d | Queue: %d" % [completed_scout_count, completed_archer_count, training.size()] _update_action_buttons() @@ -861,6 +873,10 @@ func _request_unit_training(unit_type: String) -> void: else: train_button.disabled = true coordinate_label.text = "Starting %s training..." % unit_type + var request_body := { "unitType": unit_type } + if unit_type == "Archer": + request_body["q"] = selected_q + request_body["r"] = selected_r var error: int = train_unit_request.request( LOCATIONS_BASE_URL + "/api/units/train", PackedStringArray([ @@ -868,7 +884,7 @@ func _request_unit_training(unit_type: String) -> void: "Authorization: Bearer %s" % access_token ]), HTTPClient.METHOD_POST, - JSON.stringify({ "unitType": unit_type }) + JSON.stringify(request_body) ) if error != OK: _update_action_buttons() diff --git a/game/territory-autobattler/scenes/game/hex_grid_view.gd b/game/territory-autobattler/scenes/game/hex_grid_view.gd index a4c12f5..f5158a3 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_view.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_view.gd @@ -22,6 +22,8 @@ var tiles: Array[Dictionary] = [] var visible_tiles: Dictionary = {} var owned_tiles: Dictionary = {} var rival_tiles: Dictionary = {} +var completed_archer_count := 0 +var unit_instances: Array[Dictionary] = [] func _ready() -> void: @@ -174,6 +176,10 @@ func _draw_tile(tile: Dictionary) -> void: elif not building_type.is_empty(): _draw_building_marker(center, height) + var archer_count := _archer_count_at(q, r) + if archer_count > 0: + _draw_archer_unit_marker(center, height, archer_count) + func _draw_control_regions(sorted_tiles: Array) -> void: for control in ["owned", "rival"]: @@ -417,6 +423,44 @@ 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 _draw_archer_unit_marker(center: Vector2, height: float, count: int) -> void: + var marker_center: Vector2 = center + Vector2(18.0 * zoom, -height * zoom - 14.0 * zoom) + var marker_scale: float = zoom + var cloak_color := Color(0.20, 0.38, 0.26) + var hood_color := Color(0.12, 0.23, 0.16) + var bow_color := Color(0.72, 0.50, 0.26) + var string_color := Color(0.92, 0.86, 0.66) + var arrow_color := Color(0.86, 0.78, 0.56) + var outline_color := Color(0.04, 0.06, 0.05) + var badge_color := Color(0.08, 0.12, 0.11) + var badge_border := Color(0.76, 0.62, 0.28) + + draw_circle(marker_center + Vector2(1, 2) * marker_scale, 10.0 * marker_scale, Color(0.01, 0.02, 0.02, 0.55)) + + var body := PackedVector2Array([ + marker_center + Vector2(-7.0, 8.0) * marker_scale, + marker_center + Vector2(0.0, -9.0) * marker_scale, + marker_center + Vector2(8.0, 8.0) * marker_scale + ]) + draw_colored_polygon(body, cloak_color) + draw_polyline(_closed_points(body), outline_color, 1.2 * marker_scale) + draw_circle(marker_center + Vector2(0.0, -6.0) * marker_scale, 4.5 * marker_scale, hood_color) + + var bow_center: Vector2 = marker_center + Vector2(8.0, -1.0) * marker_scale + draw_arc(bow_center, 10.0 * marker_scale, deg_to_rad(-82), deg_to_rad(82), 16, bow_color, 2.0 * marker_scale) + draw_line(bow_center + Vector2(1.8, -9.8) * marker_scale, bow_center + Vector2(1.8, 9.8) * marker_scale, string_color, 1.0 * marker_scale) + draw_line(marker_center + Vector2(1.0, -1.0) * marker_scale, marker_center + Vector2(18.0, -1.0) * marker_scale, arrow_color, 1.6 * marker_scale) + draw_line(marker_center + Vector2(18.0, -1.0) * marker_scale, marker_center + Vector2(14.0, -4.0) * marker_scale, arrow_color, 1.2 * marker_scale) + draw_line(marker_center + Vector2(18.0, -1.0) * marker_scale, marker_center + Vector2(14.0, 2.0) * marker_scale, arrow_color, 1.2 * marker_scale) + + if count > 1: + var badge_center: Vector2 = marker_center + Vector2(10.0, 9.0) * marker_scale + draw_circle(badge_center, 6.0 * marker_scale, badge_color) + draw_arc(badge_center, 6.0 * marker_scale, 0.0, TAU, 18, badge_border, 1.4 * marker_scale) + var font := ThemeDB.fallback_font + draw_string(font, badge_center + Vector2(-3.5, 3.5) * marker_scale, str(min(count, 9)), HORIZONTAL_ALIGNMENT_LEFT, -1, 9 * marker_scale, Color(0.93, 0.86, 0.58)) + + func _tile_data(q: int, r: int) -> Dictionary: for tile in tiles: if int(tile["q"]) == q and int(tile["r"]) == r: @@ -474,6 +518,19 @@ func has_owned_neighbor(q: int, r: int) -> bool: return false +func set_completed_archers(count: int) -> void: + completed_archer_count = max(0, count) + queue_redraw() + + +func set_unit_instances(units: Array) -> void: + unit_instances.clear() + for item in units: + if typeof(item) == TYPE_DICTIONARY: + unit_instances.append(item) + queue_redraw() + + func _tile_key(q: int, r: int) -> String: return "%d,%d" % [q, r] @@ -530,6 +587,9 @@ func _tile_display_label(key: String, data: Dictionary) -> String: building_label = " | Mine" if building_status == "Built" or building_status.is_empty() else " | Mine building" elif building_type == "Barracks": building_label = " | Barracks" if building_status == "Built" or building_status.is_empty() else " | Barracks building" + var archer_count := _archer_count_at(int(data.get("q", 0)), int(data.get("r", 0))) + if archer_count > 0: + building_label += " | Archers: %d" % archer_count if home_tile == Vector2i(int(data.get("q", 0)), int(data.get("r", 0))): return "%s | Your home%s" % [terrain, building_label] @@ -544,6 +604,38 @@ func _tile_display_label(key: String, data: Dictionary) -> String: return "%s | Unclaimed%s" % [terrain, building_label] +func _archer_station_tile() -> Vector2i: + var station := Vector2i(9999, 9999) + for key in owned_tiles.keys(): + var location: Dictionary = owned_tiles[key] + var building_type: String = _nullable_string(location.get("buildingType", "")) + var building_status: String = _nullable_string(location.get("buildingStatus", "")) + if building_type != "Barracks": + continue + if not (building_status == "Built" or building_status.is_empty()): + continue + + var candidate := Vector2i(int(location.get("q", 0)), int(location.get("r", 0))) + if station.x == 9999 or candidate.y < station.y or (candidate.y == station.y and candidate.x < station.x): + station = candidate + + return home_tile if station.x == 9999 else station + + +func _archer_count_at(q: int, r: int) -> int: + var count := 0 + for unit in unit_instances: + if str(unit.get("unitType", "")) != "Archer": + continue + if int(unit.get("q", 0)) == q and int(unit.get("r", 0)) == r: + count += 1 + + if count == 0 and completed_archer_count > 0 and Vector2i(q, r) == _archer_station_tile(): + count = completed_archer_count + + return count + + func _owner_username(location: Dictionary) -> String: var owner_value: Variant = location.get("ownerUsername", "") if owner_value == null: diff --git a/microservices/LocationsApi/Controllers/UnitsController.cs b/microservices/LocationsApi/Controllers/UnitsController.cs index 6d50340..98d3ead 100644 --- a/microservices/LocationsApi/Controllers/UnitsController.cs +++ b/microservices/LocationsApi/Controllers/UnitsController.cs @@ -42,7 +42,7 @@ public class UnitsController : ControllerBase try { - var training = await _units.TrainAsync(username, request.UnitType); + var training = await _units.TrainAsync(username, request); if (training is null) { return BadRequest(new { error = "Not enough resources." }); diff --git a/microservices/LocationsApi/Models/UnitDto.cs b/microservices/LocationsApi/Models/UnitDto.cs index acbd5f9..7ce6ff8 100644 --- a/microservices/LocationsApi/Models/UnitDto.cs +++ b/microservices/LocationsApi/Models/UnitDto.cs @@ -3,6 +3,8 @@ namespace LocationsApi.Models; public class TrainUnitRequest { public string UnitType { get; set; } = "Scout"; + public int? Q { get; set; } + public int? R { get; set; } } public class UnitTrainingResponse @@ -13,6 +15,8 @@ public class UnitTrainingResponse public int GoldCost { get; set; } public int WoodCost { get; set; } public int OreCost { get; set; } + public int? OriginQ { get; set; } + public int? OriginR { get; set; } public string Status { get; set; } = ""; public DateTime StartedAtUtc { get; set; } public DateTime CompletesAtUtc { get; set; } @@ -26,6 +30,8 @@ public class UnitTrainingResponse GoldCost = training.GoldCost, WoodCost = training.WoodCost, OreCost = training.OreCost, + OriginQ = training.OriginQ, + OriginR = training.OriginR, Status = training.Status, StartedAtUtc = training.StartedAtUtc, CompletesAtUtc = training.CompletesAtUtc, @@ -33,9 +39,34 @@ public class UnitTrainingResponse }; } +public class UnitInstanceResponse +{ + public string Id { get; set; } = ""; + public string Username { get; set; } = ""; + public string UnitType { get; set; } = ""; + public int Q { get; set; } + public int R { get; set; } + public string Status { get; set; } = ""; + public DateTime CreatedAtUtc { get; set; } + public DateTime UpdatedAtUtc { get; set; } + + public static UnitInstanceResponse FromUnit(UnitInstance unit) => new() + { + Id = unit.Id, + Username = unit.Username, + UnitType = unit.UnitType, + Q = unit.Q, + R = unit.R, + Status = unit.Status, + CreatedAtUtc = unit.CreatedAtUtc, + UpdatedAtUtc = unit.UpdatedAtUtc + }; +} + public class PlayerUnitsResponse { public int CompletedScouts { get; set; } public int CompletedArchers { get; set; } public List Training { get; set; } = []; + public List Units { get; set; } = []; } diff --git a/microservices/LocationsApi/Models/UnitInstance.cs b/microservices/LocationsApi/Models/UnitInstance.cs new file mode 100644 index 0000000..d41accd --- /dev/null +++ b/microservices/LocationsApi/Models/UnitInstance.cs @@ -0,0 +1,20 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace LocationsApi.Models; + +public class UnitInstance +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ""; + + public string Username { get; set; } = ""; + public string UnitType { get; set; } = "Archer"; + public int Q { get; set; } + public int R { get; set; } + public string Status { get; set; } = "Idle"; + public string? SourceTrainingId { get; set; } + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/microservices/LocationsApi/Models/UnitTraining.cs b/microservices/LocationsApi/Models/UnitTraining.cs index 52fdbf5..b43dc1f 100644 --- a/microservices/LocationsApi/Models/UnitTraining.cs +++ b/microservices/LocationsApi/Models/UnitTraining.cs @@ -14,6 +14,8 @@ public class UnitTraining public int GoldCost { get; set; } = 50; public int WoodCost { get; set; } public int OreCost { get; set; } + public int? OriginQ { get; set; } + public int? OriginR { get; set; } public string Status { get; set; } = "Training"; public DateTime StartedAtUtc { get; set; } = DateTime.UtcNow; public DateTime CompletesAtUtc { get; set; } = DateTime.UtcNow.AddMinutes(1); diff --git a/microservices/LocationsApi/Services/LocationService.cs b/microservices/LocationsApi/Services/LocationService.cs index 5650252..57c5699 100644 --- a/microservices/LocationsApi/Services/LocationService.cs +++ b/microservices/LocationsApi/Services/LocationService.cs @@ -165,6 +165,11 @@ public class LocationService throw new InvalidOperationException("This location already has a building or construction in progress."); } + if (buildingType == "Barracks" && string.Equals(location.Kind, "Home", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Barracks cannot be built on the town hall."); + } + var requiredTerrain = RequiredTerrainForBuilding(buildingType); if (requiredTerrain is not null && !string.Equals(location.Terrain, requiredTerrain, StringComparison.OrdinalIgnoreCase)) { diff --git a/microservices/LocationsApi/Services/UnitTrainingService.cs b/microservices/LocationsApi/Services/UnitTrainingService.cs index 238c33b..4825451 100644 --- a/microservices/LocationsApi/Services/UnitTrainingService.cs +++ b/microservices/LocationsApi/Services/UnitTrainingService.cs @@ -9,6 +9,7 @@ public class UnitTrainingService private const string ArcherUnitType = "Archer"; private readonly IMongoCollection _training; + private readonly IMongoCollection _units; private readonly IMongoCollection _locations; private readonly ResourceService _resources; private readonly GameSettingsService _settings; @@ -25,6 +26,7 @@ public class UnitTrainingService var client = new MongoClient(connectionString); var database = client.GetDatabase(databaseName); _training = database.GetCollection("UnitTraining"); + _units = database.GetCollection("Units"); _locations = database.GetCollection("Locations"); _resources = resources; _settings = settings; @@ -40,6 +42,15 @@ public class UnitTrainingService _training.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(x => x.Username).Ascending(x => x.Status))); + _units.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.Username).Ascending(x => x.UnitType))); + _units.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.SourceTrainingId), + new CreateIndexOptions + { + Unique = true, + Sparse = true + })); } public async Task GetMineAsync(string username) @@ -47,10 +58,11 @@ public class UnitTrainingService await CompleteFinishedTrainingAsync(username); await _training.DeleteManyAsync(x => x.Username == username && x.Status == "Consumed"); - var all = await _training.Find(x => x.Username == username).ToListAsync(); - var completedScouts = all.Count(x => x.UnitType == ScoutUnitType && x.Status == "Completed"); - var completedArchers = all.Count(x => x.UnitType == ArcherUnitType && x.Status == "Completed"); - var activeTraining = all + 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 completedArchers = allUnits.Count(x => x.UnitType == ArcherUnitType); + var activeTraining = allTraining .Where(x => x.Status == "Training") .OrderBy(x => x.CompletesAtUtc) .Select(UnitTrainingResponse.FromTraining) @@ -60,21 +72,37 @@ public class UnitTrainingService { CompletedScouts = completedScouts, CompletedArchers = completedArchers, - Training = activeTraining + Training = activeTraining, + Units = allUnits + .OrderBy(x => x.Q) + .ThenBy(x => x.R) + .ThenBy(x => x.CreatedAtUtc) + .Select(UnitInstanceResponse.FromUnit) + .ToList() }; } - public async Task TrainAsync(string username, string unitType) + public async Task TrainAsync(string username, TrainUnitRequest request) { - var normalizedUnitType = NormalizeUnitType(unitType); + var normalizedUnitType = NormalizeUnitType(request.UnitType); if (normalizedUnitType is null) { - throw new ArgumentException("Unsupported unit type.", nameof(unitType)); + throw new ArgumentException("Unsupported unit type.", nameof(request.UnitType)); } - if (normalizedUnitType == ArcherUnitType && !await HasBuiltBarracksAsync(username)) + Location? origin = null; + if (normalizedUnitType == ArcherUnitType) { - throw new InvalidOperationException("A completed Barracks is required to train Archers."); + if (request.Q is null || request.R is null) + { + throw new InvalidOperationException("Select a completed Barracks to train Archers."); + } + + origin = await GetBuiltBarracksAsync(username, request.Q.Value, request.R.Value); + if (origin is null) + { + throw new InvalidOperationException("A completed Barracks is required to train Archers."); + } } var goldCost = normalizedUnitType == ArcherUnitType ? _archerGoldCost : _scoutGoldCost; @@ -97,6 +125,8 @@ public class UnitTrainingService UnitType = normalizedUnitType, GoldCost = goldCost, WoodCost = woodCost, + OriginQ = origin?.Q, + OriginR = origin?.R, Status = "Training", StartedAtUtc = now, CompletesAtUtc = now.AddSeconds(trainingSeconds), @@ -108,14 +138,16 @@ public class UnitTrainingService return training; } - private async Task HasBuiltBarracksAsync(string username) + private async Task GetBuiltBarracksAsync(string username, int q, int r) { await CompleteFinishedBuildingsAsync(username); return await _locations.Find(x => x.OwnerUsername == username && + x.Q == q && + x.R == r && x.BuildingType == "Barracks" && (x.BuildingStatus == "Built" || x.BuildingStatus == null || x.BuildingStatus == "")) - .AnyAsync(); + .FirstOrDefaultAsync(); } private async Task CompleteFinishedBuildingsAsync(string username) @@ -157,14 +189,52 @@ public class UnitTrainingService private async Task CompleteFinishedTrainingAsync(string username) { var now = DateTime.UtcNow; - var update = Builders.Update - .Set(x => x.Status, "Completed") - .Set(x => x.CompletedAtUtc, now) - .Set(x => x.UpdatedAtUtc, now); + var readyTraining = await _training.Find(x => + x.Username == username && + x.Status == "Training" && + x.CompletesAtUtc <= now) + .ToListAsync(); - await _training.UpdateManyAsync( - x => x.Username == username && x.Status == "Training" && x.CompletesAtUtc <= now, - update); + foreach (var training in readyTraining) + { + var update = Builders.Update + .Set(x => x.Status, "Completed") + .Set(x => x.CompletedAtUtc, now) + .Set(x => x.UpdatedAtUtc, now); + + var completed = await _training.FindOneAndUpdateAsync( + x => x.Id == training.Id && x.Status == "Training", + update, + new FindOneAndUpdateOptions + { + ReturnDocument = ReturnDocument.After + }); + + if (completed is null || completed.UnitType != ArcherUnitType) + { + continue; + } + + var unit = new UnitInstance + { + Username = completed.Username, + UnitType = ArcherUnitType, + Q = completed.OriginQ ?? 0, + R = completed.OriginR ?? 0, + Status = "Idle", + SourceTrainingId = completed.Id, + CreatedAtUtc = now, + UpdatedAtUtc = now + }; + + try + { + await _units.InsertOneAsync(unit); + } + catch (MongoWriteException) + { + } + } } private static string? NormalizeUnitType(string unitType)