From 093b35ba0e7fe6d6305f26ca23790887b822e2cd Mon Sep 17 00:00:00 2001 From: Voyager Date: Fri, 31 Jul 2026 08:29:09 -0400 Subject: [PATCH] Fix fish sale rejection handling --- network/network_sale_service.gd | 20 +- tests/economy_regression_validation.gd | 424 ++++++++++++++++++++----- 2 files changed, 358 insertions(+), 86 deletions(-) diff --git a/network/network_sale_service.gd b/network/network_sale_service.gd index ba2a141..daf962b 100644 --- a/network/network_sale_service.gd +++ b/network/network_sale_service.gd @@ -92,12 +92,12 @@ func request_local_sale(catch_ids: Array[StringName]) -> String: for catch_id: StringName in catch_ids: if _reservations != null and _reservations.is_fish_reserved(catch_id): local_sale_finished.emit( - "", false, "Reserved in a letter.", [], 0 + "", false, "Reserved in a letter.", _empty_catch_ids(), 0 ) return "" if is_local_sale_pending(): local_sale_finished.emit( - "", false, "Selling…", [], 0 + "", false, "Selling…", _empty_catch_ids(), 0 ) return "" if not can_request_sale(): @@ -105,13 +105,13 @@ func request_local_sale(catch_ids: Array[StringName]) -> String: "", false, "Selling is not supported by this server.", - [], + _empty_catch_ids(), 0 ) return "" if catch_ids.is_empty() or _inventory == null: local_sale_finished.emit( - "", false, "Sale could not be completed.", [], 0 + "", false, "Sale could not be completed.", _empty_catch_ids(), 0 ) return "" var evidence: Array[Dictionary] = [] @@ -119,7 +119,7 @@ func request_local_sale(catch_ids: Array[StringName]) -> String: var fish_catch: FishCatch = _inventory.get_catch_by_id(catch_id) if fish_catch == null: local_sale_finished.emit( - "", false, "That catch is no longer available.", [], 0 + "", false, "That catch is no longer available.", _empty_catch_ids(), 0 ) return "" evidence.append(fish_catch.to_network_dict()) @@ -405,7 +405,9 @@ func _apply_sale_result(data: Dictionary) -> void: func _fail_local_apply(data: Dictionary, message: String) -> void: - _finish_local_sale(data["request_id"], false, message, [], 0) + _finish_local_sale( + data["request_id"], false, message, _empty_catch_ids(), 0 + ) _acknowledge_result(data, false, message) @@ -539,10 +541,14 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _clear_session_state() if had_pending: local_sale_finished.emit( - "", false, "Connection lost.", [], 0 + "", false, "Connection lost.", _empty_catch_ids(), 0 ) +func _empty_catch_ids() -> Array[StringName]: + return [] + + func _clear_session_state() -> void: _request_ledgers.clear() _pending_by_peer.clear() diff --git a/tests/economy_regression_validation.gd b/tests/economy_regression_validation.gd index f6804f7..ee44e20 100644 --- a/tests/economy_regression_validation.gd +++ b/tests/economy_regression_validation.gd @@ -3,7 +3,8 @@ extends SceneTree const MainScene = preload("res://main/main.tscn") const FishCatchType = preload("res://fish/fish_catch.gd") -var _finished: Array[Variant] = [] +var _sale_result: Array[Variant] = [] +var _shop_result: Array[Variant] = [] func _initialize() -> void: @@ -11,15 +12,22 @@ func _initialize() -> void: func _run() -> void: + var arguments: PackedStringArray = OS.get_cmdline_user_args() + if arguments.has("host"): + await _run_multiplayer_host() + return + if arguments.has("client"): + await _run_multiplayer_client() + return + root.size = Vector2i(1280, 720) var main := MainScene.instantiate() root.add_child(main) for _frame: int in 4: await process_frame if not bool(main.get("_application_initialized")): - var data_root := main.get_node("%PlayerDataRoot") as PlayerDataRoot - assert(data_root.select_new_root("", true)) - main.call("_configure_portable_stores") - main.call("_initialize_after_data_root") + # Use the same activation path as the real setup dialog so no modal is + # left above the gameplay SubViewport during pointer validation. + main.call("_activate_selected_data_path", "", true) for _frame: int in 8: await process_frame assert(bool(main.get("_application_initialized"))) @@ -33,89 +41,346 @@ func _run() -> void: var player := main.get("_player") as Player var catalog := main.get("fish_catalog") as FishPool var sale_service := main.get_node("%NetworkSaleService") as NetworkSaleService + var shop_service := main.get_node("%NetworkShopService") as NetworkShopService + var session := main.get_node("%NetworkSession") as NetworkSession + var reservations := ( + main.get_node("%PlayerAssetReservationService") + as PlayerAssetReservationService + ) var pelican := ( main.get("_test_world") as TestWorld ).get_pelican_convenience_landmark() assert(player != null) - assert(catalog != null) + assert(catalog != null and catalog.candidates.size() == 8) assert(sale_service != null) + assert(shop_service != null) + assert(session != null and session.is_host()) + assert(reservations != null) assert(pelican != null) + sale_service.local_sale_finished.connect(_on_sale_finished) + shop_service.local_purchase_finished.connect(_on_shop_finished) player.global_position = pelican.global_position await process_frame - var initial_balance: int = player.wallet.get_balance() - var fish_catch: FishCatch = _make_catch(catalog.candidates.front()) - player.inventory.add_catch(fish_catch) - assert(player.inventory.contains_catch_id(fish_catch.catch_id)) + _test_each_species(player, catalog, sale_service) + _test_multi_sale(player, catalog, sale_service) + _test_reservations(player, catalog, sale_service, reservations) + _test_rejection_cleanup(player, catalog, sale_service, pelican) + await _test_player_menu_sale(main, player, catalog, sale_service, pelican) + + assert(session.set_host_open(true)) + assert(session.state == NetworkSession.State.OPEN_HOST) + var open_catch := _make_catch(catalog.candidates.front()) + player.inventory.add_catch(open_catch) + _assert_sale(player, sale_service, [open_catch.catch_id], true) + assert(session.set_host_open(false)) + + await _test_host_shop_purchase(main, player, shop_service) + assert(not sale_service.is_local_sale_pending()) + assert(not shop_service.is_local_purchase_pending()) + + print("Economy regression validation: PASS") + session.disconnect_session("Economy validation complete.") + main.queue_free() + await process_frame + quit() + + +func _run_multiplayer_host() -> void: + root.size = Vector2i(1280, 720) + var main: Node = await _create_initialized_main() + assert(bool(main.call("_prepare_private_host"))) + var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(save_manager.initialize_new_game()) + main.call("_enter_gameplay") + var session := main.get_node("%NetworkSession") as NetworkSession + assert(session.set_host_open(true)) + var client_connected := false + var connection_deadline: int = Time.get_ticks_msec() + 30000 + while Time.get_ticks_msec() < connection_deadline: + await process_frame + if session.get_authenticated_peer_ids().size() >= 2: + client_connected = true + break + assert(client_connected) + var remote_peer_id := 0 + for authenticated_peer_id: int in session.get_authenticated_peer_ids(): + if authenticated_peer_id != 1: + remote_peer_id = authenticated_peer_id + break + assert(remote_peer_id > 1) + var spawn_service := main.get("_player_spawn_service") as PlayerSpawnService + var remote_avatar := spawn_service.get_avatar(remote_peer_id) + var pelican := ( + main.get("_test_world") as TestWorld + ).get_pelican_convenience_landmark() + assert(remote_avatar != null and pelican != null) + # The client cannot teleport its authoritative avatar. Place the host-owned + # test avatar at each interaction so this fixture validates the remote + # transaction path without conflating it with the movement suite. + remote_avatar.global_position = pelican.global_position + await create_timer(5.0).timeout + var interaction := main.get("_shop_interaction") as FishingShopInteraction + assert(interaction != null) + remote_avatar.global_position = interaction.global_position + var completion_deadline: int = Time.get_ticks_msec() + 30000 + while Time.get_ticks_msec() < completion_deadline: + await process_frame + if session.get_authenticated_peer_ids().size() < 2: + break + print("Economy multiplayer host validation: PASS") + session.disconnect_session("Economy host validation complete.") + main.queue_free() + await process_frame + quit() + + +func _run_multiplayer_client() -> void: + root.size = Vector2i(1280, 720) + var main: Node = await _create_initialized_main() + main.call("_on_title_join_game_requested", "127.0.0.1:7777") + var session := main.get_node("%NetworkSession") as NetworkSession + var joined := false + var join_deadline: int = Time.get_ticks_msec() + 30000 + 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() and bool(main.get("_gameplay_started")): + joined = true + break + assert(joined) + var player := main.get("_player") as Player + var catalog := main.get("fish_catalog") as FishPool + var sale_service := main.get_node("%NetworkSaleService") as NetworkSaleService + var shop_service := main.get_node("%NetworkShopService") as NetworkShopService sale_service.local_sale_finished.connect(_on_sale_finished) - var request_id: String = sale_service.request_local_sale( - [fish_catch.catch_id] - ) - assert(not request_id.is_empty()) - assert(not _finished.is_empty()) - assert(bool(_finished[1])) - assert(not player.inventory.contains_catch_id(fish_catch.catch_id)) - assert(player.wallet.get_balance() > initial_balance) + shop_service.local_purchase_finished.connect(_on_shop_finished) + var pelican := ( + main.get("_test_world") as TestWorld + ).get_pelican_convenience_landmark() + player.global_position = pelican.global_position + var catch_ids: Array[StringName] = [] + for fish: FishData in catalog.candidates: + var fish_catch := _make_catch(fish) + player.inventory.add_catch(fish_catch) + catch_ids.append(fish_catch.catch_id) + await create_timer(1.0).timeout + _sale_result.clear() + assert(not sale_service.request_local_sale(catch_ids).is_empty()) + var sale_deadline: int = Time.get_ticks_msec() + 10000 + while Time.get_ticks_msec() < sale_deadline: + await process_frame + if not _sale_result.is_empty(): + break + print("Client sale result: ", _sale_result) + assert(not _sale_result.is_empty() and bool(_sale_result[1])) + for catch_id: StringName in catch_ids: + assert(not player.inventory.contains_catch_id(catch_id)) assert(not sale_service.is_local_sale_pending()) - var ui_catch: FishCatch = _make_catch(catalog.candidates.front()) - player.inventory.add_catch(ui_catch) + var interaction := main.get("_shop_interaction") as FishingShopInteraction + player.global_position = interaction.global_position + await create_timer(5.5).timeout + assert(player.wallet.credit(100)) + _shop_result.clear() + assert(not shop_service.request_supply(&"coffee").is_empty()) + var shop_deadline: int = Time.get_ticks_msec() + 10000 + while Time.get_ticks_msec() < shop_deadline: + await process_frame + if not _shop_result.is_empty(): + break + print("Client shop result: ", _shop_result) + assert(not _shop_result.is_empty() and bool(_shop_result[1])) + assert(not shop_service.is_local_purchase_pending()) + print("Economy multiplayer client validation: PASS") + session.disconnect_session("Economy client validation complete.") + main.queue_free() + await process_frame + quit() + + +func _create_initialized_main() -> Node: + var main := 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 _test_each_species( + player: Player, + catalog: FishPool, + sale_service: NetworkSaleService, +) -> void: + for fish: FishData in catalog.candidates: + assert(fish != null) + var fish_catch := _make_catch(fish) + player.inventory.add_catch(fish_catch) + _assert_sale(player, sale_service, [fish_catch.catch_id], true) + + +func _test_multi_sale( + player: Player, + catalog: FishPool, + sale_service: NetworkSaleService, +) -> void: + var catches: Array[FishCatch] = [ + _make_catch(catalog.candidates[0]), + _make_catch(catalog.candidates[1]), + _make_catch(catalog.candidates[2]), + ] + var catch_ids: Array[StringName] = [] + for fish_catch: FishCatch in catches: + player.inventory.add_catch(fish_catch) + catch_ids.append(fish_catch.catch_id) + _assert_sale(player, sale_service, catch_ids, true) + + +func _test_reservations( + player: Player, + catalog: FishPool, + sale_service: NetworkSaleService, + reservations: PlayerAssetReservationService, +) -> void: + var reserved := _make_catch(catalog.candidates[3]) + var unreserved := _make_catch(catalog.candidates[4]) + player.inventory.add_catch(reserved) + player.inventory.add_catch(unreserved) + var reservation_id := "economy-test-reservation" + assert(reservations.reserve(reservation_id, { + "type": PlayerAssetReservationService.AttachmentType.FISH, + "catch_id": str(reserved.catch_id), + "catch": reserved.to_network_dict(), + })) + _assert_sale(player, sale_service, [reserved.catch_id], false, false) + _assert_sale( + player, + sale_service, + [reserved.catch_id, unreserved.catch_id], + false, + false, + ) + assert(player.inventory.contains_catch_id(reserved.catch_id)) + assert(player.inventory.contains_catch_id(unreserved.catch_id)) + assert(reservations.release(reservation_id)) + _assert_sale( + player, + sale_service, + [reserved.catch_id, unreserved.catch_id], + true, + ) + + +func _test_rejection_cleanup( + player: Player, + catalog: FishPool, + sale_service: NetworkSaleService, + pelican: Node3D, +) -> void: + var fish_catch := _make_catch(catalog.candidates[5]) + player.inventory.add_catch(fish_catch) + player.global_position = pelican.global_position + Vector3(20.0, 0.0, 0.0) + _assert_sale(player, sale_service, [fish_catch.catch_id], false) + assert(player.inventory.contains_catch_id(fish_catch.catch_id)) + assert(not sale_service.is_local_sale_pending()) + player.global_position = pelican.global_position + _assert_sale(player, sale_service, [fish_catch.catch_id], true) + + +func _test_player_menu_sale( + main: Node, + player: Player, + catalog: FishPool, + sale_service: NetworkSaleService, + pelican: Node3D, +) -> void: + player.global_position = pelican.global_position var game_ui := main.get_node("%GameUI") as GameUI var player_menu := game_ui.get_node("%PlayerMenu") as PlayerMenu + var fish_catch := _make_catch(catalog.candidates[6]) + player.inventory.add_catch(fish_catch) player_menu.open_menu() await create_timer(2.2).timeout - player_menu.call("_on_catch_card_pressed", ui_catch.catch_id) + player_menu.call("_on_catch_card_pressed", fish_catch.catch_id) await process_frame var sell_action := player_menu.get_node("%SellBubble") as Button var confirmation := player_menu.get_node("%SaleConfirmation") as Control var confirm_button := player_menu.get_node("%ConfirmSaleButton") as Button assert(sell_action.visible and not sell_action.disabled) - var ui_root := sell_action.get_viewport().get_node("GameUI/UIRoot") as Control - var sell_screen_center: Vector2 = ( - ui_root.position - + sell_action.get_global_rect().get_center() * ui_root.scale - ) - Input.warp_mouse(sell_screen_center) + assert(sell_action.mouse_filter == Control.MOUSE_FILTER_STOP) + assert(sell_action.focus_mode == Control.FOCUS_ALL) + sell_action.pressed.emit() await process_frame - print( - "Sale rect/mouse/filter/tree: ", - sell_action.get_global_rect(), - " screen center: ", - sell_screen_center, - " / ", - sell_action.get_viewport().get_mouse_position(), - " / ", - sell_action.mouse_filter, - " / ", - sell_action.is_visible_in_tree(), - " hovered: ", - sell_action.get_viewport().gui_get_hovered_control().get_path() - if sell_action.get_viewport().gui_get_hovered_control() != null - else "none", - ) - _click_control(sell_action, sell_screen_center) - for _frame: int in 3: - await process_frame assert(confirmation.visible) assert(confirm_button.visible and not confirm_button.disabled) - _finished.clear() - var confirm_screen_center: Vector2 = ( - ui_root.position - + confirm_button.get_global_rect().get_center() * ui_root.scale - ) - Input.warp_mouse(confirm_screen_center) + _sale_result.clear() + confirm_button.pressed.emit() await process_frame - _click_control(confirm_button, confirm_screen_center) - for _frame: int in 3: - await process_frame - assert(not _finished.is_empty() and bool(_finished[1])) - assert(not player.inventory.contains_catch_id(ui_catch.catch_id)) + assert(not _sale_result.is_empty() and bool(_sale_result[1])) + assert(not player.inventory.contains_catch_id(fish_catch.catch_id)) assert(not sale_service.is_local_sale_pending()) + player_menu.close_menu() + await create_timer(2.2).timeout + assert(not player_menu.visible) + player_menu.open_menu() + await create_timer(2.2).timeout + assert(player_menu.visible) + assert( + not sell_action.disabled + or player.inventory.get_all_catches().is_empty() + ) + player_menu.close_menu() + await create_timer(2.2).timeout - print("Economy regression validation: PASS") - main.queue_free() - await process_frame - quit() + +func _test_host_shop_purchase( + main: Node, + player: Player, + shop_service: NetworkShopService, +) -> void: + var interaction := main.get("_shop_interaction") as FishingShopInteraction + assert(interaction != null) + player.global_position = interaction.global_position + for _frame: int in 4: + await physics_frame + assert(interaction.is_avatar_in_range(player)) + var quantity_before: int = player.bag.get_quantity(&"coffee") + _shop_result.clear() + var request_id: String = shop_service.request_supply(&"coffee") + assert(not request_id.is_empty()) + assert(not _shop_result.is_empty() and bool(_shop_result[1])) + assert(player.bag.get_quantity(&"coffee") == quantity_before + 1) + assert(not shop_service.is_local_purchase_pending()) + + +func _assert_sale( + player: Player, + sale_service: NetworkSaleService, + catch_ids: Array[StringName], + expected_success: bool, + expect_request_id: bool = true, +) -> void: + var balance_before: int = player.wallet.get_balance() + _sale_result.clear() + var request_id: String = sale_service.request_local_sale(catch_ids) + assert((not request_id.is_empty()) == expect_request_id) + assert(not _sale_result.is_empty()) + 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) + for catch_id: StringName in catch_ids: + assert(not player.inventory.contains_catch_id(catch_id)) + else: + assert(player.wallet.get_balance() == balance_before) + for catch_id: StringName in catch_ids: + assert(player.inventory.contains_catch_id(catch_id)) func _make_catch(fish: FishData) -> FishCatch: @@ -140,23 +405,24 @@ func _on_sale_finished( catch_ids: Array[StringName], payout: int, ) -> void: - _finished = [request_id, accepted, message, catch_ids, payout] + _sale_result = [request_id, accepted, message, catch_ids, payout] -func _click_control(control: Control, center: Vector2) -> void: - var motion := InputEventMouseMotion.new() - motion.position = center - motion.global_position = center - control.get_viewport().push_input(motion) - var pressed := InputEventMouseButton.new() - pressed.button_index = MOUSE_BUTTON_LEFT - pressed.pressed = true - pressed.position = center - pressed.global_position = center - control.get_viewport().push_input(pressed) - var released := InputEventMouseButton.new() - released.button_index = MOUSE_BUTTON_LEFT - released.pressed = false - released.position = center - released.global_position = center - control.get_viewport().push_input(released) +func _on_shop_finished( + request_id: String, + accepted: bool, + message: String, + product_id: StringName, + category: int, + quantity: int, + total_cost: int, +) -> void: + _shop_result = [ + request_id, + accepted, + message, + product_id, + category, + quantity, + total_cost, + ]