diff --git a/client/scripts/inventory_client.gd b/client/scripts/inventory_client.gd index 6dd5984..f707984 100644 --- a/client/scripts/inventory_client.gd +++ b/client/scripts/inventory_client.gd @@ -13,6 +13,7 @@ const SCHEMA_VERSION := 1 var _http: Node var _busy: bool = false +var _sync_pending: bool = false func _ready() -> void: @@ -29,7 +30,12 @@ func _ready() -> void: func request_sync_from_server() -> void: if _busy: + _sync_pending = true return + _start_sync_request() + + +func _start_sync_request() -> void: _busy = true var url := "%s/game/players/%s/inventory" % [_base_root(), _player_path_segment()] var err: Error = _http.request(url) @@ -38,6 +44,14 @@ func request_sync_from_server() -> void: push_warning("InventoryClient: %s" % reason) _busy = false inventory_sync_failed.emit(reason) + _try_flush_pending_sync() + + +func _try_flush_pending_sync() -> void: + if not _sync_pending or _busy: + return + _sync_pending = false + _start_sync_request() func _base_root() -> String: @@ -66,16 +80,19 @@ func _on_request_completed( var reason := "HTTP failed (result=%s)" % result push_warning("InventoryClient: %s" % reason) inventory_sync_failed.emit(reason) + _try_flush_pending_sync() return if response_code == 404: var reason404 := "HTTP 404 (player unknown)" push_warning("InventoryClient: %s" % reason404) inventory_sync_failed.emit(reason404) + _try_flush_pending_sync() return if response_code < 200 or response_code >= 300: var reason_code := "HTTP %s" % response_code push_warning("InventoryClient: %s" % reason_code) inventory_sync_failed.emit(reason_code) + _try_flush_pending_sync() return var text := body.get_string_from_utf8() var snapshot: Variant = parse_inventory_json(text) @@ -83,5 +100,7 @@ func _on_request_completed( var reason_json := "non-JSON body or schemaVersion mismatch" push_warning("InventoryClient: %s" % reason_json) inventory_sync_failed.emit(reason_json) + _try_flush_pending_sync() return inventory_received.emit(snapshot as Dictionary) + _try_flush_pending_sync() diff --git a/client/scripts/skill_progression_client.gd b/client/scripts/skill_progression_client.gd index 70a9a5b..9bd7b49 100644 --- a/client/scripts/skill_progression_client.gd +++ b/client/scripts/skill_progression_client.gd @@ -13,6 +13,7 @@ const SCHEMA_VERSION := 1 var _http: Node var _busy: bool = false +var _sync_pending: bool = false func _ready() -> void: @@ -29,7 +30,12 @@ func _ready() -> void: func request_sync_from_server() -> void: if _busy: + _sync_pending = true return + _start_sync_request() + + +func _start_sync_request() -> void: _busy = true var url := "%s/game/players/%s/skill-progression" % [_base_root(), _player_path_segment()] var err: Error = _http.request(url) @@ -38,6 +44,14 @@ func request_sync_from_server() -> void: push_warning("SkillProgressionClient: %s" % reason) _busy = false progression_sync_failed.emit(reason) + _try_flush_pending_sync() + + +func _try_flush_pending_sync() -> void: + if not _sync_pending or _busy: + return + _sync_pending = false + _start_sync_request() func skill_row(skill_id: String, snapshot: Dictionary = {}) -> Dictionary: @@ -82,16 +96,19 @@ func _on_request_completed( var reason := "HTTP failed (result=%s)" % result push_warning("SkillProgressionClient: %s" % reason) progression_sync_failed.emit(reason) + _try_flush_pending_sync() return if response_code == 404: var reason404 := "HTTP 404 (player unknown)" push_warning("SkillProgressionClient: %s" % reason404) progression_sync_failed.emit(reason404) + _try_flush_pending_sync() return if response_code < 200 or response_code >= 300: var reason_code := "HTTP %s" % response_code push_warning("SkillProgressionClient: %s" % reason_code) progression_sync_failed.emit(reason_code) + _try_flush_pending_sync() return var text := body.get_string_from_utf8() var snapshot: Variant = parse_progression_json(text) @@ -99,5 +116,7 @@ func _on_request_completed( var reason_json := "non-JSON body or schemaVersion mismatch" push_warning("SkillProgressionClient: %s" % reason_json) progression_sync_failed.emit(reason_json) + _try_flush_pending_sync() return progression_received.emit(snapshot as Dictionary) + _try_flush_pending_sync() diff --git a/client/test/inventory_client_test.gd b/client/test/inventory_client_test.gd index ea39ebb..4d0259a 100644 --- a/client/test/inventory_client_test.gd +++ b/client/test/inventory_client_test.gd @@ -36,6 +36,33 @@ class MockHttpTransport: return OK +class PendingSyncHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var request_count: int = 0 + var response_code: int = 200 + var body_json: String = "" + + func request( + _url: String, + _custom_headers: PackedStringArray = PackedStringArray(), + _method: HTTPClient.Method = HTTPClient.METHOD_GET, + _request_data: String = "" + ) -> Error: + request_count += 1 + return OK + + func complete_pending() -> void: + request_completed.emit( + HTTPRequest.RESULT_SUCCESS, + response_code, + PackedStringArray(), + body_json.to_utf8_buffer() + ) + + static func _empty_inventory_json() -> String: var bag_parts: PackedStringArray = PackedStringArray() for i in range(24): @@ -111,3 +138,17 @@ func test_malformed_json_emits_inventory_sync_failed() -> void: c.call("request_sync_from_server") # Assert await assert_signal(c).is_emitted("inventory_sync_failed", any()) + + +func test_request_sync_queues_while_busy_and_flushes_after_completion() -> void: + # Arrange + var transport := PendingSyncHttpTransport.new() + transport.body_json = _empty_inventory_json() + var c := _make_client(transport) + # Act + c.call("request_sync_from_server") + c.call("request_sync_from_server") + # Assert + assert_that(transport.request_count).is_equal(1) + transport.complete_pending() + assert_that(transport.request_count).is_equal(2) diff --git a/client/test/quest_reward_hud_test.gd b/client/test/quest_reward_hud_test.gd index d134d7b..ad43efc 100644 --- a/client/test/quest_reward_hud_test.gd +++ b/client/test/quest_reward_hud_test.gd @@ -6,6 +6,8 @@ extends GdUnitTestSuite const QuestHudController := preload("res://scripts/quest_hud_controller.gd") const QuestDefinitionsClient := preload("res://scripts/quest_definitions_client.gd") const ItemDefsClient := preload("res://scripts/item_definitions_client.gd") +const InventoryClient := preload("res://scripts/inventory_client.gd") +const SkillProgressionClient := preload("res://scripts/skill_progression_client.gd") const OPERATOR_CHAIN_QUEST_ID := "prototype_quest_operator_chain" @@ -47,6 +49,29 @@ class MockEconomySyncClient: sync_count += 1 +class PendingSyncHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var request_count: int = 0 + var body_json: String = "" + + func request( + _url: String, + _custom_headers: PackedStringArray = PackedStringArray(), + _method: HTTPClient.Method = HTTPClient.METHOD_GET, + _request_data: String = "" + ) -> Error: + request_count += 1 + return OK + + func complete_pending() -> void: + request_completed.emit( + HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_json.to_utf8_buffer() + ) + + func _make_defs_client(transport: Node) -> Node: var c: Node = QuestDefinitionsClient.new() c.set("injected_http", transport) @@ -330,3 +355,47 @@ func test_boot_completed_does_not_emit_economy_refresh_signal() -> void: await (assert_signal(controller).wait_until(200).is_not_emitted( "quest_completion_reward_transition" )) + + +func test_completion_transition_queues_economy_refresh_while_clients_busy() -> void: + # Arrange + var inv_transport := PendingSyncHttpTransport.new() + var bag_parts: PackedStringArray = PackedStringArray() + for i in range(24): + bag_parts.append('{"slotIndex":%d,"quantity":0}' % i) + inv_transport.body_json = ( + '{"schemaVersion":1,"playerId":"dev-local-1","bagSlots":[%s],' + + '"equipmentSlots":[{"slotIndex":0,"quantity":0}]}' + ) % ", ".join(bag_parts) + var skill_transport := PendingSyncHttpTransport.new() + skill_transport.body_json = ( + '{"schemaVersion":1,"playerId":"dev-local-1","skills":' + + '[{"id":"salvage","xp":0,"level":1}]}' + ) + var inventory_client: Node = InventoryClient.new() + var skill_client: Node = SkillProgressionClient.new() + inventory_client.set("injected_http", inv_transport) + skill_client.set("injected_http", skill_transport) + var controller: Node = QuestHudController.new() + auto_free(inv_transport) + auto_free(skill_transport) + auto_free(inventory_client) + auto_free(skill_client) + auto_free(controller) + add_child(inventory_client) + add_child(skill_client) + add_child(controller) + controller.set("_inventory_client", inventory_client) + controller.set("_skill_progression_client", skill_client) + inventory_client.call("request_sync_from_server") + skill_client.call("request_sync_from_server") + # Act + controller.call("_on_progress_received", _active_gather_snapshot()) + controller.call("_on_progress_received", _completed_gather_with_summary_snapshot()) + # Assert + assert_int(inv_transport.request_count).is_equal(1) + assert_int(skill_transport.request_count).is_equal(1) + inv_transport.complete_pending() + skill_transport.complete_pending() + assert_int(inv_transport.request_count).is_equal(2) + assert_int(skill_transport.request_count).is_equal(2) diff --git a/client/test/skill_progression_client_test.gd b/client/test/skill_progression_client_test.gd index 334df4e..ad66cef 100644 --- a/client/test/skill_progression_client_test.gd +++ b/client/test/skill_progression_client_test.gd @@ -36,6 +36,33 @@ class MockHttpTransport: return OK +class PendingSyncHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var request_count: int = 0 + var response_code: int = 200 + var body_json: String = "" + + func request( + _url: String, + _custom_headers: PackedStringArray = PackedStringArray(), + _method: HTTPClient.Method = HTTPClient.METHOD_GET, + _request_data: String = "" + ) -> Error: + request_count += 1 + return OK + + func complete_pending() -> void: + request_completed.emit( + HTTPRequest.RESULT_SUCCESS, + response_code, + PackedStringArray(), + body_json.to_utf8_buffer() + ) + + static func _trio_progression_json() -> String: return ( '{"schemaVersion":1,"playerId":"dev-local-1","skills":[' @@ -95,3 +122,17 @@ func test_http_404_emits_progression_sync_failed() -> void: c.call("request_sync_from_server") # Assert await assert_signal(c).is_emitted("progression_sync_failed", "HTTP 404 (player unknown)") + + +func test_request_sync_queues_while_busy_and_flushes_after_completion() -> void: + # Arrange + var transport := PendingSyncHttpTransport.new() + transport.body_json = _trio_progression_json() + var c := _make_client(transport) + # Act + c.call("request_sync_from_server") + c.call("request_sync_from_server") + # Assert + assert_that(transport.request_count).is_equal(1) + transport.complete_pending() + assert_that(transport.request_count).is_equal(2)