diff --git a/game/scenes/Levels/location_level.gd b/game/scenes/Levels/location_level.gd index c390f8d..3d44459 100644 --- a/game/scenes/Levels/location_level.gd +++ b/game/scenes/Levels/location_level.gd @@ -1,530 +1,593 @@ -extends Node3D - -const CHARACTER_API_URL := "https://pchar.ranaze.com/api/Characters" -const LOCATION_API_URL := "https://ploc.ranaze.com/api/Locations" - -@export var tile_size := 16.0 -@export var block_height := 1.0 -@export_range(1, 8, 1) var tile_radius := 3 -@export var tracked_node_path: NodePath -@export var player_spawn_height := 2.0 -@export var border_color: Color = Color(0.05, 0.05, 0.05, 1.0) -@export var border_height_bias := 0.005 -@export var show_tile_labels := true -@export var tile_label_height := 0.01 -@export var tile_label_color: Color = Color(1, 1, 1, 1) - -@onready var _block: MeshInstance3D = $TerrainBlock -@onready var _player: RigidBody3D = $Player -@onready var _camera: Camera3D = $Player/Camera3D - -var _center_coord := Vector2i.ZERO -var _tiles_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 -var _known_locations: Dictionary = {} -var _locations_loaded := false -var _character_id := "" +extends Node3D + +const CHARACTER_API_URL := "https://pchar.ranaze.com/api/Characters" +const LOCATION_API_URL := "https://ploc.ranaze.com/api/Locations" + +@export var tile_size := 16.0 +@export var block_height := 1.0 +@export_range(1, 8, 1) var tile_radius := 3 +@export var tracked_node_path: NodePath +@export var player_spawn_height := 2.0 +@export var border_color: Color = Color(0.05, 0.05, 0.05, 1.0) +@export var border_height_bias := 0.005 +@export var show_tile_labels := true +@export var tile_label_height := 0.01 +@export var tile_label_color: Color = Color(1, 1, 1, 1) + +@onready var _block: MeshInstance3D = $TerrainBlock +@onready var _player: RigidBody3D = $Player +@onready var _camera: Camera3D = $Player/Camera3D + +var _center_coord := Vector2i.ZERO +var _tiles_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 +var _biome_materials: Dictionary = {} +var _known_locations: Dictionary = {} +var _locations_loaded := false +var _character_id := "" var _persisted_coord := Vector2i.ZERO var _coord_sync_in_flight := false var _queued_coord_sync: Variant = null var _locations_refresh_in_flight := false var _queued_locations_refresh := false var _interact_in_flight := false - - -func _ready() -> void: - _tiles_root = Node3D.new() - _tiles_root.name = "GeneratedTiles" - add_child(_tiles_root) - - if _camera: - _camera_start_offset = _camera.position - - _tracked_node = get_node_or_null(tracked_node_path) as Node3D - if _tracked_node == null: - _tracked_node = _player - - var start_coord := SelectedCharacter.get_coord() - _center_coord = Vector2i(roundi(start_coord.x), roundi(start_coord.y)) - _persisted_coord = _center_coord - _character_id = String(SelectedCharacter.character.get("id", SelectedCharacter.character.get("Id", ""))).strip_edges() - - _block.visible = false - await _load_existing_locations() - _ensure_selected_location_exists(_center_coord) - _rebuild_tiles(_center_coord) - _move_player_to_coord(_center_coord) - - -func _process(_delta: float) -> void: - if not _locations_loaded: - return - var target_world_pos := _get_stream_position() - var target_coord := _world_to_coord(target_world_pos) - if target_coord == _center_coord: - return - _center_coord = target_coord - _queue_coord_sync(_center_coord) - _queue_locations_refresh() - - + + +func _ready() -> void: + _tiles_root = Node3D.new() + _tiles_root.name = "GeneratedTiles" + add_child(_tiles_root) + + if _camera: + _camera_start_offset = _camera.position + + _tracked_node = get_node_or_null(tracked_node_path) as Node3D + if _tracked_node == null: + _tracked_node = _player + + var start_coord := SelectedCharacter.get_coord() + _center_coord = Vector2i(roundi(start_coord.x), roundi(start_coord.y)) + _persisted_coord = _center_coord + _character_id = String(SelectedCharacter.character.get("id", SelectedCharacter.character.get("Id", ""))).strip_edges() + + _block.visible = false + await _load_existing_locations() + _ensure_selected_location_exists(_center_coord) + _rebuild_tiles(_center_coord) + _move_player_to_coord(_center_coord) + + +func _process(_delta: float) -> void: + if not _locations_loaded: + return + var target_world_pos := _get_stream_position() + var target_coord := _world_to_coord(target_world_pos) + if target_coord == _center_coord: + return + _center_coord = target_coord + _queue_coord_sync(_center_coord) + _queue_locations_refresh() + + func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("interact"): _try_interact_current_tile() - - -func _get_stream_position() -> Vector3: - if _tracked_node: - return _tracked_node.global_position - return _coord_to_world(_center_coord) - - -func _world_to_coord(world_pos: Vector3) -> Vector2i: - return Vector2i( - roundi(world_pos.x / tile_size), - roundi(world_pos.z / tile_size) - ) - - -func _coord_to_world(coord: Vector2i) -> Vector3: - return Vector3(coord.x * tile_size, block_height * 0.5, coord.y * tile_size) - - -func _move_player_to_coord(coord: Vector2i) -> void: - if _player == null: - return - _player.global_position = Vector3(coord.x * tile_size, player_spawn_height, coord.y * tile_size) - _player.linear_velocity = Vector3.ZERO - _player.angular_velocity = Vector3.ZERO - - -func _rebuild_tiles(center: Vector2i) -> void: - var wanted_keys: Dictionary = {} - for x in range(center.x - tile_radius, center.x + tile_radius + 1): - for y in range(center.y - tile_radius, center.y + tile_radius + 1): - var coord := Vector2i(x, y) - if not _known_locations.has(coord): - continue + + +func _get_stream_position() -> Vector3: + if _tracked_node: + return _tracked_node.global_position + return _coord_to_world(_center_coord) + + +func _world_to_coord(world_pos: Vector3) -> Vector2i: + return Vector2i( + roundi(world_pos.x / tile_size), + roundi(world_pos.z / tile_size) + ) + + +func _coord_to_world(coord: Vector2i) -> Vector3: + return Vector3(coord.x * tile_size, block_height * 0.5, coord.y * tile_size) + + +func _move_player_to_coord(coord: Vector2i) -> void: + if _player == null: + return + _player.global_position = Vector3(coord.x * tile_size, player_spawn_height, coord.y * tile_size) + _player.linear_velocity = Vector3.ZERO + _player.angular_velocity = Vector3.ZERO + + +func _rebuild_tiles(center: Vector2i) -> void: + var wanted_keys: Dictionary = {} + for x in range(center.x - tile_radius, center.x + tile_radius + 1): + for y in range(center.y - tile_radius, center.y + tile_radius + 1): + var coord := Vector2i(x, y) + if not _known_locations.has(coord): + continue wanted_keys[coord] = true var location_data := _known_locations[coord] as Dictionary if _tile_nodes.has(coord): _update_tile(coord, location_data) continue _spawn_tile(coord, location_data) - - for key in _tile_nodes.keys(): - if wanted_keys.has(key): - continue - var tile_node := _tile_nodes[key] as Node3D - if tile_node: - tile_node.queue_free() - _tile_nodes.erase(key) - - + + for key in _tile_nodes.keys(): + if wanted_keys.has(key): + continue + var tile_node := _tile_nodes[key] as Node3D + if tile_node: + tile_node.queue_free() + _tile_nodes.erase(key) + + func _spawn_tile(coord: Vector2i, location_data: Dictionary) -> void: var tile_root := Node3D.new() - tile_root.name = "Tile_%d_%d" % [coord.x, coord.y] - tile_root.position = _coord_to_world(coord) - _tiles_root.add_child(tile_root) - - var tile_body := StaticBody3D.new() - tile_body.name = "TileBody" - tile_body.scale = Vector3(tile_size, block_height, tile_size) - tile_root.add_child(tile_body) - - var collision_shape := CollisionShape3D.new() - collision_shape.name = "CollisionShape3D" - collision_shape.shape = BoxShape3D.new() - tile_body.add_child(collision_shape) - + tile_root.name = "Tile_%d_%d" % [coord.x, coord.y] + tile_root.position = _coord_to_world(coord) + _tiles_root.add_child(tile_root) + + var tile_body := StaticBody3D.new() + tile_body.name = "TileBody" + tile_body.scale = Vector3(tile_size, block_height, tile_size) + tile_root.add_child(tile_body) + + var collision_shape := CollisionShape3D.new() + collision_shape.name = "CollisionShape3D" + collision_shape.shape = BoxShape3D.new() + tile_body.add_child(collision_shape) + var tile := _block.duplicate() as MeshInstance3D tile.name = "TileMesh" tile.visible = true - tile_body.add_child(tile) - - tile.add_child(_create_tile_border()) - if show_tile_labels: - tile_root.add_child(_create_tile_label(String(location_data.get("name", "")))) - _update_tile_object(tile_root, location_data) - - _tile_nodes[coord] = tile_root - - -func _update_tile(coord: Vector2i, location_data: Dictionary) -> void: - var tile_root := _tile_nodes.get(coord) as Node3D - if tile_root == null: - return - - if show_tile_labels: - var label := tile_root.get_node_or_null("LocationNameLabel") as Label3D - if label: - label.text = String(location_data.get("name", "")) - - _update_tile_object(tile_root, location_data) - - -func _update_tile_object(tile_root: Node3D, location_data: Dictionary) -> void: - var existing := tile_root.get_node_or_null("LocationObject") - if existing: - existing.queue_free() - - var object_data_variant: Variant = location_data.get("locationObject", {}) - if typeof(object_data_variant) != TYPE_DICTIONARY: - return - - var object_data := object_data_variant as Dictionary - if object_data.is_empty(): - return - - var object_root := Node3D.new() - object_root.name = "LocationObject" - object_root.position = Vector3(0.0, (block_height * 0.5) + 0.6, 0.0) - tile_root.add_child(object_root) - - var object_mesh := MeshInstance3D.new() - object_mesh.name = "ObjectMesh" - object_mesh.mesh = SphereMesh.new() - object_mesh.scale = Vector3(0.6, 0.4, 0.6) - object_mesh.material_override = _create_object_material(String(object_data.get("objectKey", ""))) - object_root.add_child(object_mesh) - - var object_label := Label3D.new() - object_label.name = "ObjectLabel" - object_label.text = _build_object_label(object_data) - object_label.position = Vector3(0.0, 0.6, 0.0) - object_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED - object_label.pixel_size = 0.01 - object_label.outline_size = 10 - object_root.add_child(object_label) - - -func _create_tile_border() -> MeshInstance3D: - var top_y := 0.5 + border_height_bias - var corners := [ - Vector3(-0.5, top_y, -0.5), - Vector3(0.5, top_y, -0.5), - Vector3(0.5, top_y, 0.5), - Vector3(-0.5, top_y, 0.5), - ] - - var border_mesh := ImmediateMesh.new() - border_mesh.surface_begin(Mesh.PRIMITIVE_LINES, _get_border_material()) - for idx in range(corners.size()): - var current: Vector3 = corners[idx] - var next: Vector3 = corners[(idx + 1) % corners.size()] - border_mesh.surface_add_vertex(current) - border_mesh.surface_add_vertex(next) - border_mesh.surface_end() - - var border := MeshInstance3D.new() - border.name = "TileBorder" - border.mesh = border_mesh - return border - - -func _get_border_material() -> StandardMaterial3D: - if _border_material: - return _border_material - - _border_material = StandardMaterial3D.new() - _border_material.albedo_color = border_color - _border_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - _border_material.disable_receive_shadows = true - _border_material.no_depth_test = true - return _border_material - - -func _create_tile_label(location_name: String) -> Label3D: - var label := Label3D.new() - label.name = "LocationNameLabel" - label.text = location_name - label.position = Vector3(0.0, (block_height * 0.5) + border_height_bias + tile_label_height, 0.0) - label.rotation_degrees = Vector3(-90.0, 0.0, 0.0) - label.billboard = BaseMaterial3D.BILLBOARD_DISABLED - label.modulate = tile_label_color - label.pixel_size = 0.01 - label.outline_size = 12 - label.no_depth_test = false - return label - - -func _create_object_material(object_key: String) -> StandardMaterial3D: - var material := StandardMaterial3D.new() - material.roughness = 1.0 - material.metallic = 0.0 - - if object_key.contains("grass"): - material.albedo_color = Color(0.28, 0.68, 0.25, 1.0) - elif object_key.contains("wood"): - material.albedo_color = Color(0.54, 0.36, 0.18, 1.0) - elif object_key.contains("stone"): - material.albedo_color = Color(0.55, 0.57, 0.6, 1.0) - else: - material.albedo_color = Color(0.85, 0.75, 0.3, 1.0) - - return material - - -func _build_object_label(object_data: Dictionary) -> String: - var object_name := String(object_data.get("name", "")).strip_edges() - var state_variant: Variant = object_data.get("state", {}) - var remaining_quantity := 0 - if typeof(state_variant) == TYPE_DICTIONARY: - remaining_quantity = int((state_variant as Dictionary).get("remainingQuantity", 0)) - if object_name.is_empty(): - object_name = "Object" - return "%s x%d" % [object_name, remaining_quantity] - - -func _ensure_selected_location_exists(coord: Vector2i) -> void: - if _known_locations.has(coord): - return + var biome_key := String(location_data.get("biomeKey", "plains")).strip_edges() + var biome_material := _get_biome_material(tile, biome_key) + if biome_material: + tile.material_override = biome_material + tile_body.add_child(tile) + + tile.add_child(_create_tile_border()) + if show_tile_labels: + tile_root.add_child(_create_tile_label(String(location_data.get("name", "")))) + _update_tile_object(tile_root, location_data) + + _tile_nodes[coord] = tile_root + + +func _update_tile(coord: Vector2i, location_data: Dictionary) -> void: + var tile_root := _tile_nodes.get(coord) as Node3D + if tile_root == null: + return + + if show_tile_labels: + var label := tile_root.get_node_or_null("LocationNameLabel") as Label3D + if label: + label.text = String(location_data.get("name", "")) + + _update_tile_object(tile_root, location_data) + + +func _update_tile_object(tile_root: Node3D, location_data: Dictionary) -> void: + var existing := tile_root.get_node_or_null("LocationObject") + if existing: + existing.queue_free() + + var object_data_variant: Variant = location_data.get("locationObject", {}) + if typeof(object_data_variant) != TYPE_DICTIONARY: + return + + var object_data := object_data_variant as Dictionary + if object_data.is_empty(): + return + + var object_root := Node3D.new() + object_root.name = "LocationObject" + object_root.position = Vector3(0.0, (block_height * 0.5) + 0.6, 0.0) + tile_root.add_child(object_root) + + var object_mesh := MeshInstance3D.new() + object_mesh.name = "ObjectMesh" + object_mesh.mesh = SphereMesh.new() + object_mesh.scale = Vector3(0.6, 0.4, 0.6) + object_mesh.material_override = _create_object_material(String(object_data.get("objectKey", ""))) + object_root.add_child(object_mesh) + + var object_label := Label3D.new() + object_label.name = "ObjectLabel" + object_label.text = _build_object_label(object_data) + object_label.position = Vector3(0.0, 0.6, 0.0) + object_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED + object_label.pixel_size = 0.01 + object_label.outline_size = 10 + object_root.add_child(object_label) + + +func _create_tile_border() -> MeshInstance3D: + var top_y := 0.5 + border_height_bias + var corners := [ + Vector3(-0.5, top_y, -0.5), + Vector3(0.5, top_y, -0.5), + Vector3(0.5, top_y, 0.5), + Vector3(-0.5, top_y, 0.5), + ] + + var border_mesh := ImmediateMesh.new() + border_mesh.surface_begin(Mesh.PRIMITIVE_LINES, _get_border_material()) + for idx in range(corners.size()): + var current: Vector3 = corners[idx] + var next: Vector3 = corners[(idx + 1) % corners.size()] + border_mesh.surface_add_vertex(current) + border_mesh.surface_add_vertex(next) + border_mesh.surface_end() + + var border := MeshInstance3D.new() + border.name = "TileBorder" + border.mesh = border_mesh + return border + + +func _get_border_material() -> StandardMaterial3D: + if _border_material: + return _border_material + + _border_material = StandardMaterial3D.new() + _border_material.albedo_color = border_color + _border_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + _border_material.disable_receive_shadows = true + _border_material.no_depth_test = true + return _border_material + + +func _create_tile_label(location_name: String) -> Label3D: + var label := Label3D.new() + label.name = "LocationNameLabel" + label.text = location_name + label.position = Vector3(0.0, (block_height * 0.5) + border_height_bias + tile_label_height, 0.0) + label.rotation_degrees = Vector3(-90.0, 0.0, 0.0) + label.billboard = BaseMaterial3D.BILLBOARD_DISABLED + label.modulate = tile_label_color + label.pixel_size = 0.01 + label.outline_size = 12 + label.no_depth_test = false + return label + + +func _create_object_material(object_key: String) -> StandardMaterial3D: + var material := StandardMaterial3D.new() + material.roughness = 1.0 + material.metallic = 0.0 + + if object_key.contains("grass"): + material.albedo_color = Color(0.28, 0.68, 0.25, 1.0) + elif object_key.contains("wood"): + material.albedo_color = Color(0.54, 0.36, 0.18, 1.0) + elif object_key.contains("stone"): + material.albedo_color = Color(0.55, 0.57, 0.6, 1.0) + else: + material.albedo_color = Color(0.85, 0.75, 0.3, 1.0) + + return material + + +func _build_object_label(object_data: Dictionary) -> String: + var object_name := String(object_data.get("name", "")).strip_edges() + var state_variant: Variant = object_data.get("state", {}) + var remaining_quantity := 0 + if typeof(state_variant) == TYPE_DICTIONARY: + remaining_quantity = int((state_variant as Dictionary).get("remainingQuantity", 0)) + if object_name.is_empty(): + object_name = "Object" + return "%s x%d" % [object_name, remaining_quantity] + + +func _ensure_selected_location_exists(coord: Vector2i) -> void: + if _known_locations.has(coord): + return _known_locations[coord] = { "id": "", "name": _selected_location_name(coord), + "biomeKey": "plains", "locationObject": {} } - - -func _selected_location_name(coord: Vector2i) -> String: - var selected_name := String(SelectedCharacter.character.get("locationName", "")).strip_edges() - if not selected_name.is_empty(): - return selected_name - var character_name := String(SelectedCharacter.character.get("name", "")).strip_edges() - if not character_name.is_empty(): - return "%s's Location" % character_name - return "Location %d,%d" % [coord.x, coord.y] - - -func _load_existing_locations() -> void: - _locations_refresh_in_flight = true - _locations_loaded = false - _known_locations.clear() - - if _character_id.is_empty(): - push_warning("Selected character is missing an id; cannot load visible locations.") - _locations_loaded = true - _locations_refresh_in_flight = false - return - - 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-locations" % [CHARACTER_API_URL, _character_id], headers, HTTPClient.METHOD_GET) - if err != OK: - push_warning("Failed to request visible locations: %s" % err) - request.queue_free() - _locations_loaded = true - _locations_refresh_in_flight = false - return - - var result: Array = await request.request_completed - request.queue_free() - - 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 locations (%s/%s): %s" % [result_code, response_code, response_body]) - _locations_loaded = true - _locations_refresh_in_flight = false - return - - var parsed: Variant = JSON.parse_string(response_body) - if typeof(parsed) != TYPE_ARRAY: - push_warning("Visible locations response was not an array.") - _locations_loaded = true - _locations_refresh_in_flight = false - return - - var loaded_count := 0 - for item in parsed: - if typeof(item) != TYPE_DICTIONARY: - continue - var location := item as Dictionary - var coord_variant: Variant = location.get("coord", {}) - if typeof(coord_variant) != TYPE_DICTIONARY: - continue - var coord_dict := coord_variant as Dictionary - var coord := Vector2i(int(coord_dict.get("x", 0)), int(coord_dict.get("y", 0))) - var location_name := String(location.get("name", "")).strip_edges() - if location_name.is_empty(): - location_name = "Location %d,%d" % [coord.x, coord.y] - var location_object := {} - var object_variant: Variant = location.get("locationObject", {}) - if typeof(object_variant) == TYPE_DICTIONARY: - location_object = (object_variant as Dictionary).duplicate(true) + + +func _selected_location_name(coord: Vector2i) -> String: + var selected_name := String(SelectedCharacter.character.get("locationName", "")).strip_edges() + if not selected_name.is_empty(): + return selected_name + var character_name := String(SelectedCharacter.character.get("name", "")).strip_edges() + if not character_name.is_empty(): + return "%s's Location" % character_name + return "Location %d,%d" % [coord.x, coord.y] + + +func _load_existing_locations() -> void: + _locations_refresh_in_flight = true + _locations_loaded = false + _known_locations.clear() + + if _character_id.is_empty(): + push_warning("Selected character is missing an id; cannot load visible locations.") + _locations_loaded = true + _locations_refresh_in_flight = false + return + + 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-locations" % [CHARACTER_API_URL, _character_id], headers, HTTPClient.METHOD_GET) + if err != OK: + push_warning("Failed to request visible locations: %s" % err) + request.queue_free() + _locations_loaded = true + _locations_refresh_in_flight = false + return + + var result: Array = await request.request_completed + request.queue_free() + + 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 locations (%s/%s): %s" % [result_code, response_code, response_body]) + _locations_loaded = true + _locations_refresh_in_flight = false + return + + var parsed: Variant = JSON.parse_string(response_body) + if typeof(parsed) != TYPE_ARRAY: + push_warning("Visible locations response was not an array.") + _locations_loaded = true + _locations_refresh_in_flight = false + return + + var loaded_count := 0 + for item in parsed: + if typeof(item) != TYPE_DICTIONARY: + continue + var location := item as Dictionary + var coord_variant: Variant = location.get("coord", {}) + if typeof(coord_variant) != TYPE_DICTIONARY: + continue + var coord_dict := coord_variant as Dictionary + var coord := Vector2i(int(coord_dict.get("x", 0)), int(coord_dict.get("y", 0))) + var location_name := String(location.get("name", "")).strip_edges() + if location_name.is_empty(): + location_name = "Location %d,%d" % [coord.x, coord.y] _known_locations[coord] = { "id": String(location.get("id", "")).strip_edges(), "name": location_name, - "locationObject": location_object + "biomeKey": String(location.get("biomeKey", "plains")).strip_edges(), + "locationObject": _parse_location_object(location.get("locationObject", {})) } loaded_count += 1 + + print("LocationLevel loaded %d visible locations for character %s." % [loaded_count, _character_id]) + if loaded_count == 0: + push_warning("Visible locations request succeeded but returned 0 locations for character %s." % _character_id) + + _locations_loaded = true + _locations_refresh_in_flight = false + _rebuild_tiles(_center_coord) + if _queued_locations_refresh: + _queued_locations_refresh = false + _queue_locations_refresh() + + +func _queue_locations_refresh() -> void: + if _locations_refresh_in_flight: + _queued_locations_refresh = true + return + _refresh_visible_locations() + + +func _refresh_visible_locations() -> void: + if _character_id.is_empty(): + return + _refresh_visible_locations_async() + + +func _refresh_visible_locations_async() -> void: + await _load_existing_locations() + + +func _try_interact_current_tile() -> void: + if _interact_in_flight: + return + if _character_id.is_empty(): + return + + var location_data: Dictionary = _known_locations.get(_center_coord, {}) + if location_data.is_empty(): + push_warning("No known location data for %s." % _center_coord) + return + + var location_id := String(location_data.get("id", "")).strip_edges() + if location_id.is_empty(): + push_warning("Current location is missing an id.") + return + + var object_data: Dictionary = location_data.get("locationObject", {}) + if object_data.is_empty(): + push_warning("Current location has no interactable object.") + return + + var object_id := String(object_data.get("id", "")).strip_edges() + if object_id.is_empty(): + push_warning("Current location object is missing an id.") + return + + _interact_in_flight = true + _interact_with_location_async(location_id, object_id) + + +func _interact_with_location_async(location_id: String, object_id: String) -> void: + 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) + headers.append("Content-Type: application/json") + + var body := JSON.stringify({ + "characterId": _character_id, + "objectId": object_id + }) + var err := request.request("%s/%s/interact" % [LOCATION_API_URL, location_id], headers, HTTPClient.METHOD_POST, body) + if err != OK: + push_warning("Failed to send location interaction request: %s" % err) + request.queue_free() + _interact_in_flight = false + return + + var result: Array = await request.request_completed + request.queue_free() + + 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("Location interaction failed (%s/%s): %s" % [result_code, response_code, response_body]) + _interact_in_flight = false + return + + var parsed: Variant = JSON.parse_string(response_body) + if typeof(parsed) != TYPE_DICTIONARY: + push_warning("Location interaction response was not an object.") + _interact_in_flight = false + return + + var interaction := parsed as Dictionary + _apply_interaction_result(location_id, interaction) + _interact_in_flight = false + + +func _apply_interaction_result(location_id: String, interaction: Dictionary) -> void: + var consumed := bool(interaction.get("consumed", false)) + var remaining_quantity := int(interaction.get("remainingQuantity", 0)) + + for coord_variant in _known_locations.keys(): + var coord: Vector2i = coord_variant + var location_data: Dictionary = _known_locations[coord] + if String(location_data.get("id", "")) != location_id: + continue + + var updated_location := location_data.duplicate(true) + if consumed: + updated_location["locationObject"] = {} + else: + var object_data: Dictionary = updated_location.get("locationObject", {}) + var state: Dictionary = object_data.get("state", {}) + state["remainingQuantity"] = remaining_quantity + object_data["state"] = state + updated_location["locationObject"] = object_data + _known_locations[coord] = updated_location + _update_tile(coord, updated_location) + return + + +func _queue_coord_sync(coord: Vector2i) -> void: + if coord == _persisted_coord: + return + if _coord_sync_in_flight: + _queued_coord_sync = coord + return + _sync_character_coord(coord) + + +func _sync_character_coord(coord: Vector2i) -> void: + if _character_id.is_empty(): + return + _coord_sync_in_flight = true + _queued_coord_sync = null + _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: + push_warning("Failed to persist character coord to %s,%s: status=%s error=%s body=%s" % [ + coord.x, + coord.y, + response.get("status", "n/a"), + response.get("error", ""), + response.get("body", "") + ]) + + _coord_sync_in_flight = false + if _queued_coord_sync != null and _queued_coord_sync is Vector2i and _queued_coord_sync != _persisted_coord: + var queued_coord: Vector2i = _queued_coord_sync + _sync_character_coord(queued_coord) + + +func _get_location_data(coord: Vector2i) -> Dictionary: + var value: Variant = _known_locations.get(coord, {}) + if typeof(value) == TYPE_DICTIONARY: + return value as Dictionary + return {} + + +func _parse_location_object(value: Variant) -> Dictionary: + if typeof(value) != TYPE_DICTIONARY: + return {} - print("LocationLevel loaded %d visible locations for character %s." % [loaded_count, _character_id]) - if loaded_count == 0: - push_warning("Visible locations request succeeded but returned 0 locations for character %s." % _character_id) + var object_data := value as Dictionary + var state_value: Variant = object_data.get("state", {}) + var state: Dictionary = {} + if typeof(state_value) == TYPE_DICTIONARY: + var raw_state := state_value as Dictionary + state = { + "itemKey": String(raw_state.get("itemKey", "")).strip_edges(), + "remainingQuantity": int(raw_state.get("remainingQuantity", 0)), + "gatherQuantity": int(raw_state.get("gatherQuantity", 1)) + } - _locations_loaded = true - _locations_refresh_in_flight = false - _rebuild_tiles(_center_coord) - if _queued_locations_refresh: - _queued_locations_refresh = false - _queue_locations_refresh() - - -func _queue_locations_refresh() -> void: - if _locations_refresh_in_flight: - _queued_locations_refresh = true - return - _refresh_visible_locations() - - -func _refresh_visible_locations() -> void: - if _character_id.is_empty(): - return - _refresh_visible_locations_async() - - -func _refresh_visible_locations_async() -> void: - await _load_existing_locations() - - -func _try_interact_current_tile() -> void: - if _interact_in_flight: - return - if _character_id.is_empty(): - return - - var location_data: Dictionary = _known_locations.get(_center_coord, {}) - if location_data.is_empty(): - push_warning("No known location data for %s." % _center_coord) - return - - var location_id := String(location_data.get("id", "")).strip_edges() - if location_id.is_empty(): - push_warning("Current location is missing an id.") - return - - var object_data: Dictionary = location_data.get("locationObject", {}) - if object_data.is_empty(): - push_warning("Current location has no interactable object.") - return - - var object_id := String(object_data.get("id", "")).strip_edges() - if object_id.is_empty(): - push_warning("Current location object is missing an id.") - return - - _interact_in_flight = true - _interact_with_location_async(location_id, object_id) - - -func _interact_with_location_async(location_id: String, object_id: String) -> void: - 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) - headers.append("Content-Type: application/json") - - var body := JSON.stringify({ - "characterId": _character_id, - "objectId": object_id - }) - var err := request.request("%s/%s/interact" % [LOCATION_API_URL, location_id], headers, HTTPClient.METHOD_POST, body) - if err != OK: - push_warning("Failed to send location interaction request: %s" % err) - request.queue_free() - _interact_in_flight = false - return - - var result: Array = await request.request_completed - request.queue_free() - - 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("Location interaction failed (%s/%s): %s" % [result_code, response_code, response_body]) - _interact_in_flight = false - return - - var parsed: Variant = JSON.parse_string(response_body) - if typeof(parsed) != TYPE_DICTIONARY: - push_warning("Location interaction response was not an object.") - _interact_in_flight = false - return - - var interaction := parsed as Dictionary - _apply_interaction_result(location_id, interaction) - _interact_in_flight = false - - -func _apply_interaction_result(location_id: String, interaction: Dictionary) -> void: - var consumed := bool(interaction.get("consumed", false)) - var remaining_quantity := int(interaction.get("remainingQuantity", 0)) - - for coord_variant in _known_locations.keys(): - var coord: Vector2i = coord_variant - var location_data: Dictionary = _known_locations[coord] - if String(location_data.get("id", "")) != location_id: - continue - - var updated_location := location_data.duplicate(true) - if consumed: - updated_location["locationObject"] = {} - else: - var object_data: Dictionary = updated_location.get("locationObject", {}) - var state: Dictionary = object_data.get("state", {}) - state["remainingQuantity"] = remaining_quantity - object_data["state"] = state - updated_location["locationObject"] = object_data - _known_locations[coord] = updated_location - _update_tile(coord, updated_location) - return - - -func _queue_coord_sync(coord: Vector2i) -> void: - if coord == _persisted_coord: - return - if _coord_sync_in_flight: - _queued_coord_sync = coord - return - _sync_character_coord(coord) - - -func _sync_character_coord(coord: Vector2i) -> void: - if _character_id.is_empty(): - return - _coord_sync_in_flight = true - _queued_coord_sync = null - _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: - push_warning("Failed to persist character coord to %s,%s: status=%s error=%s body=%s" % [ - coord.x, - coord.y, - response.get("status", "n/a"), - response.get("error", ""), - response.get("body", "") - ]) - - _coord_sync_in_flight = false - if _queued_coord_sync != null and _queued_coord_sync is Vector2i and _queued_coord_sync != _persisted_coord: - var queued_coord: Vector2i = _queued_coord_sync - _sync_character_coord(queued_coord) + return { + "id": String(object_data.get("id", "")).strip_edges(), + "objectType": String(object_data.get("objectType", "")).strip_edges(), + "objectKey": String(object_data.get("objectKey", "")).strip_edges(), + "name": String(object_data.get("name", "")).strip_edges(), + "state": state + } + + +func _get_biome_material(tile: MeshInstance3D, biome_key: String) -> Material: + var normalized_biome := biome_key if not biome_key.is_empty() else "plains" + if _biome_materials.has(normalized_biome): + return _biome_materials[normalized_biome] + + var source_material := tile.get_active_material(0) + if source_material is StandardMaterial3D: + var material := (source_material as StandardMaterial3D).duplicate() as StandardMaterial3D + material.albedo_color = _get_biome_color(normalized_biome) + _biome_materials[normalized_biome] = material + return material + + return source_material + + +func _get_biome_color(biome_key: String) -> Color: + match biome_key: + "forest": + return Color(0.36, 0.62, 0.34, 1.0) + "wetlands": + return Color(0.28, 0.52, 0.44, 1.0) + "rocky": + return Color(0.52, 0.50, 0.44, 1.0) + "desert": + return Color(0.76, 0.67, 0.38, 1.0) + _: + return Color(0.56, 0.72, 0.38, 1.0) diff --git a/microservices/CharacterApi/DOCUMENTS.md b/microservices/CharacterApi/DOCUMENTS.md index cf4733e..7e4d5e1 100644 --- a/microservices/CharacterApi/DOCUMENTS.md +++ b/microservices/CharacterApi/DOCUMENTS.md @@ -46,7 +46,15 @@ Outbound JSON documents "coord": { "x": "number", "y": "number" - } + }, + "biomeKey": "plains", + "resources": [ + { + "itemKey": "wood", + "remainingQuantity": 100, + "gatherQuantity": 3 + } + ] } ] ``` diff --git a/microservices/CharacterApi/Models/VisibleLocation.cs b/microservices/CharacterApi/Models/VisibleLocation.cs index 7378c79..f4cd32c 100644 --- a/microservices/CharacterApi/Models/VisibleLocation.cs +++ b/microservices/CharacterApi/Models/VisibleLocation.cs @@ -1,23 +1,23 @@ -using MongoDB.Bson.Serialization.Attributes; -using MongoDB.Bson; - -namespace CharacterApi.Models; - -[BsonIgnoreExtraElements] +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Bson; + +namespace CharacterApi.Models; + +[BsonIgnoreExtraElements] public class VisibleLocation { - [BsonId] - [BsonRepresentation(BsonType.ObjectId)] - public string? Id { get; set; } - - [BsonElement("name")] - public string Name { get; set; } = string.Empty; - + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + [BsonElement("name")] + public string Name { get; set; } = string.Empty; + [BsonElement("coord")] public LocationCoord Coord { get; set; } = new(); [BsonElement("biomeKey")] - public string BiomeKey { get; set; } = string.Empty; + public string BiomeKey { get; set; } = "plains"; [BsonElement("locationObject")] public VisibleLocationObject? LocationObject { get; set; } diff --git a/microservices/CharacterApi/Models/VisibleLocationResource.cs b/microservices/CharacterApi/Models/VisibleLocationResource.cs new file mode 100644 index 0000000..344836e --- /dev/null +++ b/microservices/CharacterApi/Models/VisibleLocationResource.cs @@ -0,0 +1,16 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace CharacterApi.Models; + +[BsonIgnoreExtraElements] +public class VisibleLocationResource +{ + [BsonElement("itemKey")] + public string ItemKey { get; set; } = string.Empty; + + [BsonElement("remainingQuantity")] + public int RemainingQuantity { get; set; } + + [BsonElement("gatherQuantity")] + public int GatherQuantity { get; set; } = 1; +} diff --git a/microservices/CharacterApi/Services/CharacterStore.cs b/microservices/CharacterApi/Services/CharacterStore.cs index 381a6d1..15eaf8e 100644 --- a/microservices/CharacterApi/Services/CharacterStore.cs +++ b/microservices/CharacterApi/Services/CharacterStore.cs @@ -1,16 +1,57 @@ using CharacterApi.Models; using MongoDB.Bson; using MongoDB.Driver; - -namespace CharacterApi.Services; - + +namespace CharacterApi.Services; + public class CharacterStore { private readonly IMongoCollection _col; private readonly IMongoCollection _locations; private const string CoordIndexName = "coord_x_1_coord_y_1"; + private const int WorldSeed = 1729; + private static readonly string[] BiomeOrder = ["plains", "forest", "wetlands", "rocky", "desert"]; + private static readonly Dictionary Biomes = new() + { + ["plains"] = new("plains", 3.0, new() + { + ["forest"] = 1.7, + ["wetlands"] = 0.9, + ["rocky"] = 0.8, + ["desert"] = 0.4 + }), + ["forest"] = new("forest", 3.4, new() + { + ["plains"] = 1.6, + ["wetlands"] = 1.3, + ["rocky"] = 0.5, + ["desert"] = 0.1 + }), + ["wetlands"] = new("wetlands", 3.1, new() + { + ["forest"] = 1.5, + ["plains"] = 1.1, + ["rocky"] = 0.2, + ["desert"] = 0.05 + }), + ["rocky"] = new("rocky", 3.0, new() + { + ["plains"] = 1.2, + ["forest"] = 0.6, + ["desert"] = 1.1, + ["wetlands"] = 0.1 + }), + ["desert"] = new("desert", 3.2, new() + { + ["rocky"] = 1.4, + ["plains"] = 0.8, + ["forest"] = 0.1, + ["wetlands"] = 0.05 + }) + }; public sealed record VisibleLocationResult(List Locations, int GeneratedCount); + private sealed record BiomeDefinition(string Key, double ContinuationWeight, Dictionary TransitionWeights); public CharacterStore(IConfiguration cfg) { @@ -25,9 +66,9 @@ public class CharacterStore var ownerIndex = Builders.IndexKeys.Ascending(c => c.OwnerUserId); _col.Indexes.CreateOne(new CreateIndexModel(ownerIndex)); } - - public Task CreateAsync(Character character) => _col.InsertOneAsync(character); - + + public Task CreateAsync(Character character) => _col.InsertOneAsync(character); + public Task> GetForOwnerAsync(string ownerUserId) => _col.Find(c => c.OwnerUserId == ownerUserId).ToListAsync(); @@ -42,10 +83,8 @@ public class CharacterStore return result.ModifiedCount > 0 || result.MatchedCount > 0; } - public Task> GetVisibleLocationsAsync(Character character) - { - return GetVisibleLocationsInternalAsync(character, ensureGenerated: false); - } + public Task> GetVisibleLocationsAsync(Character character) => + GetVisibleLocationsInternalAsync(character, ensureGenerated: false); public async Task GetOrCreateVisibleLocationsAsync(Character character) { @@ -73,9 +112,10 @@ public class CharacterStore if (ensureGenerated) { foreach (var document in documents) - await EnsureLocationObjectAsync(document); + await BackfillLocationStateAsync(document); documents = await _locations.Find(filter).ToListAsync(); } + return documents.Select(MapVisibleLocation).ToList(); } @@ -88,101 +128,202 @@ public class CharacterStore { for (var y = character.Coord.Y - radius; y <= character.Coord.Y + radius; y++) { - var filter = Builders.Filter.And( - Builders.Filter.Eq("coord.x", x), - Builders.Filter.Eq("coord.y", y) - ); - - var update = Builders.Update - .SetOnInsert("_id", ObjectId.GenerateNewId()) - .SetOnInsert("name", DefaultLocationName(x, y)) - .SetOnInsert("coord", new BsonDocument - { - { "x", x }, - { "y", y } - }) - .SetOnInsert("biomeKey", DetermineBiomeKey(x, y)) - .SetOnInsert("locationObject", CreateLocationObjectValueForBiome(DetermineBiomeKey(x, y), x, y)) - .SetOnInsert("locationObjectResolved", true) - .SetOnInsert("createdUtc", DateTime.UtcNow); - - try - { - var result = await _locations.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = true }); - if (result.UpsertedId is not null) - generatedCount += 1; - } - catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) - { - // Another request or service instance created it first. - } + if (await EnsureLocationStateAsync(x, y)) + generatedCount += 1; } } return generatedCount; } - private static string DefaultLocationName(int x, int y) + private async Task EnsureLocationStateAsync(int x, int y) { - if (x == 0 && y == 0) - return "Origin"; - return $"Location {x},{y}"; - } + var filter = Builders.Filter.And( + Builders.Filter.Eq("coord.x", x), + Builders.Filter.Eq("coord.y", y) + ); - private static VisibleLocation MapVisibleLocation(BsonDocument document) - { - var coord = document.GetValue("coord", new BsonDocument()).AsBsonDocument; - var locationObject = MapVisibleLocationObject(document.GetValue("locationObject", BsonNull.Value)); - var idValue = document.GetValue("_id", BsonNull.Value); - string? id = null; - if (!idValue.IsBsonNull) + var existing = await _locations.Find(filter).FirstOrDefaultAsync(); + if (existing is not null) { - id = idValue.BsonType == BsonType.ObjectId - ? idValue.AsObjectId.ToString() - : idValue.ToString(); + await BackfillLocationStateAsync(existing); + return false; } - return new VisibleLocation - { - Id = id, - Name = document.GetValue("name", "").AsString, - Coord = new LocationCoord + var biomeKey = await DetermineBiomeKeyAsync(x, y); + var update = Builders.Update + .SetOnInsert("_id", ObjectId.GenerateNewId()) + .SetOnInsert("name", DefaultLocationName(x, y)) + .SetOnInsert("coord", new BsonDocument { - X = coord.GetValue("x", 0).ToInt32(), - Y = coord.GetValue("y", 0).ToInt32() - }, - BiomeKey = document.GetValue("biomeKey", "").AsString, - LocationObject = locationObject - }; + { "x", x }, + { "y", y } + }) + .SetOnInsert("biomeKey", biomeKey) + .SetOnInsert("locationObject", CreateLocationObjectValueForBiome(biomeKey, x, y)) + .SetOnInsert("locationObjectResolved", true) + .SetOnInsert("createdUtc", DateTime.UtcNow); + + try + { + var result = await _locations.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = true }); + return result.UpsertedId is not null; + } + catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + return false; + } } - private async Task EnsureLocationObjectAsync(BsonDocument document) + private async Task BackfillLocationStateAsync(BsonDocument document) { - var hasBiome = document.TryGetValue("biomeKey", out var biomeValue) && - biomeValue.BsonType == BsonType.String && - !string.IsNullOrWhiteSpace(biomeValue.AsString); - var resolved = document.TryGetValue("locationObjectResolved", out var resolvedValue) && - resolvedValue.ToBoolean(); - if (hasBiome && resolved) - return; - - var idValue = document.GetValue("_id", BsonNull.Value); - if (idValue.IsBsonNull) - return; - var coord = document.GetValue("coord", new BsonDocument()).AsBsonDocument; var x = coord.GetValue("x", 0).ToInt32(); var y = coord.GetValue("y", 0).ToInt32(); - var biomeKey = hasBiome ? biomeValue.AsString : DetermineBiomeKey(x, y); - var locationObject = TryMigrateLegacyResource(document) ?? CreateLocationObjectValueForBiome(biomeKey, x, y); - var filter = Builders.Filter.And( - Builders.Filter.Eq("_id", idValue) - ); - var update = Builders.Update - .Set("biomeKey", biomeKey) - .Set("locationObjectResolved", true) - .Set("locationObject", locationObject); - await _locations.UpdateOneAsync(filter, update); + + var updates = new List>(); + var biomeKey = document.GetValue("biomeKey", BsonNull.Value).IsBsonNull + ? await DetermineBiomeKeyAsync(x, y) + : document.GetValue("biomeKey", "plains").AsString; + var objectResolved = document.TryGetValue("locationObjectResolved", out var resolvedValue) && + resolvedValue.ToBoolean(); + + if (!document.Contains("biomeKey")) + updates.Add(Builders.Update.Set("biomeKey", biomeKey)); + if (!objectResolved) + { + var locationObject = TryMigrateLegacyResource(document) ?? CreateLocationObjectValueForBiome(biomeKey, x, y); + updates.Add(Builders.Update.Set("locationObject", locationObject)); + updates.Add(Builders.Update.Set("locationObjectResolved", true)); + } + + if (updates.Count == 0) + return; + + var id = document.GetValue("_id").AsObjectId; + await _locations.UpdateOneAsync( + Builders.Filter.Eq("_id", id), + Builders.Update.Combine(updates)); + } + + private async Task DetermineBiomeKeyAsync(int x, int y) + { + if (x == 0 && y == 0) + return "plains"; + + var neighbors = await LoadNeighborBiomeKeysAsync(x, y); + var baseBiome = DetermineBaseBiomeKey(x, y); + if (neighbors.Count == 0) + return baseBiome; + + var dominantNeighbor = neighbors + .GroupBy(key => key) + .OrderByDescending(group => group.Count()) + .ThenBy(group => group.Key) + .First().Key; + + var bestBiome = baseBiome; + var bestScore = double.NegativeInfinity; + foreach (var candidate in BiomeOrder) + { + var score = candidate == baseBiome ? 2.5 : 0.35; + if (candidate == dominantNeighbor) + score += 1.8; + + foreach (var neighbor in neighbors) + { + if (!Biomes.TryGetValue(neighbor, out var neighborDefinition)) + continue; + + if (candidate == neighbor) + score += neighborDefinition.ContinuationWeight; + else if (neighborDefinition.TransitionWeights.TryGetValue(candidate, out var transitionWeight)) + score += transitionWeight; + } + + score += StableNoise(x, y, StableHash(candidate)) * 0.25; + if (score > bestScore) + { + bestScore = score; + bestBiome = candidate; + } + } + + return bestBiome; + } + + private async Task> LoadNeighborBiomeKeysAsync(int x, int y) + { + var coords = new[] { (x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1) }; + var filters = coords.Select(coord => + Builders.Filter.And( + Builders.Filter.Eq("coord.x", coord.Item1), + Builders.Filter.Eq("coord.y", coord.Item2))) + .ToList(); + var filter = Builders.Filter.Or(filters); + var neighbors = await _locations.Find(filter).ToListAsync(); + + return neighbors + .Where(doc => doc.Contains("biomeKey")) + .Select(doc => doc.GetValue("biomeKey", "plains").AsString) + .Where(key => !string.IsNullOrWhiteSpace(key)) + .ToList(); + } + + private static string DetermineBaseBiomeKey(int x, int y) + { + var temperature = StableNoise(x, y, 101); + var moisture = StableNoise(x, y, 202); + var ruggedness = StableNoise(x, y, 303); + + if (ruggedness > 0.74) + return "rocky"; + if (moisture > 0.72 && temperature < 0.75) + return "wetlands"; + if (moisture > 0.56) + return "forest"; + if (moisture < 0.22 && temperature > 0.58) + return "desert"; + return "plains"; + } + + private static BsonValue CreateLocationObjectValueForBiome(string biomeKey, int x, int y) + { + var roll = StableNoise(x, y, 401); + return biomeKey switch + { + "forest" => roll switch + { + < 0.35 => BsonNull.Value, + < 0.80 => CreateGatherableObjectDocument("wood", 60, 3), + < 0.95 => CreateGatherableObjectDocument("grass", 120, 10), + _ => CreateGatherableObjectDocument("stone", 40, 2) + }, + "rocky" => roll switch + { + < 0.60 => BsonNull.Value, + < 0.90 => CreateGatherableObjectDocument("stone", 40, 2), + _ => CreateGatherableObjectDocument("wood", 60, 3) + }, + "wetlands" => roll switch + { + < 0.40 => BsonNull.Value, + < 0.90 => CreateGatherableObjectDocument("grass", 120, 10), + _ => CreateGatherableObjectDocument("wood", 60, 3) + }, + "desert" => roll switch + { + < 0.70 => BsonNull.Value, + < 0.95 => CreateGatherableObjectDocument("stone", 40, 2), + _ => CreateGatherableObjectDocument("wood", 60, 3) + }, + _ => roll switch + { + < 0.50 => BsonNull.Value, + < 0.85 => CreateGatherableObjectDocument("grass", 120, 10), + _ => CreateGatherableObjectDocument("wood", 60, 3) + } + }; } private static BsonDocument? TryMigrateLegacyResource(BsonDocument document) @@ -207,6 +348,51 @@ public class CharacterStore return null; } + private static BsonDocument CreateGatherableObjectDocument(string itemKey, int remainingQuantity, int gatherQuantity) + { + var normalizedItemKey = NormalizeItemKey(itemKey); + return new BsonDocument + { + { "id", Guid.NewGuid().ToString("N") }, + { "objectType", "gatherable" }, + { "objectKey", $"{normalizedItemKey}_node" }, + { "name", HumanizeItemKey(normalizedItemKey) }, + { + "state", new BsonDocument + { + { "itemKey", normalizedItemKey }, + { "remainingQuantity", remainingQuantity }, + { "gatherQuantity", gatherQuantity } + } + } + }; + } + + private static VisibleLocation MapVisibleLocation(BsonDocument document) + { + var coord = document.GetValue("coord", new BsonDocument()).AsBsonDocument; + var locationObject = MapVisibleLocationObject(document.GetValue("locationObject", BsonNull.Value)); + var idValue = document.GetValue("_id", BsonNull.Value); + string? id = null; + if (!idValue.IsBsonNull) + { + id = idValue.BsonType == BsonType.ObjectId ? idValue.AsObjectId.ToString() : idValue.ToString(); + } + + return new VisibleLocation + { + Id = id, + Name = document.GetValue("name", "").AsString, + Coord = new LocationCoord + { + X = coord.GetValue("x", 0).ToInt32(), + Y = coord.GetValue("y", 0).ToInt32() + }, + BiomeKey = document.GetValue("biomeKey", "plains").AsString, + LocationObject = locationObject + }; + } + private static VisibleLocationObject? MapVisibleLocationObject(BsonValue value) { if (value.IsBsonNull || value is not BsonDocument document) @@ -228,102 +414,6 @@ public class CharacterStore }; } - private static string DetermineBiomeKey(int x, int y) - { - if (x == 0 && y == 0) - return "plains"; - - var regionX = FloorDiv(x, 4); - var regionY = FloorDiv(y, 4); - var roll = Math.Abs(HashCode.Combine(regionX, regionY, 7919)) % 100; - if (roll < 35) - return "plains"; - if (roll < 60) - return "forest"; - if (roll < 80) - return "rocky"; - if (roll < 92) - return "wetlands"; - return "desert"; - } - - private static BsonValue CreateLocationObjectValueForBiome(string biomeKey, int x, int y) - { - var roll = Math.Abs(HashCode.Combine(x, y, 1543)) % 100; - return biomeKey switch - { - "forest" => roll switch - { - < 35 => BsonNull.Value, - < 80 => CreateGatherableObjectDocument("wood", 60, 3), - < 95 => CreateGatherableObjectDocument("grass", 120, 10), - _ => CreateGatherableObjectDocument("stone", 40, 2) - }, - "rocky" => roll switch - { - < 60 => BsonNull.Value, - < 90 => CreateGatherableObjectDocument("stone", 40, 2), - _ => CreateGatherableObjectDocument("wood", 60, 3) - }, - "wetlands" => roll switch - { - < 40 => BsonNull.Value, - < 90 => CreateGatherableObjectDocument("grass", 120, 10), - _ => CreateGatherableObjectDocument("wood", 60, 3) - }, - "desert" => roll switch - { - < 70 => BsonNull.Value, - < 95 => CreateGatherableObjectDocument("stone", 40, 2), - _ => CreateGatherableObjectDocument("wood", 60, 3) - }, - _ => roll switch - { - < 50 => BsonNull.Value, - < 85 => CreateGatherableObjectDocument("grass", 120, 10), - _ => CreateGatherableObjectDocument("wood", 60, 3) - } - }; - } - - private static BsonDocument CreateGatherableObjectDocument(string itemKey, int remainingQuantity, int gatherQuantity) - { - var normalizedItemKey = NormalizeItemKey(itemKey); - return new BsonDocument - { - { "id", Guid.NewGuid().ToString("N") }, - { "objectType", "gatherable" }, - { "objectKey", $"{normalizedItemKey}_node" }, - { "name", HumanizeItemKey(normalizedItemKey) }, - { - "state", new BsonDocument - { - { "itemKey", normalizedItemKey }, - { "remainingQuantity", remainingQuantity }, - { "gatherQuantity", gatherQuantity } - } - } - }; - } - - private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant(); - - private static string HumanizeItemKey(string itemKey) - { - return string.Join(" ", itemKey.Split('_', StringSplitOptions.RemoveEmptyEntries) - .Where(part => part.Length > 0) - .Select(part => char.ToUpperInvariant(part[0]) + part[1..])); - } - - private static int FloorDiv(int value, int divisor) - { - var quotient = value / divisor; - var remainder = value % divisor; - if (remainder != 0 && ((remainder < 0) != (divisor < 0))) - quotient -= 1; - return quotient; - } - private void EnsureLocationCoordIndexes() { var indexes = _locations.Indexes.List().ToList(); @@ -338,11 +428,7 @@ public class CharacterStore _locations.Indexes.DropOne(name); } - var coordIndex = new BsonDocument - { - { "coord.x", 1 }, - { "coord.y", 1 } - }; + var coordIndex = new BsonDocument { { "coord.x", 1 }, { "coord.y", 1 } }; var coordIndexOptions = new CreateIndexOptions { Unique = true, Name = CoordIndexName }; _locations.Indexes.CreateOne(new CreateIndexModel(coordIndex, coordIndexOptions)); } @@ -365,14 +451,42 @@ public class CharacterStore { var filter = Builders.Filter.Eq(c => c.Id, id); if (!allowAnyOwner) - { - filter = Builders.Filter.And( - filter, - Builders.Filter.Eq(c => c.OwnerUserId, ownerUserId) - ); - } - - var result = await _col.DeleteOneAsync(filter); - return result.DeletedCount > 0; - } -} + filter = Builders.Filter.And(filter, Builders.Filter.Eq(c => c.OwnerUserId, ownerUserId)); + + var result = await _col.DeleteOneAsync(filter); + return result.DeletedCount > 0; + } + + private static string DefaultLocationName(int x, int y) + { + if (x == 0 && y == 0) + return "Origin"; + return $"Location {x},{y}"; + } + + private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant(); + + private static string HumanizeItemKey(string itemKey) + { + return string.Join(" ", itemKey.Split('_', StringSplitOptions.RemoveEmptyEntries) + .Where(part => part.Length > 0) + .Select(part => char.ToUpperInvariant(part[0]) + part[1..])); + } + + private static double StableNoise(int x, int y, int salt) + { + var value = Math.Sin((x * 12.9898) + (y * 78.233) + ((WorldSeed + salt) * 0.1597)) * 43758.5453; + return value - Math.Floor(value); + } + + private static int StableHash(string value) + { + unchecked + { + var hash = 17; + foreach (var ch in value) + hash = (hash * 31) + ch; + return hash; + } + } +} diff --git a/microservices/LocationsApi/DOCUMENTS.md b/microservices/LocationsApi/DOCUMENTS.md index 9b85cb9..5f08a25 100644 --- a/microservices/LocationsApi/DOCUMENTS.md +++ b/microservices/LocationsApi/DOCUMENTS.md @@ -39,6 +39,7 @@ Stored documents (MongoDB) "x": 0, "y": 0 }, + "biomeKey": "plains", "resources": [ { "itemKey": "wood", diff --git a/microservices/LocationsApi/Models/Location.cs b/microservices/LocationsApi/Models/Location.cs index a3737d3..dc4778e 100644 --- a/microservices/LocationsApi/Models/Location.cs +++ b/microservices/LocationsApi/Models/Location.cs @@ -1,32 +1,32 @@ -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; - -namespace LocationsApi.Models; - +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace LocationsApi.Models; + public class Location { - [BsonId] - [BsonRepresentation(BsonType.ObjectId)] - public string? Id { get; set; } - - [BsonElement("name")] - public string Name { get; set; } = string.Empty; - + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + [BsonElement("name")] + public string Name { get; set; } = string.Empty; + [BsonElement("coord")] public required Coord Coord { get; set; } + [BsonElement("biomeKey")] + public string BiomeKey { get; set; } = "plains"; + [BsonElement("resources")] public List Resources { get; set; } = []; - [BsonElement("biomeKey")] - public string BiomeKey { get; set; } = string.Empty; - [BsonElement("locationObject")] public LocationObject? LocationObject { get; set; } - - [BsonElement("locationObjectResolved")] - public bool LocationObjectResolved { get; set; } - - [BsonElement("createdUtc")] - public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; -} + + [BsonElement("locationObjectResolved")] + public bool LocationObjectResolved { get; set; } + + [BsonElement("createdUtc")] + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; +} diff --git a/microservices/LocationsApi/Services/LocationStore.cs b/microservices/LocationsApi/Services/LocationStore.cs index 4b0ce51..87523ca 100644 --- a/microservices/LocationsApi/Services/LocationStore.cs +++ b/microservices/LocationsApi/Services/LocationStore.cs @@ -36,7 +36,7 @@ public class LocationStore "$jsonSchema", new BsonDocument { { "bsonType", "object" }, - { "required", new BsonArray { "name", "coord", "createdUtc" } }, + { "required", new BsonArray { "name", "coord", "biomeKey", "createdUtc" } }, { "properties", new BsonDocument { @@ -55,6 +55,7 @@ public class LocationStore } } }, + { "biomeKey", new BsonDocument { { "bsonType", "string" } } }, { "resources", new BsonDocument { @@ -76,7 +77,6 @@ public class LocationStore } } }, - { "biomeKey", new BsonDocument { { "bsonType", new BsonArray { "string", "null" } } } }, { "locationObject", new BsonDocument { @@ -273,9 +273,9 @@ public class LocationStore { var filter = Builders.Filter.And( Builders.Filter.Eq(l => l.Coord.X, 0), - Builders.Filter.Eq(l => l.Coord.Y, 0) - ); - var existing = _col.Find(filter).FirstOrDefault(); + Builders.Filter.Eq(l => l.Coord.Y, 0) + ); + var existing = _col.Find(filter).FirstOrDefault(); if (existing is not null) return;