Updating location schema to tracker user territory and lock visibility
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -17,6 +17,7 @@ public class LocationsController : ControllerBase
|
||||
_locations = locations;
|
||||
}
|
||||
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<LocationResponse>>> 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<ActionResult<LocationResponse>> 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<ActionResult<LocationResponse>> 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<ActionResult<LocationResponse>> 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<ActionResult<List<LocationResponse>>> 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<ActionResult<LocationResponse>> Upsert([FromBody] UpsertLocationRequest request)
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -6,6 +6,9 @@ namespace LocationsApi.Services;
|
||||
public class LocationService
|
||||
{
|
||||
private readonly IMongoCollection<Location> _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<Location>("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<Location>.IndexKeys
|
||||
.Ascending(x => x.Q)
|
||||
@@ -21,6 +27,17 @@ public class LocationService
|
||||
_locations.Indexes.CreateOne(new CreateIndexModel<Location>(
|
||||
coordinateIndex,
|
||||
new CreateIndexOptions { Unique = true }));
|
||||
|
||||
var ownerHomeIndex = Builders<Location>.IndexKeys
|
||||
.Ascending(x => x.OwnerUsername)
|
||||
.Ascending(x => x.Kind);
|
||||
_locations.Indexes.CreateOne(new CreateIndexModel<Location>(
|
||||
ownerHomeIndex,
|
||||
new CreateIndexOptions<Location>
|
||||
{
|
||||
Unique = true,
|
||||
PartialFilterExpression = Builders<Location>.Filter.Eq(x => x.Kind, "Home")
|
||||
}));
|
||||
}
|
||||
|
||||
public Task<List<Location>> GetAllAsync() =>
|
||||
@@ -44,13 +61,16 @@ public class LocationService
|
||||
var update = Builders<Location>.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<Location>
|
||||
@@ -65,12 +85,22 @@ public class LocationService
|
||||
options);
|
||||
}
|
||||
|
||||
public async Task<Location> ClaimAsync(int q, int r, string username)
|
||||
public async Task<Location?> 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<Location?> GetHomeAsync(string username) =>
|
||||
await _locations.Find(x => x.OwnerUsername == username && x.Kind == "Home").FirstOrDefaultAsync();
|
||||
|
||||
public async Task<Location> 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<List<LocationResponse>> 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<LocationResponse?> 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<bool> 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<Location?> TryCreateHomeAsync(string username, int q, int r)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var update = Builders<Location>.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<Location>
|
||||
{
|
||||
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<Location> 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();
|
||||
|
||||
@@ -14,5 +14,10 @@
|
||||
"Issuer": "territory-autobattler",
|
||||
"Audience": "territory-autobattler-auth-api"
|
||||
},
|
||||
"World": {
|
||||
"HomeMinDistance": 8,
|
||||
"HomeSearchRadius": 80,
|
||||
"VisibilityDepth": 3
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,5 +14,10 @@
|
||||
"Issuer": "territory-autobattler",
|
||||
"Audience": "territory-autobattler-auth-api"
|
||||
},
|
||||
"World": {
|
||||
"HomeMinDistance": 8,
|
||||
"HomeSearchRadius": 80,
|
||||
"VisibilityDepth": 3
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -1 +1 @@
|
||||
b403eb203fdf58aebecc5d945b0abb24d1c483301d0672168baaeecc45119276
|
||||
07cb9b4cb320a27b1959ac08682032c59f0c6e7d3b6a25bd9b63a8ad902bb4b8
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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":{}}
|
||||
{"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":{}}
|
||||
@@ -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":{}}
|
||||
{"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":{}}
|
||||
@@ -1 +1 @@
|
||||
{"GlobalPropertiesHash":"igFr7v0EeKBfAjhR7vM0gDd+Q/K60wUiwt+1xe9MQyQ=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["u6f8oDnbzrimiNBDeJHAHevNSr3MAeMqrDg9p7nyxOE="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
||||
{"GlobalPropertiesHash":"igFr7v0EeKBfAjhR7vM0gDd+Q/K60wUiwt+1xe9MQyQ=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["tA5IfBScz2\u002Bs6UlS6C510zVinusuY1Dx2ibHDM/cVfc="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
||||
Reference in New Issue
Block a user