diff --git a/export_presets.cfg b/export_presets.cfg index 12c2429..5c1e387 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -114,6 +114,36 @@ application/architecture="universal" codesign/enable=false notarization/enable=false +[preset.5] + +name="Linux Dedicated Server" +platform="Linux" +runnable=false +advanced_options=false +dedicated_server=true +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="builds/*,playtest/*,scripts/*,tests/*" +export_path="builds/v0.6.4-alpha/server-linux-x86_64/NETfishingServer.x86_64" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +seed=0 +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.5.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=0 +binary_format/embed_pck=false +binary_format/architecture="x86_64" +texture_format/s3tc_bptc=true +texture_format/etc2_astc=false + [preset.2] name="Android" diff --git a/main/main.gd b/main/main.gd index 0c15905..5b48e79 100644 --- a/main/main.gd +++ b/main/main.gd @@ -39,6 +39,9 @@ const WorldPixelationPostprocessType = preload( ) const NetworkSessionType = preload("res://network/network_session.gd") const DiscoveryClientType = preload("res://network/discovery_client.gd") +const DedicatedServerConfigType = preload( + "res://server/dedicated_server_config.gd" +) const NetworkProfilePreferencesType = preload( "res://network/network_profile_preferences.gd" ) @@ -215,12 +218,17 @@ var _restore_data_setup_after_picker: bool = false var _application_initialized := false var _pending_existing_root_path := "" var _local_recovery_attempt_id: String = "" +var _dedicated_runtime: bool = false @onready var _shoreline_ambience: ShorelineAmbience = %ShorelineAmbience var _rain_ambience: RainAmbienceType func _ready() -> void: + _dedicated_runtime = _is_dedicated_server_runtime() + if _dedicated_runtime: + call_deferred("_start_dedicated_server") + return DisplayServer.window_set_title("NETfishing") _rain_ambience = RainAmbienceType.new() _rain_ambience.name = "RainAmbience" @@ -267,6 +275,83 @@ func _ready() -> void: _show_data_root_setup() +func _is_dedicated_server_runtime() -> bool: + if OS.has_feature("dedicated_server"): + return true + return "--dedicated-server" in OS.get_cmdline_user_args() + + +func _start_dedicated_server() -> void: + var config: DedicatedServerConfigType = DedicatedServerConfigType.from_runtime() + if not config.is_valid(): + _fail_dedicated_server(config.error_message) + return + var data_path: String = config.data_directory + var manifest_path: String = data_path.path_join( + PlayerDataRoot.MANIFEST_FILENAME + ) + if not FileAccess.file_exists(manifest_path): + var created: Dictionary = _data_root.create_unbound_root(data_path) + if not bool(created.get("ok", false)): + _fail_dedicated_server(str(created.get( + "message", "Could not create the server data directory." + ))) + return + if not _data_root.activate_process_root(data_path): + _fail_dedicated_server(_data_root.error_message) + return + var identity_directory: String = data_path.path_join("server") + if DirAccess.make_dir_recursive_absolute(identity_directory) != OK: + _fail_dedicated_server("Could not create the server identity directory.") + return + _host_identity.configure("host_identity", true, identity_directory) + if not _discovery.set_base_url_override(config.discovery_url): + _fail_dedicated_server("The discovery URL is invalid.") + return + _configure_portable_stores() + _initialize_application(true) + _player.set_local_control(false) + _player.visible = false + _player.collision_layer = 0 + _player.collision_mask = 0 + _player.set_physics_process(false) + if not _network_session.start_dedicated_host( + config.port, + config.max_players, + config.bind_address, + ): + _fail_dedicated_server("Could not start the dedicated server.") + return + _network_session.set_session_display_name(config.server_name) + _discovery.set_room_name(config.server_name) + if not _network_session.set_host_open(true): + _fail_dedicated_server("Could not open the dedicated server.") + return + if config.public_listing and not _discovery.set_discoverable(true): + _network_session.disconnect_session("Discovery setup failed.") + _fail_dedicated_server(_discovery.get_host_status_message()) + return + print( + "NETfishing dedicated server ready: %s on %s:%d (%d players, %s)" + % [ + config.server_name, + config.bind_address, + config.port, + config.max_players, + "public" if config.public_listing else "unlisted", + ] + ) + print( + "Server identity: %s" + % _network_session.get_host_identity_fingerprint() + ) + + +func _fail_dedicated_server(message: String) -> void: + push_error("Dedicated server startup failed: %s" % message) + get_tree().quit(1) + + func _configure_portable_stores() -> void: _save_manager.configure_storage( _data_root.path_for(&"player_save"), _data_root @@ -295,6 +380,10 @@ func _configure_portable_stores() -> void: func _initialize_after_data_root() -> void: + _initialize_application(false) + + +func _initialize_application(dedicated: bool) -> void: if _application_initialized: return _application_initialized = true @@ -314,21 +403,23 @@ func _initialize_after_data_root() -> void: _known_players, _server_trust, _host_bans, + dedicated, ) _discovery.setup(_network_session) - _world_time_visuals.setup( - _world_time, - _test_world.get_world_environment(), - _test_world.get_sun(), - _world_weather, - _player, - ) - if not _world_time.natural_time_advanced.is_connected( - _on_natural_time_advanced - ): - _world_time.natural_time_advanced.connect( - _on_natural_time_advanced + if not dedicated: + _world_time_visuals.setup( + _world_time, + _test_world.get_world_environment(), + _test_world.get_sun(), + _world_weather, + _player, ) + if not _world_time.natural_time_advanced.is_connected( + _on_natural_time_advanced + ): + _world_time.natural_time_advanced.connect( + _on_natural_time_advanced + ) _network_world_time.setup(_network_session, _world_time) _network_world_weather.setup(_network_session, _world_weather) _player_jobs.setup( @@ -357,22 +448,23 @@ func _initialize_after_data_root() -> void: _network_chat, _network_mail, ) - _network_session.set_local_appearance_snapshot( - _appearance_store.get_snapshot() - ) - _network_session.join_authenticated.connect( - _on_network_join_authenticated - ) - _network_session.connection_error.connect( - _on_network_connection_error - ) - _network_session.server_trust_required.connect( - _on_server_trust_required - ) + if not dedicated: + _network_session.set_local_appearance_snapshot( + _appearance_store.get_snapshot() + ) + _network_session.join_authenticated.connect( + _on_network_join_authenticated + ) + _network_session.connection_error.connect( + _on_network_connection_error + ) + _network_session.server_trust_required.connect( + _on_server_trust_required + ) + _network_session.server_lost.connect(_on_network_server_lost) _network_session.peer_identity_observed.connect( _on_peer_identity_observed ) - _network_session.server_lost.connect(_on_network_server_lost) _network_session.remote_recovery_requested.connect( _on_remote_recovery_requested ) @@ -389,13 +481,14 @@ func _initialize_after_data_root() -> void: _player.bag.setup(item_catalog) _player.hotbar.setup(_player.bag, item_catalog, _player.inventory) _shop_interaction = _test_world.get_fishing_shop() - _shop_interaction.setup_local_player(_player) - _shop_interaction.local_player_range_changed.connect( - _on_shop_range_changed - ) - _game_ui.set_shop_npc_player_in_range( - _shop_interaction.is_local_player_in_range() - ) + if not dedicated: + _shop_interaction.setup_local_player(_player) + _shop_interaction.local_player_range_changed.connect( + _on_shop_range_changed + ) + _game_ui.set_shop_npc_player_in_range( + _shop_interaction.is_local_player_in_range() + ) _save_manager.setup( _player.inventory, _player.collection_log, @@ -521,6 +614,8 @@ func _initialize_after_data_root() -> void: _world_time, _world_weather, ) + if dedicated: + return _game_ui.setup( _player, _player.inventory, @@ -1118,6 +1213,8 @@ func _unhandled_input(event: InputEvent) -> void: func _process(_delta: float) -> void: + if _dedicated_runtime: + return var show_shop_prompt := _can_show_shop_prompt() var shop_prompt_anchor := ( _shop_interaction.get_prompt_anchor_position() @@ -1759,6 +1856,8 @@ func _finish_quit() -> void: func _exit_tree() -> void: + if _dedicated_runtime and _network_session != null: + _network_session.disconnect_session("Dedicated server stopping.") if _title_music_tween != null: _title_music_tween.kill() _title_music_tween = null diff --git a/network/discovery_client.gd b/network/discovery_client.gd index 9b7c644..53abf29 100644 --- a/network/discovery_client.gd +++ b/network/discovery_client.gd @@ -87,6 +87,19 @@ func get_base_url() -> String: return _base_url +func set_base_url_override(value: String) -> bool: + var normalized: String = value.strip_edges() + while normalized.ends_with("/"): + normalized = normalized.left(normalized.length() - 1) + if not normalized.is_empty() and not ( + normalized.begins_with("https://") + or normalized.begins_with("http://") + ): + return false + _base_url = normalized + return true + + func get_room_name() -> String: return _room_name @@ -366,7 +379,7 @@ func _valid_public_room(room: Dictionary) -> bool: and typeof(room.get("game_version")) == TYPE_STRING and str(room.get("game_version")) == expected_version and _valid_json_integer(room.get("port"), 1, 65535) - and _valid_json_integer(room.get("current_players"), 1, 128) + and _valid_json_integer(room.get("current_players"), 0, 128) and _valid_json_integer(room.get("max_players"), 1, 128) and int(room["current_players"]) <= int(room["max_players"]) and _valid_json_integer( diff --git a/network/local_signing_identity_store.gd b/network/local_signing_identity_store.gd index 87fc4c6..a8ac7a0 100644 --- a/network/local_signing_identity_store.gd +++ b/network/local_signing_identity_store.gd @@ -9,11 +9,17 @@ var error_message: String = "" var _private_key: CryptoKey var _prefix: String = "" var _allow_generation: bool = true +var _storage_directory: String = "" -func configure(prefix: String, allow_generation: bool = true) -> void: +func configure( + prefix: String, + allow_generation: bool = true, + storage_directory: String = "", +) -> void: _prefix = prefix _allow_generation = allow_generation + _storage_directory = storage_directory.strip_edges() func load_or_create() -> bool: @@ -225,7 +231,12 @@ func _write_atomic(path: String, content: String) -> bool: func _path(extension: String) -> String: - return "user://%s%s" % [_prefix, extension] + var filename: String = "%s%s" % [_prefix, extension] + return ( + _storage_directory.path_join(filename) + if not _storage_directory.is_empty() + else "user://%s" % filename + ) func identity_type() -> String: diff --git a/network/network_session.gd b/network/network_session.gd index 2a477cb..62e39a4 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -101,6 +101,7 @@ var _local_appearance_snapshot: Dictionary = ( var _moderation_disconnect_message := "" var _host_port: int = 0 var _session_display_name: String = "NETfishing Room" +var _dedicated_host: bool = false func _ready() -> void: @@ -120,6 +121,7 @@ func setup( known_players: KnownPlayerStore, server_trust: ServerTrustStore, host_bans: HostBanStore, + dedicated: bool = false, ) -> void: _profile = profile _saved_servers = saved_servers @@ -129,9 +131,9 @@ func setup( _known_players = known_players _server_trust = server_trust _host_bans = host_bans - if _profile != null: + if not dedicated and _profile != null: _profile_ready = _profile.load_or_create() - if _player_identity != null: + if not dedicated and _player_identity != null: _profile_ready = _profile_ready and _player_identity.load_or_create() @@ -147,6 +149,34 @@ func start_private_host( or _host_identity == null ): return false + return _start_host(port, port_attempts, false) + + +func start_dedicated_host( + port: int = DEFAULT_PORT, + max_players: int = DEFAULT_SESSION_MAX_PLAYERS, + bind_address: String = "*", +) -> bool: + if ( + state != State.INACTIVE + or _spawn_service == null + or _host_identity == null + or max_players < 1 + or max_players > 128 + or bind_address.is_empty() + ): + return false + session_max_players = max_players + transport_max_clients = maxi(transport_max_clients, max_players) + return _start_host(port, 1, true, bind_address) + + +func _start_host( + port: int, + port_attempts: int, + dedicated: bool, + bind_address: String = "*", +) -> bool: if not _host_identity.load_or_create(): _fail(_host_identity.error_message) return false @@ -154,16 +184,20 @@ func start_private_host( _fail("The hosting port must be from 1 to 65535.") return false _operation_generation += 1 - _set_state(State.STARTING_PRIVATE_HOST, "Starting private game...") + _set_state( + State.STARTING_PRIVATE_HOST, + "Starting dedicated server..." if dedicated else "Starting private game...", + ) var selected_port: int = 0 var final_port: int = mini(port + port_attempts - 1, 65535) for candidate_port: int in range(port, final_port + 1): - if not _can_bind_udp_port(candidate_port): + if not _can_bind_udp_port(candidate_port, bind_address): continue _replace_transport() var error: Error = _transport.start_host( candidate_port, transport_max_clients, + bind_address, ) if error == OK: selected_port = candidate_port @@ -175,11 +209,28 @@ func start_private_host( ) return false _host_port = selected_port + _dedicated_host = dedicated var peer: MultiplayerPeer = _transport.get_multiplayer_peer() peer.refuse_new_connections = true multiplayer.multiplayer_peer = peer _session_id = Crypto.new().generate_random_bytes(16).hex_encode() _registry.clear() + _spawn_service.clear_remote_players() + if not dedicated: + _register_player_host() + _set_state( + State.PRIVATE_HOST, + ( + "Dedicated server • UDP %d" + if dedicated else "Private game • UDP %d" + ) % selected_port, + ) + host_openness_changed.emit(false) + _emit_peer_count() + return true + + +func _register_player_host() -> void: _registry.add_peer( 1, _profile.profile_id, @@ -215,21 +266,20 @@ func start_private_host( "client_nonce": host_profile_hello["client_nonce"], } _archive_authenticated_identity(host_record) - _spawn_service.clear_remote_players() _spawn_service.register_local_player(1) var host_avatar := _spawn_service.get_avatar(1) if host_avatar != null: host_avatar.apply_appearance_snapshot(_local_appearance_snapshot) - _set_state(State.PRIVATE_HOST, "Private game • UDP %d" % selected_port) - host_openness_changed.emit(false) - _emit_peer_count() - return true func get_host_port() -> int: return _host_port if is_host() else 0 +func is_dedicated_host() -> bool: + return is_host() and _dedicated_host + + func set_session_display_name(value: String) -> void: var cleaned: String = value.strip_edges().left(48) if cleaned.is_empty(): @@ -244,9 +294,9 @@ func get_session_display_name() -> String: return _session_display_name -static func _can_bind_udp_port(port: int) -> bool: +static func _can_bind_udp_port(port: int, bind_address: String = "*") -> bool: var probe := PacketPeerUDP.new() - var error: Error = probe.bind(port) + var error: Error = probe.bind(port, bind_address) probe.close() return error == OK @@ -328,7 +378,9 @@ func set_host_open(is_open: bool) -> bool: _set_state( State.OPEN_HOST if is_open else State.PRIVATE_HOST, ( - "Open game • UDP %d • %d / %d players" + "Dedicated server • UDP %d • %d / %d players" + if _dedicated_host + else "Open game • UDP %d • %d / %d players" if is_open else "Private game • UDP %d • %d / %d players" ) % [_host_port, _registry.size(), session_max_players] @@ -623,6 +675,8 @@ func _apply_display_name(peer_id: int, value: String) -> void: func get_local_peer_id() -> int: + if is_dedicated_host(): + return 0 return multiplayer.get_unique_id() if is_gameplay_session_active() else 0 @@ -1792,3 +1846,4 @@ func _teardown_peer() -> void: _server_identity_public_key = "" _session_identity_keys.clear() _host_port = 0 + _dedicated_host = false diff --git a/network/player_data_root.gd b/network/player_data_root.gd index ba831f4..525c545 100644 --- a/network/player_data_root.gd +++ b/network/player_data_root.gd @@ -169,6 +169,13 @@ func use_existing_root(path: String) -> bool: return true +func activate_process_root(path: String) -> bool: + _load_bootstrap_identity() + override_active = true + mode = Mode.COMMAND_LINE_OVERRIDE + return _activate_existing(path, "", false) + + func path_for(store_owner: StringName) -> String: var relative: String = { &"player_save": "player/player_save.json", diff --git a/server/dedicated_server_config.gd b/server/dedicated_server_config.gd new file mode 100644 index 0000000..457c382 --- /dev/null +++ b/server/dedicated_server_config.gd @@ -0,0 +1,166 @@ +class_name DedicatedServerConfig +extends RefCounted + +const DEFAULT_NAME: String = "NETfishing Dedicated Server" +const DEFAULT_BIND_ADDRESS: String = "*" +const DEFAULT_PORT: int = 7777 +const DEFAULT_MAX_PLAYERS: int = 8 + +var server_name: String = DEFAULT_NAME +var bind_address: String = DEFAULT_BIND_ADDRESS +var port: int = DEFAULT_PORT +var max_players: int = DEFAULT_MAX_PLAYERS +var public_listing: bool = false +var discovery_url: String = "" +var data_directory: String = "" +var error_message: String = "" + + +static func from_runtime() -> DedicatedServerConfig: + var result := DedicatedServerConfig.new() + result.data_directory = ProjectSettings.globalize_path( + "user://dedicated-server-data" + ) + result.discovery_url = str(ProjectSettings.get_setting( + "network/discovery/base_url", "" + )).strip_edges() + var config_path: String = OS.get_environment( + "NETFISHING_SERVER_CONFIG" + ).strip_edges() + for argument: String in OS.get_cmdline_user_args(): + if argument.begins_with("--config="): + config_path = argument.trim_prefix("--config=").strip_edges() + if not config_path.is_empty() and not result._load_file(config_path): + return result + result._apply_environment() + result._apply_arguments(OS.get_cmdline_user_args()) + result._validate() + return result + + +func is_valid() -> bool: + return error_message.is_empty() + + +func _load_file(path: String) -> bool: + var file := ConfigFile.new() + var error: Error = file.load(path) + if error != OK: + error_message = "Could not load server configuration: %s" % path + return false + server_name = str(file.get_value("server", "name", server_name)) + bind_address = str(file.get_value( + "server", "bind_address", bind_address + )) + port = int(file.get_value("server", "port", port)) + max_players = int(file.get_value( + "server", "max_players", max_players + )) + public_listing = bool(file.get_value( + "server", "public", public_listing + )) + data_directory = str(file.get_value( + "server", "data_directory", data_directory + )) + discovery_url = str(file.get_value( + "discovery", "url", discovery_url + )) + return true + + +func _apply_environment() -> void: + server_name = _environment_string( + "NETFISHING_SERVER_NAME", server_name + ) + bind_address = _environment_string( + "NETFISHING_SERVER_BIND", bind_address + ) + port = _environment_int("NETFISHING_SERVER_PORT", port) + max_players = _environment_int( + "NETFISHING_SERVER_MAX_PLAYERS", max_players + ) + public_listing = _environment_bool( + "NETFISHING_SERVER_PUBLIC", public_listing + ) + discovery_url = _environment_string( + "NETFISHING_DISCOVERY_URL", discovery_url + ) + data_directory = _environment_string( + "NETFISHING_DATA_DIR", data_directory + ) + + +func _apply_arguments(arguments: PackedStringArray) -> void: + for argument: String in arguments: + if argument.begins_with("--name="): + server_name = argument.trim_prefix("--name=") + elif argument.begins_with("--bind="): + bind_address = argument.trim_prefix("--bind=") + elif argument.begins_with("--port="): + port = _parse_int(argument.trim_prefix("--port="), port) + elif argument.begins_with("--max-players="): + max_players = _parse_int( + argument.trim_prefix("--max-players="), max_players + ) + elif argument.begins_with("--data-dir="): + data_directory = argument.trim_prefix("--data-dir=") + elif argument.begins_with("--discovery-url="): + discovery_url = argument.trim_prefix("--discovery-url=") + elif argument == "--public": + public_listing = true + elif argument == "--private": + public_listing = false + + +func _validate() -> void: + server_name = server_name.strip_edges().left(48) + bind_address = bind_address.strip_edges() + discovery_url = discovery_url.strip_edges().trim_suffix("/") + data_directory = data_directory.strip_edges() + if server_name.is_empty(): + error_message = "Server name cannot be empty." + elif not _safe_text(server_name): + error_message = "Server name contains unsupported characters." + elif bind_address.is_empty(): + error_message = "Bind address cannot be empty." + elif port < 1 or port > 65535: + error_message = "Server port must be between 1 and 65535." + elif max_players < 1 or max_players > 128: + error_message = "Maximum players must be between 1 and 128." + elif not data_directory.is_empty() and not data_directory.is_absolute_path(): + error_message = "Server data directory must be an absolute path." + elif public_listing and not ( + discovery_url.begins_with("https://") + or discovery_url.begins_with("http://") + ): + error_message = "A public server requires a discovery URL." + + +static func _safe_text(value: String) -> bool: + for index: int in value.length(): + var codepoint: int = value.unicode_at(index) + if codepoint < 32 or codepoint == 127: + return false + return true + + +static func _environment_string(name: String, fallback: String) -> String: + return OS.get_environment(name) if OS.has_environment(name) else fallback + + +static func _environment_int(name: String, fallback: int) -> int: + return ( + _parse_int(OS.get_environment(name), fallback) + if OS.has_environment(name) else fallback + ) + + +static func _environment_bool(name: String, fallback: bool) -> bool: + if not OS.has_environment(name): + return fallback + var value: String = OS.get_environment(name).strip_edges().to_lower() + return value in ["1", "true", "yes", "on"] + + +static func _parse_int(value: String, fallback: int) -> int: + return int(value) if value.strip_edges().is_valid_int() else fallback diff --git a/server/dedicated_server_config.gd.uid b/server/dedicated_server_config.gd.uid new file mode 100644 index 0000000..720cccc --- /dev/null +++ b/server/dedicated_server_config.gd.uid @@ -0,0 +1 @@ +uid://nmg477qkcbhe diff --git a/tests/dedicated_host_session_validation.gd b/tests/dedicated_host_session_validation.gd new file mode 100644 index 0000000..6f1de7d --- /dev/null +++ b/tests/dedicated_host_session_validation.gd @@ -0,0 +1,51 @@ +extends SceneTree + +const TEST_PORT: int = 35777 + + +func _init() -> void: + call_deferred("_run") + + +func _run() -> void: + var harness := Node.new() + root.add_child(harness) + var players := Node3D.new() + harness.add_child(players) + var spawn_service := PlayerSpawnService.new() + harness.add_child(spawn_service) + spawn_service.setup(players, null, Transform3D.IDENTITY) + var host_identity := HostIdentityStore.new() + harness.add_child(host_identity) + var known_players := KnownPlayerStore.new() + harness.add_child(known_players) + var host_bans := HostBanStore.new() + harness.add_child(host_bans) + var session := NetworkSession.new() + harness.add_child(session) + await process_frame + session.setup( + null, + null, + spawn_service, + null, + host_identity, + known_players, + null, + host_bans, + true, + ) + assert(session.start_dedicated_host(TEST_PORT, 5, "127.0.0.1")) + assert(session.is_dedicated_host()) + assert(session.get_local_peer_id() == 0) + assert(session.get_player_count() == 0) + assert(session.get_session_max_players() == 5) + assert(session.get_host_port() == TEST_PORT) + assert(session.set_host_open(true)) + assert(session.is_open_host()) + session.disconnect_session("Dedicated host validation complete.") + assert(not session.is_session_active()) + assert(not session.is_dedicated_host()) + harness.queue_free() + print("DEDICATED_HOST_SESSION_VALIDATION_OK") + quit(0) diff --git a/tests/dedicated_host_session_validation.gd.uid b/tests/dedicated_host_session_validation.gd.uid new file mode 100644 index 0000000..e50ebb3 --- /dev/null +++ b/tests/dedicated_host_session_validation.gd.uid @@ -0,0 +1 @@ +uid://ctky0gkb63nqa diff --git a/tests/dedicated_server_client_validation.gd b/tests/dedicated_server_client_validation.gd new file mode 100644 index 0000000..9688af2 --- /dev/null +++ b/tests/dedicated_server_client_validation.gd @@ -0,0 +1,49 @@ +extends SceneTree + +const MainScene = preload("res://main/main.tscn") +const TEST_ENDPOINT: String = "127.0.0.1:7777" +const EXPECTED_SERVER_NAME: String = "Dedicated-Client-Test" + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + root.size = Vector2i(1280, 720) + var main: Node = MainScene.instantiate() + root.add_child(main) + for _frame: int in 4: + await process_frame + if not bool(main.get("_application_initialized")): + main.call("_activate_selected_data_path", "", true) + for _frame: int in 8: + await process_frame + assert(bool(main.get("_application_initialized"))) + main.call("_on_title_join_game_requested", TEST_ENDPOINT) + var session := main.get_node("%NetworkSession") as NetworkSession + var joined: bool = false + var deadline: int = Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < deadline: + await process_frame + if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY: + main.call("_confirm_server_trust") + if session.is_joined_client() and bool(main.get("_gameplay_started")): + joined = true + break + assert(joined) + assert(session.get_local_peer_id() > 1) + assert(session.get_player_count() == 1) + var metadata: Dictionary = session.get_last_server_metadata() + assert(str(metadata.get("server_display_name", "")) == EXPECTED_SERVER_NAME) + var spawn_service := main.get_node( + "%PlayerSpawnService" + ) as PlayerSpawnService + assert(spawn_service.get_avatar(1) == null) + assert(spawn_service.get_avatar(session.get_local_peer_id()) != null) + print("DEDICATED_SERVER_CLIENT_VALIDATION_OK") + session.disconnect_session("Dedicated client validation complete.") + main.queue_free() + for _frame: int in 4: + await process_frame + quit() diff --git a/tests/dedicated_server_client_validation.gd.uid b/tests/dedicated_server_client_validation.gd.uid new file mode 100644 index 0000000..ed7a3b7 --- /dev/null +++ b/tests/dedicated_server_client_validation.gd.uid @@ -0,0 +1 @@ +uid://cwum3r4puerll diff --git a/tests/dedicated_server_config_validation.gd b/tests/dedicated_server_config_validation.gd new file mode 100644 index 0000000..6548d62 --- /dev/null +++ b/tests/dedicated_server_config_validation.gd @@ -0,0 +1,63 @@ +extends SceneTree + +const ConfigType = preload("res://server/dedicated_server_config.gd") + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var defaults := ConfigType.new() + defaults.set("data_directory", "/tmp/netfishing-server") + defaults.call("_validate") + assert(defaults.is_valid()) + assert(not bool(defaults.get("public_listing"))) + + var path: String = ProjectSettings.globalize_path( + "user://dedicated-server-validation.cfg" + ) + var file := ConfigFile.new() + file.set_value("server", "name", "Configured Room") + file.set_value("server", "bind_address", "127.0.0.1") + file.set_value("server", "port", 17777) + file.set_value("server", "max_players", 12) + file.set_value("server", "public", true) + file.set_value("server", "data_directory", "/tmp/configured-server") + file.set_value("discovery", "url", "https://discovery.netfishing.org/") + assert(file.save(path) == OK) + + var configured := ConfigType.new() + assert(bool(configured.call("_load_file", path))) + configured.call("_validate") + assert(configured.is_valid()) + assert(str(configured.get("server_name")) == "Configured Room") + assert(str(configured.get("bind_address")) == "127.0.0.1") + assert(int(configured.get("port")) == 17777) + assert(int(configured.get("max_players")) == 12) + assert(bool(configured.get("public_listing"))) + assert(str(configured.get("discovery_url")) == "https://discovery.netfishing.org") + assert(DirAccess.remove_absolute(path) == OK) + + var invalid := ConfigType.new() + invalid.set("public_listing", true) + invalid.set("data_directory", "/tmp/invalid-server") + invalid.call("_validate") + assert(not invalid.is_valid()) + + var discovery := DiscoveryClient.new() + assert(bool(discovery.call("_valid_public_room", { + "room_id": "empty-dedicated-room", + "room_name": "Empty Dedicated Room", + "address": "203.0.113.10", + "port": 7777, + "current_players": 0, + "max_players": 8, + "game_version": str(ProjectSettings.get_setting( + "application/config/version", "unknown" + )), + "protocol_version": NetworkProtocol.PROTOCOL_VERSION, + }))) + discovery.free() + print("DEDICATED_SERVER_CONFIG_VALIDATION_OK") + quit() diff --git a/tests/dedicated_server_config_validation.gd.uid b/tests/dedicated_server_config_validation.gd.uid new file mode 100644 index 0000000..4bc60d1 --- /dev/null +++ b/tests/dedicated_server_config_validation.gd.uid @@ -0,0 +1 @@ +uid://vm5lql2w6h3e