NEO-73: clear orphaned gather pending on coalesced interact.

Track pending gather interactable id; drop flag when a different
interact completes (Bugbot PR #107). Add GdUnit coalescing test.
pull/107/head
VinPropane 2026-05-24 19:24:00 -04:00
parent 8de08a67a8
commit d0622701eb
3 changed files with 56 additions and 6 deletions

View File

@ -83,6 +83,7 @@ var _interactables_catalog: Array = []
var _last_progression_snapshot: Dictionary = {}
var _progression_error: String = ""
var _pending_gather_feedback: bool = false
var _pending_gather_interactable_id: String = ""
var _gather_pre_scrap_qty: int = 0
@onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D
@ -375,7 +376,7 @@ func _on_inventory_sync_failed(reason: String) -> void:
_last_inventory_snapshot = {}
_render_inventory_label()
if _pending_gather_feedback:
_pending_gather_feedback = false
_clear_pending_gather_feedback()
_render_gather_feedback_label("Gather: inventory refresh failed")
@ -527,13 +528,21 @@ func _render_gather_feedback_label(text: String = "Gather: —") -> void:
_gather_feedback_label.text = text
func _clear_pending_gather_feedback() -> void:
_pending_gather_feedback = false
_pending_gather_interactable_id = ""
func _on_interaction_result_for_gather(
interactable_id: String, allowed: bool, reason_code: String
) -> void:
if _pending_gather_feedback and interactable_id != _pending_gather_interactable_id:
_clear_pending_gather_feedback()
return
if not PrototypeInteractablePicker.is_resource_node_id(_interactables_catalog, interactable_id):
return
if not allowed:
_pending_gather_feedback = false
_clear_pending_gather_feedback()
var rc := reason_code.strip_edges()
if rc.is_empty():
_render_gather_feedback_label("Gather: denied (no reasonCode)")
@ -551,9 +560,10 @@ func _on_interaction_result_for_gather(
func _on_interaction_request_failed_for_gather(interactable_id: String, _reason: String) -> void:
if not _pending_gather_feedback:
return
if not PrototypeInteractablePicker.is_resource_node_id(_interactables_catalog, interactable_id):
if interactable_id != _pending_gather_interactable_id:
_clear_pending_gather_feedback()
return
_pending_gather_feedback = false
_clear_pending_gather_feedback()
func _total_bag_qty(item_id: String, snapshot: Dictionary = {}) -> int:
@ -577,9 +587,10 @@ func _total_bag_qty(item_id: String, snapshot: Dictionary = {}) -> int:
func _finalize_pending_gather_feedback() -> void:
if not _pending_gather_feedback:
return
_pending_gather_feedback = false
var pre_qty: int = _gather_pre_scrap_qty
_clear_pending_gather_feedback()
var new_qty: int = _total_bag_qty(SCRAP_METAL_BULK_ID)
var delta: int = new_qty - _gather_pre_scrap_qty
var delta: int = new_qty - pre_qty
if delta <= 0:
_render_gather_feedback_label("Gather: ok (no scrap delta)")
return
@ -826,6 +837,7 @@ func _forward_interact_resource_nearest() -> void:
_interactables_catalog, _player.global_position
)
_gather_pre_scrap_qty = _total_bag_qty(SCRAP_METAL_BULK_ID)
_pending_gather_interactable_id = node_id
_pending_gather_feedback = true
if _interaction_client.has_method("post_interact_id"):
_interaction_client.call("post_interact_id", node_id)

View File

@ -43,6 +43,8 @@ class GatherRefreshHarness:
extends Node
var inventory_sync_calls: int = 0
var skill_sync_calls: int = 0
var pending_gather: bool = false
var pending_gather_interactable_id: String = ""
var _inventory: SpySyncClient
var _skill: SpySyncClient
var _ix: Node
@ -57,19 +59,37 @@ class GatherRefreshHarness:
parent.add_child(_skill)
_ix.connect("interaction_result_received", Callable(self, "_on_interaction_result"))
func queue_resource_gather(interactable_id: String) -> void:
pending_gather_interactable_id = interactable_id
pending_gather = true
func _clear_pending_gather() -> void:
pending_gather = false
pending_gather_interactable_id = ""
func _on_interaction_result(
interactable_id: String, allowed: bool, _reason_code: String
) -> void:
if pending_gather and interactable_id != pending_gather_interactable_id:
_clear_pending_gather()
return
if not Picker.is_resource_node_id([], interactable_id):
return
if not allowed:
_clear_pending_gather()
return
_inventory.request_sync_from_server()
_skill.request_sync_from_server()
func post_resource_alpha() -> void:
queue_resource_gather("prototype_resource_node_alpha")
_ix.call("post_interact_id", "prototype_resource_node_alpha")
func emit_terminal_allowed() -> void:
_ix.set("_current_interactable_id", "prototype_terminal")
var body_bytes: PackedByteArray = '{"schemaVersion":1,"allowed":true}'.to_utf8_buffer()
_ix.call("_on_request_completed", 0, 200, PackedStringArray(), body_bytes)
func test_allowed_resource_interact_triggers_both_refreshes() -> void:
# Arrange
@ -102,3 +122,20 @@ func test_denied_resource_interact_skips_refreshes() -> void:
# Assert
assert_that(harness._inventory.sync_calls).is_equal(0)
assert_that(harness._skill.sync_calls).is_equal(0)
func test_terminal_result_clears_orphaned_gather_pending() -> void:
# Arrange
var transport := MockInteractTransport.new()
var harness := GatherRefreshHarness.new()
auto_free(transport)
auto_free(harness)
add_child(harness)
harness.setup(self, transport)
harness.queue_resource_gather("prototype_resource_node_alpha")
# Act
harness.emit_terminal_allowed()
# Assert
assert_that(harness.pending_gather).is_false()
assert_that(harness._inventory.sync_calls).is_equal(0)
assert_that(harness._skill.sync_calls).is_equal(0)

View File

@ -44,6 +44,7 @@ The branch adds **`skill_progression_client.gd`**, **`prototype_interactable_pic
- ~~Nit: **`Gather: ok — inventory refresh failed`** (`main.gd` **`_on_inventory_sync_failed`**) reads as success; consider **`Gather: inventory refresh failed`** for clarity.~~ **Done.**
- Nit: Rapid **R** while a gather POST is in flight overwrites **`_gather_pre_scrap_qty`**; delta can be wrong if two responses complete out of order. Prototype-acceptable; last-wins POST coalescing already exists on the interaction client.
- ~~**Bugbot:** Gather feedback flag orphaned when interaction coalesced (**E** overwrites pending **R** POST) — stale **`_pending_gather_feedback`** could paint spurious delta on manual **I**.~~ **Done.** Track **`_pending_gather_interactable_id`**; clear pending when a different interactable completes or fails.
- Nit: **`KNOWN_RESOURCE_NODE_IDS`** in **`prototype_interactable_picker.gd`** duplicates the frozen four-node table — intentional catalog-empty fallback; keep in sync with **`E3_M1`** if ids ever change (they are frozen).
## Verification