diff --git a/game/territory-autobattler/scenes/game/hex_grid_screen.gd b/game/territory-autobattler/scenes/game/hex_grid_screen.gd index c8fcc0f..3c1625a 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_screen.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_screen.gd @@ -1,15 +1,21 @@ extends Control const SESSION_PATH := "user://auth_session.json" +const LOCATIONS_BASE_URL := "https://tloc.ranaze.com" const HexGridView := preload("res://scenes/game/hex_grid_view.gd") var coordinate_label: Label var title_font: Font var grid_view +var http_request: HTTPRequest +var visible_request: HTTPRequest +var session: Dictionary = {} func _ready() -> void: + session = _load_session() _build_ui() + _assign_home_location() func _build_ui() -> void: @@ -97,6 +103,14 @@ func _build_ui() -> void: coordinate_label.add_theme_color_override("font_color", Color(0.72, 0.8, 0.78)) bottom_margin.add_child(coordinate_label) + http_request = HTTPRequest.new() + http_request.request_completed.connect(_on_home_request_completed) + add_child(http_request) + + visible_request = HTTPRequest.new() + visible_request.request_completed.connect(_on_visible_request_completed) + add_child(visible_request) + func _on_tile_hovered(q: int, r: int, terrain: String) -> void: if coordinate_label: @@ -109,23 +123,110 @@ func _on_tile_selected(q: int, r: int, terrain: String) -> void: func _session_name() -> String: + if not session.is_empty(): + var session_username: String = str(session.get("username", "Signed in")) + var session_role: String = str(session.get("role", "USER")) + return "%s | %s" % [session_username, session_role] + + return "Signed in" + + +func _load_session() -> Dictionary: if get_tree().has_meta("auth_session"): var current_session = get_tree().get_meta("auth_session") if typeof(current_session) == TYPE_DICTIONARY: - var current_username: String = str(current_session.get("username", "Signed in")) - var current_role: String = str(current_session.get("role", "USER")) - return "%s | %s" % [current_username, current_role] + return current_session var file := FileAccess.open(SESSION_PATH, FileAccess.READ) if not file: - return "Signed in" + return {} var parsed = JSON.parse_string(file.get_as_text()) if typeof(parsed) == TYPE_DICTIONARY: - var username: String = str(parsed.get("username", "Signed in")) - var role: String = str(parsed.get("role", "USER")) - return "%s | %s" % [username, role] - return "Signed in" + return parsed + return {} + + +func _assign_home_location() -> void: + var access_token: String = str(session.get("accessToken", "")) + if access_token.is_empty(): + coordinate_label.text = "No auth token available. Home territory was not assigned." + return + + coordinate_label.text = "Assigning home territory..." + var error: int = http_request.request( + LOCATIONS_BASE_URL + "/api/locations/home", + PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % access_token + ]), + HTTPClient.METHOD_POST, + "" + ) + if error != OK: + coordinate_label.text = "Could not contact the locations service." + + +func _on_home_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + if result != HTTPRequest.RESULT_SUCCESS: + coordinate_label.text = "Locations service request failed." + return + + var text: String = body.get_string_from_utf8() + if response_code < 200 or response_code >= 300: + coordinate_label.text = "Home assignment failed: %s" % text + return + + var parsed = JSON.parse_string(text) + if typeof(parsed) != TYPE_DICTIONARY: + coordinate_label.text = "Locations service returned invalid home data." + return + + var home: Dictionary = parsed + var q: int = int(home.get("q", 0)) + var r: int = int(home.get("r", 0)) + var terrain: String = str(home.get("terrain", "Command")) + grid_view.set_home_location(q, r) + coordinate_label.text = "Home territory assigned at q:%d r:%d | %s" % [q, r, terrain] + _load_visible_locations() + + +func _load_visible_locations() -> void: + var access_token: String = str(session.get("accessToken", "")) + if access_token.is_empty(): + coordinate_label.text = "No auth token available. Visibility was not loaded." + return + + var error: int = visible_request.request( + LOCATIONS_BASE_URL + "/api/locations/visible", + PackedStringArray([ + "Authorization: Bearer %s" % access_token + ]), + HTTPClient.METHOD_GET + ) + if error != OK: + coordinate_label.text = "Could not request visible territory." + + +func _on_visible_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + if result != HTTPRequest.RESULT_SUCCESS: + coordinate_label.text = "Visible territory request failed." + return + + var text: String = body.get_string_from_utf8() + if response_code < 200 or response_code >= 300: + coordinate_label.text = "Visible territory failed: %s" % text + return + + var parsed = JSON.parse_string(text) + if typeof(parsed) != TYPE_ARRAY: + coordinate_label.text = "Locations service returned invalid visibility data." + return + + var username: String = str(session.get("username", "")) + var visible_locations: Array = parsed + grid_view.set_visible_locations(visible_locations, username) + coordinate_label.text = "Visible territory loaded. Sight extends 3 hexes beyond controlled land." func _panel_style(color: Color, border_color: Color, border_width: int, radius: int) -> StyleBoxFlat: diff --git a/game/territory-autobattler/scenes/game/hex_grid_view.gd b/game/territory-autobattler/scenes/game/hex_grid_view.gd index 83c81cb..824a386 100644 --- a/game/territory-autobattler/scenes/game/hex_grid_view.gd +++ b/game/territory-autobattler/scenes/game/hex_grid_view.gd @@ -14,7 +14,10 @@ var drag_distance: float = 0.0 var last_mouse_position: Vector2 = Vector2.ZERO var hovered_tile: Vector2i = Vector2i(9999, 9999) var selected_tile: Vector2i = Vector2i(9999, 9999) +var home_tile: Vector2i = Vector2i(9999, 9999) var tiles: Array[Dictionary] = [] +var visible_tiles: Dictionary = {} +var owned_tiles: Dictionary = {} func _ready() -> void: @@ -26,19 +29,34 @@ func _ready() -> void: func _build_tiles() -> void: tiles.clear() - var radius: int = 7 - for q in range(-radius, radius + 1): - var r_min: int = int(max(-radius, -q - radius)) - var r_max: int = int(min(radius, -q + radius)) - for r in range(r_min, r_max + 1): - var height: float = _tile_height(q, r) - tiles.append({ - "q": q, - "r": r, - "height": height, - "terrain": _terrain_name(q, r, height), - "color": _terrain_color(q, r, height) - }) + _build_tiles_around(0, 0, 7) + + +func _build_tiles_around(center_q: int, center_r: int, radius: int) -> void: + tiles.clear() + for dq in range(-radius, radius + 1): + var min_dr: int = int(max(-radius, -dq - radius)) + var max_dr: int = int(min(radius, -dq + radius)) + for dr in range(min_dr, max_dr + 1): + _add_or_update_tile(center_q + dq, center_r + dr) + + +func _add_or_update_tile(q: int, r: int, terrain_override: String = "") -> void: + var height: float = _tile_height(q, r) + var terrain: String = terrain_override if not terrain_override.is_empty() else _terrain_name(q, r, height) + var existing: Dictionary = _tile_data(q, r) + if not existing.is_empty(): + existing["terrain"] = terrain + existing["color"] = _terrain_color_for_name(terrain, q, r, height) + return + + tiles.append({ + "q": q, + "r": r, + "height": height, + "terrain": terrain, + "color": _terrain_color_for_name(terrain, q, r, height) + }) func _gui_input(event: InputEvent) -> void: @@ -106,12 +124,22 @@ func _draw_tile(tile: Dictionary) -> void: var coord: Vector2i = Vector2i(q, r) var is_hovered: bool = coord == hovered_tile var is_selected: bool = coord == selected_tile + var key: String = _tile_key(q, r) + var is_visible: bool = visible_tiles.has(key) + var is_owned: bool = owned_tiles.has(key) var side_points: PackedVector2Array = PackedVector2Array([points[2], points[3], points[4], points[4] + Vector2(0, 14), points[3] + Vector2(0, 14), points[2] + Vector2(0, 14)]) - draw_colored_polygon(side_points, color.darkened(0.28)) + var draw_color: Color = color if is_visible else Color(0.045, 0.065, 0.065) + draw_colored_polygon(side_points, draw_color.darkened(0.28)) - if is_selected: + if not is_visible: + draw_colored_polygon(points, Color(0.055, 0.075, 0.075)) + elif is_selected: draw_colored_polygon(points, color.lightened(0.22)) + elif coord == home_tile: + draw_colored_polygon(points, Color(0.72, 0.58, 0.2)) + elif is_owned: + draw_colored_polygon(points, Color(0.34, 0.52, 0.36)) elif is_hovered: draw_colored_polygon(points, color.lightened(0.13)) else: @@ -119,8 +147,14 @@ func _draw_tile(tile: Dictionary) -> void: draw_polyline(_closed_points(points), Color(0.04, 0.065, 0.06, 0.72), 2.0) - if is_selected: + if not is_visible: + draw_polyline(_closed_points(points), Color(0.11, 0.15, 0.15, 0.75), 1.0) + elif is_selected: draw_polyline(_closed_points(points), Color(0.95, 0.76, 0.28), 4.0) + elif coord == home_tile: + draw_polyline(_closed_points(points), Color(1.0, 0.88, 0.38), 3.0) + elif is_owned: + draw_polyline(_closed_points(points), Color(0.66, 0.9, 0.56), 2.5) func _update_hover(mouse_position: Vector2) -> void: @@ -143,15 +177,20 @@ func _update_hover(mouse_position: Vector2) -> void: hovered_tile = closest if hovered_tile.x != 9999: var data: Dictionary = _tile_data(hovered_tile.x, hovered_tile.y) - tile_hovered.emit(hovered_tile.x, hovered_tile.y, str(data.get("terrain", "Open"))) + var key: String = _tile_key(hovered_tile.x, hovered_tile.y) + var terrain: String = str(data.get("terrain", "Open")) if visible_tiles.has(key) else "Unexplored" + tile_hovered.emit(hovered_tile.x, hovered_tile.y, terrain) queue_redraw() func _tile_center(q: int, r: int) -> Vector2: + return _screen_point(_iso_world_point(q, r)) + + +func _iso_world_point(q: int, r: int) -> Vector2: var world_x: float = HEX_SIZE * SQRT_3 * (q + r * 0.5) var world_y: float = HEX_SIZE * 1.5 * r - var iso: Vector2 = Vector2(world_x - world_y, (world_x + world_y) * ISO_Y_SCALE) - return _screen_point(iso) + return Vector2(world_x - world_y, (world_x + world_y) * ISO_Y_SCALE) func _screen_point(world_point: Vector2) -> Vector2: @@ -182,6 +221,42 @@ func _tile_data(q: int, r: int) -> Dictionary: return {} +func set_home_location(q: int, r: int) -> void: + home_tile = Vector2i(q, r) + selected_tile = home_tile + _build_tiles_around(q, r, 9) + camera_offset = -_iso_world_point(q, r) + queue_redraw() + + +func set_visible_locations(locations: Array, username: String) -> void: + visible_tiles.clear() + owned_tiles.clear() + + for item in locations: + if typeof(item) != TYPE_DICTIONARY: + continue + + var location: Dictionary = item + var q: int = int(location.get("q", 0)) + var r: int = int(location.get("r", 0)) + var terrain: String = str(location.get("terrain", "Field")) + var owner: String = str(location.get("ownerUsername", "")) + var key: String = _tile_key(q, r) + + visible_tiles[key] = location + _add_or_update_tile(q, r, terrain) + + if not owner.is_empty() and owner == username: + owned_tiles[key] = location + + queue_redraw() + + +func _tile_key(q: int, r: int) -> String: + return "%d,%d" % [q, r] + + func _tile_height(q: int, r: int) -> float: var distance: int = max(max(abs(q), abs(r)), abs(-q - r)) if distance <= 1: @@ -207,6 +282,10 @@ func _terrain_name(q: int, r: int, height: float) -> String: func _terrain_color(q: int, r: int, height: float) -> Color: var terrain: String = _terrain_name(q, r, height) + return _terrain_color_for_name(terrain, q, r, height) + + +func _terrain_color_for_name(terrain: String, _q: int, _r: int, _height: float) -> Color: match terrain: "Command": return Color(0.68, 0.58, 0.28) diff --git a/microservices/LocationsApi/Controllers/LocationsController.cs b/microservices/LocationsApi/Controllers/LocationsController.cs index b7d241a..6803d83 100644 --- a/microservices/LocationsApi/Controllers/LocationsController.cs +++ b/microservices/LocationsApi/Controllers/LocationsController.cs @@ -17,6 +17,7 @@ public class LocationsController : ControllerBase _locations = locations; } + [Authorize(Roles = "USER,SUPER")] [HttpGet] public async Task>> GetAll( [FromQuery] int? minQ, @@ -24,6 +25,25 @@ public class LocationsController : ControllerBase [FromQuery] int? minR, [FromQuery] int? maxR) { + if (!User.IsInRole("SUPER")) + { + var username = User.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrWhiteSpace(username)) + { + return Unauthorized(); + } + + var visible = await _locations.GetVisibleAsync(username); + if (minQ.HasValue && maxQ.HasValue && minR.HasValue && maxR.HasValue) + { + visible = visible + .Where(x => x.Q >= minQ.Value && x.Q <= maxQ.Value && x.R >= minR.Value && x.R <= maxR.Value) + .ToList(); + } + + return Ok(visible); + } + var locations = minQ.HasValue && maxQ.HasValue && minR.HasValue && maxR.HasValue ? await _locations.GetRangeAsync(minQ.Value, maxQ.Value, minR.Value, maxR.Value) : await _locations.GetAllAsync(); @@ -31,9 +51,22 @@ public class LocationsController : ControllerBase return Ok(locations.Select(LocationResponse.FromLocation).ToList()); } + [Authorize(Roles = "USER,SUPER")] [HttpGet("{q:int}/{r:int}")] public async Task> Get(int q, int r) { + if (!User.IsInRole("SUPER")) + { + var username = User.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrWhiteSpace(username)) + { + return Unauthorized(); + } + + var visibleLocation = await _locations.GetVisibleLocationAsync(username, q, r); + return visibleLocation is null ? NotFound() : Ok(visibleLocation); + } + var location = await _locations.GetAsync(q, r); return location is null ? NotFound() : Ok(LocationResponse.FromLocation(location)); } @@ -48,10 +81,61 @@ public class LocationsController : ControllerBase return Unauthorized(); } + if (!User.IsInRole("SUPER") && !await _locations.CanSeeAsync(username, request.Q, request.R)) + { + return NotFound(); + } + var location = await _locations.ClaimAsync(request.Q, request.R, username); + if (location is null) + { + return Conflict("Location is already controlled by another player."); + } + return Ok(LocationResponse.FromLocation(location)); } + [Authorize(Roles = "USER,SUPER")] + [HttpGet("home")] + public async Task> GetHome() + { + var username = User.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrWhiteSpace(username)) + { + return Unauthorized(); + } + + var home = await _locations.GetHomeAsync(username); + return home is null ? NotFound() : Ok(LocationResponse.FromLocation(home)); + } + + [Authorize(Roles = "USER,SUPER")] + [HttpPost("home")] + public async Task> AssignHome() + { + var username = User.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrWhiteSpace(username)) + { + return Unauthorized(); + } + + var home = await _locations.AssignHomeAsync(username); + return Ok(LocationResponse.FromLocation(home)); + } + + [Authorize(Roles = "USER,SUPER")] + [HttpGet("visible")] + public async Task>> GetVisible([FromQuery] int? depth) + { + var username = User.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrWhiteSpace(username)) + { + return Unauthorized(); + } + + return Ok(await _locations.GetVisibleAsync(username, depth)); + } + [Authorize(Roles = "SUPER")] [HttpPut] public async Task> Upsert([FromBody] UpsertLocationRequest request) diff --git a/microservices/LocationsApi/Models/Dto.cs b/microservices/LocationsApi/Models/Dto.cs index 8566049..879deba 100644 --- a/microservices/LocationsApi/Models/Dto.cs +++ b/microservices/LocationsApi/Models/Dto.cs @@ -21,10 +21,13 @@ public class LocationResponse 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; } + public DateTime? HomeAssignedAtUtc { get; set; } public DateTime UpdatedAtUtc { get; set; } + public bool IsVisible { get; set; } = true; public static LocationResponse FromLocation(Location location) => new() { @@ -32,9 +35,48 @@ public class LocationResponse Q = location.Q, R = location.R, Terrain = location.Terrain, + Kind = location.Kind, OwnerUsername = location.OwnerUsername, Label = location.Label, Level = location.Level, - UpdatedAtUtc = location.UpdatedAtUtc + HomeAssignedAtUtc = location.HomeAssignedAtUtc, + UpdatedAtUtc = location.UpdatedAtUtc, + IsVisible = true + }; + + public static LocationResponse FromCoordinate(int q, int r) => new() + { + Q = q, + R = r, + Terrain = LocationTerrain.FromCoordinate(q, r), + Kind = "Open", + Level = 1, + UpdatedAtUtc = DateTime.UtcNow, + IsVisible = true }; } + +public static class LocationTerrain +{ + public static string FromCoordinate(int q, int r) + { + var height = Math.Max(Math.Max(Math.Abs(q), Math.Abs(r)), Math.Abs(-q - r)); + if (height <= 1) + { + return "Command"; + } + if ((q * 19 + r * 7) % 5 == 0) + { + return "Highland"; + } + if ((q + r) % 4 == 0) + { + return "Forest"; + } + if ((q * 3 - r) % 6 == 0) + { + return "Ore"; + } + return "Field"; + } +} diff --git a/microservices/LocationsApi/Models/Location.cs b/microservices/LocationsApi/Models/Location.cs index be419a9..da2b09a 100644 --- a/microservices/LocationsApi/Models/Location.cs +++ b/microservices/LocationsApi/Models/Location.cs @@ -12,9 +12,11 @@ public class Location 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 DateTime? HomeAssignedAtUtc { get; set; } public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow; } diff --git a/microservices/LocationsApi/README.md b/microservices/LocationsApi/README.md index 16260c4..537eef7 100644 --- a/microservices/LocationsApi/README.md +++ b/microservices/LocationsApi/README.md @@ -4,10 +4,14 @@ Tracks hex locations for the territory map. ## Endpoints -- `GET /api/locations` List all known locations. -- `GET /api/locations?minQ=-7&maxQ=7&minR=-7&maxR=7` List a coordinate range. -- `GET /api/locations/{q}/{r}` Fetch one location. +- `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. +- `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. +- `GET /api/locations/visible?depth=3` Override visibility depth for a request. - `PUT /api/locations` Upsert location data (SUPER only). `claim` and `put` require the same JWT issued by AuthApi. diff --git a/microservices/LocationsApi/Services/LocationService.cs b/microservices/LocationsApi/Services/LocationService.cs index 125dc13..240e5de 100644 --- a/microservices/LocationsApi/Services/LocationService.cs +++ b/microservices/LocationsApi/Services/LocationService.cs @@ -6,6 +6,9 @@ namespace LocationsApi.Services; public class LocationService { private readonly IMongoCollection _locations; + private readonly int _homeMinDistance; + private readonly int _homeSearchRadius; + private readonly int _visibilityDepth; public LocationService(IConfiguration cfg) { @@ -14,6 +17,9 @@ public class LocationService var client = new MongoClient(connectionString); var database = client.GetDatabase(databaseName); _locations = database.GetCollection("Locations"); + _homeMinDistance = int.TryParse(cfg["World:HomeMinDistance"], out var minDistance) ? Math.Max(1, minDistance) : 8; + _homeSearchRadius = int.TryParse(cfg["World:HomeSearchRadius"], out var searchRadius) ? Math.Max(_homeMinDistance, searchRadius) : 80; + _visibilityDepth = int.TryParse(cfg["World:VisibilityDepth"], out var visibilityDepth) ? Math.Max(0, visibilityDepth) : 3; var coordinateIndex = Builders.IndexKeys .Ascending(x => x.Q) @@ -21,6 +27,17 @@ public class LocationService _locations.Indexes.CreateOne(new CreateIndexModel( coordinateIndex, new CreateIndexOptions { Unique = true })); + + var ownerHomeIndex = Builders.IndexKeys + .Ascending(x => x.OwnerUsername) + .Ascending(x => x.Kind); + _locations.Indexes.CreateOne(new CreateIndexModel( + ownerHomeIndex, + new CreateIndexOptions + { + Unique = true, + PartialFilterExpression = Builders.Filter.Eq(x => x.Kind, "Home") + })); } public Task> GetAllAsync() => @@ -44,13 +61,16 @@ public class LocationService var update = Builders.Update .SetOnInsert(x => x.CreatedAtUtc, now) .Set(x => x.Terrain, NormalizeTerrain(request.Terrain)) + .Set(x => x.Kind, "Open") .Set(x => x.Label, request.Label) .Set(x => x.Level, Math.Max(1, request.Level)) .Set(x => x.UpdatedAtUtc, now); if (!string.IsNullOrWhiteSpace(ownerUsername)) { - update = update.Set(x => x.OwnerUsername, ownerUsername); + update = update + .Set(x => x.OwnerUsername, ownerUsername) + .Set(x => x.Kind, "Claimed"); } var options = new FindOneAndUpdateOptions @@ -65,12 +85,22 @@ public class LocationService options); } - public async Task ClaimAsync(int q, int r, string username) + public async Task ClaimAsync(int q, int r, string username) { var existing = await GetAsync(q, r); if (existing is not null) { + if (!string.IsNullOrWhiteSpace(existing.OwnerUsername) && + !string.Equals(existing.OwnerUsername, username, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + existing.OwnerUsername = username; + if (string.IsNullOrWhiteSpace(existing.Kind) || existing.Kind == "Open") + { + existing.Kind = "Claimed"; + } existing.UpdatedAtUtc = DateTime.UtcNow; await _locations.ReplaceOneAsync(x => x.Id == existing.Id, existing); return existing; @@ -79,6 +109,203 @@ public class LocationService return await UpsertAsync(new UpsertLocationRequest { Q = q, R = r, Terrain = "Field" }, username); } + public async Task GetHomeAsync(string username) => + await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync(); + + public async Task AssignHomeAsync(string username) + { + var existingHome = await GetHomeAsync(username); + if (existingHome is not null) + { + return existingHome; + } + + var homeLocations = await _locations.Find(x => x.Kind == "Home").ToListAsync(); + + foreach (var candidate in HomeCandidates(username)) + { + if (!IsFarEnoughFromHomes(candidate.q, candidate.r, homeLocations)) + { + continue; + } + + var existingLocation = await GetAsync(candidate.q, candidate.r); + if (existingLocation is not null && !string.IsNullOrWhiteSpace(existingLocation.OwnerUsername)) + { + continue; + } + + var home = await TryCreateHomeAsync(username, candidate.q, candidate.r); + if (home is not null) + { + return home; + } + + homeLocations = await _locations.Find(x => x.Kind == "Home").ToListAsync(); + } + + throw new InvalidOperationException("No valid home location is available in the configured search radius."); + } + + public async Task> GetVisibleAsync(string username, int? visibilityDepth = null) + { + var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync(); + if (ownedLocations.Count == 0) + { + ownedLocations.Add(await AssignHomeAsync(username)); + } + + var depth = Math.Max(0, visibilityDepth ?? _visibilityDepth); + var visibleCoordinates = new HashSet<(int q, int r)>(); + + foreach (var owned in ownedLocations) + { + foreach (var coordinate in HexRange(owned.Q, owned.R, depth)) + { + visibleCoordinates.Add(coordinate); + } + } + + var minQ = visibleCoordinates.Min(x => x.q); + var maxQ = visibleCoordinates.Max(x => x.q); + var minR = visibleCoordinates.Min(x => x.r); + var maxR = visibleCoordinates.Max(x => x.r); + + var existingLocations = await GetRangeAsync(minQ, maxQ, minR, maxR); + var existingByCoordinate = existingLocations.ToDictionary(x => (x.Q, x.R), x => x); + + return visibleCoordinates + .OrderBy(x => x.q) + .ThenBy(x => x.r) + .Select(coordinate => + existingByCoordinate.TryGetValue(coordinate, out var existing) + ? LocationResponse.FromLocation(existing) + : LocationResponse.FromCoordinate(coordinate.q, coordinate.r)) + .ToList(); + } + + public async Task GetVisibleLocationAsync(string username, int q, int r) + { + if (!await CanSeeAsync(username, q, r)) + { + return null; + } + + var existing = await GetAsync(q, r); + return existing is null + ? LocationResponse.FromCoordinate(q, r) + : LocationResponse.FromLocation(existing); + } + + public async Task CanSeeAsync(string username, int q, int r) + { + var ownedLocations = await _locations.Find(x => x.OwnerUsername == username).ToListAsync(); + if (ownedLocations.Count == 0) + { + return false; + } + + return ownedLocations.Any(owned => HexDistance(owned.Q, owned.R, q, r) <= _visibilityDepth); + } + + private async Task TryCreateHomeAsync(string username, int q, int r) + { + var now = DateTime.UtcNow; + var update = Builders.Update + .SetOnInsert(x => x.CreatedAtUtc, now) + .Set(x => x.Terrain, "Command") + .Set(x => x.Kind, "Home") + .Set(x => x.OwnerUsername, username) + .Set(x => x.Label, $"{username}'s Home") + .Set(x => x.Level, 1) + .Set(x => x.HomeAssignedAtUtc, now) + .Set(x => x.UpdatedAtUtc, now); + + try + { + return await _locations.FindOneAndUpdateAsync( + x => x.Q == q && x.R == r && (x.OwnerUsername == null || x.OwnerUsername == username), + update, + new FindOneAndUpdateOptions + { + IsUpsert = true, + ReturnDocument = ReturnDocument.After + }); + } + catch (MongoWriteException) + { + return await GetHomeAsync(username); + } + catch (MongoCommandException) + { + return await GetHomeAsync(username); + } + } + + private IEnumerable<(int q, int r)> HomeCandidates(string username) + { + var preferredDirection = Math.Abs(username.GetHashCode()) % 6; + yield return (0, 0); + + for (var radius = _homeMinDistance; radius <= _homeSearchRadius; radius++) + { + foreach (var candidate in HexRing(radius, preferredDirection)) + { + yield return candidate; + } + } + } + + private static IEnumerable<(int q, int r)> HexRing(int radius, int directionOffset) + { + var directions = new (int q, int r)[] + { + (1, 0), + (1, -1), + (0, -1), + (-1, 0), + (-1, 1), + (0, 1) + }; + + var q = directions[(4 + directionOffset) % 6].q * radius; + var r = directions[(4 + directionOffset) % 6].r * radius; + + for (var side = 0; side < 6; side++) + { + var direction = directions[(side + directionOffset) % 6]; + for (var step = 0; step < radius; step++) + { + yield return (q, r); + q += direction.q; + r += direction.r; + } + } + } + + private static IEnumerable<(int q, int r)> HexRange(int centerQ, int centerR, int radius) + { + for (var dq = -radius; dq <= radius; dq++) + { + var minDr = Math.Max(-radius, -dq - radius); + var maxDr = Math.Min(radius, -dq + radius); + for (var dr = minDr; dr <= maxDr; dr++) + { + yield return (centerQ + dq, centerR + dr); + } + } + } + + private bool IsFarEnoughFromHomes(int q, int r, List homeLocations) => + homeLocations.All(home => HexDistance(q, r, home.Q, home.R) >= _homeMinDistance); + + private static int HexDistance(int q1, int r1, int q2, int r2) + { + var s1 = -q1 - r1; + var s2 = -q2 - r2; + return Math.Max(Math.Max(Math.Abs(q1 - q2), Math.Abs(r1 - r2)), Math.Abs(s1 - s2)); + } + private static string NormalizeTerrain(string terrain) { var trimmed = terrain.Trim(); diff --git a/microservices/LocationsApi/appsettings.json b/microservices/LocationsApi/appsettings.json index ea15e83..db4cbce 100644 --- a/microservices/LocationsApi/appsettings.json +++ b/microservices/LocationsApi/appsettings.json @@ -14,5 +14,10 @@ "Issuer": "territory-autobattler", "Audience": "territory-autobattler-auth-api" }, + "World": { + "HomeMinDistance": 8, + "HomeSearchRadius": 80, + "VisibilityDepth": 3 + }, "AllowedHosts": "*" } diff --git a/microservices/LocationsApi/bin/Debug/net8.0/LocationsApi.dll b/microservices/LocationsApi/bin/Debug/net8.0/LocationsApi.dll index 3773376..fc8f215 100644 Binary files a/microservices/LocationsApi/bin/Debug/net8.0/LocationsApi.dll and b/microservices/LocationsApi/bin/Debug/net8.0/LocationsApi.dll differ diff --git a/microservices/LocationsApi/bin/Debug/net8.0/LocationsApi.exe b/microservices/LocationsApi/bin/Debug/net8.0/LocationsApi.exe index f04bd48..9a418e4 100644 Binary files a/microservices/LocationsApi/bin/Debug/net8.0/LocationsApi.exe and b/microservices/LocationsApi/bin/Debug/net8.0/LocationsApi.exe differ diff --git a/microservices/LocationsApi/bin/Debug/net8.0/LocationsApi.pdb b/microservices/LocationsApi/bin/Debug/net8.0/LocationsApi.pdb index 2745646..c46eeaa 100644 Binary files a/microservices/LocationsApi/bin/Debug/net8.0/LocationsApi.pdb and b/microservices/LocationsApi/bin/Debug/net8.0/LocationsApi.pdb differ diff --git a/microservices/LocationsApi/bin/Debug/net8.0/appsettings.json b/microservices/LocationsApi/bin/Debug/net8.0/appsettings.json index ea15e83..db4cbce 100644 --- a/microservices/LocationsApi/bin/Debug/net8.0/appsettings.json +++ b/microservices/LocationsApi/bin/Debug/net8.0/appsettings.json @@ -14,5 +14,10 @@ "Issuer": "territory-autobattler", "Audience": "territory-autobattler-auth-api" }, + "World": { + "HomeMinDistance": 8, + "HomeSearchRadius": 80, + "VisibilityDepth": 3 + }, "AllowedHosts": "*" } diff --git a/microservices/LocationsApi/k8s/configmap.yaml b/microservices/LocationsApi/k8s/configmap.yaml index 2059e8b..31447d2 100644 --- a/microservices/LocationsApi/k8s/configmap.yaml +++ b/microservices/LocationsApi/k8s/configmap.yaml @@ -8,3 +8,6 @@ data: MongoDB__DatabaseName: territory-autobattler Jwt__Issuer: territory-autobattler Jwt__Audience: territory-autobattler-auth-api + World__HomeMinDistance: "8" + World__HomeSearchRadius: "80" + World__VisibilityDepth: "3" diff --git a/microservices/LocationsApi/obj/Debug/net8.0/LocationsApi.AssemblyInfo.cs b/microservices/LocationsApi/obj/Debug/net8.0/LocationsApi.AssemblyInfo.cs index 3bd71f3..29e4c8e 100644 --- a/microservices/LocationsApi/obj/Debug/net8.0/LocationsApi.AssemblyInfo.cs +++ b/microservices/LocationsApi/obj/Debug/net8.0/LocationsApi.AssemblyInfo.cs @@ -13,7 +13,7 @@ using System.Reflection; [assembly: System.Reflection.AssemblyCompanyAttribute("LocationsApi")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+9207fe36a3c0b115c7ad6fd3d148dd6ca2094375")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+dd151c5af16980e9e4d4fdc393f96a5b9d53747c")] [assembly: System.Reflection.AssemblyProductAttribute("LocationsApi")] [assembly: System.Reflection.AssemblyTitleAttribute("LocationsApi")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/microservices/LocationsApi/obj/Debug/net8.0/LocationsApi.AssemblyInfoInputs.cache b/microservices/LocationsApi/obj/Debug/net8.0/LocationsApi.AssemblyInfoInputs.cache index a6041ef..cf5788d 100644 --- a/microservices/LocationsApi/obj/Debug/net8.0/LocationsApi.AssemblyInfoInputs.cache +++ b/microservices/LocationsApi/obj/Debug/net8.0/LocationsApi.AssemblyInfoInputs.cache @@ -1 +1 @@ -b403eb203fdf58aebecc5d945b0abb24d1c483301d0672168baaeecc45119276 +07cb9b4cb320a27b1959ac08682032c59f0c6e7d3b6a25bd9b63a8ad902bb4b8 diff --git a/microservices/LocationsApi/obj/Debug/net8.0/LocationsApi.dll b/microservices/LocationsApi/obj/Debug/net8.0/LocationsApi.dll index 3773376..fc8f215 100644 Binary files a/microservices/LocationsApi/obj/Debug/net8.0/LocationsApi.dll and b/microservices/LocationsApi/obj/Debug/net8.0/LocationsApi.dll differ diff --git a/microservices/LocationsApi/obj/Debug/net8.0/LocationsApi.pdb b/microservices/LocationsApi/obj/Debug/net8.0/LocationsApi.pdb index 2745646..c46eeaa 100644 Binary files a/microservices/LocationsApi/obj/Debug/net8.0/LocationsApi.pdb and b/microservices/LocationsApi/obj/Debug/net8.0/LocationsApi.pdb differ diff --git a/microservices/LocationsApi/obj/Debug/net8.0/apphost.exe b/microservices/LocationsApi/obj/Debug/net8.0/apphost.exe index f04bd48..9a418e4 100644 Binary files a/microservices/LocationsApi/obj/Debug/net8.0/apphost.exe and b/microservices/LocationsApi/obj/Debug/net8.0/apphost.exe differ diff --git a/microservices/LocationsApi/obj/Debug/net8.0/ref/LocationsApi.dll b/microservices/LocationsApi/obj/Debug/net8.0/ref/LocationsApi.dll index f3568be..df9f38a 100644 Binary files a/microservices/LocationsApi/obj/Debug/net8.0/ref/LocationsApi.dll and b/microservices/LocationsApi/obj/Debug/net8.0/ref/LocationsApi.dll differ diff --git a/microservices/LocationsApi/obj/Debug/net8.0/refint/LocationsApi.dll b/microservices/LocationsApi/obj/Debug/net8.0/refint/LocationsApi.dll index f3568be..df9f38a 100644 Binary files a/microservices/LocationsApi/obj/Debug/net8.0/refint/LocationsApi.dll and b/microservices/LocationsApi/obj/Debug/net8.0/refint/LocationsApi.dll differ diff --git a/microservices/LocationsApi/obj/Debug/net8.0/rjsmcshtml.dswa.cache.json b/microservices/LocationsApi/obj/Debug/net8.0/rjsmcshtml.dswa.cache.json index 5bf114b..99820e5 100644 --- a/microservices/LocationsApi/obj/Debug/net8.0/rjsmcshtml.dswa.cache.json +++ b/microservices/LocationsApi/obj/Debug/net8.0/rjsmcshtml.dswa.cache.json @@ -1 +1 @@ -{"GlobalPropertiesHash":"w7mmSUeJy3EjTBKOw9i7DTiAbSD0tO4IicJPYORYxAc=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["u6f8oDnbzrimiNBDeJHAHevNSr3MAeMqrDg9p7nyxOE=","5iNsYgTJ7UV3yFd6ljv5hsMAG441CEYl/SrWmWfNtXc=","9WHrt72HnJ5zi2iiKthnOFXj64buUk7n8VJpIRqlWUs=","H8SD/zn5odFOCH8IKkG0GxtQdpeP5bXD\u002BPPQyk8g9Tk=","TQpYeFHS2qgMU/ptlMMsuvxql0RCmqmAhUrgJv\u002BHh9A=","AubgranklSc/XcuzOmTCVcnLDHalwHX672pKnqMGGH0=","bRq\u002BCEv/2oksFWboXE/6CRNvoz7a//ybXVNKledXUhc=","aIYHzbHzogvauDRzzlcHDs54OxiLFduH5H6wBtjQMKk="],"CachedAssets":{},"CachedCopyCandidates":{}} \ No newline at end of file +{"GlobalPropertiesHash":"w7mmSUeJy3EjTBKOw9i7DTiAbSD0tO4IicJPYORYxAc=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["tA5IfBScz2\u002Bs6UlS6C510zVinusuY1Dx2ibHDM/cVfc=","5iNsYgTJ7UV3yFd6ljv5hsMAG441CEYl/SrWmWfNtXc=","TEE2WzAfi463TJjsb9i\u002BmdcaoLigQZZABaffYxHGL7s=","H8SD/zn5odFOCH8IKkG0GxtQdpeP5bXD\u002BPPQyk8g9Tk=","TQpYeFHS2qgMU/ptlMMsuvxql0RCmqmAhUrgJv\u002BHh9A=","AubgranklSc/XcuzOmTCVcnLDHalwHX672pKnqMGGH0=","vddsqaGugGe8mVuoS05nGn\u002BxiJBwaOAoWwVS4\u002BKYa8A=","yZPWhf5bVqIwacMkpdMp2P17I1egbzHFZlg/l3vFlKk="],"CachedAssets":{},"CachedCopyCandidates":{}} \ No newline at end of file diff --git a/microservices/LocationsApi/obj/Debug/net8.0/rjsmrazor.dswa.cache.json b/microservices/LocationsApi/obj/Debug/net8.0/rjsmrazor.dswa.cache.json index 2cdbd37..d461a5f 100644 --- a/microservices/LocationsApi/obj/Debug/net8.0/rjsmrazor.dswa.cache.json +++ b/microservices/LocationsApi/obj/Debug/net8.0/rjsmrazor.dswa.cache.json @@ -1 +1 @@ -{"GlobalPropertiesHash":"GFtoKVzjVKzTL2sykHz39KP6vofGVDcii0x1tMRS6e8=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["u6f8oDnbzrimiNBDeJHAHevNSr3MAeMqrDg9p7nyxOE=","5iNsYgTJ7UV3yFd6ljv5hsMAG441CEYl/SrWmWfNtXc=","9WHrt72HnJ5zi2iiKthnOFXj64buUk7n8VJpIRqlWUs=","H8SD/zn5odFOCH8IKkG0GxtQdpeP5bXD\u002BPPQyk8g9Tk=","TQpYeFHS2qgMU/ptlMMsuvxql0RCmqmAhUrgJv\u002BHh9A=","AubgranklSc/XcuzOmTCVcnLDHalwHX672pKnqMGGH0=","bRq\u002BCEv/2oksFWboXE/6CRNvoz7a//ybXVNKledXUhc=","aIYHzbHzogvauDRzzlcHDs54OxiLFduH5H6wBtjQMKk="],"CachedAssets":{},"CachedCopyCandidates":{}} \ No newline at end of file +{"GlobalPropertiesHash":"GFtoKVzjVKzTL2sykHz39KP6vofGVDcii0x1tMRS6e8=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["tA5IfBScz2\u002Bs6UlS6C510zVinusuY1Dx2ibHDM/cVfc=","5iNsYgTJ7UV3yFd6ljv5hsMAG441CEYl/SrWmWfNtXc=","TEE2WzAfi463TJjsb9i\u002BmdcaoLigQZZABaffYxHGL7s=","H8SD/zn5odFOCH8IKkG0GxtQdpeP5bXD\u002BPPQyk8g9Tk=","TQpYeFHS2qgMU/ptlMMsuvxql0RCmqmAhUrgJv\u002BHh9A=","AubgranklSc/XcuzOmTCVcnLDHalwHX672pKnqMGGH0=","vddsqaGugGe8mVuoS05nGn\u002BxiJBwaOAoWwVS4\u002BKYa8A=","yZPWhf5bVqIwacMkpdMp2P17I1egbzHFZlg/l3vFlKk="],"CachedAssets":{},"CachedCopyCandidates":{}} \ No newline at end of file diff --git a/microservices/LocationsApi/obj/Debug/net8.0/rpswa.dswa.cache.json b/microservices/LocationsApi/obj/Debug/net8.0/rpswa.dswa.cache.json index 25e9ae2..10f5105 100644 --- a/microservices/LocationsApi/obj/Debug/net8.0/rpswa.dswa.cache.json +++ b/microservices/LocationsApi/obj/Debug/net8.0/rpswa.dswa.cache.json @@ -1 +1 @@ -{"GlobalPropertiesHash":"igFr7v0EeKBfAjhR7vM0gDd+Q/K60wUiwt+1xe9MQyQ=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["u6f8oDnbzrimiNBDeJHAHevNSr3MAeMqrDg9p7nyxOE="],"CachedAssets":{},"CachedCopyCandidates":{}} \ No newline at end of file +{"GlobalPropertiesHash":"igFr7v0EeKBfAjhR7vM0gDd+Q/K60wUiwt+1xe9MQyQ=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["tA5IfBScz2\u002Bs6UlS6C510zVinusuY1Dx2ibHDM/cVfc="],"CachedAssets":{},"CachedCopyCandidates":{}} \ No newline at end of file