From 96df1688514e903769063d645a3d99901324e190 Mon Sep 17 00:00:00 2001 From: Voyager Date: Tue, 11 Aug 2026 17:57:09 -0400 Subject: [PATCH 1/5] Fix release validation coverage --- tests/economy_regression_validation.gd | 20 +++++++++++++++----- tests/job_system_validation.gd | 4 +++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/economy_regression_validation.gd b/tests/economy_regression_validation.gd index f7d0d12..2ae44c2 100644 --- a/tests/economy_regression_validation.gd +++ b/tests/economy_regression_validation.gd @@ -82,7 +82,8 @@ func _run() -> void: print("Economy regression validation: PASS") session.disconnect_session("Economy validation complete.") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame quit() @@ -555,19 +556,25 @@ func _test_fishing_shop_sale_ui( assert(not shop.has_node("ShopPanel/Margin/Layout/ModeTabs")) var shop_tabs: Array = shop.get("_shop_tabs") as Array assert(shop_tabs.size() == 6) + var art_supplies_tab := shop_tabs[4] as Button + assert(art_supplies_tab != null and art_supplies_tab.text == "Art Supplies") var sell_mode := shop_tabs[5] as Button assert(sell_mode != null and sell_mode.text == "Sell Fish") + await _activate_focused_button(art_supplies_tab, ui_viewport) + await process_frame var stock_sections: Array[String] = [] for child: Node in shop.get_node("%SuppliesList").get_children(): if child is Label: stock_sections.append((child as Label).text) - assert(stock_sections == ["supplies", "art kit", "markers", "brushes", "grids"]) + assert(stock_sections == ["art kit", "markers", "brushes", "grids"]) await _activate_focused_button(sell_mode, ui_viewport) await process_frame assert(shop.visible and not player_menu.visible) assert(not shop.has_node("ShopPanel/Margin/Layout/Body/FishSales")) assert((shop.get_node("%ShopCoolerPage") as Control).visible) - assert(not (shop.get_node("%ShopPanel") as Control).visible) + assert((shop.get_node("%ShopPanel") as Control).visible) + assert(not (shop.get_node("ShopPanel/Margin/Layout/Body") as Control).visible) + assert(not (shop.get_node("%Feedback") as Control).visible) var mounted_cooler := player_menu.get("_cooler_page") as Control assert(mounted_cooler != null and mounted_cooler.visible) assert( @@ -613,7 +620,8 @@ func _test_fishing_shop_sale_ui( assert(not (shop.get_node("%ShopCoolerPage") as Control).visible) assert(not player_menu.is_shop_cooler_mounted()) shop.close_shop() - await process_frame + await shop.menu_visibility_changed + assert(not shop.visible) func _assert_sale( @@ -634,7 +642,9 @@ func _assert_sale( assert(bool(_sale_result[1]) == expected_success) assert(not sale_service.is_local_sale_pending()) if expected_success: - assert(player.wallet.get_balance() > balance_before) + var payout: int = int(_sale_result[4]) + assert(payout >= 0) + assert(player.wallet.get_balance() == balance_before + payout) for catch_id: StringName in catch_ids: assert(not player.inventory.contains_catch_id(catch_id)) else: diff --git a/tests/job_system_validation.gd b/tests/job_system_validation.gd index 6061a31..b6036eb 100644 --- a/tests/job_system_validation.gd +++ b/tests/job_system_validation.gd @@ -187,8 +187,10 @@ func _run() -> void: assert(int(save_data.get("save_version", -1)) == 7) assert(PlayerJobService.validate_save_data(save_data.get("jobs", {}))) + session.disconnect_session("Job system validation complete.") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame print("Job system validation: PASS") quit() From b044d35021443796f3334603d0cad47f2ca74dec Mon Sep 17 00:00:00 2001 From: Voyager Date: Tue, 11 Aug 2026 20:02:54 -0400 Subject: [PATCH 2/5] Prepare v0.6.6-alpha release --- export_presets.cfg | 26 +-- fishing/fishing_presentation.gd | 19 ++- playtest/README-PLAYTEST.txt | 24 +-- project.godot | 2 +- scripts/build_playtest.sh | 6 +- tests/art_tools_validation.gd | 11 +- ...ontroller_focus_presentation_validation.gd | 9 ++ tests/controller_focus_recovery_validation.gd | 100 +++++++++++- tests/economy_regression_validation.gd | 56 ++++--- tests/fish_hotbar_showcase_validation.gd | 46 ++++-- tests/fish_showcase_multiplayer_validation.gd | 29 +++- tests/fishing_authority_validation.gd | 55 +++++-- tests/fishing_multiplayer_validation.gd | 8 +- tests/fishing_surface_validation.gd | 51 +++++- tests/inventory_notepad_art_validation.gd | 4 +- tests/job_multiplayer_validation.gd | 8 +- tests/logbook_runtime_validation.gd | 10 +- tests/logbook_validation.gd | 4 +- tests/player_experience_ui_validation.gd | 18 ++- .../surface_drawing_multiplayer_validation.gd | 8 +- tests/surface_drawing_runtime_validation.gd | 4 +- tests/surface_drawing_validation.gd | 4 + tests/ui_scaling_runtime_validation.gd | 4 +- tests/world_time_multiplayer_validation.gd | 8 +- ui/controller_focus_presentation.gd | 2 + ui/controller_focus_recovery.gd | 149 +++++++++++++++++- ui/fishing_shop.gd | 15 ++ ui/pause_menu.gd | 20 ++- ui/title_screen.tscn | 2 +- 29 files changed, 568 insertions(+), 134 deletions(-) diff --git a/export_presets.cfg b/export_presets.cfg index 2365877..1df6f73 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -9,7 +9,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.6.5-alpha/windows-x86_64/NETfishing.exe" +export_path="builds/v0.6.6-alpha/windows-x86_64/NETfishing.exe" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -35,11 +35,11 @@ application/modify_resources=true application/icon="res://art/exported/system_icons/netfishing.ico" application/console_wrapper_icon="" application/icon_interpolation=4 -application/file_version="0.6.5.0" -application/product_version="0.6.5.0" +application/file_version="0.6.6.0" +application/product_version="0.6.6.0" application/company_name="" application/product_name="NETfishing" -application/file_description="NETfishing v0.6.5-alpha" +application/file_description="NETfishing v0.6.6-alpha" application/copyright="" application/trademarks="" application/export_angle=0 @@ -62,7 +62,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.6.5-alpha/linux-arm64/NETfishing.arm64" +export_path="builds/v0.6.6-alpha/linux-arm64/NETfishing.arm64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -92,7 +92,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.6.5-alpha/macos/NETfishing.zip" +export_path="builds/v0.6.6-alpha/macos/NETfishing.zip" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -108,8 +108,8 @@ custom_template/release="" application/bundle_identifier="io.woofmeow.netfishing" application/icon="res://art/exported/system_icons/netfishing_1024.png" application/icon_interpolation=0 -application/short_version="0.6.4" -application/version="0.6.4" +application/short_version="0.6.6" +application/version="0.6.6" application/architecture="universal" codesign/enable=false notarization/enable=false @@ -125,7 +125,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*,tests/*" -export_path="builds/v0.6.5-alpha/server-linux-x86_64/NETfishingServer.x86_64" +export_path="builds/v0.6.6-alpha/server-linux-x86_64/NETfishingServer.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -155,7 +155,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.6.5-alpha/android/NETfishing.apk" +export_path="builds/v0.6.6-alpha/android/NETfishing.apk" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -176,8 +176,8 @@ architectures/armeabi-v7a=false architectures/arm64-v8a=true architectures/x86=false architectures/x86_64=false -version/code=60500 -version/name="v0.6.5-alpha" +version/code=60600 +version/name="v0.6.6-alpha" package/unique_name="io.woofmeow.netfishing" package/name="NETfishing" package/signed=true @@ -214,7 +214,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.6.5-alpha/linux-x86_64/NETfishing.x86_64" +export_path="builds/v0.6.6-alpha/linux-x86_64/NETfishing.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" diff --git a/fishing/fishing_presentation.gd b/fishing/fishing_presentation.gd index c38ddd6..71fedac 100644 --- a/fishing/fishing_presentation.gd +++ b/fishing/fishing_presentation.gd @@ -431,7 +431,7 @@ func _draw_line_ribbon(points: PackedVector3Array) -> void: return var half_thickness: float = line_thickness * 0.5 - _line_mesh.surface_begin(Mesh.PRIMITIVE_TRIANGLES) + var ribbon_vertices := PackedVector3Array() for point_index: int in range(1, points.size()): var start: Vector3 = points[point_index - 1] var end: Vector3 = points[point_index] @@ -452,12 +452,17 @@ func _draw_line_ribbon(points: PackedVector3Array) -> void: var start_right: Vector3 = to_local(start + side) var end_right: Vector3 = to_local(end + side) var end_left: Vector3 = to_local(end - side) - _line_mesh.surface_add_vertex(start_left) - _line_mesh.surface_add_vertex(start_right) - _line_mesh.surface_add_vertex(end_right) - _line_mesh.surface_add_vertex(start_left) - _line_mesh.surface_add_vertex(end_right) - _line_mesh.surface_add_vertex(end_left) + ribbon_vertices.append(start_left) + ribbon_vertices.append(start_right) + ribbon_vertices.append(end_right) + ribbon_vertices.append(start_left) + ribbon_vertices.append(end_right) + ribbon_vertices.append(end_left) + if ribbon_vertices.is_empty(): + return + _line_mesh.surface_begin(Mesh.PRIMITIVE_TRIANGLES) + for vertex: Vector3 in ribbon_vertices: + _line_mesh.surface_add_vertex(vertex) _line_mesh.surface_end() diff --git a/playtest/README-PLAYTEST.txt b/playtest/README-PLAYTEST.txt index 47e0a86..042898d 100644 --- a/playtest/README-PLAYTEST.txt +++ b/playtest/README-PLAYTEST.txt @@ -1,12 +1,13 @@ NETfishing -v0.6.5-alpha -Alpha 0.6.5 +v0.6.6-alpha +Alpha 0.6.6 Thank you for trying this early private playtest. -NETfishing is currently a local gameplay prototype for a future -multiplayer-first game. Networking and multiplayer are NOT implemented in -this build. +NETfishing is an early multiplayer fishing game. This alpha includes local +progression, player-hosted and dedicated multiplayer sessions, public room +discovery, and direct UDP connections. Expect rough edges and please keep a +backup of any save or identity data you care about. INSTALLATION @@ -60,6 +61,9 @@ FISHING FEATURES TO TRY +- Hosting a private or public room and joining compatible online sessions +- Direct-IP, saved-server, recent-server, and public-room connection flows +- Text chat, player calls, player lists, relationships, and moderation tools - Movement, jumping, camera rotation, and zoom - Short, medium, and maximum-distance casts - Valid water and invalid land casts @@ -101,11 +105,11 @@ Please include: - How movement, casting, chase pressure, barrier clicking, menus, and camera controls felt -KNOWN PRE-ALPHA LIMITATIONS +KNOWN ALPHA LIMITATIONS -- Multiplayer/networking is not implemented. -- Fish and character art are temporary. -- UI and balance values are placeholders. -- There is no audio. +- Public sessions remain direct connections; restrictive NAT or carrier-grade + networks may prevent some players from joining. +- Multiplayer, dedicated-server deployment, UI, and balance are still alpha. +- Some fish and character artwork remains temporary. - There is no installer or automatic updater. - Windows builds are unsigned. diff --git a/project.godot b/project.godot index ac103f3..3a76545 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="NETFISHING" -config/version="0.6.5-alpha" +config/version="0.6.6-alpha" run/main_scene="res://main/main.tscn" config/features=PackedStringArray("4.7", "GL Compatibility") config/icon="res://art/exported/system_icons/netfishing_256.png" diff --git a/scripts/build_playtest.sh b/scripts/build_playtest.sh index 7ddf4f9..c0cd14b 100755 --- a/scripts/build_playtest.sh +++ b/scripts/build_playtest.sh @@ -4,12 +4,12 @@ set -euo pipefail readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" -readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.6.5-alpha" +readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.6.6-alpha" readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly README_SOURCE="${PROJECT_ROOT}/playtest/README-PLAYTEST.txt" -readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.6.5-alpha-windows-x86_64.zip" -readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.6.5-alpha-linux-x86_64.zip" +readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.6.6-alpha-windows-x86_64.zip" +readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.6.6-alpha-linux-x86_64.zip" readonly GODOT_BIN="${GODOT_BIN:-godot}" if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index 874cc4e..1fa4dee 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -109,6 +109,7 @@ func _run() -> void: select_button.button_index = JOY_BUTTON_BACK select_button.pressed = true assert(bool(game_ui.call("_handle_controller_chat_controls", select_button))) + await process_frame assert(chat_ui.is_open()) assert(chat_panel.mouse_filter == Control.MOUSE_FILTER_STOP) var typed_chat_entry := chat_ui.find_child( @@ -121,6 +122,8 @@ func _run() -> void: accept_button.pressed = true assert(bool(game_ui.call("_handle_controller_chat_controls", accept_button))) assert(typed_chat_entry.virtual_keyboard_enabled) + await process_frame + assert(typed_chat_entry.has_focus()) var left_bumper := InputEventJoypadButton.new() left_bumper.button_index = JOY_BUTTON_LEFT_SHOULDER left_bumper.pressed = true @@ -129,10 +132,8 @@ func _run() -> void: assert(chat_panel.mouse_filter == Control.MOUSE_FILTER_IGNORE) assert(not typed_chat_entry.virtual_keyboard_enabled) assert(bool(game_ui.call("_handle_controller_chat_controls", left_bumper))) - assert(chat_ui.is_open()) - assert(not typed_chat_entry.virtual_keyboard_enabled) - assert(bool(game_ui.call("_handle_controller_chat_controls", left_bumper))) assert(not chat_ui.is_open()) + assert(not typed_chat_entry.virtual_keyboard_enabled) assert(bool(game_ui.call("_handle_controller_chat_controls", select_button))) assert(not chat_ui.is_open()) assert(chat_ui.is_collapsed()) @@ -315,5 +316,7 @@ func _run() -> void: var session := main.get_node("%NetworkSession") as NetworkSession session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() diff --git a/tests/controller_focus_presentation_validation.gd b/tests/controller_focus_presentation_validation.gd index 638e0cc..f0d0355 100644 --- a/tests/controller_focus_presentation_validation.gd +++ b/tests/controller_focus_presentation_validation.gd @@ -39,6 +39,15 @@ func _run() -> void: standard_button.material != null, "ordinary controller focus receives inversion", ) + var pointer_motion := InputEventMouseMotion.new() + pointer_motion.position = Vector2(4.0, 4.0) + presentation._input(pointer_motion) + await process_frame + _expect( + standard_button.material == null, + "mouse motion clears controller focus presentation", + ) + presentation._input(controller_event) authored_selector.grab_focus() await process_frame diff --git a/tests/controller_focus_recovery_validation.gd b/tests/controller_focus_recovery_validation.gd index e34afaa..f598511 100644 --- a/tests/controller_focus_recovery_validation.gd +++ b/tests/controller_focus_recovery_validation.gd @@ -23,14 +23,45 @@ func _run() -> void: var original := _make_button("cute", "cute (show variants)") option_list.add_child(original) await process_frame + original.grab_focus() + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == null, + "programmatic focus stays pending outside navigation mode", + ) + var keyboard_navigation := InputEventKey.new() + keyboard_navigation.keycode = KEY_DOWN + keyboard_navigation.pressed = true + recovery._input(keyboard_navigation) + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == original, + "keyboard navigation restores pending initial focus", + ) + var menu_shortcut := InputEventKey.new() + menu_shortcut.physical_keycode = KEY_TAB + menu_shortcut.pressed = true + recovery._input(menu_shortcut) + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == null, + "the menu shortcut does not leave a pseudo-focused option", + ) var controller_event := InputEventJoypadButton.new() controller_event.button_index = JOY_BUTTON_A controller_event.pressed = true recovery._input(controller_event) - original.grab_focus() await process_frame - _expect(root.gui_get_focus_owner() == original, "original option receives focus") + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == original, + "controller navigation restores pending initial focus", + ) option_list.remove_child(original) original.queue_free() @@ -53,6 +84,63 @@ func _run() -> void: root.gui_get_focus_owner() == explicit_target, "explicit focus changes take precedence over recovery", ) + var pointer_motion := InputEventMouseMotion.new() + pointer_motion.position = Vector2(4.0, 4.0) + recovery._input(pointer_motion) + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == null, + "mouse motion releases controller focus", + ) + await process_frame + _expect( + root.gui_get_focus_owner() == null, + "mouse motion prevents automatic focus recovery", + ) + recovery._input(controller_event) + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == explicit_target, + "controller navigation can resume from pointer mode", + ) + var pointer_press := InputEventMouseButton.new() + pointer_press.button_index = MOUSE_BUTTON_LEFT + pointer_press.pressed = true + recovery._input(pointer_press) + await process_frame + _expect( + root.gui_get_focus_owner() == explicit_target, + "mouse-down preserves focus through button activation", + ) + var pointer_release := InputEventMouseButton.new() + pointer_release.button_index = MOUSE_BUTTON_LEFT + pointer_release.pressed = false + recovery._input(pointer_release) + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == null, + "mouse-up clears the completed button focus", + ) + recovery._input(controller_event) + await process_frame + await process_frame + + var outside_button := _make_button("outside", "outside") + stage.add_child(outside_button) + option_list.hide() + root.gui_release_focus() + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == null, + "focus recovery never escapes a hidden menu scope", + ) + option_list.show() + explicit_target.grab_focus() + await process_frame var leave_world_ui_event := InputEventJoypadButton.new() leave_world_ui_event.button_index = JOY_BUTTON_LEFT_SHOULDER leave_world_ui_event.pressed = true @@ -64,6 +152,14 @@ func _run() -> void: root.gui_get_focus_owner() == null, "LB intentionally leaving world UI is never recovered", ) + var accessibility_only := Control.new() + accessibility_only.focus_mode = Control.FOCUS_ACCESSIBILITY + stage.add_child(accessibility_only) + await process_frame + _expect( + not bool(recovery.call("_is_focusable", accessibility_only)), + "accessibility-only controls are not recovery targets", + ) stage.queue_free() if _failures.is_empty(): diff --git a/tests/economy_regression_validation.gd b/tests/economy_regression_validation.gd index 2ae44c2..f0046fa 100644 --- a/tests/economy_regression_validation.gd +++ b/tests/economy_regression_validation.gd @@ -127,7 +127,9 @@ func _run_multiplayer_host() -> void: print("Economy multiplayer host validation: PASS") session.disconnect_session("Economy host validation complete.") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() @@ -229,7 +231,9 @@ func _run_multiplayer_client() -> void: print("Economy multiplayer client validation: PASS") session.disconnect_session("Economy client validation complete.") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() @@ -408,7 +412,7 @@ func _test_player_menu_sale( assert(sell_action.mouse_filter == Control.MOUSE_FILTER_STOP) assert(sell_action.focus_mode == Control.FOCUS_ALL) assert(ui_viewport != null) - await _activate_focused_button(sell_action, ui_viewport) + await _activate_pointer_control(sell_action, ui_viewport) await process_frame assert(confirmation.visible) assert(confirm_button.visible and not confirm_button.disabled) @@ -417,7 +421,7 @@ func _test_player_menu_sale( > (player_menu.get_node("%CoolerOuterWall") as Control).z_index ) _sale_result.clear() - await _activate_focused_button(confirm_button, ui_viewport) + await _activate_pointer_control(confirm_button, ui_viewport) await process_frame assert(not _sale_result.is_empty() and bool(_sale_result[1])) assert(not player.inventory.contains_catch_id(fish_catch.catch_id)) @@ -436,18 +440,29 @@ func _test_player_menu_sale( await create_timer(2.2).timeout -func _activate_focused_button( - button: Button, +func _activate_pointer_control( + control: Control, ui_viewport: SubViewport, ) -> void: - button.grab_focus() + var presenter := ui_viewport.get_parent() as SubViewportContainer + assert(presenter != null) + var local_center: Vector2 = control.get_global_transform_with_canvas() * ( + control.size * 0.5 + ) + var center: Vector2 = presenter.position + local_center * presenter.scale + var motion := InputEventMouseMotion.new() + motion.position = center + motion.global_position = center + root.push_input(motion, true) await process_frame - assert(button.has_focus()) + assert(ui_viewport.gui_get_hovered_control() == control) for is_pressed: bool in [true, false]: - var accept := InputEventAction.new() - accept.action = &"ui_accept" - accept.pressed = is_pressed - ui_viewport.push_input(accept, false) + var click := InputEventMouseButton.new() + click.button_index = MOUSE_BUTTON_LEFT + click.position = center + click.global_position = center + click.pressed = is_pressed + root.push_input(click, true) await process_frame @@ -560,14 +575,14 @@ func _test_fishing_shop_sale_ui( assert(art_supplies_tab != null and art_supplies_tab.text == "Art Supplies") var sell_mode := shop_tabs[5] as Button assert(sell_mode != null and sell_mode.text == "Sell Fish") - await _activate_focused_button(art_supplies_tab, ui_viewport) + await _activate_pointer_control(art_supplies_tab, ui_viewport) await process_frame var stock_sections: Array[String] = [] for child: Node in shop.get_node("%SuppliesList").get_children(): if child is Label: stock_sections.append((child as Label).text) assert(stock_sections == ["art kit", "markers", "brushes", "grids"]) - await _activate_focused_button(sell_mode, ui_viewport) + await _activate_pointer_control(sell_mode, ui_viewport) await process_frame assert(shop.visible and not player_menu.visible) assert(not shop.has_node("ShopPanel/Margin/Layout/Body/FishSales")) @@ -584,6 +599,11 @@ func _test_fishing_shop_sale_ui( var water_surface := player_menu.get("_cooler_water_surface") as ColorRect assert(cooler_outer_wall != null and cooler_outer_wall.visible) assert(water_surface.visible and water_surface.material is ShaderMaterial) + var cooler_sort_option := player_menu.get("_cooler_sort_option") as Control + await _activate_pointer_control(cooler_sort_option, ui_viewport) + var cooler_choice_panel := cooler_sort_option.get("_choice_panel") as Control + assert(cooler_choice_panel.visible) + cooler_sort_option.call("close_choices") assert( StringName( (player_menu.get("_sale_buyer_override") as FishBuyerProfile).id @@ -594,10 +614,10 @@ func _test_fishing_shop_sale_ui( var reserved_button := fish_nodes.get(reserved_catch.catch_id) as Button assert(fish_button != null and fish_button.visible) assert(reserved_button != null and reserved_button.visible) - await _activate_focused_button(fish_button, ui_viewport) + await _activate_pointer_control(fish_button, ui_viewport) var sell_button := player_menu.get("_sell_bubble") as Button assert(sell_button.visible and not sell_button.disabled) - await _activate_focused_button(sell_button, ui_viewport) + await _activate_pointer_control(sell_button, ui_viewport) await process_frame var confirmation := player_menu.get("_sale_confirmation") as Control var confirm_button := player_menu.get("_confirm_sale_button") as Button @@ -607,14 +627,14 @@ func _test_fishing_shop_sale_ui( > cooler_outer_wall.z_index ) _sale_result.clear() - await _activate_focused_button(confirm_button, ui_viewport) + await _activate_pointer_control(confirm_button, ui_viewport) await process_frame assert(not _sale_result.is_empty() and bool(_sale_result[1])) assert(not player.inventory.contains_catch_id(fish_catch.catch_id)) assert(player.wallet.get_balance() == balance_before + fish_catch.sale_value) assert(not sale_service.is_local_sale_pending()) assert(reservations.release(reservation_id)) - await _activate_focused_button(shop_tabs[0] as Button, ui_viewport) + await _activate_pointer_control(shop_tabs[0] as Button, ui_viewport) await process_frame assert(shop.visible and (shop.get_node("%ShopPanel") as Control).visible) assert(not (shop.get_node("%ShopCoolerPage") as Control).visible) diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd index ec802b0..8919221 100644 --- a/tests/fish_hotbar_showcase_validation.gd +++ b/tests/fish_hotbar_showcase_validation.gd @@ -3,7 +3,6 @@ extends SceneTree const MainScene = preload("res://main/main.tscn") const FishCatchType = preload("res://fish/fish_catch.gd") const FishDataType = preload("res://fish/fish_data.gd") -const FishingSpotType = preload("res://fishing/fishing_spot.gd") func _initialize() -> void: @@ -40,7 +39,6 @@ func _run() -> void: var service := main.get_node( "%NetworkFishShowcaseService" ) as NetworkFishShowcaseService - var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType assert(player != null and fish_catalog != null and service != null) var fish: FishDataType = fish_catalog.get_fish_by_id(&"bluegill") assert(fish != null) @@ -83,22 +81,18 @@ func _run() -> void: assert(player.hotbar.get_selected_fish_catch_id() == fish_catch.catch_id) assert(player.hotbar.get_selected_item_id().is_empty()) - var press := InputEventMouseButton.new() - press.button_index = MOUSE_BUTTON_LEFT - press.pressed = true - fishing_spot.call("_unhandled_input", press) - await process_frame assert(service.is_local_showcase_visible()) assert(service.get_local_showcase_catch_id() == fish_catch.catch_id) - assert((player.get_node("%HeldFishDisplay") as Node3D).visible) - fishing_spot.call("_unhandled_input", press) + await _wait_for_held_fish_visibility(player, true) + assert(service.toggle_selected_fish()) await process_frame assert(not service.is_local_showcase_visible()) - assert(not (player.get_node("%HeldFishDisplay") as Node3D).visible) + await _wait_for_held_fish_visibility(player, false) var saved_weather: WorldWeatherService.Weather = ( world_weather.get_weather() ) var saved_weather_seconds: float = world_weather.get_seconds_remaining() + var saved_time_hours: float = world_time.get_time_hours() assert(save_manager.save_now()) var save_path: String = str(save_manager.get("_save_path")) @@ -116,7 +110,7 @@ func _run() -> void: == 125 ) assert(absf( - float((parsed as Dictionary)["world"]["time_hours"]) - 19.75 + float((parsed as Dictionary)["world"]["time_hours"]) - saved_time_hours ) < 0.01) assert( int((parsed as Dictionary)["world"]["weather"]) @@ -149,7 +143,7 @@ func _run() -> void: ) assert(save_manager.load_player_data()) assert(player.experience.get_total_experience() == 125) - assert(absf(world_time.get_time_hours() - 19.75) < 0.01) + assert(absf(world_time.get_time_hours() - saved_time_hours) < 0.01) assert(world_weather.get_weather() == saved_weather) assert(absf( world_weather.get_seconds_remaining() - saved_weather_seconds @@ -163,14 +157,13 @@ func _run() -> void: FishQuality.Tier.EXCEPTIONAL, ) ) - assert(not service.is_local_showcase_visible()) - assert(service.toggle_selected_fish()) assert(service.is_local_showcase_visible()) + await _wait_for_held_fish_visibility(player, true) assert(player.inventory.remove_catch_by_id(fish_catch.catch_id) != null) await process_frame assert(player.hotbar.get_fish_catch_id(1).is_empty()) assert(not service.is_local_showcase_visible()) - assert(not (player.get_node("%HeldFishDisplay") as Node3D).visible) + await _wait_for_held_fish_visibility(player, false) var valid_state: Dictionary = { "session_id": "session", @@ -202,5 +195,26 @@ func _run() -> void: print("Fish hotbar showcase validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() + + +func _wait_for_held_fish_visibility( + player: Player, + expected_visible: bool, +) -> void: + var held_fish_display := player.get("_held_fish_display") as Node3D + assert(held_fish_display != null) + var deadline_msec: int = Time.get_ticks_msec() + 2000 + while ( + held_fish_display.visible != expected_visible + and Time.get_ticks_msec() < deadline_msec + ): + await process_frame + assert( + held_fish_display.visible == expected_visible, + "Held fish display did not become %s within the timeout." + % expected_visible, + ) diff --git a/tests/fish_showcase_multiplayer_validation.gd b/tests/fish_showcase_multiplayer_validation.gd index 23f02ef..72f4a59 100644 --- a/tests/fish_showcase_multiplayer_validation.gd +++ b/tests/fish_showcase_multiplayer_validation.gd @@ -35,8 +35,8 @@ func _run_host() -> void: var fish_catch: FishCatch = _add_bluegill(main, player) assert(player.hotbar.assign_fish(1, fish_catch.catch_id)) assert(player.hotbar.select_slot(1)) - assert(service.toggle_selected_fish()) assert(service.is_local_showcase_visible()) + await _wait_for_held_fish_visibility(player, true) assert(session.set_host_open(true)) var remote_peer_id: int = 0 @@ -66,7 +66,9 @@ func _run_host() -> void: print("Fish showcase multiplayer host validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() @@ -118,8 +120,8 @@ func _run_client() -> void: var fish_catch: FishCatch = _add_bluegill(main, player) assert(player.hotbar.assign_fish(1, fish_catch.catch_id)) assert(player.hotbar.select_slot(1)) - assert(service.toggle_selected_fish()) - assert((player.get("_held_fish_display") as Node3D).visible) + assert(service.is_local_showcase_visible()) + await _wait_for_held_fish_visibility(player, true) await create_timer(2.0).timeout assert(service.toggle_selected_fish()) var held_display := player.get("_held_fish_display") as Node3D @@ -132,7 +134,9 @@ func _run_client() -> void: print("Fish showcase multiplayer client validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() @@ -166,3 +170,18 @@ func _create_initialized_main() -> Node: await process_frame assert(bool(main.get("_application_initialized"))) return main + + +func _wait_for_held_fish_visibility( + player: Player, + expected_visible: bool, +) -> void: + var held_fish_display := player.get("_held_fish_display") as Node3D + assert(held_fish_display != null) + var deadline_msec: int = Time.get_ticks_msec() + 2000 + while ( + held_fish_display.visible != expected_visible + and Time.get_ticks_msec() < deadline_msec + ): + await process_frame + assert(held_fish_display.visible == expected_visible) diff --git a/tests/fishing_authority_validation.gd b/tests/fishing_authority_validation.gd index dc79c44..52ab878 100644 --- a/tests/fishing_authority_validation.gd +++ b/tests/fishing_authority_validation.gd @@ -36,8 +36,14 @@ func _run() -> void: assert(not session.is_open_host()) assert(service != null and fishing_spot != null and player != null) assert(player.hotbar.get_selected_item_id() == &"basic_fishing_rod") + var pond := main.get_node( + "TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond" + ) as Node3D + var pond_region := pond.get_node("FishingRegion") as FishableWaterRegion + assert(pond != null and pond_region != null) + var pond_surface_y: float = pond_region.get_surface_height() - player.global_position = Vector3(-0.5, 3.95, 2.1) + player.global_position = pond.global_position + Vector3(8.9, 1.44, 0.0) var visuals := player.get_node("Visuals") as Node3D visuals.rotation.y = PI * 0.5 for _frame: int in 4: @@ -50,8 +56,8 @@ func _run() -> void: fishing_spot.set("_cast_charge", 0.32) fishing_spot.call("_update_cast_charge", 0.0) var aimed_target: Vector3 = fishing_spot.get("_cast_target") - assert(aimed_target.x < -1.35) - assert(is_equal_approx(aimed_target.y, 2.51)) + assert(aimed_target.x < player.global_position.x - 0.85) + assert(is_equal_approx(aimed_target.y, pond_surface_y)) assert(fishing_spot.is_target_fishable(aimed_target)) fishing_spot.call("_confirm_cast") assert(fishing_spot.state == FishingSpotType.FishingState.CASTING) @@ -68,7 +74,7 @@ func _run() -> void: var attempts: Dictionary = service.get("_attempts") var attempt: NetworkFishingAttempt = attempts.get(session.get_local_peer_id()) assert(attempt != null) - assert(is_equal_approx(attempt.target.y, 2.51)) + assert(is_equal_approx(attempt.target.y, pond_surface_y)) assert(attempt.bobber_position.is_equal_approx(attempt.target)) fishing_spot.set("_withdrawal_input_held", true) @@ -124,29 +130,42 @@ func _run() -> void: fish, ) assert(fish_catch != null and fish_catch.is_valid()) - var baseline_controller := CatchController.new() - root.add_child(baseline_controller) - baseline_controller.start_authoritative_encounter( + var reference_controller := CatchController.new() + root.add_child(reference_controller) + reference_controller.start_authoritative_encounter( fish.catch_profile, attempt.reel_speed, attempt.barrier_damage, attempt.encounter_seed, - FishQuality.Tier.BORING, + fish_catch.quality, + int(fish.rarity), + fish.get_weight_percentile(fish_catch.weight_lb), ) var quality_barriers: Array = attempt.controller.get("_barriers") - var baseline_barriers: Array = baseline_controller.get("_barriers") - assert(quality_barriers.size() == baseline_barriers.size()) + var reference_barriers: Array = reference_controller.get("_barriers") + assert(quality_barriers.size() == reference_barriers.size()) for barrier_index: int in quality_barriers.size(): var quality_barrier: RefCounted = quality_barriers[barrier_index] - var baseline_barrier: RefCounted = baseline_barriers[barrier_index] + var reference_barrier: RefCounted = reference_barriers[barrier_index] assert( - int(quality_barrier.get("maximum_health")) - == FishQuality.apply_barrier_health( - int(baseline_barrier.get("maximum_health")), - fish_catch.quality, + is_equal_approx( + float(quality_barrier.get("position")), + float(reference_barrier.get("position")), ) ) - baseline_controller.queue_free() + assert( + int(quality_barrier.get("maximum_health")) + == int(reference_barrier.get("maximum_health")) + ) + assert( + int(quality_barrier.get("maximum_health")) + >= FishQuality.BARRIER_HEALTH_MINIMUMS[fish_catch.quality] + ) + assert( + int(quality_barrier.get("maximum_health")) + <= FishQuality.BARRIER_HEALTH_MAXIMUMS[fish_catch.quality] + ) + reference_controller.queue_free() service.call("_cancel_attempt", session.get_local_peer_id(), "") await process_frame assert(not service.has_local_attempt()) @@ -154,5 +173,7 @@ func _run() -> void: print("Fishing authority validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() diff --git a/tests/fishing_multiplayer_validation.gd b/tests/fishing_multiplayer_validation.gd index 2dae5e3..f6a8b40 100644 --- a/tests/fishing_multiplayer_validation.gd +++ b/tests/fishing_multiplayer_validation.gd @@ -150,7 +150,9 @@ func _run_host() -> void: print("Fishing multiplayer host validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() @@ -325,7 +327,9 @@ func _run_client() -> void: print("Fishing multiplayer client validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() diff --git a/tests/fishing_surface_validation.gd b/tests/fishing_surface_validation.gd index 15cc428..6ed3486 100644 --- a/tests/fishing_surface_validation.gd +++ b/tests/fishing_surface_validation.gd @@ -266,11 +266,47 @@ func _validate_bite_wait_distribution() -> void: if wait_seconds >= FishingSpotType.BITE_LONG_MAX_SECONDS: very_long_count += 1 var average_wait_seconds: float = total_wait_seconds / 10000.0 - assert(quick_count >= 2300 and quick_count <= 2700) + var quick_ratio: float = float(quick_count) / 10000.0 + var very_long_ratio: float = float(very_long_count) / 10000.0 + var very_long_probability: float = 1.0 - ( + FishingSpotType.BITE_QUICK_PROBABILITY + + FishingSpotType.BITE_TYPICAL_PROBABILITY + + FishingSpotType.BITE_LONG_PROBABILITY + ) + var expected_average_wait_seconds: float = ( + FishingSpotType.BITE_QUICK_PROBABILITY + * ( + FishingSpotType.BITE_QUICK_MIN_SECONDS + + FishingSpotType.BITE_QUICK_MAX_SECONDS + ) + * 0.5 + + FishingSpotType.BITE_TYPICAL_PROBABILITY + * ( + FishingSpotType.BITE_QUICK_MAX_SECONDS + + FishingSpotType.BITE_TYPICAL_MAX_SECONDS + ) + * 0.5 + + FishingSpotType.BITE_LONG_PROBABILITY + * ( + FishingSpotType.BITE_TYPICAL_MAX_SECONDS + + FishingSpotType.BITE_LONG_MAX_SECONDS + ) + * 0.5 + + very_long_probability + * ( + FishingSpotType.BITE_LONG_MAX_SECONDS + + FishingSpotType.BITE_MAX_SECONDS + ) + * 0.5 + ) + assert(absf( + quick_ratio - FishingSpotType.BITE_QUICK_PROBABILITY + ) <= 0.02) assert(typical_or_long_count > quick_count) - assert(very_long_count >= 100 and very_long_count <= 300) - assert(average_wait_seconds >= 45.0) - assert(average_wait_seconds <= 49.0) + assert(absf(very_long_ratio - very_long_probability) <= 0.005) + assert(absf( + average_wait_seconds - expected_average_wait_seconds + ) <= 1.0) fishing_spot.queue_free() await process_frame @@ -305,11 +341,16 @@ func _validate_remote_presentation() -> void: fish_catch.sale_value = 1 assert(fish_catch.is_valid()) presentation.play_return(fish_catch) - await create_timer(0.6).timeout var catch_display := player.find_child( "CatchDisplay", true, false ) as Node3D assert(catch_display != null) + var showcase_deadline_msec: int = Time.get_ticks_msec() + 2000 + while ( + not catch_display.visible + and Time.get_ticks_msec() < showcase_deadline_msec + ): + await process_frame assert(catch_display.visible) await presentation.return_completed assert(not catch_display.visible) diff --git a/tests/inventory_notepad_art_validation.gd b/tests/inventory_notepad_art_validation.gd index cbe3414..cba0797 100644 --- a/tests/inventory_notepad_art_validation.gd +++ b/tests/inventory_notepad_art_validation.gd @@ -62,7 +62,9 @@ func _run() -> void: await _capture_inventory_pages(player_menu) print("Inventory notepad artwork validation: PASS") presentation_stage.queue_free() - await process_frame + for _frame: int in 10: + await process_frame + await create_timer(0.1).timeout quit() diff --git a/tests/job_multiplayer_validation.gd b/tests/job_multiplayer_validation.gd index 5994eae..b40fc2e 100644 --- a/tests/job_multiplayer_validation.gd +++ b/tests/job_multiplayer_validation.gd @@ -56,7 +56,9 @@ func _run_host() -> void: print("Job multiplayer host validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() @@ -93,7 +95,9 @@ func _run_client() -> void: print("Job multiplayer client validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() diff --git a/tests/logbook_runtime_validation.gd b/tests/logbook_runtime_validation.gd index 77280ea..3df30a6 100644 --- a/tests/logbook_runtime_validation.gd +++ b/tests/logbook_runtime_validation.gd @@ -79,10 +79,15 @@ func _run() -> void: "_show_section_immediate", PlayerMenu.Section.PROFILE ) await process_frame - assert(not bool( + assert(bool( player_menu.call("_handle_direct_page_shortcut", shortcut) )) + await create_timer(0.5).timeout assert(player_menu.visible) + assert(logbook.visible) + assert( + player_menu.get("_current_section") == PlayerMenu.Section.LOGBOOK + ) player_menu.close_menu(PlayerMenu.CloseReason.TEARDOWN, false) print( @@ -90,7 +95,8 @@ func _run() -> void: % [root.size.x, root.size.y] ) main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame quit() diff --git a/tests/logbook_validation.gd b/tests/logbook_validation.gd index 38b7ade..866a34f 100644 --- a/tests/logbook_validation.gd +++ b/tests/logbook_validation.gd @@ -171,7 +171,7 @@ func _validate_page() -> void: page.activate() await process_frame assert(page.get("_category") == WaterType.Type.FRESH_WATER) - assert((page.get("_catalog_grid") as GridContainer).columns == 5) + assert((page.get("_catalog_grid") as GridContainer).columns == 4) var initial_detail_body := page.get("_detail_body") as VBoxContainer assert(not initial_detail_body.get_parent() is ScrollContainer) assert( @@ -209,7 +209,7 @@ func _validate_page() -> void: assert(portrait.source_texture == fish.display_texture) assert( portrait.custom_minimum_size - == LogbookPortraitType.ENTRY_FRAME_SIZE + == LogbookPage.CATALOG_PORTRAIT_SIZE ) assert( portrait.expand_mode diff --git a/tests/player_experience_ui_validation.gd b/tests/player_experience_ui_validation.gd index 93c23eb..31ed497 100644 --- a/tests/player_experience_ui_validation.gd +++ b/tests/player_experience_ui_validation.gd @@ -12,6 +12,14 @@ func _run() -> void: root.add_child(game_ui) await process_frame game_ui.set("_gameplay_ui_enabled", true) + var panel := game_ui.get_node("%ExperienceProgressPanel") as PanelContainer + var bubble := game_ui.get_node("%ExperienceBubble") as PanelContainer + var bubble_label := game_ui.get_node("%ExperienceBubbleLabel") as Label + var award_label := game_ui.get_node("%ExperienceAwardLabel") as Label + assert(panel != null and bubble != null) + assert(not panel.visible and not bubble.visible) + game_ui.call("_on_showcase_changed", "bluegill", "common", 1.0, 0, true) + await process_frame game_ui.call( "_on_experience_awarded", 50, @@ -20,13 +28,6 @@ func _run() -> void: 1, 1, ) - var panel := game_ui.get_node("%ExperienceProgressPanel") as PanelContainer - var bubble := game_ui.get_node("%ExperienceBubble") as PanelContainer - var bubble_label := game_ui.get_node("%ExperienceBubbleLabel") as Label - var award_label := game_ui.get_node("%ExperienceAwardLabel") as Label - assert(panel != null and bubble != null) - assert(not panel.visible and not bubble.visible) - game_ui.call("_on_showcase_changed", "bluegill", "common", 1.0, 0, true) await process_frame assert(not panel.visible and not bubble.visible) game_ui.call("_on_showcase_changed", "", "", 0.0, 0, false) @@ -43,5 +44,8 @@ func _run() -> void: await create_timer(1.2).timeout assert(not panel.visible and not bubble.visible) game_ui.queue_free() + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout print("Player experience UI validation: PASS") quit() diff --git a/tests/surface_drawing_multiplayer_validation.gd b/tests/surface_drawing_multiplayer_validation.gd index 12925ba..7b4d8d6 100644 --- a/tests/surface_drawing_multiplayer_validation.gd +++ b/tests/surface_drawing_multiplayer_validation.gd @@ -114,7 +114,9 @@ func _run_host() -> void: print("Surface drawing multiplayer host validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() @@ -207,7 +209,9 @@ func _run_client() -> void: print("Surface drawing multiplayer client validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() diff --git a/tests/surface_drawing_runtime_validation.gd b/tests/surface_drawing_runtime_validation.gd index 6e6e68d..b8c00d9 100644 --- a/tests/surface_drawing_runtime_validation.gd +++ b/tests/surface_drawing_runtime_validation.gd @@ -110,7 +110,9 @@ func _run() -> void: print("Surface drawing runtime validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() diff --git a/tests/surface_drawing_validation.gd b/tests/surface_drawing_validation.gd index aa94f0f..596cec0 100644 --- a/tests/surface_drawing_validation.gd +++ b/tests/surface_drawing_validation.gd @@ -21,6 +21,8 @@ func _run() -> void: await _validate_blocked_author_visibility() _validate_peer_capability_tracking() print("Surface drawing validation: PASS") + for _frame: int in 4: + await process_frame quit() @@ -118,6 +120,7 @@ func _validate_art_unlocks() -> void: assert(unlocks.is_brush_size_unlocked(4)) assert(unlocks.is_grid_size_unlocked(128)) assert(not unlocks.restore_mask(PlayerArtUnlocks.ALL_UNLOCK_MASK + 1)) + unlocks.free() func _validate_grid_snapping() -> void: @@ -265,6 +268,7 @@ func _validate_blocked_author_visibility() -> void: }) var world := Node3D.new() root.add_child(world) + world.add_child(relationships) var canvas := SurfaceDrawingCanvas.new() world.add_child(canvas) var state: Dictionary = { diff --git a/tests/ui_scaling_runtime_validation.gd b/tests/ui_scaling_runtime_validation.gd index d329755..f7144eb 100644 --- a/tests/ui_scaling_runtime_validation.gd +++ b/tests/ui_scaling_runtime_validation.gd @@ -205,6 +205,8 @@ func _run() -> void: presenter.queue_free() root.size = original_size - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout print("UI scaling runtime validation: PASS") quit() diff --git a/tests/world_time_multiplayer_validation.gd b/tests/world_time_multiplayer_validation.gd index 2d82aea..6801de0 100644 --- a/tests/world_time_multiplayer_validation.gd +++ b/tests/world_time_multiplayer_validation.gd @@ -83,7 +83,9 @@ func _run_host() -> void: print("World time multiplayer host validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() @@ -195,7 +197,9 @@ func _run_client() -> void: print("World time multiplayer client validation: PASS") session.disconnect_session("") main.queue_free() - await process_frame + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout quit() diff --git a/ui/controller_focus_presentation.gd b/ui/controller_focus_presentation.gd index 88954ac..7223ca7 100644 --- a/ui/controller_focus_presentation.gd +++ b/ui/controller_focus_presentation.gd @@ -41,6 +41,8 @@ func _input(event: InputEvent) -> void: CONTROLLER_MOTION_THRESHOLD ): _set_controller_active(true) + elif event is InputEventMouseMotion: + _set_controller_active(false) elif event is InputEventMouseButton: if (event as InputEventMouseButton).pressed: _set_controller_active(false) diff --git a/ui/controller_focus_recovery.gd b/ui/controller_focus_recovery.gd index 55bc6ad..bcaf6c8 100644 --- a/ui/controller_focus_recovery.gd +++ b/ui/controller_focus_recovery.gd @@ -3,11 +3,28 @@ extends Node const CONTROLLER_AXIS_THRESHOLD: float = 0.35 const SEMANTIC_MATCH_BONUS: float = 1000000.0 +const KEYBOARD_NAVIGATION_ACTIONS: Array[StringName] = [ + &"ui_accept", + &"ui_cancel", + &"ui_up", + &"ui_down", + &"ui_left", + &"ui_right", + &"ui_focus_next", + &"ui_focus_prev", + &"ui_page_up", + &"ui_page_down", + &"ui_home", + &"ui_end", +] var _controller_active: bool = false +var _focus_navigation_active: bool = false var _last_focus_center: Vector2 = Vector2.ZERO var _last_focus_key: String = "" var _scope_chain: Array[WeakRef] = [] +var _pending_focus: WeakRef +var _pointer_button_down: bool = false var _recovery_generation: int = 0 @@ -27,23 +44,51 @@ func _input(event: InputEvent) -> void: if event is InputEventJoypadButton: var button_event := event as InputEventJoypadButton if button_event.pressed: + _pointer_button_down = false _controller_active = true + _focus_navigation_active = true if button_event.button_index == JOY_BUTTON_LEFT_SHOULDER: _recovery_generation += 1 _scope_chain.clear() + _pending_focus = null + return + _request_pending_focus() return if event is InputEventJoypadMotion: if absf((event as InputEventJoypadMotion).axis_value) >= ( CONTROLLER_AXIS_THRESHOLD ): + _pointer_button_down = false _controller_active = true + _focus_navigation_active = true + _request_pending_focus() + return + if event is InputEventMouseMotion: + _pointer_button_down = ( + (event as InputEventMouseMotion).button_mask != 0 + ) + _leave_focus_navigation(not _pointer_button_down) return if event is InputEventMouseButton: - if (event as InputEventMouseButton).pressed: - _controller_active = false + var mouse_button := event as InputEventMouseButton + if mouse_button.button_index in [ + MOUSE_BUTTON_LEFT, + MOUSE_BUTTON_RIGHT, + MOUSE_BUTTON_MIDDLE, + ]: + _pointer_button_down = mouse_button.pressed + _leave_focus_navigation(false) + if not mouse_button.pressed: + _release_current_pointer_focus.call_deferred() return if event is InputEventKey and (event as InputEventKey).pressed: + _pointer_button_down = false _controller_active = false + if _is_keyboard_navigation_event(event): + _focus_navigation_active = true + _request_pending_focus() + else: + _leave_focus_navigation() func _process(_delta: float) -> void: @@ -58,8 +103,16 @@ func _process(_delta: float) -> void: func _on_gui_focus_changed(control: Control) -> void: _recovery_generation += 1 if control != null: - if _controller_active and control is BaseButton: - _remember_focus(control) + if _focus_navigation_active: + _pending_focus = null + if _controller_active and control is BaseButton: + _remember_focus(control) + return + if _keeps_pointer_focus(control): + return + if _is_focusable(control): + _pending_focus = weakref(control) + _release_focus_if_inactive.call_deferred(control) return if not _controller_active or _scope_chain.is_empty(): return @@ -92,13 +145,18 @@ func _recover_focus(generation: int) -> void: scope == null or not is_instance_valid(scope) or not scope.is_inside_tree() - or not scope.is_visible_in_tree() ): continue + if not scope.is_visible_in_tree(): + _scope_chain.clear() + return var replacement := _best_replacement_in(scope) if replacement != null: replacement.grab_focus() - return + else: + _scope_chain.clear() + return + _scope_chain.clear() func _best_replacement_in(scope: Control) -> Control: @@ -124,7 +182,7 @@ func _is_focusable(control: Control) -> bool: control == null or not control.is_inside_tree() or not control.is_visible_in_tree() - or control.focus_mode == Control.FOCUS_NONE + or control.focus_mode not in [Control.FOCUS_CLICK, Control.FOCUS_ALL] ): return false var button := control as BaseButton @@ -140,3 +198,80 @@ func _semantic_key(control: Control) -> String: tooltip = tooltip.trim_suffix(" (show variants)") tooltip = tooltip.trim_suffix(" (hide variants)") return "%s|%s|%s" % [control.get_class(), label, tooltip] + + +func _leave_focus_navigation(release_focus: bool = true) -> void: + _controller_active = false + _focus_navigation_active = false + _recovery_generation += 1 + _scope_chain.clear() + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if focus_owner == null or _keeps_pointer_focus(focus_owner): + return + if _is_focusable(focus_owner): + _pending_focus = weakref(focus_owner) + if release_focus: + _release_focus_if_inactive.call_deferred(focus_owner) + + +func _release_focus_if_inactive(control: Control) -> void: + if ( + _focus_navigation_active + or _pointer_button_down + or control == null + or not is_instance_valid(control) + or get_viewport().gui_get_focus_owner() != control + or _keeps_pointer_focus(control) + ): + return + get_viewport().gui_release_focus() + + +func _release_current_pointer_focus() -> void: + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if focus_owner != null: + _release_focus_if_inactive(focus_owner) + + +func _request_pending_focus() -> void: + if ( + not _focus_navigation_active + or _pending_focus == null + or get_viewport().gui_get_focus_owner() != null + ): + return + _restore_pending_focus.call_deferred(_recovery_generation) + + +func _restore_pending_focus(generation: int) -> void: + if ( + generation != _recovery_generation + or not _focus_navigation_active + or _pending_focus == null + or get_viewport().gui_get_focus_owner() != null + ): + return + var target := _pending_focus.get_ref() as Control + if not _is_focusable(target): + _pending_focus = null + return + _pending_focus = null + target.grab_focus() + + +func _is_keyboard_navigation_event(event: InputEvent) -> bool: + if event.is_action_pressed(&"open_backpack"): + return false + if ( + event.is_action_pressed(&"ui_cancel") + and get_viewport().gui_get_focus_owner() == null + ): + return false + for action: StringName in KEYBOARD_NAVIGATION_ACTIONS: + if event.is_action_pressed(action): + return true + return false + + +func _keeps_pointer_focus(control: Control) -> bool: + return control is LineEdit or control is TextEdit diff --git a/ui/fishing_shop.gd b/ui/fishing_shop.gd index 9f93cf8..3c7d7f4 100644 --- a/ui/fishing_shop.gd +++ b/ui/fishing_shop.gd @@ -74,6 +74,8 @@ const SUPPLY_PRICE_HORIZONTAL_PADDING: float = 12.0 @onready var _wallet_label: Label = %WalletLabel @onready var _shop_panel: PanelContainer = %ShopPanel +@onready var _shop_panel_margin: MarginContainer = $ShopPanel/Margin +@onready var _shop_panel_layout: VBoxContainer = $ShopPanel/Margin/Layout @onready var _shop_cooler_page: Control = %ShopCoolerPage @onready var _shop_cooler_mount: Control = %ShopCoolerMount @onready var _shop_body: HBoxContainer = $ShopPanel/Margin/Layout/Body @@ -367,6 +369,7 @@ func get_shop_cooler_mount() -> Control: func activate_shop_cooler_page() -> void: _cooler_page_active = true _shop_panel.show() + _set_shop_panel_background_pointer_blocking(false) _shop_body.hide() _feedback.hide() _shop_cooler_page.show() @@ -377,6 +380,7 @@ func deactivate_shop_cooler_page() -> void: _shop_tab_bar.show() _cooler_page_active = false _cooler_modal_open = false + _set_shop_panel_background_pointer_blocking(true) for tab: OrganizerTab in _shop_tabs: tab.disabled = false _shop_cooler_page.hide() @@ -385,6 +389,17 @@ func deactivate_shop_cooler_page() -> void: _feedback.show() +func _set_shop_panel_background_pointer_blocking(blocking: bool) -> void: + var mouse_filter := ( + Control.MOUSE_FILTER_STOP + if blocking + else Control.MOUSE_FILTER_IGNORE + ) + _shop_panel.mouse_filter = mouse_filter + _shop_panel_margin.mouse_filter = mouse_filter + _shop_panel_layout.mouse_filter = mouse_filter + + func set_shop_cooler_modal_open(is_open: bool) -> void: _cooler_modal_open = is_open for tab: OrganizerTab in _shop_tabs: diff --git a/ui/pause_menu.gd b/ui/pause_menu.gd index 3d2baa8..dccac73 100644 --- a/ui/pause_menu.gd +++ b/ui/pause_menu.gd @@ -351,7 +351,10 @@ func _open_confirmation( else BubbleConfirmationPageType.InitialFocus.CONFIRM ) ) - _begin_root_exit(_show_confirmation) + # Confirmation actions reject input while the root page is still leaving. + # Finish that exit before exposing an interactive confirmation page so a + # pointer click can never land in the overlap window and be discarded. + _begin_root_exit(_show_confirmation, false) func _show_confirmation() -> void: @@ -450,7 +453,10 @@ func _finish_root_entry(generation: int) -> void: _root_transition_active = false -func _begin_root_exit(completed: Callable) -> void: +func _begin_root_exit( + completed: Callable, + overlap_next_page: bool = true, +) -> void: if _root_transition_active or not _root_page.visible: return _root_transition_generation += 1 @@ -458,9 +464,14 @@ func _begin_root_exit(completed: Callable) -> void: _emit_transition_flurry() var generation: int = _root_transition_generation _root_page.transition_out( - _finish_root_exit.bind(generation), + _finish_root_exit.bind( + generation, + Callable() if overlap_next_page else completed, + ), 0.0 ) + if not overlap_next_page: + return await get_tree().create_timer( UIMotion.BUBBLE_TRANSITION_OVERLAP_DELAY ).timeout @@ -471,10 +482,13 @@ func _begin_root_exit(completed: Callable) -> void: func _finish_root_exit( generation: int, + completed: Callable, ) -> void: if generation != _root_transition_generation or not visible: return _root_transition_active = false + if completed.is_valid(): + completed.call() func _finish_user_close() -> void: diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn index 040aebe..d285ea9 100644 --- a/ui/title_screen.tscn +++ b/ui/title_screen.tscn @@ -209,7 +209,7 @@ unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) theme_override_font_sizes/font_size = 22 -text = "v0.6.5-alpha" +text = "v0.6.6-alpha" horizontal_alignment = 1 [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"] From d944367301c936042c6e59dbdebc864619b9093d Mon Sep 17 00:00:00 2001 From: Voyager Date: Tue, 11 Aug 2026 20:31:02 -0400 Subject: [PATCH 3/5] Prepare v0.6.7-alpha release --- export_presets.cfg | 26 +++++++------- network/discovery_client.gd | 36 ++++++++++++++++++- playtest/README-PLAYTEST.txt | 4 +-- project.godot | 2 +- scripts/build_playtest.sh | 6 ++-- tests/dedicated_server_config_validation.gd | 40 +++++++++++++++++++++ ui/title_screen.tscn | 2 +- 7 files changed, 95 insertions(+), 21 deletions(-) diff --git a/export_presets.cfg b/export_presets.cfg index 1df6f73..347829e 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -9,7 +9,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.6.6-alpha/windows-x86_64/NETfishing.exe" +export_path="builds/v0.6.7-alpha/windows-x86_64/NETfishing.exe" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -35,11 +35,11 @@ application/modify_resources=true application/icon="res://art/exported/system_icons/netfishing.ico" application/console_wrapper_icon="" application/icon_interpolation=4 -application/file_version="0.6.6.0" -application/product_version="0.6.6.0" +application/file_version="0.6.7.0" +application/product_version="0.6.7.0" application/company_name="" application/product_name="NETfishing" -application/file_description="NETfishing v0.6.6-alpha" +application/file_description="NETfishing v0.6.7-alpha" application/copyright="" application/trademarks="" application/export_angle=0 @@ -62,7 +62,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.6.6-alpha/linux-arm64/NETfishing.arm64" +export_path="builds/v0.6.7-alpha/linux-arm64/NETfishing.arm64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -92,7 +92,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.6.6-alpha/macos/NETfishing.zip" +export_path="builds/v0.6.7-alpha/macos/NETfishing.zip" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -108,8 +108,8 @@ custom_template/release="" application/bundle_identifier="io.woofmeow.netfishing" application/icon="res://art/exported/system_icons/netfishing_1024.png" application/icon_interpolation=0 -application/short_version="0.6.6" -application/version="0.6.6" +application/short_version="0.6.7" +application/version="0.6.7" application/architecture="universal" codesign/enable=false notarization/enable=false @@ -125,7 +125,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*,tests/*" -export_path="builds/v0.6.6-alpha/server-linux-x86_64/NETfishingServer.x86_64" +export_path="builds/v0.6.7-alpha/server-linux-x86_64/NETfishingServer.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -155,7 +155,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.6.6-alpha/android/NETfishing.apk" +export_path="builds/v0.6.7-alpha/android/NETfishing.apk" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" @@ -176,8 +176,8 @@ architectures/armeabi-v7a=false architectures/arm64-v8a=true architectures/x86=false architectures/x86_64=false -version/code=60600 -version/name="v0.6.6-alpha" +version/code=60700 +version/name="v0.6.7-alpha" package/unique_name="io.woofmeow.netfishing" package/name="NETfishing" package/signed=true @@ -214,7 +214,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="builds/*,playtest/*,scripts/*" -export_path="builds/v0.6.6-alpha/linux-x86_64/NETfishing.x86_64" +export_path="builds/v0.6.7-alpha/linux-x86_64/NETfishing.x86_64" patches=PackedStringArray() encryption_include_filters="" encryption_exclude_filters="" diff --git a/network/discovery_client.gd b/network/discovery_client.gd index f2263e8..bdb7134 100644 --- a/network/discovery_client.gd +++ b/network/discovery_client.gd @@ -865,12 +865,46 @@ func _parse_response_dictionary(body: PackedByteArray) -> Dictionary: func _request_failure(response: Dictionary) -> String: var error: Variant = response.get("error", {}) if typeof(error) == TYPE_DICTIONARY: - var message: String = str((error as Dictionary).get("message", "")).strip_edges() + var details := error as Dictionary + if str(details.get("code", "")) == "game_version_mismatch": + var required_version: String = str( + details.get("required_game_version", "") + ).strip_edges() + if not required_version.is_empty(): + return _discovery_version_mismatch_message(required_version) + var message: String = str(details.get("message", "")).strip_edges() if not message.is_empty(): return message return "Room discovery is temporarily unavailable." +func _discovery_version_mismatch_message(required_version: String) -> String: + var local_version: String = NetworkProtocol.game_version() + var rejection: NetworkProtocol.RejectionCode = ( + NetworkProtocol.game_version_rejection( + local_version, + required_version, + ) + ) + match rejection: + NetworkProtocol.RejectionCode.CLIENT_OUTDATED: + return ( + "Your NETfishing version is out of date. Update to %s to " + + "enable public discovery. This room will not be listed " + + "until you update." + ) % required_version + NetworkProtocol.RejectionCode.SERVER_OUTDATED: + return ( + "Public discovery is still on NETfishing %s. This room " + + "will not be listed until discovery is updated for %s." + ) % [required_version, local_version] + _: + return ( + "Public discovery requires NETfishing %s. This room will " + + "not be listed until both versions match." + ) % required_version + + func _on_session_state_changed(state: NetworkSession.State) -> void: if state == NetworkSession.State.CONNECTING and not _pending_join_token.is_empty(): _set_public_join_state(PublicJoinState.CONNECTING) diff --git a/playtest/README-PLAYTEST.txt b/playtest/README-PLAYTEST.txt index 042898d..258ea6a 100644 --- a/playtest/README-PLAYTEST.txt +++ b/playtest/README-PLAYTEST.txt @@ -1,6 +1,6 @@ NETfishing -v0.6.6-alpha -Alpha 0.6.6 +v0.6.7-alpha +Alpha 0.6.7 Thank you for trying this early private playtest. diff --git a/project.godot b/project.godot index 3a76545..27fe914 100644 --- a/project.godot +++ b/project.godot @@ -11,7 +11,7 @@ config_version=5 [application] config/name="NETFISHING" -config/version="0.6.6-alpha" +config/version="0.6.7-alpha" run/main_scene="res://main/main.tscn" config/features=PackedStringArray("4.7", "GL Compatibility") config/icon="res://art/exported/system_icons/netfishing_256.png" diff --git a/scripts/build_playtest.sh b/scripts/build_playtest.sh index c0cd14b..3a47661 100755 --- a/scripts/build_playtest.sh +++ b/scripts/build_playtest.sh @@ -4,12 +4,12 @@ set -euo pipefail readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" -readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.6.6-alpha" +readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.6.7-alpha" readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly README_SOURCE="${PROJECT_ROOT}/playtest/README-PLAYTEST.txt" -readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.6.6-alpha-windows-x86_64.zip" -readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.6.6-alpha-linux-x86_64.zip" +readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.6.7-alpha-windows-x86_64.zip" +readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.6.7-alpha-linux-x86_64.zip" readonly GODOT_BIN="${GODOT_BIN:-godot}" if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then diff --git a/tests/dedicated_server_config_validation.gd b/tests/dedicated_server_config_validation.gd index 7911f6d..ecc721c 100644 --- a/tests/dedicated_server_config_validation.gd +++ b/tests/dedicated_server_config_validation.gd @@ -107,6 +107,46 @@ func _run() -> void: "0.6.5-beta", "0.6.5-alpha" ) == NetworkProtocol.RejectionCode.SERVER_OUTDATED ) + var outdated_discovery_error := { + "error": { + "code": "game_version_mismatch", + "message": "generic mismatch", + "required_game_version": "0.6.8-alpha", + }, + } + var outdated_message: String = str( + discovery.call("_request_failure", outdated_discovery_error) + ) + assert("out of date" in outdated_message) + assert("0.6.8-alpha" in outdated_message) + assert("will not be listed" in outdated_message) + discovery.set( + "_host_request_kind", + DiscoveryClient.HostRequestKind.CREATE, + ) + discovery.set("_host_request_in_flight", true) + discovery.call( + "_on_host_request_completed", + HTTPRequest.RESULT_SUCCESS, + HTTPClient.RESPONSE_CONFLICT, + PackedStringArray(), + JSON.stringify(outdated_discovery_error).to_utf8_buffer(), + ) + assert(discovery.get_host_state() == DiscoveryClient.HostState.ERROR) + assert(discovery.host_status_is_error()) + assert(discovery.get_host_status_message() == outdated_message) + var newer_discovery_error := { + "error": { + "code": "game_version_mismatch", + "message": "generic mismatch", + "required_game_version": "0.6.6-alpha", + }, + } + var newer_message: String = str( + discovery.call("_request_failure", newer_discovery_error) + ) + assert("discovery is updated" in newer_message) + assert("will not be listed" in newer_message) discovery.free() print("DEDICATED_SERVER_CONFIG_VALIDATION_OK") quit() diff --git a/ui/title_screen.tscn b/ui/title_screen.tscn index d285ea9..285da68 100644 --- a/ui/title_screen.tscn +++ b/ui/title_screen.tscn @@ -209,7 +209,7 @@ unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) theme_override_font_sizes/font_size = 22 -text = "v0.6.6-alpha" +text = "v0.6.7-alpha" horizontal_alignment = 1 [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"] From 41192e4ef5a3cba3117c53caeacfb08a02dc85e4 Mon Sep 17 00:00:00 2001 From: Voyager Date: Tue, 11 Aug 2026 22:58:33 -0400 Subject: [PATCH 4/5] Add identity-backed server moderation --- docs/ARCHITECTURE.md | 8 + main/main.gd | 9 +- network/discovery_client.gd | 8 + network/network_player_list_service.gd | 352 +++++++++++++++++-- network/network_session.gd | 131 +++++++ network/player_list_entry.gd | 2 + scripts/run_validations.sh | 3 + server/dedicated_server_config.gd | 51 +++ tests/dedicated_host_session_validation.gd | 9 + tests/dedicated_server_config_validation.gd | 57 +++ tests/operator_multiplayer_validation.gd | 232 ++++++++++++ tests/operator_multiplayer_validation.gd.uid | 1 + ui/players_page.gd | 38 +- ui/settings_panel.gd | 22 ++ ui/settings_panel.tscn | 5 + 15 files changed, 896 insertions(+), 32 deletions(-) create mode 100644 tests/operator_multiplayer_validation.gd create mode 100644 tests/operator_multiplayer_validation.gd.uid diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 33745bc..4e18113 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -46,6 +46,14 @@ derives trusted context from registered peers, authoritative regions, and server-owned state before mutating inventory, wallet, progression, or shared world state. See [`decisions/0001-host-authority.md`](decisions/0001-host-authority.md). +Moderation follows the same boundary. Player hosts may grant session-scoped +operator status to an authenticated identity. Dedicated servers derive +operators from their configured fingerprint allowlist. Operator status is +replicated for presentation, but kick, ban, unban, and artwork-reset requests +are always reauthorized against the authenticated sender by the host. Only a +player host can grant or revoke operators; operators cannot moderate the host +or another operator. + Protocol compatibility is defined in `network/network_protocol.gd`. A visible release version is not a reason to change the protocol number. diff --git a/main/main.gd b/main/main.gd index 4647005..29cf997 100644 --- a/main/main.gd +++ b/main/main.gd @@ -308,7 +308,15 @@ func _start_dedicated_server() -> void: if not _discovery.set_base_url_override(config.discovery_url): _fail_dedicated_server("The discovery URL is invalid.") return + if not _network_session.configure_dedicated_operators( + config.operator_fingerprints + ): + _fail_dedicated_server("The server operator list is invalid.") + return _configure_portable_stores() + if not _discovery.configure_dedicated_runtime(config.server_name): + _fail_dedicated_server("The server room name is invalid.") + return _initialize_application(true) _player.set_local_control(false) _player.visible = false @@ -323,7 +331,6 @@ func _start_dedicated_server() -> void: _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 diff --git a/network/discovery_client.gd b/network/discovery_client.gd index bdb7134..476cff4 100644 --- a/network/discovery_client.gd +++ b/network/discovery_client.gd @@ -58,6 +58,7 @@ var _session: NetworkSession var _base_url: String = "" var _room_name: String = DEFAULT_ROOM_NAME var _room_name_uses_default: bool = true +var _host_settings_persistence_enabled: bool = true var _discoverable: bool = false var _host_status_message: String = "" var _host_status_is_error: bool = false @@ -218,6 +219,11 @@ func get_public_join_state() -> PublicJoinState: return _public_join_state +func configure_dedicated_runtime(room_name: String) -> bool: + _host_settings_persistence_enabled = false + return set_room_name(room_name) + + func set_room_name(value: String) -> bool: var cleaned: String = _sanitize_room_name(value) if cleaned.is_empty(): @@ -1047,6 +1053,8 @@ func _load_settings() -> void: func _save_settings() -> void: + if not _host_settings_persistence_enabled: + return var config := ConfigFile.new() config.set_value("host", "room_name", _room_name) config.set_value( diff --git a/network/network_player_list_service.gd b/network/network_player_list_service.gd index c4307f1..4742d14 100644 --- a/network/network_player_list_service.gd +++ b/network/network_player_list_service.gd @@ -15,6 +15,9 @@ var _surface_drawing: NetworkSurfaceDrawingService var _revision := 0 var _host_block_pairs: Dictionary[String, bool] = {} var _peer_fingerprints: Dictionary[int, String] = {} +var _remote_bans: Array[Dictionary] = [] +var _ban_snapshot_requested: bool = false +var _ban_snapshot_loaded: bool = false func setup( @@ -40,6 +43,7 @@ func setup( func(_count: int, _maximum: int) -> void: _sync_authenticated_peers() ) _session.state_changed.connect(_on_session_state_changed) + _session.operator_status_changed.connect(_on_operator_status_changed) _relationships.relationship_changed.connect(_on_relationship_changed) _bans.bans_changed.connect(_changed) _chat.set_relationship_store(_relationships) @@ -64,13 +68,24 @@ func get_entries() -> Array[PlayerListEntry]: entry.compact_fingerprint = NetworkIdentityCrypto.compact_suffix(record.identity_fingerprint) entry.display_name = record.display_name entry.is_host = peer_id == 1 + entry.is_operator = _session.is_peer_operator(peer_id) entry.is_local_player = peer_id == local_id entry.continuity_state = _known.identity_status(record.identity_fingerprint, record.display_name) entry.ping_to_host_ms = _session.get_peer_rtt_ms(peer_id) entry.muted = _relationships.is_muted(record.identity_fingerprint) entry.blocked = blocked - entry.can_kick = _session.is_host() and peer_id != local_id and peer_id != 1 + entry.can_kick = ( + _session.can_local_moderate() + and peer_id != local_id + and peer_id != 1 + and (_session.is_host() or not entry.is_operator) + ) entry.can_ban = entry.can_kick + entry.can_manage_operator = ( + _session.can_manage_operators() + and peer_id != local_id + and peer_id != 1 + ) entry.revision = _revision result.append(entry) result.sort_custom(_entry_before) @@ -89,6 +104,14 @@ func is_local_host() -> bool: return _session.is_host() +func is_local_moderator() -> bool: + return _session.can_local_moderate() + + +func can_manage_operators() -> bool: + return _session.can_manage_operators() + + func is_open_host() -> bool: return _session.is_open_host() @@ -102,7 +125,12 @@ func get_relationships() -> Array[Dictionary]: func get_bans() -> Array[Dictionary]: - return _bans.get_bans(_session.get_host_identity_fingerprint()) if _session.is_host() else [] + if _session.is_host(): + return _bans.get_bans(_session.get_host_identity_fingerprint()) + if not _session.is_local_operator(): + return [] + _request_ban_snapshot() + return _remote_bans.duplicate(true) func set_surface_drawing_service( @@ -131,14 +159,12 @@ func get_session_artwork_counts() -> Vector2i: func reset_session_artwork() -> bool: - if not _session.is_host() or _surface_drawing == null: + if _session.is_host(): + return _reset_session_artwork_on_host() + if not _session.is_local_operator(): return false - var ok: bool = _surface_drawing.clear_session_artwork() - moderation_finished.emit( - ok, - "Session artwork cleared." if ok else "Session artwork could not be cleared.", - ) - return ok + request_reset_session_artwork.rpc_id(1) + return true func set_muted(fingerprint: String, display_name: String, value: bool) -> bool: @@ -154,12 +180,13 @@ func set_blocked(fingerprint: String, display_name: String, value: bool) -> bool func kick(peer_id: int, fingerprint: String, revision: int) -> bool: - if not _valid_moderation_target(peer_id, fingerprint, revision): - moderation_finished.emit(false, "That player is no longer connected.") + if _session.is_host(): + return _kick_on_host(peer_id, fingerprint, revision, true) + if not _session.is_local_operator(): + moderation_finished.emit(false, "Only the host or an operator can remove players.") return false - var ok := _session.kick_authenticated_peer(peer_id, fingerprint) - moderation_finished.emit(ok, "Player removed." if ok else "Player could not be removed.") - return ok + request_kick.rpc_id(1, peer_id, fingerprint) + return true func ban( @@ -168,22 +195,158 @@ func ban( display_name: String, revision: int, ) -> bool: - if not _valid_moderation_target(peer_id, fingerprint, revision): - moderation_finished.emit(false, "That player is no longer connected.") + if _session.is_host(): + return _ban_on_host( + peer_id, fingerprint, display_name, revision, true + ) + if not _session.is_local_operator(): + moderation_finished.emit(false, "Only the host or an operator can ban players.") return false - var host_fingerprint := _session.get_host_identity_fingerprint() - if not _bans.ban(host_fingerprint, fingerprint, display_name): - moderation_finished.emit(false, "Ban could not be saved.") - return false - var ok := _session.kick_authenticated_peer(peer_id, fingerprint, true) - moderation_finished.emit(ok, "Player banned." if ok else "Ban saved.") + request_ban.rpc_id(1, peer_id, fingerprint) return true func unban(fingerprint: String) -> bool: - if not _session.is_host(): + if _session.is_host(): + var ok: bool = _bans.unban( + _session.get_host_identity_fingerprint(), fingerprint + ) + moderation_finished.emit( + ok, "Player unbanned." if ok else "Ban could not be removed." + ) + return ok + if not _session.is_local_operator(): + moderation_finished.emit(false, "Only the host or an operator can remove bans.") return false - return _bans.unban(_session.get_host_identity_fingerprint(), fingerprint) + request_unban.rpc_id(1, fingerprint) + return true + + +func set_operator( + peer_id: int, + fingerprint: String, + enabled: bool, + revision: int, +) -> bool: + if ( + not _session.can_manage_operators() + or not _valid_moderation_target( + peer_id, fingerprint, revision, true, true + ) + ): + moderation_finished.emit(false, "That player is no longer connected.") + return false + var ok: bool = _session.set_peer_operator(peer_id, fingerprint, enabled) + moderation_finished.emit( + ok, + ("Player is now an operator." if enabled else "Operator access removed.") + if ok + else "Operator access could not be changed.", + ) + return ok + + +@rpc("any_peer", "call_remote", "reliable", 0) +func request_kick(peer_id: int, fingerprint: String) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if not _valid_operator_sender(sender_id): + return + var ok: bool = _kick_on_host(peer_id, fingerprint, -1, false) + _send_moderation_result( + sender_id, ok, "Player removed." if ok else "Player could not be removed." + ) + + +@rpc("any_peer", "call_remote", "reliable", 0) +func request_ban(peer_id: int, fingerprint: String) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if not _valid_operator_sender(sender_id): + return + var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id) + var display_name: String = record.display_name if record != null else "Player" + var ok: bool = _ban_on_host( + peer_id, fingerprint, display_name, -1, false + ) + _send_moderation_result( + sender_id, ok, "Player banned." if ok else "Player could not be banned." + ) + _send_ban_snapshot(sender_id) + + +@rpc("any_peer", "call_remote", "reliable", 0) +func request_unban(fingerprint: String) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if ( + not _valid_operator_sender(sender_id) + or not NetworkIdentityCrypto.valid_fingerprint(fingerprint) + ): + return + var ok: bool = _bans.unban( + _session.get_host_identity_fingerprint(), fingerprint + ) + _send_moderation_result( + sender_id, ok, "Player unbanned." if ok else "Ban could not be removed." + ) + _send_ban_snapshot(sender_id) + + +@rpc("any_peer", "call_remote", "reliable", 0) +func request_reset_session_artwork() -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if not _valid_operator_sender(sender_id): + return + var ok: bool = _reset_session_artwork_on_host(false) + _send_moderation_result( + sender_id, + ok, + "Session artwork cleared." + if ok + else "Session artwork could not be cleared.", + ) + + +@rpc("any_peer", "call_remote", "reliable", 0) +func request_ban_snapshot() -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if _valid_operator_sender(sender_id): + _send_ban_snapshot(sender_id) + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_moderation_result(success: bool, message: String) -> void: + if not _session.is_joined_client(): + return + moderation_finished.emit(success, message.left(120)) + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_ban_snapshot(records: Array) -> void: + if not _session.is_local_operator() or records.size() > HostBanStore.MAX_BANS: + return + var sanitized: Array[Dictionary] = [] + for value: Variant in records: + if typeof(value) != TYPE_DICTIONARY: + return + var record: Dictionary = value + var fingerprint: String = str(record.get("target_fingerprint", "")) + var display_name: String = str( + record.get("last_known_display_name", "Player") + ).strip_edges().left(NetworkProtocol.MAX_DISPLAY_NAME_LENGTH) + if ( + not NetworkIdentityCrypto.valid_fingerprint(fingerprint) + or display_name.is_empty() + or typeof(record.get("banned_unix")) != TYPE_INT + ): + return + sanitized.append({ + "target_fingerprint": fingerprint, + "last_known_display_name": display_name, + "banned_unix": int(record["banned_unix"]), + }) + _remote_bans = sanitized + _ban_snapshot_requested = false + _ban_snapshot_loaded = true + _changed() func is_locally_blocked(fingerprint: String) -> bool: @@ -271,6 +434,22 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: ]: _host_block_pairs.clear() _peer_fingerprints.clear() + _remote_bans.clear() + _ban_snapshot_requested = false + _ban_snapshot_loaded = false + elif state == NetworkSession.State.JOINED_CLIENT: + _request_ban_snapshot() + _changed() + + +func _on_operator_status_changed(peer_id: int, enabled: bool) -> void: + if peer_id == _session.get_local_peer_id(): + if enabled: + _request_ban_snapshot() + else: + _remote_bans.clear() + _ban_snapshot_requested = false + _ban_snapshot_loaded = false _changed() @@ -295,16 +474,137 @@ func _peer_for_fingerprint(fingerprint: String) -> int: return 0 -func _valid_moderation_target(peer_id: int, fingerprint: String, revision: int) -> bool: - if not _session.is_host() or revision != _revision or peer_id == 1: +func _valid_moderation_target( + peer_id: int, + fingerprint: String, + revision: int, + require_revision: bool, + can_target_operator: bool, +) -> bool: + if ( + not _session.is_host() + or (require_revision and revision != _revision) + or peer_id == 1 + or (not can_target_operator and _session.is_peer_operator(peer_id)) + ): return false var record := _session.get_peer_record(peer_id) return record != null and record.identity_fingerprint == fingerprint +func _valid_operator_sender(peer_id: int) -> bool: + return ( + _session.is_host() + and peer_id > 1 + and _session.is_authenticated_peer(peer_id) + and _session.is_peer_operator(peer_id) + ) + + +func _kick_on_host( + peer_id: int, + fingerprint: String, + revision: int, + local_host_request: bool, +) -> bool: + if not _valid_moderation_target( + peer_id, + fingerprint, + revision, + local_host_request, + local_host_request, + ): + if local_host_request: + moderation_finished.emit(false, "That player is no longer connected.") + return false + var ok: bool = _session.kick_authenticated_peer(peer_id, fingerprint) + if local_host_request: + moderation_finished.emit( + ok, "Player removed." if ok else "Player could not be removed." + ) + return ok + + +func _ban_on_host( + peer_id: int, + fingerprint: String, + display_name: String, + revision: int, + local_host_request: bool, +) -> bool: + if not _valid_moderation_target( + peer_id, + fingerprint, + revision, + local_host_request, + local_host_request, + ): + if local_host_request: + moderation_finished.emit(false, "That player is no longer connected.") + return false + var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id) + var trusted_display_name: String = ( + record.display_name if record != null else display_name + ) + var host_fingerprint: String = _session.get_host_identity_fingerprint() + if not _bans.ban(host_fingerprint, fingerprint, trusted_display_name): + if local_host_request: + moderation_finished.emit(false, "Ban could not be saved.") + return false + var ok: bool = _session.kick_authenticated_peer(peer_id, fingerprint, true) + if local_host_request: + moderation_finished.emit(ok, "Player banned." if ok else "Ban saved.") + return true + + +func _reset_session_artwork_on_host( + emit_local_result: bool = true, +) -> bool: + if not _session.is_host() or _surface_drawing == null: + return false + var ok: bool = _surface_drawing.clear_session_artwork() + if emit_local_result: + moderation_finished.emit( + ok, + "Session artwork cleared." + if ok + else "Session artwork could not be cleared.", + ) + return ok + + +func _send_moderation_result( + peer_id: int, + success: bool, + message: String, +) -> void: + receive_moderation_result.rpc_id(peer_id, success, message.left(120)) + + +func _send_ban_snapshot(peer_id: int) -> void: + if not _valid_operator_sender(peer_id): + return + receive_ban_snapshot.rpc_id( + peer_id, + _bans.get_bans(_session.get_host_identity_fingerprint()), + ) + + +func _request_ban_snapshot() -> void: + if ( + _session.is_local_operator() + and not _ban_snapshot_requested + and not _ban_snapshot_loaded + ): + _ban_snapshot_requested = true + request_ban_snapshot.rpc_id(1) + + func _entry_before(a: PlayerListEntry, b: PlayerListEntry) -> bool: if a.is_host != b.is_host: return a.is_host + if a.is_operator != b.is_operator: + return a.is_operator if a.is_local_player != b.is_local_player: return a.is_local_player var compared := a.display_name.naturalnocasecmp_to(b.display_name) diff --git a/network/network_session.gd b/network/network_session.gd index 1d332da..2043072 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -32,6 +32,7 @@ signal server_trust_required( is_changed: bool, ) signal peer_identity_observed(peer_id: int, status: String) +signal operator_status_changed(peer_id: int, is_operator: bool) signal server_lost signal remote_recovery_requested(peer_id: int, entry_position: Vector3) signal remote_recovery_presentation_changed( @@ -102,6 +103,10 @@ var _moderation_disconnect_message := "" var _host_port: int = 0 var _session_display_name: String = "NETfishing Room" var _dedicated_host: bool = false +var _configured_operator_fingerprints: Dictionary[String, bool] = {} +var _session_operator_fingerprints: Dictionary[String, bool] = {} +var _operator_peer_ids: Dictionary[int, bool] = {} +var _local_operator: bool = false func _ready() -> void: @@ -137,6 +142,20 @@ func setup( _profile_ready = _profile_ready and _player_identity.load_or_create() +func configure_dedicated_operators( + fingerprints: PackedStringArray, +) -> bool: + if state != State.INACTIVE: + return false + var configured: Dictionary[String, bool] = {} + for fingerprint: String in fingerprints: + if not NetworkIdentityCrypto.valid_fingerprint(fingerprint): + return false + configured[fingerprint] = true + _configured_operator_fingerprints = configured + return true + + func start_private_host( port: int = DEFAULT_PORT, port_attempts: int = 1, @@ -215,6 +234,9 @@ func _start_host( multiplayer.multiplayer_peer = peer _session_id = Crypto.new().generate_random_bytes(16).hex_encode() _registry.clear() + _session_operator_fingerprints.clear() + _operator_peer_ids.clear() + _local_operator = false _spawn_service.clear_remote_players() if not dedicated: _register_player_host() @@ -301,6 +323,44 @@ func is_dedicated_host() -> bool: return is_host() and _dedicated_host +func is_local_operator() -> bool: + return state == State.JOINED_CLIENT and _local_operator + + +func can_local_moderate() -> bool: + return is_host() or is_local_operator() + + +func can_manage_operators() -> bool: + return is_host() and not _dedicated_host + + +func is_peer_operator(peer_id: int) -> bool: + return bool(_operator_peer_ids.get(peer_id, false)) + + +func set_peer_operator( + peer_id: int, + fingerprint: String, + enabled: bool, +) -> bool: + if not can_manage_operators() or peer_id <= 1: + return false + var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id) + if ( + record == null + or not record.identity_authenticated + or record.identity_fingerprint != fingerprint + ): + return false + if enabled: + _session_operator_fingerprints[fingerprint] = true + else: + _session_operator_fingerprints.erase(fingerprint) + _set_operator_status(peer_id, enabled) + return true + + func set_session_display_name(value: String) -> void: var cleaned: String = value.strip_edges().left(48) if cleaned.is_empty(): @@ -822,6 +882,7 @@ func _on_peer_disconnected(peer_id: int) -> void: _pending_authentication.erase(peer_id) _pending_identity_challenges.erase(peer_id) _authenticated_identity_cache.erase(peer_id) + _operator_peer_ids.erase(peer_id) var recovery_attempt: String = _recovery_attempts.get(peer_id, "") if not recovery_attempt.is_empty(): _recovery_attempts.erase(peer_id) @@ -1136,6 +1197,11 @@ func submit_client_hello(data: Dictionary) -> void: NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE ) return + var operator_enabled: bool = _operator_for_fingerprint( + str(identity["fingerprint"]) + ) + if operator_enabled: + _operator_peer_ids[sender_id] = true var submitted_appearance := CharacterCustomizationCatalog.sanitized_snapshot( data["cosmetic_snapshot"] ) @@ -1180,9 +1246,12 @@ func submit_client_hello(data: Dictionary) -> void: ) ) receive_spawn_list.rpc_id(sender_id, _build_spawn_list()) + receive_operator_snapshot.rpc_id(sender_id, _operator_peer_id_snapshot()) receive_peer_spawn.rpc( _make_spawn_entry(sender_id, display_name, spawn_transform) ) + if operator_enabled: + receive_operator_status.rpc(sender_id, true) peer_authenticated.emit(sender_id, display_name) _emit_peer_count() @@ -1334,12 +1403,41 @@ func receive_peer_spawn(entry: Dictionary) -> void: func receive_peer_despawn(peer_id: int) -> void: if state != State.JOINED_CLIENT: return + _operator_peer_ids.erase(peer_id) _registry.remove_peer(peer_id) _spawn_service.remove_peer(peer_id) peer_removed.emit(peer_id) _emit_peer_count() +@rpc("authority", "call_remote", "reliable", 0) +func receive_operator_snapshot(peer_ids: PackedInt32Array) -> void: + if ( + state != State.JOINED_CLIENT + or peer_ids.size() > get_session_max_players() + ): + return + _operator_peer_ids.clear() + for peer_id: int in peer_ids: + if peer_id > 1: + _operator_peer_ids[peer_id] = true + _update_local_operator() + for peer_id: int in _operator_peer_ids: + operator_status_changed.emit(peer_id, true) + + +@rpc("authority", "call_remote", "reliable", 0) +func receive_operator_status(peer_id: int, enabled: bool) -> void: + if state != State.JOINED_CLIENT or peer_id <= 1: + return + if enabled: + _operator_peer_ids[peer_id] = true + else: + _operator_peer_ids.erase(peer_id) + _update_local_operator() + operator_status_changed.emit(peer_id, enabled) + + func _apply_spawn_entry(entry: Dictionary) -> void: if ( typeof(entry.get("peer_id")) != TYPE_INT @@ -1470,6 +1568,36 @@ func _sanitized_capabilities(value: Variant) -> PackedStringArray: return result +func _operator_for_fingerprint(fingerprint: String) -> bool: + return bool(( + _configured_operator_fingerprints + if _dedicated_host + else _session_operator_fingerprints + ).get(fingerprint, false)) + + +func _operator_peer_id_snapshot() -> PackedInt32Array: + var result: PackedInt32Array = PackedInt32Array() + for peer_id: int in _operator_peer_ids: + result.append(peer_id) + result.sort() + return result + + +func _set_operator_status(peer_id: int, enabled: bool) -> void: + if enabled: + _operator_peer_ids[peer_id] = true + else: + _operator_peer_ids.erase(peer_id) + operator_status_changed.emit(peer_id, enabled) + receive_operator_status.rpc(peer_id, enabled) + + +func _update_local_operator() -> void: + var local_peer_id: int = multiplayer.get_unique_id() + _local_operator = bool(_operator_peer_ids.get(local_peer_id, false)) + + func _verify_spawn_identity(entry: Dictionary) -> bool: var fingerprint := str(entry.get("identity_fingerprint", "")) var public_pem := NetworkIdentityCrypto.normalize_public_pem( @@ -1929,5 +2057,8 @@ func _teardown_peer() -> void: _server_identity_fingerprint = "" _server_identity_public_key = "" _session_identity_keys.clear() + _session_operator_fingerprints.clear() + _operator_peer_ids.clear() + _local_operator = false _host_port = 0 _dedicated_host = false diff --git a/network/player_list_entry.gd b/network/player_list_entry.gd index fbe0d8f..f401da6 100644 --- a/network/player_list_entry.gd +++ b/network/player_list_entry.gd @@ -6,6 +6,7 @@ var full_fingerprint := "" var compact_fingerprint := "" var display_name := "" var is_host := false +var is_operator := false var is_local_player := false var continuity_state := "" var ping_to_host_ms := -1 @@ -13,4 +14,5 @@ var muted := false var blocked := false var can_kick := false var can_ban := false +var can_manage_operator := false var revision := 0 diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index 8279bac..17252ba 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -10,6 +10,7 @@ readonly RUN_ROOT="$(mktemp -d -t netfishing-validations.XXXXXX)" readonly -a QUICK_TESTS=( "tests/android_readiness_validation.gd" + "tests/dedicated_server_config_validation.gd" "tests/fish_catalog_content_validation.gd" "tests/fish_quality_validation.gd" "tests/fishing_audio_validation.gd" @@ -32,6 +33,7 @@ readonly -a RUNTIME_TESTS=( readonly -a HOST_TESTS=( "tests/art_tools_validation.gd" + "tests/dedicated_host_session_validation.gd" "tests/economy_regression_validation.gd" "tests/fish_hotbar_showcase_validation.gd" "tests/fishing_authority_validation.gd" @@ -44,6 +46,7 @@ readonly -a NETWORK_TESTS=( "tests/fish_showcase_multiplayer_validation.gd" "tests/fishing_multiplayer_validation.gd" "tests/job_multiplayer_validation.gd" + "tests/operator_multiplayer_validation.gd" "tests/surface_drawing_multiplayer_validation.gd" "tests/world_time_multiplayer_validation.gd" ) diff --git a/server/dedicated_server_config.gd b/server/dedicated_server_config.gd index 457c382..0618466 100644 --- a/server/dedicated_server_config.gd +++ b/server/dedicated_server_config.gd @@ -5,6 +5,7 @@ const DEFAULT_NAME: String = "NETfishing Dedicated Server" const DEFAULT_BIND_ADDRESS: String = "*" const DEFAULT_PORT: int = 7777 const DEFAULT_MAX_PLAYERS: int = 8 +const MAX_OPERATORS: int = 64 var server_name: String = DEFAULT_NAME var bind_address: String = DEFAULT_BIND_ADDRESS @@ -13,6 +14,7 @@ var max_players: int = DEFAULT_MAX_PLAYERS var public_listing: bool = false var discovery_url: String = "" var data_directory: String = "" +var operator_fingerprints: PackedStringArray = PackedStringArray() var error_message: String = "" @@ -65,6 +67,9 @@ func _load_file(path: String) -> bool: discovery_url = str(file.get_value( "discovery", "url", discovery_url )) + operator_fingerprints = _parse_fingerprint_list(file.get_value( + "moderation", "operators", operator_fingerprints + )) return true @@ -88,6 +93,10 @@ func _apply_environment() -> void: data_directory = _environment_string( "NETFISHING_DATA_DIR", data_directory ) + if OS.has_environment("NETFISHING_SERVER_OPERATORS"): + operator_fingerprints = _parse_fingerprint_list( + OS.get_environment("NETFISHING_SERVER_OPERATORS") + ) func _apply_arguments(arguments: PackedStringArray) -> void: @@ -106,6 +115,10 @@ func _apply_arguments(arguments: PackedStringArray) -> void: data_directory = argument.trim_prefix("--data-dir=") elif argument.begins_with("--discovery-url="): discovery_url = argument.trim_prefix("--discovery-url=") + elif argument.begins_with("--operators="): + operator_fingerprints = _parse_fingerprint_list( + argument.trim_prefix("--operators=") + ) elif argument == "--public": public_listing = true elif argument == "--private": @@ -117,6 +130,7 @@ func _validate() -> void: bind_address = bind_address.strip_edges() discovery_url = discovery_url.strip_edges().trim_suffix("/") data_directory = data_directory.strip_edges() + operator_fingerprints = _normalized_fingerprints(operator_fingerprints) if server_name.is_empty(): error_message = "Server name cannot be empty." elif not _safe_text(server_name): @@ -134,6 +148,17 @@ func _validate() -> void: or discovery_url.begins_with("http://") ): error_message = "A public server requires a discovery URL." + elif operator_fingerprints.size() > MAX_OPERATORS: + error_message = "A server may configure at most %d operators." % ( + MAX_OPERATORS + ) + else: + for fingerprint: String in operator_fingerprints: + if not NetworkIdentityCrypto.valid_fingerprint(fingerprint): + error_message = ( + "Server operator fingerprints must be 64 lowercase hex characters." + ) + break static func _safe_text(value: String) -> bool: @@ -164,3 +189,29 @@ static func _environment_bool(name: String, fallback: bool) -> bool: static func _parse_int(value: String, fallback: int) -> int: return int(value) if value.strip_edges().is_valid_int() else fallback + + +static func _parse_fingerprint_list(value: Variant) -> PackedStringArray: + var values: PackedStringArray = PackedStringArray() + if typeof(value) == TYPE_STRING: + var text: String = str(value) + for separator: String in [";", "\n", "\r", "\t", " "]: + text = text.replace(separator, ",") + values = text.split(",", false) + elif typeof(value) in [TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY]: + for entry: Variant in value: + if typeof(entry) in [TYPE_STRING, TYPE_STRING_NAME]: + values.append(str(entry)) + return _normalized_fingerprints(values) + + +static func _normalized_fingerprints( + values: PackedStringArray, +) -> PackedStringArray: + var result: PackedStringArray = PackedStringArray() + for value: String in values: + var fingerprint: String = value.strip_edges().to_lower() + if not fingerprint.is_empty() and fingerprint not in result: + result.append(fingerprint) + result.sort() + return result diff --git a/tests/dedicated_host_session_validation.gd b/tests/dedicated_host_session_validation.gd index 6f1de7d..2d098bd 100644 --- a/tests/dedicated_host_session_validation.gd +++ b/tests/dedicated_host_session_validation.gd @@ -1,6 +1,9 @@ extends SceneTree const TEST_PORT: int = 35777 +const OPERATOR_FINGERPRINT: String = ( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +) func _init() -> void: @@ -35,12 +38,18 @@ func _run() -> void: host_bans, true, ) + assert(session.configure_dedicated_operators( + PackedStringArray([OPERATOR_FINGERPRINT]) + )) 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(bool(session.call( + "_operator_for_fingerprint", OPERATOR_FINGERPRINT + ))) assert(session.set_host_open(true)) assert(session.is_open_host()) session.disconnect_session("Dedicated host validation complete.") diff --git a/tests/dedicated_server_config_validation.gd b/tests/dedicated_server_config_validation.gd index ecc721c..2da24a3 100644 --- a/tests/dedicated_server_config_validation.gd +++ b/tests/dedicated_server_config_validation.gd @@ -1,6 +1,12 @@ extends SceneTree const ConfigType = preload("res://server/dedicated_server_config.gd") +const OPERATOR_A: String = ( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +) +const OPERATOR_B: String = ( + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" +) func _initialize() -> void: @@ -25,6 +31,9 @@ func _run() -> void: file.set_value("server", "public", true) file.set_value("server", "data_directory", "/tmp/configured-server") file.set_value("discovery", "url", "https://discovery.netfishing.org/") + file.set_value( + "moderation", "operators", PackedStringArray([OPERATOR_B, OPERATOR_A]) + ) assert(file.save(path) == OK) var configured := ConfigType.new() @@ -37,8 +46,35 @@ func _run() -> void: assert(int(configured.get("max_players")) == 12) assert(bool(configured.get("public_listing"))) assert(str(configured.get("discovery_url")) == "https://discovery.netfishing.org") + assert( + configured.get("operator_fingerprints") + == PackedStringArray([OPERATOR_A, OPERATOR_B]) + ) assert(DirAccess.remove_absolute(path) == OK) + var parsed := ConfigType.new() + parsed.set( + "operator_fingerprints", + ConfigType._parse_fingerprint_list( + "%s, %s;%s" % [OPERATOR_B.to_upper(), OPERATOR_A, OPERATOR_A] + ), + ) + parsed.set("data_directory", "/tmp/parsed-server") + parsed.call("_validate") + assert(parsed.is_valid()) + assert( + parsed.get("operator_fingerprints") + == PackedStringArray([OPERATOR_A, OPERATOR_B]) + ) + + var invalid_operator := ConfigType.new() + invalid_operator.set( + "operator_fingerprints", PackedStringArray(["not-a-fingerprint"]) + ) + invalid_operator.set("data_directory", "/tmp/invalid-operator-server") + invalid_operator.call("_validate") + assert(not invalid_operator.is_valid()) + var invalid := ConfigType.new() invalid.set("public_listing", true) invalid.set("data_directory", "/tmp/invalid-server") @@ -58,6 +94,27 @@ func _run() -> void: DiscoveryClient.UPNP_RETRY_INTERVAL_SECONDS < DiscoveryClient.UPNP_RENEW_INTERVAL_SECONDS ) + var discovery_settings_path: String = ProjectSettings.globalize_path( + DiscoveryClient.SETTINGS_PATH + ) + assert(not FileAccess.file_exists(discovery_settings_path)) + assert(discovery.set_room_name("Client Room")) + var client_settings: PackedByteArray = FileAccess.get_file_as_bytes( + discovery_settings_path + ) + assert(not client_settings.is_empty()) + var dedicated_discovery := DiscoveryClient.new() + dedicated_discovery.call("_load_settings") + assert( + dedicated_discovery.configure_dedicated_runtime("Headless Room") + ) + assert(dedicated_discovery.get_room_name() == "Headless Room") + assert( + FileAccess.get_file_as_bytes(discovery_settings_path) + == client_settings + ) + dedicated_discovery.free() + assert(DirAccess.remove_absolute(discovery_settings_path) == OK) assert( discovery.get_host_state() == DiscoveryClient.HostState.CLOSED diff --git a/tests/operator_multiplayer_validation.gd b/tests/operator_multiplayer_validation.gd new file mode 100644 index 0000000..5847aa3 --- /dev/null +++ b/tests/operator_multiplayer_validation.gd @@ -0,0 +1,232 @@ +extends SceneTree + +const MainScene = preload("res://main/main.tscn") +const TEST_PORT: int = 18140 +const FIRST_BANNED_FINGERPRINT: String = ( + "1111111111111111111111111111111111111111111111111111111111111111" +) +const SECOND_BANNED_FINGERPRINT: String = ( + "2222222222222222222222222222222222222222222222222222222222222222" +) + +var _moderation_results: Array[Dictionary] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var arguments: PackedStringArray = OS.get_cmdline_user_args() + if arguments.has("host"): + await _run_host() + return + if arguments.has("client"): + await _run_client() + return + push_error("Operator multiplayer validation needs host or client mode.") + quit(1) + + +func _run_host() -> void: + var main: Node = await _create_initialized_main() + var session := main.get_node("%NetworkSession") as NetworkSession + var service := ( + main.get_node("%NetworkPlayerListService") + as NetworkPlayerListService + ) + var bans := main.get_node("%HostBanStore") as HostBanStore + assert(session.start_private_host(TEST_PORT)) + assert(session.set_host_open(true)) + + var remote_peer_id: int = await _wait_for_remote_peer(session) + assert(remote_peer_id > 1) + var remote_record: PeerRegistry.PeerRecord = session.get_peer_record( + remote_peer_id + ) + assert(remote_record != null and remote_record.identity_authenticated) + var host_fingerprint: String = session.get_host_identity_fingerprint() + assert(bans.ban( + host_fingerprint, FIRST_BANNED_FINGERPRINT, "First Banned Player" + )) + + var entry: PlayerListEntry = _entry_for_peer(service, remote_peer_id) + assert(entry != null and entry.can_manage_operator) + assert(service.set_operator( + remote_peer_id, + remote_record.identity_fingerprint, + true, + entry.revision, + )) + assert(session.is_peer_operator(remote_peer_id)) + var players_page := main.find_child( + "PlayersPage", true, false + ) as PlayersPage + assert(players_page != null) + players_page.call("_refresh") + assert(_has_button_text(players_page, "deop")) + + var unban_deadline: int = Time.get_ticks_msec() + 12000 + while ( + Time.get_ticks_msec() < unban_deadline + and bans.is_banned(host_fingerprint, FIRST_BANNED_FINGERPRINT) + ): + await process_frame + assert(not bans.is_banned( + host_fingerprint, FIRST_BANNED_FINGERPRINT + )) + assert(bans.ban( + host_fingerprint, SECOND_BANNED_FINGERPRINT, "Second Banned Player" + )) + + entry = _entry_for_peer(service, remote_peer_id) + assert(entry != null and entry.is_operator) + assert(service.set_operator( + remote_peer_id, + remote_record.identity_fingerprint, + false, + entry.revision, + )) + assert(not session.is_peer_operator(remote_peer_id)) + + var disconnect_deadline: int = Time.get_ticks_msec() + 8000 + while ( + Time.get_ticks_msec() < disconnect_deadline + and session.is_authenticated_peer(remote_peer_id) + ): + await process_frame + assert(not session.is_authenticated_peer(remote_peer_id)) + assert(bans.is_banned(host_fingerprint, SECOND_BANNED_FINGERPRINT)) + print("Operator multiplayer host validation: PASS") + session.disconnect_session("") + main.queue_free() + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout + quit() + + +func _run_client() -> void: + var main: Node = await _create_initialized_main() + main.call( + "_on_title_join_game_requested", "127.0.0.1:%d" % TEST_PORT + ) + var session := main.get_node("%NetworkSession") as NetworkSession + var service := ( + main.get_node("%NetworkPlayerListService") + as NetworkPlayerListService + ) + service.moderation_finished.connect( + func(success: bool, message: String) -> void: + _moderation_results.append({ + "success": success, + "message": message, + }) + ) + var join_deadline: int = Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < join_deadline: + await process_frame + if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY: + main.call("_confirm_server_trust") + if session.is_joined_client(): + break + assert(session.is_joined_client()) + + var operator_deadline: int = Time.get_ticks_msec() + 10000 + while Time.get_ticks_msec() < operator_deadline and not session.is_local_operator(): + await process_frame + assert(session.is_local_operator()) + assert(service.is_local_moderator()) + var players_page := main.find_child( + "PlayersPage", true, false + ) as PlayersPage + assert(players_page != null) + players_page.call("_refresh") + var tabs := players_page.get("_tabs") as HBoxContainer + assert((tabs.get_child(2) as Button).visible) + players_page.call("_select_tab", 2) + assert(int(players_page.get("_current_tab")) == 2) + var local_entry: PlayerListEntry = _entry_for_peer( + service, session.get_local_peer_id() + ) + assert(local_entry != null and local_entry.is_operator) + + var ban_deadline: int = Time.get_ticks_msec() + 10000 + while ( + Time.get_ticks_msec() < ban_deadline + and not _has_ban(service.get_bans(), FIRST_BANNED_FINGERPRINT) + ): + await process_frame + assert(_has_ban(service.get_bans(), FIRST_BANNED_FINGERPRINT)) + assert(service.unban(FIRST_BANNED_FINGERPRINT)) + var result_deadline: int = Time.get_ticks_msec() + 8000 + while Time.get_ticks_msec() < result_deadline and _moderation_results.is_empty(): + await process_frame + assert(not _moderation_results.is_empty()) + assert(bool(_moderation_results.back().get("success", false))) + + var deop_deadline: int = Time.get_ticks_msec() + 10000 + while Time.get_ticks_msec() < deop_deadline and session.is_local_operator(): + await process_frame + assert(not session.is_local_operator()) + assert(not service.is_local_moderator()) + assert(not (tabs.get_child(2) as Button).visible) + assert(int(players_page.get("_current_tab")) == 0) + service.request_unban.rpc_id(1, SECOND_BANNED_FINGERPRINT) + await create_timer(1.0).timeout + print("Operator multiplayer client validation: PASS") + session.disconnect_session("") + main.queue_free() + for _frame: int in 4: + await process_frame + await create_timer(0.1).timeout + quit() + + +func _create_initialized_main() -> Node: + 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"))) + return main + + +func _wait_for_remote_peer(session: NetworkSession) -> int: + var deadline: int = Time.get_ticks_msec() + 20000 + while Time.get_ticks_msec() < deadline: + await process_frame + for peer_id: int in session.get_authenticated_peer_ids(): + if peer_id != session.get_local_peer_id(): + return peer_id + return 0 + + +func _entry_for_peer( + service: NetworkPlayerListService, + peer_id: int, +) -> PlayerListEntry: + for entry: PlayerListEntry in service.get_entries(): + if entry.peer_id == peer_id: + return entry + return null + + +func _has_ban(records: Array[Dictionary], fingerprint: String) -> bool: + for record: Dictionary in records: + if str(record.get("target_fingerprint", "")) == fingerprint: + return true + return false + + +func _has_button_text(root_node: Node, text: String) -> bool: + for node: Node in root_node.find_children("*", "Button", true, false): + var button := node as Button + if button != null and button.text == text: + return true + return false diff --git a/tests/operator_multiplayer_validation.gd.uid b/tests/operator_multiplayer_validation.gd.uid new file mode 100644 index 0000000..2951ea6 --- /dev/null +++ b/tests/operator_multiplayer_validation.gd.uid @@ -0,0 +1 @@ +uid://cv17xsf20ue5t diff --git a/ui/players_page.gd b/ui/players_page.gd index 8c93247..380299e 100644 --- a/ui/players_page.gd +++ b/ui/players_page.gd @@ -194,7 +194,7 @@ func _set_host_toggle_state( func _select_tab(index: int) -> void: - if index == 2 and (_service == null or not _service.is_local_host()): + if index == 2 and (_service == null or not _service.is_local_moderator()): return _current_tab = index _refresh() @@ -207,10 +207,12 @@ func _refresh() -> void: _count_label.text = "%d / %d connected" % [ _service.get_connected_count(), _service.get_max_players(), ] + if _current_tab == 2 and not _service.is_local_moderator(): + _current_tab = 0 for index: int in _tabs.get_child_count(): var button := _tabs.get_child(index) as Button button.button_pressed = index == _current_tab - button.visible = index != 2 or _service.is_local_host() + button.visible = index != 2 or _service.is_local_moderator() _refresh_host_settings() for child: Node in _list.get_children(): child.queue_free() @@ -330,7 +332,7 @@ func _on_host_status_changed(message: String, is_error: bool) -> void: func _build_active_rows() -> void: - if _service.is_local_host(): + if _service.is_local_moderator(): _build_session_artwork_controls() var entries := _service.get_entries() if entries.is_empty(): @@ -339,10 +341,12 @@ func _build_active_rows() -> void: for entry: PlayerListEntry in entries: var row := _make_row() var identity := Label.new() - identity.custom_minimum_size.x = 515 + identity.custom_minimum_size.x = 430 var markers: Array[String] = [] if entry.is_host: markers.append("host") + if entry.is_operator: + markers.append("operator") if entry.is_local_player: markers.append("You") identity.text = "%s%s · %s\n%s" % [ @@ -351,7 +355,7 @@ func _build_active_rows() -> void: entry.compact_fingerprint, entry.continuity_state, ] - identity.tooltip_text = NetworkIdentityCrypto.format_fingerprint( + identity.tooltip_text = "Full identity fingerprint:\n%s" % ( entry.full_fingerprint ) identity.add_theme_color_override( @@ -381,6 +385,12 @@ func _build_active_rows() -> void: block.pressed.connect(_confirm_block.bind(entry)) UtilityPageStyle.apply_ocean_button(block) row.add_child(block) + if entry.can_manage_operator: + var operator := Button.new() + operator.text = "deop" if entry.is_operator else "op" + operator.pressed.connect(_confirm_operator.bind(entry)) + UtilityPageStyle.apply_ocean_button(operator) + row.add_child(operator) var kick := Button.new() kick.text = "kick" kick.disabled = not entry.can_kick @@ -539,6 +549,24 @@ func _confirm_ban(entry: PlayerListEntry) -> void: ) +func _confirm_operator(entry: PlayerListEntry) -> void: + var enabled: bool = not entry.is_operator + _confirm( + ( + "Grant operator access to %s for this room session?" + if enabled + else "Remove operator access from %s?" + ) % entry.display_name, + func() -> void: + _service.set_operator( + entry.peer_id, + entry.full_fingerprint, + enabled, + entry.revision, + ) + ) + + func _confirm_unban(fingerprint: String) -> void: _confirm("Unban %s?" % NetworkIdentityCrypto.compact_suffix(fingerprint), func() -> void: _service.unban(fingerprint) diff --git a/ui/settings_panel.gd b/ui/settings_panel.gd index 1b10bfd..4a7ec9b 100644 --- a/ui/settings_panel.gd +++ b/ui/settings_panel.gd @@ -156,6 +156,7 @@ func _ready() -> void: %ChangeDataFolder.pressed.connect(_choose_data_folder) %ExportPlayerIdentity.pressed.connect(_choose_identity_export.bind("player")) %ImportPlayerIdentity.pressed.connect(_choose_identity_import.bind("player")) + %CopyPlayerFingerprint.pressed.connect(_copy_player_fingerprint) %ExportHostIdentity.pressed.connect(_choose_identity_export.bind("host")) %ImportHostIdentity.pressed.connect(_choose_identity_import.bind("host")) for index: int in _world_options.size(): @@ -485,6 +486,16 @@ func _refresh_data_page() -> void: _data_root.override_active or (_network_session != null and _network_session.is_session_active()) ) + var fingerprint: String = ( + _player_identity.fingerprint if _player_identity != null else "" + ) + %CopyPlayerFingerprint.disabled = not NetworkIdentityCrypto.valid_fingerprint( + fingerprint + ) + %CopyPlayerFingerprint.text = ( + "Copy Player Fingerprint · %s" + % NetworkIdentityCrypto.compact_suffix(fingerprint) + ) func _open_data_folder() -> void: @@ -492,6 +503,17 @@ func _open_data_folder() -> void: _feedback.text = "Could not open the data folder." +func _copy_player_fingerprint() -> void: + var fingerprint: String = ( + _player_identity.fingerprint if _player_identity != null else "" + ) + if not NetworkIdentityCrypto.valid_fingerprint(fingerprint): + _feedback.text = "Player identity is unavailable." + return + DisplayServer.clipboard_set(fingerprint) + _feedback.text = "Full player fingerprint copied for server operator setup." + + func _choose_data_folder() -> void: if _data_root == null or _data_root.override_active: _feedback.text = "The data folder is externally managed." diff --git a/ui/settings_panel.tscn b/ui/settings_panel.tscn index 4cf1681..df2e261 100644 --- a/ui/settings_panel.tscn +++ b/ui/settings_panel.tscn @@ -913,6 +913,11 @@ layout_mode = 2 text = "Active identity keys stay on this device.\nEncrypted backups can be stored in your synced data folder.\nDo not play the same profile on two devices at the same time." autowrap_mode = 2 +[node name="CopyPlayerFingerprint" type="Button" parent="DataPage/Paper/Content"] +unique_name_in_owner = true +layout_mode = 2 +text = "Copy Player Fingerprint" + [node name="IdentityGrid" type="GridContainer" parent="DataPage/Paper/Content"] layout_mode = 2 columns = 2 From 24b957046365e2be867cb2da4b24ebfd0dafbb1f Mon Sep 17 00:00:00 2001 From: Voyager Date: Tue, 11 Aug 2026 22:58:46 -0400 Subject: [PATCH 5/5] Honor authored barrier health bands --- fish/fish_quality.gd | 43 ++++++-------- fishing/bass_catch_profile.tres | 2 - fishing/carp_catch_profile.tres | 2 - fishing/catch_controller.gd | 17 +----- fishing/catch_difficulty_profile.gd | 7 --- fishing/common_catch_profile.tres | 2 - fishing/fishing_spot.gd | 2 - network/network_fishing_service.gd | 2 - tests/fish_quality_validation.gd | 83 +++++++++++++++++---------- tests/fishing_authority_validation.gd | 4 +- 10 files changed, 73 insertions(+), 91 deletions(-) diff --git a/fish/fish_quality.gd b/fish/fish_quality.gd index 6ae4031..61534e0 100644 --- a/fish/fish_quality.gd +++ b/fish/fish_quality.gd @@ -23,16 +23,11 @@ const MINNOW_ROLL_WEIGHTS: Array[float] = [34.0, 27.0, 20.0, 13.0, 6.0] const SARDINE_ROLL_WEIGHTS: Array[float] = [25.0, 28.0, 23.0, 16.0, 8.0] const LUMINOUS_ROE_ROLL_WEIGHTS: Array[float] = [16.0, 26.0, 26.0, 20.0, 12.0] const SALE_MULTIPLIERS: Array[float] = [1.0, 1.1, 1.25, 1.5, 2.0] -# Legacy profile multiplier retained for serialized/profile compatibility. -# New encounters use the weighted quality/rarity/weight bands below. +# Legacy multiplier helpers remain available for compatibility. New encounters +# roll directly within the authored quality bands below. const BARRIER_HEALTH_MULTIPLIERS: Array[float] = [1.0, 1.25, 1.6, 2.2, 3.25] -# Barrier health is weighted 70% by quality, 20% by rarity, and 10% by the -# catch's position in its authored weight range. The upper bounds define the -# intended per-barrier challenge bands; a small seeded variance keeps barriers -# from feeling identical without crossing those bands. -const BARRIER_QUALITY_WEIGHT: float = 0.70 -const BARRIER_RARITY_WEIGHT: float = 0.20 -const BARRIER_WEIGHT_WEIGHT: float = 0.10 +# Inclusive per-barrier health bands authored for each quality tier. Encounter +# generation rolls uniformly across the selected tier's complete band. const BARRIER_HEALTH_MINIMUMS: Array[int] = [1, 10, 50, 100, 200] const BARRIER_HEALTH_MAXIMUMS: Array[int] = [9, 50, 100, 200, 400] const DISPLAY_NAMES: PackedStringArray = [ @@ -89,32 +84,26 @@ static func apply_barrier_health(base_health: int, quality: int) -> int: ) -static func barrier_health_for_catch( - quality: int, - rarity: int, - weight_percentile: float, - variance: float = 1.0, -) -> int: +static func barrier_health_range(quality: int) -> Vector2i: var safe_quality: int = ( quality if is_valid(quality) else Tier.BORING ) - var safe_rarity: float = clampf(float(rarity) / 4.0, 0.0, 1.0) - var safe_weight: float = clampf(weight_percentile, 0.0, 1.0) - var weighted_health: float = float(BARRIER_HEALTH_MAXIMUMS[safe_quality]) * ( - BARRIER_QUALITY_WEIGHT - + BARRIER_RARITY_WEIGHT * safe_rarity - + BARRIER_WEIGHT_WEIGHT * safe_weight - ) - var varied_health: int = roundi( - weighted_health * clampf(variance, 0.8, 1.2) - ) - return clampi( - varied_health, + return Vector2i( BARRIER_HEALTH_MINIMUMS[safe_quality], BARRIER_HEALTH_MAXIMUMS[safe_quality], ) +static func roll_barrier_health( + rng: RandomNumberGenerator, + quality: int, +) -> int: + var health_range: Vector2i = barrier_health_range(quality) + if rng == null: + return health_range.x + return rng.randi_range(health_range.x, health_range.y) + + static func roll( rng: RandomNumberGenerator, weight_multipliers: Array[float] = [], diff --git a/fishing/bass_catch_profile.tres b/fishing/bass_catch_profile.tres index 6385eec..24024a7 100644 --- a/fishing/bass_catch_profile.tres +++ b/fishing/bass_catch_profile.tres @@ -6,8 +6,6 @@ script = ExtResource("1_profile") barrier_count_min = 3 barrier_count_max = 4 -barrier_health_min = 4 -barrier_health_max = 7 first_barrier_margin = 0.14 final_barrier_margin = 0.1 minimum_barrier_spacing = 0.14 diff --git a/fishing/carp_catch_profile.tres b/fishing/carp_catch_profile.tres index 94a8470..e1e0efe 100644 --- a/fishing/carp_catch_profile.tres +++ b/fishing/carp_catch_profile.tres @@ -6,8 +6,6 @@ script = ExtResource("1_profile") barrier_count_min = 3 barrier_count_max = 4 -barrier_health_min = 5 -barrier_health_max = 8 first_barrier_margin = 0.13 final_barrier_margin = 0.09 minimum_barrier_spacing = 0.14 diff --git a/fishing/catch_controller.gd b/fishing/catch_controller.gd index 62769aa..7e75370 100644 --- a/fishing/catch_controller.gd +++ b/fishing/catch_controller.gd @@ -69,8 +69,6 @@ var _reel_input_held: bool = false var _reel_speed: float = 0.0 var _click_power: int = 1 var _fish_quality: int = FishQualityType.Tier.BORING -var _fish_rarity: int = 0 -var _fish_weight_percentile: float = 0.0 var _chase_delay_remaining: float = 0.0 var _failure_epsilon: float = 0.0001 var _auto_click_accumulator: float = 0.0 @@ -113,8 +111,6 @@ func start_encounter( reel_speed: float, click_power: int, fish_quality: int = FishQualityType.Tier.BORING, - fish_rarity: int = 0, - fish_weight_percentile: float = 0.0, ) -> void: reset() if profile == null: @@ -128,8 +124,6 @@ func start_encounter( if FishQualityType.is_valid(fish_quality) else FishQualityType.Tier.BORING ) - _fish_rarity = clampi(fish_rarity, 0, 4) - _fish_weight_percentile = clampf(fish_weight_percentile, 0.0, 1.0) _chase_delay_remaining = CHASE_START_DELAY chase_progress = -CHASE_START_OFFSET _seed_encounter_rng() @@ -145,8 +139,6 @@ func start_authoritative_encounter( click_power: int, encounter_seed_value: int, fish_quality: int = FishQualityType.Tier.BORING, - fish_rarity: int = 0, - fish_weight_percentile: float = 0.0, ) -> void: var previous_test_mode: bool = use_deterministic_test_seed var previous_seed: int = deterministic_test_seed @@ -157,8 +149,6 @@ func start_authoritative_encounter( reel_speed, click_power, fish_quality, - fish_rarity, - fish_weight_percentile, ) use_deterministic_test_seed = previous_test_mode deterministic_test_seed = previous_seed @@ -327,11 +317,8 @@ func _generate_barriers(profile: CatchDifficultyProfileType) -> void: + spacing * float(barrier_index) + random_offsets[barrier_index] * random_slack ) - var health: int = FishQualityType.barrier_health_for_catch( - _fish_quality, - _fish_rarity, - _fish_weight_percentile, - _rng.randf_range(0.9, 1.1), + var health: int = FishQualityType.roll_barrier_health( + _rng, _fish_quality ) _barriers.append(Barrier.new(position, health)) diff --git a/fishing/catch_difficulty_profile.gd b/fishing/catch_difficulty_profile.gd index bd2508c..60aa190 100644 --- a/fishing/catch_difficulty_profile.gd +++ b/fishing/catch_difficulty_profile.gd @@ -3,8 +3,6 @@ extends Resource @export_range(0, 12, 1) var barrier_count_min: int = 2 @export_range(0, 12, 1) var barrier_count_max: int = 4 -@export_range(1, 100, 1) var barrier_health_min: int = 3 -@export_range(1, 100, 1) var barrier_health_max: int = 7 @export_range(0.0, 0.45, 0.01) var first_barrier_margin: float = 0.15 @export_range(0.0, 0.45, 0.01) var final_barrier_margin: float = 0.10 @export_range(0.01, 1.0, 0.01) var minimum_barrier_spacing: float = 0.15 @@ -15,11 +13,6 @@ func get_barrier_count_range() -> Vector2i: return Vector2i(minimum, maxi(minimum, barrier_count_max)) -func get_barrier_health_range() -> Vector2i: - var minimum: int = maxi(1, barrier_health_min) - return Vector2i(minimum, maxi(minimum, barrier_health_max)) - - func get_generation_interval() -> Vector2: var start: float = clampf(first_barrier_margin, 0.0, 0.95) var finish: float = clampf(1.0 - final_barrier_margin, start, 1.0) diff --git a/fishing/common_catch_profile.tres b/fishing/common_catch_profile.tres index 4af3284..86c2b74 100644 --- a/fishing/common_catch_profile.tres +++ b/fishing/common_catch_profile.tres @@ -6,8 +6,6 @@ script = ExtResource("1_profile") barrier_count_min = 2 barrier_count_max = 4 -barrier_health_min = 3 -barrier_health_max = 7 first_barrier_margin = 0.15 final_barrier_margin = 0.1 minimum_barrier_spacing = 0.15 diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index e0db860..243a069 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -1045,8 +1045,6 @@ func _activate_bite(confirmation_override: bool = false) -> void: _get_effective_reel_speed(), _get_effective_barrier_damage(), _pending_catch.quality, - int(_pending_catch.fish.rarity), - _pending_catch.fish.get_weight_percentile(_pending_catch.weight_lb), ) _catch_controller.set_reel_input( Input.is_action_pressed("fish_primary") diff --git a/network/network_fishing_service.gd b/network/network_fishing_service.gd index 7303f12..a5a2154 100644 --- a/network/network_fishing_service.gd +++ b/network/network_fishing_service.gd @@ -548,8 +548,6 @@ func _start_bite(attempt: NetworkFishingAttempt) -> void: attempt.barrier_damage, attempt.encounter_seed, fish_catch.quality, - int(fish.rarity), - fish.get_weight_percentile(fish_catch.weight_lb), ) var data: Dictionary = { "attempt_id": attempt.attempt_id, diff --git a/tests/fish_quality_validation.gd b/tests/fish_quality_validation.gd index 366128f..72ebecc 100644 --- a/tests/fish_quality_validation.gd +++ b/tests/fish_quality_validation.gd @@ -83,40 +83,68 @@ func _validate_barrier_challenge_curve() -> void: var expected_maximums: Array[int] = [9, 50, 100, 200, 400] var previous_health: int = 0 for quality: int in FishQualityType.TIER_COUNT: + var expected_range := Vector2i( + expected_minimums[quality], expected_maximums[quality] + ) + assert( + FishQualityType.barrier_health_range(quality) + == expected_range + ) + assert( + FishQualityType.roll_barrier_health(null, quality) + == expected_range.x + ) + var rng := RandomNumberGenerator.new() + rng.seed = 73013 + quality + var observed_counts: Dictionary[int, int] = {} + var observed_minimum: int = expected_range.y + var observed_maximum: int = expected_range.x + var observed_total: int = 0 + var band_size: int = expected_range.y - expected_range.x + 1 + var sample_count: int = maxi(10000, band_size * 250) + for _sample: int in sample_count: + var rolled_health: int = FishQualityType.roll_barrier_health( + rng, quality + ) + assert( + rolled_health >= expected_range.x + and rolled_health <= expected_range.y + ) + observed_counts[rolled_health] = ( + int(observed_counts.get(rolled_health, 0)) + 1 + ) + observed_minimum = mini(observed_minimum, rolled_health) + observed_maximum = maxi(observed_maximum, rolled_health) + observed_total += rolled_health + assert(observed_minimum == expected_range.x) + assert(observed_maximum == expected_range.y) + for authored_health: int in range( + expected_range.x, expected_range.y + 1 + ): + assert(observed_counts.has(authored_health)) + var observed_average: float = ( + float(observed_total) / float(sample_count) + ) + var authored_midpoint: float = ( + float(expected_range.x + expected_range.y) / 2.0 + ) + assert( + absf(observed_average - authored_midpoint) + < maxf(0.15, float(band_size) * 0.01) + ) var health: int = FishQualityType.apply_barrier_health(8, quality) assert(health > previous_health) previous_health = health assert(FishQualityType.apply_barrier_health(8, -1) == 8) assert(FishQualityType.apply_barrier_health(0, FishQualityType.Tier.SHINY) == 4) - var impressive_low_input: int = FishQualityType.barrier_health_for_catch( - FishQualityType.Tier.IMPRESSIVE, - 0, - 0.0, - 1.0, - ) - var impressive_high_input: int = FishQualityType.barrier_health_for_catch( - FishQualityType.Tier.IMPRESSIVE, - 4, - 1.0, - 1.0, - ) - assert(impressive_low_input >= 50 and impressive_low_input <= 100) - assert(impressive_high_input > impressive_low_input) - assert(impressive_high_input <= 100) assert( - FishQualityType.barrier_health_for_catch( - FishQualityType.Tier.BORING, - 4, - 1.0, - 1.0, - ) <= 9 + FishQualityType.barrier_health_range(-1) + == Vector2i(1, 9) ) var profile := CatchDifficultyProfile.new() profile.barrier_count_min = 1 profile.barrier_count_max = 1 - profile.barrier_health_min = 8 - profile.barrier_health_max = 8 profile.first_barrier_margin = 0.2 profile.final_barrier_margin = 0.2 profile.minimum_barrier_spacing = 0.1 @@ -141,12 +169,9 @@ func _validate_barrier_challenge_curve() -> void: assert(health <= expected_maximums[quality]) controller.queue_free() - var shiny_health: int = FishQualityType.barrier_health_for_catch( - FishQualityType.Tier.SHINY, - 4, - 1.0, - 1.0, - ) + var shiny_health: int = FishQualityType.barrier_health_range( + FishQualityType.Tier.SHINY + ).y var base_power_clicks: int = ceili(float(shiny_health) / 1.0) var max_power_clicks: int = ceili( float(shiny_health) diff --git a/tests/fishing_authority_validation.gd b/tests/fishing_authority_validation.gd index 52ab878..20f02e1 100644 --- a/tests/fishing_authority_validation.gd +++ b/tests/fishing_authority_validation.gd @@ -104,7 +104,7 @@ func _run() -> void: assert(not bobber.visible) # A private host rolls and retains the authoritative catch before the fight - # so its quality can scale the same barriers clients receive in snapshots. + # so its quality selects the barrier band clients receive in snapshots. fishing_spot.call("_begin_aiming", player) fishing_spot.set("_cast_charge", 0.32) fishing_spot.call("_update_cast_charge", 0.0) @@ -138,8 +138,6 @@ func _run() -> void: attempt.barrier_damage, attempt.encounter_seed, fish_catch.quality, - int(fish.rarity), - fish.get_weight_percentile(fish_catch.weight_lb), ) var quality_barriers: Array = attempt.controller.get("_barriers") var reference_barriers: Array = reference_controller.get("_barriers")