From da61ec8bf441aa52a2426149b85d218c5fd3976b Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 19 May 2026 14:58:51 -0500 Subject: [PATCH] updating unit movement --- .../scenes/game/hex_grid_screen.gd | 8 +- .../scenes/game/hex_grid_view.gd | 34 ++++- .../Controllers/LocationsController.cs | 2 +- microservices/LocationsApi/Models/Dto.cs | 36 ++++- .../LocationsApi/Models/LocationIntel.cs | 29 ++++ .../LocationsApi/Services/LocationService.cs | 134 +++++++++++++++++- .../Services/UnitTrainingService.cs | 61 +++++++- 7 files changed, 281 insertions(+), 23 deletions(-) create mode 100644 microservices/LocationsApi/Models/LocationIntel.cs diff --git a/game/territory-autobattler/scenes/game/hex_grid_screen.gd b/game/territory-autobattler/scenes/game/hex_grid_screen.gd index ce55d94..a4f911b 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_screen.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_screen.gd @@ -498,9 +498,9 @@ 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 not grid_view.is_visible_non_rival_tile(q, r): + if not grid_view.is_visible_tile(q, r): if coordinate_label: - coordinate_label.text = "Archers can only move to visible non-rival tiles." + coordinate_label.text = "Archers can only move to visible tiles." return pending_move_q = q @@ -1037,8 +1037,8 @@ func _move_selected_archers(to_q: int, to_r: int) -> void: if not grid_view.is_neighbor(selected_archer_q, selected_archer_r, to_q, to_r): coordinate_label.text = "Archers can only move to an adjacent tile." return - if not grid_view.is_visible_non_rival_tile(to_q, to_r): - coordinate_label.text = "Archers can only move to visible non-rival tiles." + if not grid_view.is_visible_tile(to_q, to_r): + coordinate_label.text = "Archers can only move to visible tiles." return var access_token: String = str(session.get("accessToken", "")) diff --git a/game/territory-autobattler/scenes/game/hex_grid_view.gd b/game/territory-autobattler/scenes/game/hex_grid_view.gd index 12e8f4a..bf587bf 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_view.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_view.gd @@ -75,7 +75,8 @@ func _gui_input(event: InputEvent) -> void: if event.pressed: drag_distance = 0.0 if not event.pressed and drag_distance < 6.0 and hovered_tile.x != 9999: - selected_tile = hovered_tile + var clicked_unit_tile := _archer_tile_at(event.position) + selected_tile = clicked_unit_tile if clicked_unit_tile.x != 9999 else hovered_tile var data: Dictionary = _tile_data(selected_tile.x, selected_tile.y) 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" @@ -442,7 +443,7 @@ func _draw_building_marker(center: Vector2, height: float) -> void: 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_center: Vector2 = _archer_marker_center(center, height) 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) @@ -487,6 +488,31 @@ func _draw_archer_unit_marker(center: Vector2, height: float, count: int) -> voi draw_string(font, badge_rect.position + Vector2(3.5, 9.0) * marker_scale, count_text, HORIZONTAL_ALIGNMENT_LEFT, -1, badge_font_size, Color(0.93, 0.86, 0.58)) +func _archer_marker_center(center: Vector2, height: float) -> Vector2: + return center + Vector2(18.0 * zoom, -height * zoom - 14.0 * zoom) + + +func _archer_tile_at(mouse_position: Vector2) -> Vector2i: + var closest := Vector2i(9999, 9999) + var closest_distance := 999999.0 + for tile in tiles: + var q: int = int(tile["q"]) + var r: int = int(tile["r"]) + if _archer_count_at(q, r) <= 0: + continue + + var center: Vector2 = _tile_center(q, r) + var height: float = float(tile["height"]) + var distance: float = _archer_marker_center(center, height).distance_to(mouse_position) + if distance < closest_distance: + closest_distance = distance + closest = Vector2i(q, r) + + if closest_distance > 26.0 * zoom: + return Vector2i(9999, 9999) + return closest + + func _tile_data(q: int, r: int) -> Dictionary: for tile in tiles: if int(tile["q"]) == q and int(tile["r"]) == r: @@ -583,6 +609,10 @@ func is_visible_non_rival_tile(q: int, r: int) -> bool: return visible_tiles.has(key) and not rival_tiles.has(key) +func is_visible_tile(q: int, r: int) -> bool: + return visible_tiles.has(_tile_key(q, r)) + + func _tile_key(q: int, r: int) -> String: return "%d,%d" % [q, r] diff --git a/microservices/LocationsApi/Controllers/LocationsController.cs b/microservices/LocationsApi/Controllers/LocationsController.cs index cf2ca22..7b1bc93 100644 --- a/microservices/LocationsApi/Controllers/LocationsController.cs +++ b/microservices/LocationsApi/Controllers/LocationsController.cs @@ -50,7 +50,7 @@ public class LocationsController : ControllerBase ? await _locations.GetRangeAsync(minQ.Value, maxQ.Value, minR.Value, maxR.Value) : await _locations.GetAllAsync(); - return Ok(locations.Select(LocationResponse.FromLocation).ToList()); + return Ok(locations.Select(location => LocationResponse.FromLocation(location)).ToList()); } [Authorize(Roles = "USER,SUPER")] diff --git a/microservices/LocationsApi/Models/Dto.cs b/microservices/LocationsApi/Models/Dto.cs index c306a5d..2bf2340 100644 --- a/microservices/LocationsApi/Models/Dto.cs +++ b/microservices/LocationsApi/Models/Dto.cs @@ -41,7 +41,7 @@ public class LocationResponse public DateTime UpdatedAtUtc { get; set; } public bool IsVisible { get; set; } = true; - public static LocationResponse FromLocation(Location location) => new() + public static LocationResponse FromLocation(Location location, bool revealDetails = true) => new() { Id = location.Id, Q = location.Q, @@ -51,16 +51,38 @@ public class LocationResponse OwnerUsername = location.OwnerUsername, Label = location.Label, Level = location.Level, - BuildingType = location.BuildingType, - BuildingStatus = (location.BuildingType == "LogCamp" || location.BuildingType == "Mine" || location.BuildingType == "Barracks") && string.IsNullOrWhiteSpace(location.BuildingStatus) + BuildingType = revealDetails ? location.BuildingType : null, + BuildingStatus = revealDetails && (location.BuildingType == "LogCamp" || location.BuildingType == "Mine" || location.BuildingType == "Barracks") && string.IsNullOrWhiteSpace(location.BuildingStatus) ? "Built" - : location.BuildingStatus, - BuildingStartedAtUtc = location.BuildingStartedAtUtc, - BuildingCompletesAtUtc = location.BuildingCompletesAtUtc, - BuildingBuiltAtUtc = location.BuildingBuiltAtUtc, + : revealDetails ? location.BuildingStatus : null, + BuildingStartedAtUtc = revealDetails ? location.BuildingStartedAtUtc : null, + BuildingCompletesAtUtc = revealDetails ? location.BuildingCompletesAtUtc : null, + BuildingBuiltAtUtc = revealDetails ? location.BuildingBuiltAtUtc : null, HomeAssignedAtUtc = location.HomeAssignedAtUtc, UpdatedAtUtc = location.UpdatedAtUtc, IsVisible = true }; + public static LocationResponse FromIntel(LocationIntel intel) => new() + { + Id = intel.Id, + Q = intel.Q, + R = intel.R, + Terrain = intel.Terrain, + Kind = intel.Kind, + OwnerUsername = intel.OwnerUsername, + Label = intel.Label, + Level = intel.Level, + BuildingType = intel.BuildingType, + BuildingStatus = (intel.BuildingType == "LogCamp" || intel.BuildingType == "Mine" || intel.BuildingType == "Barracks") && string.IsNullOrWhiteSpace(intel.BuildingStatus) + ? "Built" + : intel.BuildingStatus, + BuildingStartedAtUtc = intel.BuildingStartedAtUtc, + BuildingCompletesAtUtc = intel.BuildingCompletesAtUtc, + BuildingBuiltAtUtc = intel.BuildingBuiltAtUtc, + HomeAssignedAtUtc = intel.HomeAssignedAtUtc, + UpdatedAtUtc = intel.ObservedAtUtc, + IsVisible = true + }; + } diff --git a/microservices/LocationsApi/Models/LocationIntel.cs b/microservices/LocationsApi/Models/LocationIntel.cs new file mode 100644 index 0000000..59c6396 --- /dev/null +++ b/microservices/LocationsApi/Models/LocationIntel.cs @@ -0,0 +1,29 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace LocationsApi.Models; + +public class LocationIntel +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ""; + + public string Username { get; set; } = ""; + public int Q { get; set; } + public int R { get; set; } + public string Terrain { get; set; } = "Field"; + public string Kind { get; set; } = "Open"; + public string? OwnerUsername { get; set; } + public string? Label { get; set; } + public int Level { get; set; } = 1; + public string? BuildingType { get; set; } + public string? BuildingStatus { get; set; } + public DateTime? BuildingStartedAtUtc { get; set; } + public DateTime? BuildingCompletesAtUtc { get; set; } + public DateTime? BuildingBuiltAtUtc { get; set; } + public DateTime? HomeAssignedAtUtc { get; set; } + public DateTime ObservedAtUtc { get; set; } = DateTime.UtcNow; + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; +} diff --git a/microservices/LocationsApi/Services/LocationService.cs b/microservices/LocationsApi/Services/LocationService.cs index 57c5699..c5a7f4d 100644 --- a/microservices/LocationsApi/Services/LocationService.cs +++ b/microservices/LocationsApi/Services/LocationService.cs @@ -6,6 +6,8 @@ namespace LocationsApi.Services; public class LocationService { private readonly IMongoCollection _locations; + private readonly IMongoCollection _units; + private readonly IMongoCollection _intel; private readonly ResourceService _resources; private readonly GameSettingsService _settings; private readonly int _homeMinDistance; @@ -27,6 +29,8 @@ public class LocationService var client = new MongoClient(connectionString); var database = client.GetDatabase(databaseName); _locations = database.GetCollection("Locations"); + _units = database.GetCollection("Units"); + _intel = database.GetCollection("LocationIntel"); _resources = resources; _settings = settings; _homeMinDistance = int.TryParse(cfg["World:HomeMinDistance"], out var minDistance) ? Math.Max(1, minDistance) : 8; @@ -74,6 +78,13 @@ public class LocationService Unique = true, PartialFilterExpression = Builders.Filter.Eq(x => x.Kind, "Home") })); + + _intel.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys + .Ascending(x => x.Username) + .Ascending(x => x.Q) + .Ascending(x => x.R), + new CreateIndexOptions { Unique = true })); } public Task> GetAllAsync() => @@ -315,8 +326,10 @@ public class LocationService ownedLocations.Add(await AssignHomeAsync(username)); } + var playerUnits = await _units.Find(x => x.Username == username).ToListAsync(); var depth = Math.Max(0, visibilityDepth ?? _visibilityDepth); var visibleCoordinates = new HashSet<(int q, int r)>(); + var occupiedCoordinates = playerUnits.Select(x => (x.Q, x.R)).ToHashSet(); foreach (var owned in ownedLocations) { @@ -326,6 +339,14 @@ public class LocationService } } + foreach (var unit in playerUnits) + { + foreach (var coordinate in HexRange(unit.Q, unit.R, 1)) + { + visibleCoordinates.Add(coordinate); + } + } + var minQ = visibleCoordinates.Min(x => x.q); var maxQ = visibleCoordinates.Max(x => x.q); var minR = visibleCoordinates.Min(x => x.r); @@ -349,12 +370,15 @@ public class LocationService } } + await RefreshIntelAsync(username, occupiedCoordinates, existingByCoordinate); + var intelByCoordinate = await GetIntelByCoordinateAsync(username, visibleCoordinates); + return visibleCoordinates .OrderBy(x => x.q) .ThenBy(x => x.r) .Select(coordinate => existingByCoordinate.TryGetValue(coordinate, out var existing) - ? LocationResponse.FromLocation(existing) + ? VisibleResponseFor(username, existing, occupiedCoordinates, intelByCoordinate) : throw new InvalidOperationException($"Visible location {coordinate.q},{coordinate.r} was not generated.")) .ToList(); } @@ -369,7 +393,10 @@ public class LocationService var existing = await GetAsync(q, r); if (existing is not null) { - return LocationResponse.FromLocation(existing); + var occupiedCoordinates = await GetOccupiedCoordinatesAsync(username); + await RefreshIntelAsync(username, occupiedCoordinates, new Dictionary<(int Q, int R), Location> { [(existing.Q, existing.R)] = existing }); + var intelByCoordinate = await GetIntelByCoordinateAsync(username, new[] { (q, r) }); + return VisibleResponseFor(username, existing, occupiedCoordinates, intelByCoordinate); } var nearbyLocations = await GetRangeAsync(q - 1, q + 1, r - 1, r + 1); @@ -388,7 +415,13 @@ public class LocationService return false; } - return ownedLocations.Any(owned => HexDistance(owned.Q, owned.R, q, r) <= _visibilityDepth); + if (ownedLocations.Any(owned => HexDistance(owned.Q, owned.R, q, r) <= _visibilityDepth)) + { + return true; + } + + var playerUnits = await _units.Find(x => x.Username == username).ToListAsync(); + return playerUnits.Any(unit => HexDistance(unit.Q, unit.R, q, r) <= 1); } public async Task IsAdjacentToOwnedAsync(string username, int q, int r) @@ -434,6 +467,101 @@ public class LocationService } } + private async Task> GetOccupiedCoordinatesAsync(string username) + { + var units = await _units.Find(x => x.Username == username).ToListAsync(); + return units.Select(x => (x.Q, x.R)).ToHashSet(); + } + + private async Task> GetIntelByCoordinateAsync( + string username, + IEnumerable<(int q, int r)> coordinates) + { + var coordinateList = coordinates.Select(x => (x.q, x.r)).ToList(); + if (coordinateList.Count == 0) + { + return new Dictionary<(int Q, int R), LocationIntel>(); + } + + var minQ = coordinateList.Min(x => x.q); + var maxQ = coordinateList.Max(x => x.q); + var minR = coordinateList.Min(x => x.r); + var maxR = coordinateList.Max(x => x.r); + var coordinateSet = coordinateList.ToHashSet(); + var records = await _intel.Find(x => + x.Username == username && + x.Q >= minQ && x.Q <= maxQ && + x.R >= minR && x.R <= maxR) + .ToListAsync(); + + return records + .Where(x => coordinateSet.Contains((x.Q, x.R))) + .ToDictionary(x => (x.Q, x.R), x => x); + } + + private async Task RefreshIntelAsync( + string username, + HashSet<(int Q, int R)> occupiedCoordinates, + Dictionary<(int Q, int R), Location> existingByCoordinate) + { + var now = DateTime.UtcNow; + foreach (var coordinate in occupiedCoordinates) + { + if (!existingByCoordinate.TryGetValue(coordinate, out var location)) + { + location = await GetAsync(coordinate.Q, coordinate.R); + if (location is null) + { + continue; + } + } + + if (string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var update = Builders.Update + .SetOnInsert(x => x.CreatedAtUtc, now) + .Set(x => x.Terrain, location.Terrain) + .Set(x => x.Kind, location.Kind) + .Set(x => x.OwnerUsername, location.OwnerUsername) + .Set(x => x.Label, location.Label) + .Set(x => x.Level, location.Level) + .Set(x => x.BuildingType, location.BuildingType) + .Set(x => x.BuildingStatus, location.BuildingStatus) + .Set(x => x.BuildingStartedAtUtc, location.BuildingStartedAtUtc) + .Set(x => x.BuildingCompletesAtUtc, location.BuildingCompletesAtUtc) + .Set(x => x.BuildingBuiltAtUtc, location.BuildingBuiltAtUtc) + .Set(x => x.HomeAssignedAtUtc, location.HomeAssignedAtUtc) + .Set(x => x.ObservedAtUtc, now) + .Set(x => x.UpdatedAtUtc, now); + + await _intel.UpdateOneAsync( + x => x.Username == username && x.Q == coordinate.Q && x.R == coordinate.R, + update, + new UpdateOptions { IsUpsert = true }); + } + } + + private static LocationResponse VisibleResponseFor( + string username, + Location location, + HashSet<(int Q, int R)> occupiedCoordinates, + Dictionary<(int Q, int R), LocationIntel> intelByCoordinate) + { + if (string.IsNullOrWhiteSpace(location.OwnerUsername) || + string.Equals(location.OwnerUsername, username, StringComparison.OrdinalIgnoreCase) || + occupiedCoordinates.Contains((location.Q, location.R))) + { + return LocationResponse.FromLocation(location); + } + + return intelByCoordinate.TryGetValue((location.Q, location.R), out var intel) + ? LocationResponse.FromIntel(intel) + : LocationResponse.FromLocation(location, revealDetails: false); + } + private async Task> GenerateVisibleLocationsAsync( List<(int q, int r)> missingCoordinates, Dictionary<(int Q, int R), Location> existingByCoordinate) diff --git a/microservices/LocationsApi/Services/UnitTrainingService.cs b/microservices/LocationsApi/Services/UnitTrainingService.cs index 6c5c0c7..02f6dce 100644 --- a/microservices/LocationsApi/Services/UnitTrainingService.cs +++ b/microservices/LocationsApi/Services/UnitTrainingService.cs @@ -160,7 +160,7 @@ public class UnitTrainingService if (!await CanMoveToAsync(username, request.ToQ, request.ToR)) { - throw new InvalidOperationException("Archers can only move to visible non-rival tiles."); + throw new InvalidOperationException("Archers can only move to visible tiles."); } var now = DateTime.UtcNow; @@ -187,6 +187,8 @@ public class UnitTrainingService .Set(x => x.Status, "Idle") .Set(x => x.UpdatedAtUtc, now)); + await ClaimTargetIfEmptyAsync(username, request.ToQ, request.ToR, now); + return await _units.Find(x => x.Username == username && x.UnitType == ArcherUnitType && @@ -226,15 +228,62 @@ public class UnitTrainingService private async Task CanMoveToAsync(string username, int q, int r) { var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync(); - if (!ownedLocations.Any(owned => HexDistance(owned.Q, owned.R, q, r) <= _visibilityDepth)) + if (ownedLocations.Any(owned => HexDistance(owned.Q, owned.R, q, r) <= _visibilityDepth)) { - return false; + return true; } + var playerUnits = await _units.Find(x => x.Username == username).ToListAsync(); + return playerUnits.Any(unit => HexDistance(unit.Q, unit.R, q, r) <= 1); + } + + 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(); var target = await _locations.Find(x => x.Q == q && x.R == r).FirstOrDefaultAsync(); - return target is null || - string.IsNullOrWhiteSpace(target.OwnerUsername) || - string.Equals(target.OwnerUsername, username, StringComparison.OrdinalIgnoreCase); + var hasBuilding = target is not null && !string.IsNullOrWhiteSpace(target.BuildingType); + + if (hasOtherUnits || hasBuilding) + { + return; + } + + if (target is null) + { + var location = new Location + { + Q = q, + R = r, + Terrain = "Field", + Kind = "Claimed", + OwnerUsername = username, + Level = 1, + CreatedAtUtc = now, + UpdatedAtUtc = now + }; + + try + { + await _locations.InsertOneAsync(location); + } + catch (MongoWriteException) + { + } + + return; + } + + if (string.Equals(target.OwnerUsername, username, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + await _locations.UpdateOneAsync( + x => x.Id == target.Id && (x.BuildingType == null || x.BuildingType == ""), + Builders.Update + .Set(x => x.OwnerUsername, username) + .Set(x => x.Kind, "Claimed") + .Set(x => x.UpdatedAtUtc, now)); } private static int HexDistance(int q1, int r1, int q2, int r2)