NEO-132: queue economy sync while clients are busy

Gather/craft start inventory and skill GETs in parallel with quest-progress;
post-completion _refresh_economy_hud was dropped when those clients were _busy.
Match quest_progress_client pending-sync flush so a follow-up GET runs after
the in-flight request completes.
pull/171/head
VinPropane 2026-06-14 14:19:53 -04:00
parent acf3d079d2
commit e2991a2926
5 changed files with 189 additions and 0 deletions

View File

@ -13,6 +13,7 @@ const SCHEMA_VERSION := 1
var _http: Node var _http: Node
var _busy: bool = false var _busy: bool = false
var _sync_pending: bool = false
func _ready() -> void: func _ready() -> void:
@ -29,7 +30,12 @@ func _ready() -> void:
func request_sync_from_server() -> void: func request_sync_from_server() -> void:
if _busy: if _busy:
_sync_pending = true
return return
_start_sync_request()
func _start_sync_request() -> void:
_busy = true _busy = true
var url := "%s/game/players/%s/inventory" % [_base_root(), _player_path_segment()] var url := "%s/game/players/%s/inventory" % [_base_root(), _player_path_segment()]
var err: Error = _http.request(url) var err: Error = _http.request(url)
@ -38,6 +44,14 @@ func request_sync_from_server() -> void:
push_warning("InventoryClient: %s" % reason) push_warning("InventoryClient: %s" % reason)
_busy = false _busy = false
inventory_sync_failed.emit(reason) 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: func _base_root() -> String:
@ -66,16 +80,19 @@ func _on_request_completed(
var reason := "HTTP failed (result=%s)" % result var reason := "HTTP failed (result=%s)" % result
push_warning("InventoryClient: %s" % reason) push_warning("InventoryClient: %s" % reason)
inventory_sync_failed.emit(reason) inventory_sync_failed.emit(reason)
_try_flush_pending_sync()
return return
if response_code == 404: if response_code == 404:
var reason404 := "HTTP 404 (player unknown)" var reason404 := "HTTP 404 (player unknown)"
push_warning("InventoryClient: %s" % reason404) push_warning("InventoryClient: %s" % reason404)
inventory_sync_failed.emit(reason404) inventory_sync_failed.emit(reason404)
_try_flush_pending_sync()
return return
if response_code < 200 or response_code >= 300: if response_code < 200 or response_code >= 300:
var reason_code := "HTTP %s" % response_code var reason_code := "HTTP %s" % response_code
push_warning("InventoryClient: %s" % reason_code) push_warning("InventoryClient: %s" % reason_code)
inventory_sync_failed.emit(reason_code) inventory_sync_failed.emit(reason_code)
_try_flush_pending_sync()
return return
var text := body.get_string_from_utf8() var text := body.get_string_from_utf8()
var snapshot: Variant = parse_inventory_json(text) var snapshot: Variant = parse_inventory_json(text)
@ -83,5 +100,7 @@ func _on_request_completed(
var reason_json := "non-JSON body or schemaVersion mismatch" var reason_json := "non-JSON body or schemaVersion mismatch"
push_warning("InventoryClient: %s" % reason_json) push_warning("InventoryClient: %s" % reason_json)
inventory_sync_failed.emit(reason_json) inventory_sync_failed.emit(reason_json)
_try_flush_pending_sync()
return return
inventory_received.emit(snapshot as Dictionary) inventory_received.emit(snapshot as Dictionary)
_try_flush_pending_sync()

View File

@ -13,6 +13,7 @@ const SCHEMA_VERSION := 1
var _http: Node var _http: Node
var _busy: bool = false var _busy: bool = false
var _sync_pending: bool = false
func _ready() -> void: func _ready() -> void:
@ -29,7 +30,12 @@ func _ready() -> void:
func request_sync_from_server() -> void: func request_sync_from_server() -> void:
if _busy: if _busy:
_sync_pending = true
return return
_start_sync_request()
func _start_sync_request() -> void:
_busy = true _busy = true
var url := "%s/game/players/%s/skill-progression" % [_base_root(), _player_path_segment()] var url := "%s/game/players/%s/skill-progression" % [_base_root(), _player_path_segment()]
var err: Error = _http.request(url) var err: Error = _http.request(url)
@ -38,6 +44,14 @@ func request_sync_from_server() -> void:
push_warning("SkillProgressionClient: %s" % reason) push_warning("SkillProgressionClient: %s" % reason)
_busy = false _busy = false
progression_sync_failed.emit(reason) 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: func skill_row(skill_id: String, snapshot: Dictionary = {}) -> Dictionary:
@ -82,16 +96,19 @@ func _on_request_completed(
var reason := "HTTP failed (result=%s)" % result var reason := "HTTP failed (result=%s)" % result
push_warning("SkillProgressionClient: %s" % reason) push_warning("SkillProgressionClient: %s" % reason)
progression_sync_failed.emit(reason) progression_sync_failed.emit(reason)
_try_flush_pending_sync()
return return
if response_code == 404: if response_code == 404:
var reason404 := "HTTP 404 (player unknown)" var reason404 := "HTTP 404 (player unknown)"
push_warning("SkillProgressionClient: %s" % reason404) push_warning("SkillProgressionClient: %s" % reason404)
progression_sync_failed.emit(reason404) progression_sync_failed.emit(reason404)
_try_flush_pending_sync()
return return
if response_code < 200 or response_code >= 300: if response_code < 200 or response_code >= 300:
var reason_code := "HTTP %s" % response_code var reason_code := "HTTP %s" % response_code
push_warning("SkillProgressionClient: %s" % reason_code) push_warning("SkillProgressionClient: %s" % reason_code)
progression_sync_failed.emit(reason_code) progression_sync_failed.emit(reason_code)
_try_flush_pending_sync()
return return
var text := body.get_string_from_utf8() var text := body.get_string_from_utf8()
var snapshot: Variant = parse_progression_json(text) var snapshot: Variant = parse_progression_json(text)
@ -99,5 +116,7 @@ func _on_request_completed(
var reason_json := "non-JSON body or schemaVersion mismatch" var reason_json := "non-JSON body or schemaVersion mismatch"
push_warning("SkillProgressionClient: %s" % reason_json) push_warning("SkillProgressionClient: %s" % reason_json)
progression_sync_failed.emit(reason_json) progression_sync_failed.emit(reason_json)
_try_flush_pending_sync()
return return
progression_received.emit(snapshot as Dictionary) progression_received.emit(snapshot as Dictionary)
_try_flush_pending_sync()

View File

@ -36,6 +36,33 @@ class MockHttpTransport:
return OK 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: static func _empty_inventory_json() -> String:
var bag_parts: PackedStringArray = PackedStringArray() var bag_parts: PackedStringArray = PackedStringArray()
for i in range(24): for i in range(24):
@ -111,3 +138,17 @@ func test_malformed_json_emits_inventory_sync_failed() -> void:
c.call("request_sync_from_server") c.call("request_sync_from_server")
# Assert # Assert
await assert_signal(c).is_emitted("inventory_sync_failed", any()) 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)

View File

@ -6,6 +6,8 @@ extends GdUnitTestSuite
const QuestHudController := preload("res://scripts/quest_hud_controller.gd") const QuestHudController := preload("res://scripts/quest_hud_controller.gd")
const QuestDefinitionsClient := preload("res://scripts/quest_definitions_client.gd") const QuestDefinitionsClient := preload("res://scripts/quest_definitions_client.gd")
const ItemDefsClient := preload("res://scripts/item_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" const OPERATOR_CHAIN_QUEST_ID := "prototype_quest_operator_chain"
@ -47,6 +49,29 @@ class MockEconomySyncClient:
sync_count += 1 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: func _make_defs_client(transport: Node) -> Node:
var c: Node = QuestDefinitionsClient.new() var c: Node = QuestDefinitionsClient.new()
c.set("injected_http", transport) 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( await (assert_signal(controller).wait_until(200).is_not_emitted(
"quest_completion_reward_transition" "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)

View File

@ -36,6 +36,33 @@ class MockHttpTransport:
return OK 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: static func _trio_progression_json() -> String:
return ( return (
'{"schemaVersion":1,"playerId":"dev-local-1","skills":[' '{"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") c.call("request_sync_from_server")
# Assert # Assert
await assert_signal(c).is_emitted("progression_sync_failed", "HTTP 404 (player unknown)") 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)