Adding player sensing
All checks were successful
Deploy Promiscuity Auth API / deploy (push) Successful in 47s
Deploy Promiscuity Character API / deploy (push) Successful in 59s
Deploy Promiscuity Inventory API / deploy (push) Successful in 47s
Deploy Promiscuity Locations API / deploy (push) Successful in 47s
k8s smoke test / test (push) Successful in 9s
All checks were successful
Deploy Promiscuity Auth API / deploy (push) Successful in 47s
Deploy Promiscuity Character API / deploy (push) Successful in 59s
Deploy Promiscuity Inventory API / deploy (push) Successful in 47s
Deploy Promiscuity Locations API / deploy (push) Successful in 47s
k8s smoke test / test (push) Successful in 9s
This commit is contained in:
parent
c596b760cf
commit
bca8e3374a
@ -6,6 +6,8 @@ const INVENTORY_API_URL := "https://pinv.ranaze.com/api/inventory"
|
||||
const START_SCREEN_SCENE := "res://scenes/UI/start_screen.tscn"
|
||||
const SETTINGS_SCENE := "res://scenes/UI/Settings.tscn"
|
||||
const CHARACTER_SLOT_COUNT := 6
|
||||
const VISIBLE_CHARACTERS_REFRESH_INTERVAL := 5.0
|
||||
const HEARTBEAT_INTERVAL := 10.0
|
||||
|
||||
@export var tile_size := 8.0
|
||||
@export var block_height := 1.0
|
||||
@ -34,8 +36,9 @@ const CHARACTER_SLOT_COUNT := 6
|
||||
@onready var _inventory_status_label: Label = $InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/StatusLabel
|
||||
|
||||
var _center_coord := Vector2i.ZERO
|
||||
var _tiles_root: Node3D
|
||||
var _tracked_node: Node3D
|
||||
var _tiles_root: Node3D
|
||||
var _remote_players_root: Node3D
|
||||
var _tracked_node: Node3D
|
||||
var _tile_nodes: Dictionary = {}
|
||||
var _camera_start_offset := Vector3(0.0, 6.0, 10.0)
|
||||
var _border_material: StandardMaterial3D
|
||||
@ -53,12 +56,20 @@ var _inventory_request_in_flight := false
|
||||
var _character_inventory_items: Array = []
|
||||
var _selected_character_item_id := ""
|
||||
var _selected_ground_item_id := ""
|
||||
var _visible_characters_in_flight := false
|
||||
var _heartbeat_in_flight := false
|
||||
var _visible_character_refresh_elapsed := 0.0
|
||||
var _heartbeat_elapsed := 0.0
|
||||
var _remote_character_nodes: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_tiles_root = Node3D.new()
|
||||
_tiles_root.name = "GeneratedTiles"
|
||||
add_child(_tiles_root)
|
||||
_remote_players_root = Node3D.new()
|
||||
_remote_players_root.name = "RemotePlayers"
|
||||
add_child(_remote_players_root)
|
||||
|
||||
if _camera:
|
||||
_camera_start_offset = _camera.position
|
||||
@ -75,6 +86,7 @@ func _ready() -> void:
|
||||
_block.visible = false
|
||||
_deactivate_player_for_load()
|
||||
await _load_existing_locations()
|
||||
_refresh_visible_characters()
|
||||
_ensure_selected_location_exists(_center_coord)
|
||||
_rebuild_tiles(_center_coord)
|
||||
_move_player_to_coord(_center_coord)
|
||||
@ -84,6 +96,14 @@ func _ready() -> void:
|
||||
func _process(_delta: float) -> void:
|
||||
if not _locations_loaded:
|
||||
return
|
||||
_visible_character_refresh_elapsed += _delta
|
||||
_heartbeat_elapsed += _delta
|
||||
if _visible_character_refresh_elapsed >= VISIBLE_CHARACTERS_REFRESH_INTERVAL:
|
||||
_visible_character_refresh_elapsed = 0.0
|
||||
_refresh_visible_characters()
|
||||
if _heartbeat_elapsed >= HEARTBEAT_INTERVAL:
|
||||
_heartbeat_elapsed = 0.0
|
||||
_send_presence_heartbeat()
|
||||
if _inventory_menu.visible:
|
||||
return
|
||||
var target_world_pos := _get_stream_position()
|
||||
@ -899,7 +919,7 @@ func _selected_location_name(coord: Vector2i) -> String:
|
||||
return "Location %d,%d" % [coord.x, coord.y]
|
||||
|
||||
|
||||
func _load_existing_locations() -> void:
|
||||
func _load_existing_locations() -> void:
|
||||
_locations_refresh_in_flight = true
|
||||
_locations_loaded = false
|
||||
_known_locations.clear()
|
||||
@ -974,9 +994,10 @@ func _load_existing_locations() -> void:
|
||||
_locations_loaded = true
|
||||
_locations_refresh_in_flight = false
|
||||
_rebuild_tiles(_center_coord)
|
||||
if _queued_locations_refresh:
|
||||
_queued_locations_refresh = false
|
||||
_queue_locations_refresh()
|
||||
_refresh_visible_characters()
|
||||
if _queued_locations_refresh:
|
||||
_queued_locations_refresh = false
|
||||
_queue_locations_refresh()
|
||||
|
||||
|
||||
func _queue_locations_refresh() -> void:
|
||||
@ -1110,12 +1131,13 @@ func _sync_character_coord(coord: Vector2i) -> void:
|
||||
_sync_character_coord_async(coord)
|
||||
|
||||
|
||||
func _sync_character_coord_async(coord: Vector2i) -> void:
|
||||
var response := await CharacterService.update_character_coord(_character_id, coord)
|
||||
if response.get("ok", false):
|
||||
_persisted_coord = coord
|
||||
SelectedCharacter.set_coord(coord)
|
||||
else:
|
||||
func _sync_character_coord_async(coord: Vector2i) -> void:
|
||||
var response := await CharacterService.update_character_coord(_character_id, coord)
|
||||
if response.get("ok", false):
|
||||
_persisted_coord = coord
|
||||
SelectedCharacter.set_coord(coord)
|
||||
_refresh_visible_characters()
|
||||
else:
|
||||
push_warning("Failed to persist character coord to %s,%s: status=%s error=%s body=%s" % [
|
||||
coord.x,
|
||||
coord.y,
|
||||
@ -1224,6 +1246,120 @@ func _fetch_location_inventory(location_id: String) -> Array:
|
||||
|
||||
var payload := parsed as Dictionary
|
||||
return _parse_floor_inventory_items(payload.get("items", []))
|
||||
|
||||
|
||||
func _refresh_visible_characters() -> void:
|
||||
if _visible_characters_in_flight or _character_id.is_empty():
|
||||
return
|
||||
_refresh_visible_characters_async()
|
||||
|
||||
|
||||
func _refresh_visible_characters_async() -> void:
|
||||
_visible_characters_in_flight = true
|
||||
var request := HTTPRequest.new()
|
||||
add_child(request)
|
||||
|
||||
var headers := PackedStringArray()
|
||||
if not AuthState.access_token.is_empty():
|
||||
headers.append("Authorization: Bearer %s" % AuthState.access_token)
|
||||
|
||||
var err := request.request("%s/%s/visible-characters" % [CHARACTER_API_URL, _character_id], headers, HTTPClient.METHOD_GET)
|
||||
if err != OK:
|
||||
request.queue_free()
|
||||
push_warning("Failed to request visible characters: %s" % err)
|
||||
_visible_characters_in_flight = false
|
||||
return
|
||||
|
||||
var result: Array = await request.request_completed
|
||||
request.queue_free()
|
||||
_visible_characters_in_flight = false
|
||||
|
||||
var result_code: int = result[0]
|
||||
var response_code: int = result[1]
|
||||
var response_body: String = result[3].get_string_from_utf8()
|
||||
if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
push_warning("Failed to load visible characters (%s/%s): %s" % [result_code, response_code, response_body])
|
||||
return
|
||||
|
||||
var parsed: Variant = JSON.parse_string(response_body)
|
||||
if typeof(parsed) != TYPE_ARRAY:
|
||||
return
|
||||
|
||||
_apply_visible_characters(parsed as Array)
|
||||
|
||||
|
||||
func _apply_visible_characters(characters: Array) -> void:
|
||||
var wanted_ids := {}
|
||||
for character_variant in characters:
|
||||
if typeof(character_variant) != TYPE_DICTIONARY:
|
||||
continue
|
||||
var character := character_variant as Dictionary
|
||||
var character_id := String(character.get("id", "")).strip_edges()
|
||||
if character_id.is_empty() or character_id == _character_id:
|
||||
continue
|
||||
wanted_ids[character_id] = true
|
||||
_upsert_remote_character(character)
|
||||
|
||||
for character_id in _remote_character_nodes.keys():
|
||||
if wanted_ids.has(character_id):
|
||||
continue
|
||||
var remote_node := _remote_character_nodes[character_id] as Node3D
|
||||
if remote_node:
|
||||
remote_node.queue_free()
|
||||
_remote_character_nodes.erase(character_id)
|
||||
|
||||
|
||||
func _upsert_remote_character(character: Dictionary) -> void:
|
||||
var character_id := String(character.get("id", "")).strip_edges()
|
||||
var coord_value: Variant = character.get("coord", {})
|
||||
if typeof(coord_value) != TYPE_DICTIONARY:
|
||||
return
|
||||
var coord_dict := coord_value as Dictionary
|
||||
var coord := Vector2i(int(coord_dict.get("x", 0)), int(coord_dict.get("y", 0)))
|
||||
|
||||
var remote_root := _remote_character_nodes.get(character_id) as Node3D
|
||||
if remote_root == null:
|
||||
remote_root = Node3D.new()
|
||||
remote_root.name = "RemoteCharacter_%s" % character_id
|
||||
_remote_players_root.add_child(remote_root)
|
||||
|
||||
var visual := _player_visual.duplicate() as Node3D
|
||||
visual.name = "Visual"
|
||||
visual.visible = true
|
||||
remote_root.add_child(visual)
|
||||
|
||||
var label := Label3D.new()
|
||||
label.name = "NameLabel"
|
||||
label.position = Vector3(0.0, 1.8, 0.0)
|
||||
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
label.pixel_size = 0.01
|
||||
label.outline_size = 10
|
||||
remote_root.add_child(label)
|
||||
|
||||
_remote_character_nodes[character_id] = remote_root
|
||||
|
||||
remote_root.position = Vector3(coord.x * tile_size, _get_tile_surface_y(coord), coord.y * tile_size)
|
||||
var name_label := remote_root.get_node_or_null("NameLabel") as Label3D
|
||||
if name_label:
|
||||
name_label.text = String(character.get("name", "Player")).strip_edges()
|
||||
|
||||
|
||||
func _send_presence_heartbeat() -> void:
|
||||
if _heartbeat_in_flight or _character_id.is_empty():
|
||||
return
|
||||
_send_presence_heartbeat_async()
|
||||
|
||||
|
||||
func _send_presence_heartbeat_async() -> void:
|
||||
_heartbeat_in_flight = true
|
||||
var response := await CharacterService.heartbeat_character(_character_id)
|
||||
if not response.get("ok", false):
|
||||
push_warning("Failed to send presence heartbeat: status=%s error=%s body=%s" % [
|
||||
response.get("status", "n/a"),
|
||||
response.get("error", ""),
|
||||
response.get("body", "")
|
||||
])
|
||||
_heartbeat_in_flight = false
|
||||
|
||||
|
||||
func _get_biome_material(tile: MeshInstance3D, biome_key: String) -> Material:
|
||||
|
||||
@ -24,6 +24,10 @@ func update_character_coord(character_id: String, coord: Vector2i) -> Dictionary
|
||||
}
|
||||
})
|
||||
return await _request(HTTPClient.METHOD_PUT, url, payload)
|
||||
|
||||
func heartbeat_character(character_id: String) -> Dictionary:
|
||||
var url := "%s/%s/heartbeat" % [CHARACTER_API_URL, character_id]
|
||||
return await _request(HTTPClient.METHOD_POST, url, "")
|
||||
|
||||
func _request(method: int, url: String, body: String = "") -> Dictionary:
|
||||
var request := HTTPRequest.new()
|
||||
|
||||
@ -13,6 +13,7 @@ namespace CharacterApi.Controllers;
|
||||
[Route("api/[controller]")]
|
||||
public class CharactersController : ControllerBase
|
||||
{
|
||||
private static readonly TimeSpan PresenceTimeout = TimeSpan.FromSeconds(45);
|
||||
private readonly CharacterStore _characters;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IConfiguration _configuration;
|
||||
@ -43,6 +44,7 @@ public class CharactersController : ControllerBase
|
||||
Name = req.Name.Trim(),
|
||||
Coord = new Coord { X = 0, Y = 0 },
|
||||
VisionRadius = 3,
|
||||
LastSeenUtc = DateTime.UtcNow,
|
||||
CreatedUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
@ -89,6 +91,8 @@ public class CharactersController : ControllerBase
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
await _characters.TouchAsync(id);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Visible locations requested for character {CharacterId} at ({X},{Y}) radius {VisionRadius} by user {UserId}",
|
||||
character.Id,
|
||||
@ -164,6 +168,53 @@ public class CharactersController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id}/visible-characters")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> VisibleCharacters(string id)
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
|
||||
var allowAnyOwner = User.IsInRole("SUPER");
|
||||
var character = await _characters.GetByIdAsync(id);
|
||||
if (character is null)
|
||||
return NotFound();
|
||||
if (!allowAnyOwner && character.OwnerUserId != userId)
|
||||
return Forbid();
|
||||
|
||||
await _characters.TouchAsync(id);
|
||||
|
||||
var onlineSinceUtc = DateTime.UtcNow.Subtract(PresenceTimeout);
|
||||
var visibleCharacters = await _characters.GetVisibleOthersAsync(
|
||||
id,
|
||||
character.Coord.X,
|
||||
character.Coord.Y,
|
||||
character.VisionRadius > 0 ? character.VisionRadius : 3,
|
||||
onlineSinceUtc);
|
||||
|
||||
return Ok(visibleCharacters);
|
||||
}
|
||||
|
||||
[HttpPost("{id}/heartbeat")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Heartbeat(string id)
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
|
||||
var allowAnyOwner = User.IsInRole("SUPER");
|
||||
var character = await _characters.GetByIdAsync(id);
|
||||
if (character is null)
|
||||
return NotFound();
|
||||
if (!allowAnyOwner && character.OwnerUserId != userId)
|
||||
return Forbid();
|
||||
|
||||
await _characters.TouchAsync(id);
|
||||
return Ok(new { lastSeenUtc = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
[HttpPut("{id}/coord")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> UpdateCoord(string id, [FromBody] UpdateCharacterCoordRequest req)
|
||||
|
||||
@ -17,5 +17,7 @@ public class Character
|
||||
|
||||
public int VisionRadius { get; set; } = 3;
|
||||
|
||||
public DateTime LastSeenUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
12
microservices/CharacterApi/Models/VisibleCharacter.cs
Normal file
12
microservices/CharacterApi/Models/VisibleCharacter.cs
Normal file
@ -0,0 +1,12 @@
|
||||
namespace CharacterApi.Models;
|
||||
|
||||
public class VisibleCharacter
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public Coord Coord { get; set; } = new();
|
||||
|
||||
public DateTime LastSeenUtc { get; set; }
|
||||
}
|
||||
@ -17,6 +17,8 @@ public class CharacterStore
|
||||
|
||||
var ownerIndex = Builders<Character>.IndexKeys.Ascending(c => c.OwnerUserId);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<Character>(ownerIndex));
|
||||
var coordIndex = Builders<Character>.IndexKeys.Ascending("Coord.X").Ascending("Coord.Y");
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<Character>(coordIndex));
|
||||
}
|
||||
|
||||
public Task CreateAsync(Character character) => _col.InsertOneAsync(character);
|
||||
@ -30,11 +32,38 @@ public class CharacterStore
|
||||
public async Task<bool> UpdateCoordAsync(string id, Coord coord)
|
||||
{
|
||||
var filter = Builders<Character>.Filter.Eq(c => c.Id, id);
|
||||
var update = Builders<Character>.Update.Set(c => c.Coord, coord);
|
||||
var update = Builders<Character>.Update
|
||||
.Set(c => c.Coord, coord)
|
||||
.Set(c => c.LastSeenUtc, DateTime.UtcNow);
|
||||
var result = await _col.UpdateOneAsync(filter, update);
|
||||
return result.ModifiedCount > 0 || result.MatchedCount > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> TouchAsync(string id)
|
||||
{
|
||||
var filter = Builders<Character>.Filter.Eq(c => c.Id, id);
|
||||
var update = Builders<Character>.Update.Set(c => c.LastSeenUtc, DateTime.UtcNow);
|
||||
var result = await _col.UpdateOneAsync(filter, update);
|
||||
return result.ModifiedCount > 0 || result.MatchedCount > 0;
|
||||
}
|
||||
|
||||
public Task<List<VisibleCharacter>> GetVisibleOthersAsync(string characterId, int x, int y, int radius, DateTime onlineSinceUtc) =>
|
||||
_col.Find(c =>
|
||||
c.Id != characterId &&
|
||||
c.Coord.X >= x - radius &&
|
||||
c.Coord.X <= x + radius &&
|
||||
c.Coord.Y >= y - radius &&
|
||||
c.Coord.Y <= y + radius &&
|
||||
c.LastSeenUtc >= onlineSinceUtc)
|
||||
.Project(c => new VisibleCharacter
|
||||
{
|
||||
Id = c.Id ?? string.Empty,
|
||||
Name = c.Name,
|
||||
Coord = c.Coord,
|
||||
LastSeenUtc = c.LastSeenUtc
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<bool> DeleteForOwnerAsync(string id, string ownerUserId, bool allowAnyOwner)
|
||||
{
|
||||
var filter = Builders<Character>.Filter.Eq(c => c.Id, id);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user